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

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

@@ -26,6 +26,7 @@ import (
invoicesPkg "rmser/internal/infrastructure/repository/invoices" invoicesPkg "rmser/internal/infrastructure/repository/invoices"
ocrRepoPkg "rmser/internal/infrastructure/repository/ocr" ocrRepoPkg "rmser/internal/infrastructure/repository/ocr"
opsRepoPkg "rmser/internal/infrastructure/repository/operations" opsRepoPkg "rmser/internal/infrastructure/repository/operations"
photosPkg "rmser/internal/infrastructure/repository/photos"
recipesPkg "rmser/internal/infrastructure/repository/recipes" recipesPkg "rmser/internal/infrastructure/repository/recipes"
recRepoPkg "rmser/internal/infrastructure/repository/recommendations" recRepoPkg "rmser/internal/infrastructure/repository/recommendations"
suppliersPkg "rmser/internal/infrastructure/repository/suppliers" suppliersPkg "rmser/internal/infrastructure/repository/suppliers"
@@ -37,6 +38,7 @@ import (
draftsServicePkg "rmser/internal/services/drafts" draftsServicePkg "rmser/internal/services/drafts"
invoicesServicePkg "rmser/internal/services/invoices" invoicesServicePkg "rmser/internal/services/invoices"
ocrServicePkg "rmser/internal/services/ocr" ocrServicePkg "rmser/internal/services/ocr"
photosServicePkg "rmser/internal/services/photos"
recServicePkg "rmser/internal/services/recommend" recServicePkg "rmser/internal/services/recommend"
"rmser/internal/services/sync" "rmser/internal/services/sync"
@@ -79,6 +81,7 @@ func main() {
opsRepo := opsRepoPkg.NewRepository(database) opsRepo := opsRepoPkg.NewRepository(database)
recRepo := recRepoPkg.NewRepository(database) recRepo := recRepoPkg.NewRepository(database)
ocrRepo := ocrRepoPkg.NewRepository(database) ocrRepo := ocrRepoPkg.NewRepository(database)
photosRepo := photosPkg.NewRepository(database)
draftsRepo := draftsPkg.NewRepository(database) draftsRepo := draftsPkg.NewRepository(database)
supplierRepo := suppliersPkg.NewRepository(database) supplierRepo := suppliersPkg.NewRepository(database)
@@ -92,14 +95,16 @@ func main() {
syncService := sync.NewService(rmsFactory, accountRepo, catalogRepo, recipesRepo, invoicesRepo, opsRepo, supplierRepo) syncService := sync.NewService(rmsFactory, accountRepo, catalogRepo, recipesRepo, invoicesRepo, opsRepo, supplierRepo)
recService := recServicePkg.NewService(recRepo) recService := recServicePkg.NewService(recRepo)
ocrService := ocrServicePkg.NewService(ocrRepo, catalogRepo, draftsRepo, accountRepo, pyClient, cfg.App.StoragePath) ocrService := ocrServicePkg.NewService(ocrRepo, catalogRepo, draftsRepo, accountRepo, photosRepo, pyClient, cfg.App.StoragePath)
draftsService := draftsServicePkg.NewService(draftsRepo, ocrRepo, catalogRepo, accountRepo, supplierRepo, invoicesRepo, rmsFactory, billingService) draftsService := draftsServicePkg.NewService(draftsRepo, ocrRepo, catalogRepo, accountRepo, supplierRepo, photosRepo, invoicesRepo, rmsFactory, billingService)
invoicesService := invoicesServicePkg.NewService(invoicesRepo, draftsRepo, supplierRepo, rmsFactory) invoicesService := invoicesServicePkg.NewService(invoicesRepo, draftsRepo, supplierRepo, rmsFactory)
photosService := photosServicePkg.NewService(photosRepo, draftsRepo, accountRepo)
// 7. Handlers // 7. Handlers
draftsHandler := handlers.NewDraftsHandler(draftsService) draftsHandler := handlers.NewDraftsHandler(draftsService)
billingHandler := handlers.NewBillingHandler(billingService) billingHandler := handlers.NewBillingHandler(billingService)
ocrHandler := handlers.NewOCRHandler(ocrService) ocrHandler := handlers.NewOCRHandler(ocrService)
photosHandler := handlers.NewPhotosHandler(photosService)
recommendHandler := handlers.NewRecommendationsHandler(recService) recommendHandler := handlers.NewRecommendationsHandler(recService)
settingsHandler := handlers.NewSettingsHandler(accountRepo, catalogRepo) settingsHandler := handlers.NewSettingsHandler(accountRepo, catalogRepo)
invoicesHandler := handlers.NewInvoiceHandler(invoicesService, syncService) invoicesHandler := handlers.NewInvoiceHandler(invoicesService, syncService)
@@ -152,6 +157,9 @@ func main() {
// Settings // Settings
api.GET("/settings", settingsHandler.GetSettings) api.GET("/settings", settingsHandler.GetSettings)
api.POST("/settings", settingsHandler.UpdateSettings) api.POST("/settings", settingsHandler.UpdateSettings)
// Photos Storage
api.GET("/photos", photosHandler.GetPhotos)
api.DELETE("/photos/:id", photosHandler.DeletePhoto)
// User Management // User Management
api.GET("/settings/users", settingsHandler.GetServerUsers) api.GET("/settings/users", settingsHandler.GetServerUsers)
api.PATCH("/settings/users/:userId", settingsHandler.UpdateUserRole) api.PATCH("/settings/users/:userId", settingsHandler.UpdateUserRole)

View 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
}

View File

@@ -13,6 +13,7 @@ import (
"rmser/internal/domain/invoices" "rmser/internal/domain/invoices"
"rmser/internal/domain/ocr" "rmser/internal/domain/ocr"
"rmser/internal/domain/operations" "rmser/internal/domain/operations"
"rmser/internal/domain/photos"
"rmser/internal/domain/recipes" "rmser/internal/domain/recipes"
"rmser/internal/domain/recommendations" "rmser/internal/domain/recommendations"
"rmser/internal/domain/suppliers" "rmser/internal/domain/suppliers"
@@ -68,6 +69,7 @@ func NewPostgresDB(dsn string) *gorm.DB {
&recommendations.Recommendation{}, &recommendations.Recommendation{},
&ocr.ProductMatch{}, &ocr.ProductMatch{},
&ocr.UnmatchedItem{}, &ocr.UnmatchedItem{},
&photos.ReceiptPhoto{},
) )
if err != nil { if err != nil {
panic(fmt.Sprintf("ошибка миграции БД: %v", err)) panic(fmt.Sprintf("ошибка миграции БД: %v", err))

View 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
}

View File

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

View File

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

View 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"})
}

View File

@@ -0,0 +1,215 @@
import React, { useState } from "react";
import {
Card,
Image,
Button,
Popconfirm,
Tag,
Pagination,
Empty,
Spin,
message,
Tooltip,
} from "antd";
import {
DeleteOutlined,
ReloadOutlined,
FileImageOutlined,
CheckCircleOutlined,
FileTextOutlined,
} from "@ant-design/icons";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, getStaticUrl } from "../../services/api";
import { AxiosError } from "axios";
import type { ReceiptPhoto, PhotoStatus } from "../../services/types";
export const PhotoStorageTab: React.FC = () => {
const [page, setPage] = useState(1);
const queryClient = useQueryClient();
const { data, isLoading, isError } = useQuery({
queryKey: ["photos", page],
queryFn: () => api.getPhotos(page, 18), // 18 - удобно делится на 2, 3, 6 колонок
});
const deleteMutation = useMutation({
mutationFn: ({ id, force }: { id: string; force: boolean }) =>
api.deletePhoto(id, force),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["photos"] });
message.success("Фото удалено");
},
// Исправленная типизация:
onError: (error: AxiosError<{ error: string }>) => {
if (error.response?.status === 409) {
message.warning(
"Это фото связано с черновиком. Используйте кнопку 'Удалить' с подтверждением."
);
} else {
message.error(error.response?.data?.error || "Ошибка удаления");
}
},
});
const regenerateMutation = useMutation({
mutationFn: (id: string) => api.regenerateDraftFromPhoto(id),
onSuccess: () => {
message.success("Черновик восстановлен");
// Можно редиректить, но пока просто обновим список
},
onError: () => {
message.error("Ошибка восстановления");
},
});
const getStatusTag = (status: PhotoStatus) => {
switch (status) {
case "ORPHAN":
return <Tag color="default">Без привязки</Tag>;
case "HAS_DRAFT":
return (
<Tag icon={<FileTextOutlined />} color="processing">
Черновик
</Tag>
);
case "HAS_INVOICE":
return (
<Tag icon={<CheckCircleOutlined />} color="success">
В iiko
</Tag>
);
default:
return <Tag>{status}</Tag>;
}
};
if (isLoading) {
return (
<div style={{ textAlign: "center", padding: 40 }}>
<Spin size="large" />
</div>
);
}
if (isError) {
return <Empty description="Ошибка загрузки фото" />;
}
if (!data?.photos?.length) {
return <Empty description="Нет загруженных фото" />;
}
return (
<div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))",
gap: 16,
marginBottom: 24,
}}
>
{data.photos.map((photo: ReceiptPhoto) => (
<Card
key={photo.id}
hoverable
size="small"
cover={
<div
style={{
height: 160,
overflow: "hidden",
position: "relative",
}}
>
<Image
src={getStaticUrl(photo.file_url)}
alt={photo.file_name}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
preview={{ mask: <FileImageOutlined /> }}
/>
</div>
}
actions={[
photo.can_regenerate ? (
<Tooltip title="Создать черновик заново">
<Button
type="text"
icon={<ReloadOutlined />}
onClick={() => regenerateMutation.mutate(photo.id)}
loading={regenerateMutation.isPending}
size="small"
/>
</Tooltip>
) : (
<span />
), // Placeholder для выравнивания
photo.can_delete ? (
<Popconfirm
title="Удалить фото?"
description={
photo.status === "HAS_DRAFT"
? "Внимание! Черновик тоже будет удален."
: "Восстановить будет невозможно."
}
onConfirm={() =>
deleteMutation.mutate({
id: photo.id,
force: photo.status === "HAS_DRAFT",
})
}
okText="Удалить"
cancelText="Отмена"
okButtonProps={{ danger: true }}
>
<Button
type="text"
danger
icon={<DeleteOutlined />}
size="small"
/>
</Popconfirm>
) : (
<Tooltip title="Нельзя удалить: накладная уже в iiko">
<Button
type="text"
disabled
icon={<DeleteOutlined />}
size="small"
/>
</Tooltip>
),
]}
>
<Card.Meta
title={
<div
style={{
fontSize: 12,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{new Date(photo.created_at).toLocaleDateString()}
</div>
}
description={getStatusTag(photo.status)}
/>
</Card>
))}
</div>
<Pagination
current={page}
total={data.total}
pageSize={data.limit}
onChange={setPage}
showSizeChanger={false}
style={{ textAlign: "center" }}
simple
/>
</div>
);
};

