mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
добавил архив фото, откуда можно удалить и посмотреть
This commit is contained in:
12
cmd/main.go
12
cmd/main.go
@@ -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)
|
||||||
|
|||||||
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/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))
|
||||||
|
|||||||
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/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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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"})
|
||||||
|
}
|
||||||
215
rmser-view/src/components/settings/PhotoStorageTab.tsx
Normal file
215
rmser-view/src/components/settings/PhotoStorageTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user