View File

@@ -20,11 +20,13 @@ import {
SettingOutlined, SettingOutlined,
FolderOpenOutlined, FolderOpenOutlined,
TeamOutlined, TeamOutlined,
CameraOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../services/api"; import { api } from "../services/api";
import type { UserSettings } from "../services/types"; import type { UserSettings } from "../services/types";
import { TeamList } from "../components/settings/TeamList"; import { TeamList } from "../components/settings/TeamList";
import { PhotoStorageTab } from "../components/settings/PhotoStorageTab";
const { Title, Text } = Typography; const { Title, Text } = Typography;
@@ -207,6 +209,16 @@ export const SettingsPage: React.FC = () => {
}); });
} }
// Добавляем вкладку с фото (доступна для OWNER)
if (currentUserRole === "OWNER") {
tabsItems.push({
key: "photos",
label: "Архив фото",
icon: <CameraOutlined />,
children: <PhotoStorageTab />,
});
}
return ( return (
<div style={{ padding: "0 16px 80px" }}> <div style={{ padding: "0 16px 80px" }}>
<Title level={4} style={{ marginTop: 16 }}> <Title level={4} style={{ marginTop: 16 }}>

View File

@@ -25,7 +25,8 @@ import type {
UnifiedInvoice, UnifiedInvoice,
ServerUser, ServerUser,
UserRole, UserRole,
InvoiceDetails InvoiceDetails,
GetPhotosResponse
} from './types'; } from './types';
// Базовый URL // Базовый URL
@@ -255,4 +256,21 @@ export const api = {
syncInvoices: async (): Promise<void> => { syncInvoices: async (): Promise<void> => {
await apiClient.post('/invoices/sync'); await apiClient.post('/invoices/sync');
}, },
}; getPhotos: async (page = 1, limit = 20): Promise<GetPhotosResponse> => {
const { data } = await apiClient.get<GetPhotosResponse>('/photos', {
params: { page, limit }
});
return data;
},
deletePhoto: async (id: string, force = false): Promise<void> => {
await apiClient.delete(`/photos/${id}`, {
params: { force }
});
},
regenerateDraftFromPhoto: async (id: string): Promise<void> => {
await apiClient.post(`/photos/${id}/regenerate`);
},
};

View File

@@ -274,4 +274,28 @@ export interface InvoiceDetails {
total: number; total: number;
}[]; }[];
photo_url: string | null; photo_url: string | null;
}
export type PhotoStatus = 'ORPHAN' | 'HAS_DRAFT' | 'HAS_INVOICE';
export interface ReceiptPhoto {
id: string;
rms_server_id: string;
uploaded_by: string;
file_url: string;
file_name: string;
file_size: number;
draft_id?: string;
invoice_id?: string;
created_at: string;
status: PhotoStatus;
can_delete: boolean;
can_regenerate: boolean;
}
export interface GetPhotosResponse {
photos: ReceiptPhoto[];
total: number;
page: number;
limit: number;
} }