редактирование и удаление сопоставлений

список накладных с позициями
This commit is contained in:
2025-12-29 10:46:05 +03:00
parent c2d382cb6a
commit 310a64e3ba
30 changed files with 1250 additions and 8173 deletions

View File

@@ -35,6 +35,7 @@ import (
// Services // Services
billingServicePkg "rmser/internal/services/billing" billingServicePkg "rmser/internal/services/billing"
draftsServicePkg "rmser/internal/services/drafts" draftsServicePkg "rmser/internal/services/drafts"
invoicesServicePkg "rmser/internal/services/invoices"
ocrServicePkg "rmser/internal/services/ocr" ocrServicePkg "rmser/internal/services/ocr"
recServicePkg "rmser/internal/services/recommend" recServicePkg "rmser/internal/services/recommend"
"rmser/internal/services/sync" "rmser/internal/services/sync"
@@ -93,6 +94,7 @@ func main() {
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, pyClient, cfg.App.StoragePath)
draftsService := draftsServicePkg.NewService(draftsRepo, ocrRepo, catalogRepo, accountRepo, supplierRepo, invoicesRepo, rmsFactory, billingService) draftsService := draftsServicePkg.NewService(draftsRepo, ocrRepo, catalogRepo, accountRepo, supplierRepo, invoicesRepo, rmsFactory, billingService)
invoicesService := invoicesServicePkg.NewService(invoicesRepo, draftsRepo, supplierRepo, rmsFactory)
// 7. Handlers // 7. Handlers
draftsHandler := handlers.NewDraftsHandler(draftsService) draftsHandler := handlers.NewDraftsHandler(draftsService)
@@ -100,6 +102,7 @@ func main() {
ocrHandler := handlers.NewOCRHandler(ocrService) ocrHandler := handlers.NewOCRHandler(ocrService)
recommendHandler := handlers.NewRecommendationsHandler(recService) recommendHandler := handlers.NewRecommendationsHandler(recService)
settingsHandler := handlers.NewSettingsHandler(accountRepo, catalogRepo) settingsHandler := handlers.NewSettingsHandler(accountRepo, catalogRepo)
invoicesHandler := handlers.NewInvoiceHandler(invoicesService)
// 8. Telegram Bot (Передаем syncService) // 8. Telegram Bot (Передаем syncService)
if cfg.Telegram.Token != "" { if cfg.Telegram.Token != "" {
@@ -168,8 +171,12 @@ func main() {
api.POST("/ocr/match", ocrHandler.SaveMatch) api.POST("/ocr/match", ocrHandler.SaveMatch)
api.DELETE("/ocr/match", ocrHandler.DeleteMatch) api.DELETE("/ocr/match", ocrHandler.DeleteMatch)
api.GET("/ocr/unmatched", ocrHandler.GetUnmatched) api.GET("/ocr/unmatched", ocrHandler.GetUnmatched)
api.DELETE("/ocr/unmatched", ocrHandler.DiscardUnmatched)
api.GET("/ocr/search", ocrHandler.SearchProducts) api.GET("/ocr/search", ocrHandler.SearchProducts)
// Invoices
api.GET("/invoices/:id", invoicesHandler.GetInvoice)
// Manual Sync Trigger // Manual Sync Trigger
api.POST("/sync/all", func(c *gin.Context) { api.POST("/sync/all", func(c *gin.Context) {
userID := c.MustGet("userID").(uuid.UUID) userID := c.MustGet("userID").(uuid.UUID)

2
go.mod
View File

@@ -1,6 +1,6 @@
module rmser module rmser
go 1.25.1 go 1.25.5
require ( require (
github.com/gin-contrib/cors v1.7.6 github.com/gin-contrib/cors v1.7.6

View File

@@ -28,9 +28,11 @@ type DraftInvoice struct {
SenderPhotoURL string `gorm:"type:text" json:"photo_url"` SenderPhotoURL string `gorm:"type:text" json:"photo_url"`
Status string `gorm:"type:varchar(50);default:'PROCESSING'" json:"status"` Status string `gorm:"type:varchar(50);default:'PROCESSING'" json:"status"`
DocumentNumber string `gorm:"type:varchar(100)" json:"document_number"` DocumentNumber string `gorm:"type:varchar(100)" json:"document_number"`
DateIncoming *time.Time `json:"date_incoming"` // Входящий номер документа
SupplierID *uuid.UUID `gorm:"type:uuid" json:"supplier_id"` IncomingDocumentNumber string `gorm:"type:varchar(100)" json:"incoming_document_number"`
DateIncoming *time.Time `json:"date_incoming"`
SupplierID *uuid.UUID `gorm:"type:uuid" json:"supplier_id"`
StoreID *uuid.UUID `gorm:"type:uuid" json:"store_id"` StoreID *uuid.UUID `gorm:"type:uuid" json:"store_id"`
Store *catalog.Store `gorm:"foreignKey:StoreID" json:"store,omitempty"` Store *catalog.Store `gorm:"foreignKey:StoreID" json:"store,omitempty"`
@@ -67,6 +69,7 @@ type DraftInvoiceItem struct {
type Repository interface { type Repository interface {
Create(draft *DraftInvoice) error Create(draft *DraftInvoice) error
GetByID(id uuid.UUID) (*DraftInvoice, error) GetByID(id uuid.UUID) (*DraftInvoice, error)
GetByRMSInvoiceID(rmsInvoiceID uuid.UUID) (*DraftInvoice, error)
Update(draft *DraftInvoice) error Update(draft *DraftInvoice) error
CreateItems(items []DraftInvoiceItem) error CreateItems(items []DraftInvoiceItem) error
UpdateItem(itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error UpdateItem(itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error
@@ -76,4 +79,7 @@ type Repository interface {
// GetActive возвращает активные черновики для СЕРВЕРА (а не юзера) // GetActive возвращает активные черновики для СЕРВЕРА (а не юзера)
GetActive(serverID uuid.UUID) ([]DraftInvoice, error) GetActive(serverID uuid.UUID) ([]DraftInvoice, error)
// GetRMSInvoiceIDToPhotoURLMap возвращает мапу rms_invoice_id -> sender_photo_url для сервера, где rms_invoice_id не NULL
GetRMSInvoiceIDToPhotoURLMap(serverID uuid.UUID) (map[uuid.UUID]string, error)
} }

View File

@@ -42,6 +42,7 @@ type InvoiceItem struct {
} }
type Repository interface { type Repository interface {
GetByID(id uuid.UUID) (*Invoice, error)
GetLastInvoiceDate(serverID uuid.UUID) (*time.Time, error) GetLastInvoiceDate(serverID uuid.UUID) (*time.Time, error)
GetByPeriod(serverID uuid.UUID, from, to time.Time) ([]Invoice, error) GetByPeriod(serverID uuid.UUID, from, to time.Time) ([]Invoice, error)
SaveInvoices(invoices []Invoice) error SaveInvoices(invoices []Invoice) error

View File

@@ -23,6 +23,7 @@ type Supplier struct {
type Repository interface { type Repository interface {
SaveBatch(suppliers []Supplier) error SaveBatch(suppliers []Supplier) error
GetByID(id uuid.UUID) (*Supplier, error)
// GetRankedByUsage возвращает поставщиков, отсортированных по частоте использования в накладных за N дней // GetRankedByUsage возвращает поставщиков, отсортированных по частоте использования в накладных за N дней
GetRankedByUsage(serverID uuid.UUID, daysLookBack int) ([]Supplier, error) GetRankedByUsage(serverID uuid.UUID, daysLookBack int) ([]Supplier, error)
Count(serverID uuid.UUID) (int64, error) Count(serverID uuid.UUID) (int64, error)

View File

@@ -38,17 +38,32 @@ func (r *pgRepository) GetByID(id uuid.UUID) (*drafts.DraftInvoice, error) {
return &draft, nil return &draft, nil
} }
func (r *pgRepository) GetByRMSInvoiceID(rmsInvoiceID uuid.UUID) (*drafts.DraftInvoice, error) {
var draft drafts.DraftInvoice
err := r.db.
Where("rms_invoice_id = ?", rmsInvoiceID).
First(&draft).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return &draft, nil
}
func (r *pgRepository) Update(draft *drafts.DraftInvoice) error { func (r *pgRepository) Update(draft *drafts.DraftInvoice) error {
return r.db.Model(draft).Updates(map[string]interface{}{ return r.db.Model(draft).Updates(map[string]interface{}{
"status": draft.Status, "status": draft.Status,
"document_number": draft.DocumentNumber, "document_number": draft.DocumentNumber,
"date_incoming": draft.DateIncoming, "incoming_document_number": draft.IncomingDocumentNumber, // Добавлено поле для входящего номера документа
"supplier_id": draft.SupplierID, "date_incoming": draft.DateIncoming,
"store_id": draft.StoreID, "supplier_id": draft.SupplierID,
"comment": draft.Comment, "store_id": draft.StoreID,
"rms_invoice_id": draft.RMSInvoiceID, "comment": draft.Comment,
"rms_server_id": draft.RMSServerID, "rms_invoice_id": draft.RMSInvoiceID,
"updated_at": gorm.Expr("NOW()"), "rms_server_id": draft.RMSServerID,
"updated_at": gorm.Expr("NOW()"),
}).Error }).Error
} }
@@ -107,3 +122,23 @@ func (r *pgRepository) GetActive(serverID uuid.UUID) ([]drafts.DraftInvoice, err
return list, err return list, err
} }
// GetRMSInvoiceIDToPhotoURLMap возвращает мапу rms_invoice_id -> sender_photo_url для сервера, где rms_invoice_id не NULL
func (r *pgRepository) GetRMSInvoiceIDToPhotoURLMap(serverID uuid.UUID) (map[uuid.UUID]string, error) {
var draftsList []drafts.DraftInvoice
err := r.db.
Select("rms_invoice_id", "sender_photo_url").
Where("rms_server_id = ? AND rms_invoice_id IS NOT NULL", serverID).
Find(&draftsList).Error
if err != nil {
return nil, err
}
result := make(map[uuid.UUID]string)
for _, d := range draftsList {
if d.RMSInvoiceID != nil {
result[*d.RMSInvoiceID] = d.SenderPhotoURL
}
}
return result, nil
}

View File

@@ -18,6 +18,19 @@ func NewRepository(db *gorm.DB) invoices.Repository {
return &pgRepository{db: db} return &pgRepository{db: db}
} }
func (r *pgRepository) GetByID(id uuid.UUID) (*invoices.Invoice, error) {
var inv invoices.Invoice
err := r.db.
Preload("Items").
Preload("Items.Product").
Where("id = ?", id).
First(&inv).Error
if err != nil {
return nil, err
}
return &inv, nil
}
func (r *pgRepository) GetLastInvoiceDate(serverID uuid.UUID) (*time.Time, error) { func (r *pgRepository) GetLastInvoiceDate(serverID uuid.UUID) (*time.Time, error) {
var inv invoices.Invoice var inv invoices.Invoice
// Ищем последнюю накладную только для этого сервера // Ищем последнюю накладную только для этого сервера
@@ -35,7 +48,8 @@ func (r *pgRepository) GetByPeriod(serverID uuid.UUID, from, to time.Time) ([]in
var list []invoices.Invoice var list []invoices.Invoice
err := r.db. err := r.db.
Preload("Items"). Preload("Items").
Where("rms_server_id = ? AND date_incoming BETWEEN ? AND ?", serverID, from, to). Preload("Items.Product").
Where("rms_server_id = ? AND date_incoming BETWEEN ? AND ? AND status != ?", serverID, from, to, "DELETED").
Order("date_incoming DESC"). Order("date_incoming DESC").
Find(&list).Error Find(&list).Error
return list, err return list, err

View File

@@ -81,6 +81,7 @@ func (r *pgRepository) GetAllMatches(serverID uuid.UUID) ([]ocr.ProductMatch, er
err := r.db. err := r.db.
Preload("Product"). Preload("Product").
Preload("Product.MainUnit"). Preload("Product.MainUnit").
Preload("Product.Containers").
Preload("Container"). Preload("Container").
Where("rms_server_id = ?", serverID). Where("rms_server_id = ?", serverID).
Order("updated_at DESC"). Order("updated_at DESC").

View File

@@ -51,6 +51,15 @@ func (r *pgRepository) GetRankedByUsage(serverID uuid.UUID, daysLookBack int) ([
return result, err return result, err
} }
func (r *pgRepository) GetByID(id uuid.UUID) (*suppliers.Supplier, error) {
var supplier suppliers.Supplier
err := r.db.Where("id = ? AND is_deleted = ?", id, false).First(&supplier).Error
if err != nil {
return nil, err
}
return &supplier, nil
}
func (r *pgRepository) Count(serverID uuid.UUID) (int64, error) { func (r *pgRepository) Count(serverID uuid.UUID) (int64, error) {
var count int64 var count int64
err := r.db.Model(&suppliers.Supplier{}). err := r.db.Model(&suppliers.Supplier{}).

View File

@@ -572,12 +572,13 @@ func (c *Client) CreateIncomingInvoice(inv invoices.Invoice) (string, error) {
} }
reqDTO := IncomingInvoiceImportXML{ reqDTO := IncomingInvoiceImportXML{
DocumentNumber: inv.DocumentNumber, DocumentNumber: inv.DocumentNumber,
DateIncoming: inv.DateIncoming.Format("02.01.2006"), IncomingDocumentNumber: inv.IncomingDocumentNumber, // Присваиваем входящий номер документа из домена
DefaultStore: inv.DefaultStoreID.String(), DateIncoming: inv.DateIncoming.Format("02.01.2006"),
Supplier: inv.SupplierID.String(), DefaultStore: inv.DefaultStoreID.String(),
Status: status, Supplier: inv.SupplierID.String(),
Comment: comment, Status: status,
Comment: comment,
} }
if inv.ID != uuid.Nil { if inv.ID != uuid.Nil {

View File

@@ -195,10 +195,11 @@ type StoreReportItemXML struct {
// IncomingInvoiceImportXML описывает структуру для POST запроса импорта // IncomingInvoiceImportXML описывает структуру для POST запроса импорта
type IncomingInvoiceImportXML struct { type IncomingInvoiceImportXML struct {
XMLName xml.Name `xml:"document"` XMLName xml.Name `xml:"document"`
ID string `xml:"id,omitempty"` // GUID, если редактируем ID string `xml:"id,omitempty"` // GUID, если редактируем
DocumentNumber string `xml:"documentNumber,omitempty"` DocumentNumber string `xml:"documentNumber,omitempty"`
DateIncoming string `xml:"dateIncoming,omitempty"` // Format: dd.MM.yyyy IncomingDocumentNumber string `xml:"incomingDocumentNumber,omitempty"` // Входящий номер документа
DateIncoming string `xml:"dateIncoming,omitempty"` // Format: dd.MM.yyyy
Invoice string `xml:"invoice,omitempty"` // Номер счет-фактуры Invoice string `xml:"invoice,omitempty"` // Номер счет-фактуры
DefaultStore string `xml:"defaultStore"` // GUID склада (обязательно) DefaultStore string `xml:"defaultStore"` // GUID склада (обязательно)
Supplier string `xml:"supplier"` // GUID поставщика (обязательно) Supplier string `xml:"supplier"` // GUID поставщика (обязательно)

View File

@@ -152,7 +152,7 @@ func (s *Service) DeleteDraft(id uuid.UUID) (string, error) {
return draft.Status, nil return draft.Status, nil
} }
func (s *Service) UpdateDraftHeader(id uuid.UUID, storeID *uuid.UUID, supplierID *uuid.UUID, date time.Time, comment string) error { func (s *Service) UpdateDraftHeader(id uuid.UUID, storeID *uuid.UUID, supplierID *uuid.UUID, date time.Time, comment string, incomingDocNum string) error {
draft, err := s.draftRepo.GetByID(id) draft, err := s.draftRepo.GetByID(id)
if err != nil { if err != nil {
return err return err
@@ -164,6 +164,7 @@ func (s *Service) UpdateDraftHeader(id uuid.UUID, storeID *uuid.UUID, supplierID
draft.SupplierID = supplierID draft.SupplierID = supplierID
draft.DateIncoming = &date draft.DateIncoming = &date
draft.Comment = comment draft.Comment = comment
draft.IncomingDocumentNumber = incomingDocNum
return s.draftRepo.Update(draft) return s.draftRepo.Update(draft)
} }
@@ -270,14 +271,15 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) {
// 4. Сборка Invoice // 4. Сборка Invoice
inv := invoices.Invoice{ inv := invoices.Invoice{
ID: uuid.Nil, ID: uuid.Nil,
DocumentNumber: draft.DocumentNumber, DocumentNumber: draft.DocumentNumber,
DateIncoming: *draft.DateIncoming, DateIncoming: *draft.DateIncoming,
SupplierID: *draft.SupplierID, SupplierID: *draft.SupplierID,
DefaultStoreID: *draft.StoreID, DefaultStoreID: *draft.StoreID,
Status: targetStatus, Status: targetStatus,
Comment: draft.Comment, Comment: draft.Comment,
Items: make([]invoices.InvoiceItem, 0, len(draft.Items)), IncomingDocumentNumber: draft.IncomingDocumentNumber,
Items: make([]invoices.InvoiceItem, 0, len(draft.Items)),
} }
for _, dItem := range draft.Items { for _, dItem := range draft.Items {
@@ -338,7 +340,25 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) {
return "", err return "", err
} }
// 6. Обновление статуса черновика // 6. Поиск UUID созданной накладной
invoices, err := client.FetchInvoices(*draft.DateIncoming, *draft.DateIncoming)
if err != nil {
logger.Log.Warn("Не удалось получить список накладных для поиска UUID", zap.Error(err), zap.Time("date", *draft.DateIncoming))
} else {
found := false
for _, invoice := range invoices {
if invoice.DocumentNumber == docNum {
draft.RMSInvoiceID = &invoice.ID
found = true
break
}
}
if !found {
logger.Log.Warn("UUID созданной накладной не найден", zap.String("document_number", docNum), zap.Time("date", *draft.DateIncoming))
}
}
// 7. Обновление статуса черновика
draft.Status = drafts.StatusCompleted draft.Status = drafts.StatusCompleted
s.draftRepo.Update(draft) s.draftRepo.Update(draft)
@@ -471,6 +491,8 @@ type UnifiedInvoiceDTO struct {
ItemsCount int `json:"items_count"` ItemsCount int `json:"items_count"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
IsAppCreated bool `json:"is_app_created"` IsAppCreated bool `json:"is_app_created"`
PhotoURL string `json:"photo_url"`
ItemsPreview string `json:"items_preview"`
} }
func (s *Service) GetUnifiedList(userID uuid.UUID, from, to time.Time) ([]UnifiedInvoiceDTO, error) { func (s *Service) GetUnifiedList(userID uuid.UUID, from, to time.Time) ([]UnifiedInvoiceDTO, error) {
@@ -491,6 +513,12 @@ func (s *Service) GetUnifiedList(userID uuid.UUID, from, to time.Time) ([]Unifie
return nil, err return nil, err
} }
// 3. Получаем мапу rms_invoice_id -> sender_photo_url
photoMap, err := s.draftRepo.GetRMSInvoiceIDToPhotoURLMap(server.ID)
if err != nil {
return nil, err
}
result := make([]UnifiedInvoiceDTO, 0, len(draftsList)+len(invoicesList)) result := make([]UnifiedInvoiceDTO, 0, len(draftsList)+len(invoicesList))
// Маппим черновики // Маппим черновики
@@ -510,6 +538,19 @@ func (s *Service) GetUnifiedList(userID uuid.UUID, from, to time.Time) ([]Unifie
date = *d.DateIncoming date = *d.DateIncoming
} }
// Формируем ItemsPreview для черновиков
var itemsPreview string
if len(d.Items) > 0 {
names := make([]string, 0, 3)
for i, it := range d.Items {
if i >= 3 {
break
}
names = append(names, it.RawName)
}
itemsPreview = strings.Join(names, ", ")
}
result = append(result, UnifiedInvoiceDTO{ result = append(result, UnifiedInvoiceDTO{
ID: d.ID, ID: d.ID,
Type: "DRAFT", Type: "DRAFT",
@@ -522,6 +563,8 @@ func (s *Service) GetUnifiedList(userID uuid.UUID, from, to time.Time) ([]Unifie
ItemsCount: len(d.Items), ItemsCount: len(d.Items),
CreatedAt: d.CreatedAt, CreatedAt: d.CreatedAt,
IsAppCreated: true, IsAppCreated: true,
PhotoURL: d.SenderPhotoURL,
ItemsPreview: itemsPreview,
}) })
} }
@@ -533,7 +576,29 @@ func (s *Service) GetUnifiedList(userID uuid.UUID, from, to time.Time) ([]Unifie
} }
val, _ := sum.Float64() val, _ := sum.Float64()
isOurs := strings.Contains(strings.ToUpper(inv.Comment), "RMSER") // Определяем IsAppCreated и PhotoURL через мапу
isAppCreated := false
photoURL := ""
if url, exists := photoMap[inv.ID]; exists {
isAppCreated = true
photoURL = url
}
// Формируем ItemsPreview для синхронизированных накладных
var itemsPreview string
if len(inv.Items) > 0 {
names := make([]string, 0, 3)
for i, it := range inv.Items {
if i >= 3 {
break
}
// Предполагаем, что Product подгружен, иначе нужно добавить Preload
if it.Product.Name != "" {
names = append(names, it.Product.Name)
}
}
itemsPreview = strings.Join(names, ", ")
}
result = append(result, UnifiedInvoiceDTO{ result = append(result, UnifiedInvoiceDTO{
ID: inv.ID, ID: inv.ID,
@@ -545,7 +610,9 @@ func (s *Service) GetUnifiedList(userID uuid.UUID, from, to time.Time) ([]Unifie
TotalSum: val, TotalSum: val,
ItemsCount: len(inv.Items), ItemsCount: len(inv.Items),
CreatedAt: inv.CreatedAt, CreatedAt: inv.CreatedAt,
IsAppCreated: isOurs, IsAppCreated: isAppCreated,
PhotoURL: photoURL,
ItemsPreview: itemsPreview,
}) })
} }
@@ -553,3 +620,34 @@ func (s *Service) GetUnifiedList(userID uuid.UUID, from, to time.Time) ([]Unifie
// (Здесь можно добавить библиотеку sort или оставить как есть, если БД уже отсортировала части) // (Здесь можно добавить библиотеку sort или оставить как есть, если БД уже отсортировала части)
return result, nil return result, nil
} }
func (s *Service) GetInvoiceDetails(invoiceID, userID uuid.UUID) (*invoices.Invoice, string, error) {
// Получить накладную
inv, err := s.invoiceRepo.GetByID(invoiceID)
if err != nil {
return nil, "", err
}
// Проверить, что пользователь имеет доступ к серверу накладной
server, err := s.accountRepo.GetActiveServer(userID)
if err != nil || server == nil {
return nil, "", errors.New("нет активного сервера")
}
if inv.RMSServerID != server.ID {
return nil, "", errors.New("накладная не принадлежит активному серверу")
}
// Попытаться найти черновик по rms_invoice_id
draft, err := s.draftRepo.GetByRMSInvoiceID(invoiceID)
if err != nil {
return nil, "", err
}
photoURL := ""
if draft != nil {
photoURL = draft.SenderPhotoURL
}
return inv, photoURL, nil
}

View File

@@ -8,20 +8,28 @@ import (
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
"go.uber.org/zap" "go.uber.org/zap"
"rmser/internal/domain/invoices" "rmser/internal/domain/drafts"
invDomain "rmser/internal/domain/invoices"
"rmser/internal/domain/suppliers"
"rmser/internal/infrastructure/rms" "rmser/internal/infrastructure/rms"
"rmser/pkg/logger" "rmser/pkg/logger"
) )
type Service struct { type Service struct {
rmsClient rms.ClientI repo invDomain.Repository
draftsRepo drafts.Repository
supplierRepo suppliers.Repository
rmsFactory *rms.Factory
// Здесь можно добавить репозитории каталога и контрагентов для валидации, // Здесь можно добавить репозитории каталога и контрагентов для валидации,
// но для краткости пока опустим глубокую валидацию. // но для краткости пока опустим глубокую валидацию.
} }
func NewService(rmsClient rms.ClientI) *Service { func NewService(repo invDomain.Repository, draftsRepo drafts.Repository, supplierRepo suppliers.Repository, rmsFactory *rms.Factory) *Service {
return &Service{ return &Service{
rmsClient: rmsClient, repo: repo,
draftsRepo: draftsRepo,
supplierRepo: supplierRepo,
rmsFactory: rmsFactory,
} }
} }
@@ -39,7 +47,7 @@ type CreateRequestDTO struct {
} }
// SendInvoiceToRMS валидирует DTO, собирает доменную модель и отправляет в RMS // SendInvoiceToRMS валидирует DTO, собирает доменную модель и отправляет в RMS
func (s *Service) SendInvoiceToRMS(req CreateRequestDTO) (string, error) { func (s *Service) SendInvoiceToRMS(req CreateRequestDTO, userID uuid.UUID) (string, error) {
// 1. Базовая валидация // 1. Базовая валидация
if len(req.Items) == 0 { if len(req.Items) == 0 {
return "", fmt.Errorf("список товаров пуст") return "", fmt.Errorf("список товаров пуст")
@@ -51,20 +59,20 @@ func (s *Service) SendInvoiceToRMS(req CreateRequestDTO) (string, error) {
} }
// 2. Сборка доменной модели // 2. Сборка доменной модели
inv := invoices.Invoice{ inv := invDomain.Invoice{
ID: uuid.Nil, // Новый документ ID: uuid.Nil, // Новый документ
DocumentNumber: req.DocumentNumber, DocumentNumber: req.DocumentNumber,
DateIncoming: dateInc, DateIncoming: dateInc,
SupplierID: req.SupplierID, SupplierID: req.SupplierID,
DefaultStoreID: req.StoreID, DefaultStoreID: req.StoreID,
Status: "NEW", Status: "NEW",
Items: make([]invoices.InvoiceItem, 0, len(req.Items)), Items: make([]invDomain.InvoiceItem, 0, len(req.Items)),
} }
for _, itemDTO := range req.Items { for _, itemDTO := range req.Items {
sum := itemDTO.Amount.Mul(itemDTO.Price) // Пересчитываем сумму sum := itemDTO.Amount.Mul(itemDTO.Price) // Пересчитываем сумму
inv.Items = append(inv.Items, invoices.InvoiceItem{ inv.Items = append(inv.Items, invDomain.InvoiceItem{
ProductID: itemDTO.ProductID, ProductID: itemDTO.ProductID,
Amount: itemDTO.Amount, Amount: itemDTO.Amount,
Price: itemDTO.Price, Price: itemDTO.Price,
@@ -72,15 +80,89 @@ func (s *Service) SendInvoiceToRMS(req CreateRequestDTO) (string, error) {
}) })
} }
// 3. Отправка через клиент // 3. Получение клиента RMS
client, err := s.rmsFactory.GetClientForUser(userID)
if err != nil {
return "", fmt.Errorf("ошибка получения клиента RMS: %w", err)
}
// 4. Отправка через клиент
logger.Log.Info("Отправка накладной в RMS", logger.Log.Info("Отправка накладной в RMS",
zap.String("supplier", req.SupplierID.String()), zap.String("supplier", req.SupplierID.String()),
zap.Int("items_count", len(inv.Items))) zap.Int("items_count", len(inv.Items)))
docNum, err := s.rmsClient.CreateIncomingInvoice(inv) docNum, err := client.CreateIncomingInvoice(inv)
if err != nil { if err != nil {
return "", err return "", err
} }
return docNum, nil return docNum, nil
} }
// InvoiceDetailsDTO - DTO для ответа на запрос деталей накладной
type InvoiceDetailsDTO struct {
ID uuid.UUID `json:"id"`
Number string `json:"number"`
Date string `json:"date"`
Status string `json:"status"`
Supplier struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
} `json:"supplier"`
Items []struct {
Name string `json:"name"`
Quantity float64 `json:"quantity"`
Price float64 `json:"price"`
Total float64 `json:"total"`
} `json:"items"`
PhotoURL *string `json:"photo_url"`
}
// GetInvoice возвращает детали синхронизированной накладной по ID
func (s *Service) GetInvoice(id uuid.UUID) (*InvoiceDetailsDTO, error) {
// 1. Получить накладную из репозитория
inv, err := s.repo.GetByID(id)
if err != nil {
return nil, fmt.Errorf("ошибка получения накладной: %w", err)
}
// 2. Получить поставщика
supplier, err := s.supplierRepo.GetByID(inv.SupplierID)
if err != nil {
return nil, fmt.Errorf("ошибка получения поставщика: %w", err)
}
// 3. Проверить, есть ли draft с photo_url
var photoURL *string
draft, err := s.draftsRepo.GetByRMSInvoiceID(inv.ID)
if err == nil && draft != nil {
photoURL = &draft.SenderPhotoURL
}
// 4. Собрать DTO
dto := &InvoiceDetailsDTO{
ID: inv.ID,
Number: inv.DocumentNumber,
Date: inv.DateIncoming.Format("2006-01-02"),
Status: "COMPLETED", // Для синхронизированных накладных статус всегда COMPLETED
Items: make([]struct {
Name string `json:"name"`
Quantity float64 `json:"quantity"`
Price float64 `json:"price"`
Total float64 `json:"total"`
}, len(inv.Items)),
PhotoURL: photoURL,
}
dto.Supplier.ID = supplier.ID
dto.Supplier.Name = supplier.Name
for i, item := range inv.Items {
dto.Items[i].Name = item.Product.Name
dto.Items[i].Quantity, _ = item.Amount.Float64()
dto.Items[i].Price, _ = item.Price.Float64()
dto.Items[i].Total, _ = item.Sum.Float64()
}
return dto, nil
}

View File

@@ -249,3 +249,14 @@ func (s *Service) GetUnmatchedItems(userID uuid.UUID) ([]ocr.UnmatchedItem, erro
} }
return s.ocrRepo.GetTopUnmatched(server.ID, 50) return s.ocrRepo.GetTopUnmatched(server.ID, 50)
} }
func (s *Service) DiscardUnmatched(userID uuid.UUID, rawName string) error {
server, err := s.accountRepo.GetActiveServer(userID)
if err != nil || server == nil {
return fmt.Errorf("no server")
}
if err := s.checkWriteAccess(userID, server.ID); err != nil {
return err
}
return s.ocrRepo.DeleteUnmatched(server.ID, rawName)
}

View File

@@ -160,10 +160,11 @@ func (h *DraftsHandler) UpdateItem(c *gin.Context) {
} }
type CommitRequestDTO struct { type CommitRequestDTO struct {
DateIncoming string `json:"date_incoming"` // YYYY-MM-DD DateIncoming string `json:"date_incoming"` // YYYY-MM-DD
StoreID string `json:"store_id"` StoreID string `json:"store_id"`
SupplierID string `json:"supplier_id"` SupplierID string `json:"supplier_id"`
Comment string `json:"comment"` Comment string `json:"comment"`
IncomingDocNum string `json:"incoming_doc_num"`
} }
func (h *DraftsHandler) CommitDraft(c *gin.Context) { func (h *DraftsHandler) CommitDraft(c *gin.Context) {
@@ -196,7 +197,7 @@ func (h *DraftsHandler) CommitDraft(c *gin.Context) {
return return
} }
if err := h.service.UpdateDraftHeader(draftID, &storeID, &supplierID, date, req.Comment); err != nil { if err := h.service.UpdateDraftHeader(draftID, &storeID, &supplierID, date, req.Comment, req.IncomingDocNum); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update header: " + err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update header: " + err.Error()})
return return
} }

View File

@@ -4,14 +4,17 @@ import (
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap" "go.uber.org/zap"
"rmser/internal/services/drafts"
invService "rmser/internal/services/invoices" invService "rmser/internal/services/invoices"
"rmser/pkg/logger" "rmser/pkg/logger"
) )
type InvoiceHandler struct { type InvoiceHandler struct {
service *invService.Service service *invService.Service
draftsService *drafts.Service
} }
func NewInvoiceHandler(service *invService.Service) *InvoiceHandler { func NewInvoiceHandler(service *invService.Service) *InvoiceHandler {
@@ -35,7 +38,8 @@ func (h *InvoiceHandler) SendInvoice(c *gin.Context) {
return return
} }
docNum, err := h.service.SendInvoiceToRMS(req) userID := c.MustGet("userID").(uuid.UUID)
docNum, err := h.service.SendInvoiceToRMS(req, userID)
if err != nil { if err != nil {
logger.Log.Error("Ошибка отправки накладной", zap.Error(err)) logger.Log.Error("Ошибка отправки накладной", zap.Error(err))
// Возвращаем 502 Bad Gateway, т.к. ошибка скорее всего на стороне RMS // Возвращаем 502 Bad Gateway, т.к. ошибка скорее всего на стороне RMS
@@ -48,3 +52,36 @@ func (h *InvoiceHandler) SendInvoice(c *gin.Context) {
"created_number": docNum, "created_number": docNum,
}) })
} }
// GetInvoice godoc
// @Summary Получить детали накладной
// @Description Возвращает детали синхронизированной накладной по ID
// @Tags invoices
// @Produce json
// @Param id path string true "Invoice ID"
// @Success 200 {object} invService.InvoiceDetailsDTO
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Failure 500 {object} map[string]string
func (h *InvoiceHandler) GetInvoice(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID накладной обязателен"})
return
}
uuidID, err := uuid.Parse(id)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Неверный формат ID"})
return
}
dto, err := h.service.GetInvoice(uuidID)
if err != nil {
logger.Log.Error("Ошибка получения накладной", zap.Error(err), zap.String("id", id))
c.JSON(http.StatusInternalServerError, gin.H{"error": "Ошибка получения накладной"})
return
}
c.JSON(http.StatusOK, dto)
}

View File

@@ -132,3 +132,20 @@ func (h *OCRHandler) GetUnmatched(c *gin.Context) {
} }
c.JSON(http.StatusOK, items) c.JSON(http.StatusOK, items)
} }
func (h *OCRHandler) DiscardUnmatched(c *gin.Context) {
userID := c.MustGet("userID").(uuid.UUID)
rawName := c.Query("raw_name")
if rawName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "raw_name is required"})
return
}
if err := h.service.DiscardUnmatched(userID, rawName); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "discarded"})
}

View File

@@ -5,12 +5,10 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RMSer App</title> <title>RMSer App</title>
<!-- Скрипт Telegram WebApp (желательно добавить) -->
<script src="https://telegram.org/js/telegram-web-app.js"></script> <script src="https://telegram.org/js/telegram-web-app.js"></script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<!-- ВОТ ЗДЕСЬ ДОЛЖЕН БЫТЬ ПРАВИЛЬНЫЙ ПУТЬ -->
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ import { Providers } from "./components/layout/Providers";
import { AppLayout } from "./components/layout/AppLayout"; import { AppLayout } from "./components/layout/AppLayout";
import { OcrLearning } from "./pages/OcrLearning"; import { OcrLearning } from "./pages/OcrLearning";
import { InvoiceDraftPage } from "./pages/InvoiceDraftPage"; import { InvoiceDraftPage } from "./pages/InvoiceDraftPage";
import { InvoiceViewPage } from "./pages/InvoiceViewPage";
import { DraftsList } from "./pages/DraftsList"; import { DraftsList } from "./pages/DraftsList";
import { SettingsPage } from "./pages/SettingsPage"; import { SettingsPage } from "./pages/SettingsPage";
import { UNAUTHORIZED_EVENT } from "./services/api"; import { UNAUTHORIZED_EVENT } from "./services/api";
@@ -87,7 +88,8 @@ function App() {
<Route index element={<Navigate to="/invoices" replace />} /> <Route index element={<Navigate to="/invoices" replace />} />
<Route path="ocr" element={<OcrLearning />} /> <Route path="ocr" element={<OcrLearning />} />
<Route path="invoices" element={<DraftsList />} /> <Route path="invoices" element={<DraftsList />} />
<Route path="invoice/:id" element={<InvoiceDraftPage />} /> <Route path="invoice/draft/:id" element={<InvoiceDraftPage />} />
<Route path="invoice/view/:id" element={<InvoiceViewPage />} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Route> </Route>

View File

@@ -1,46 +1,117 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo, useEffect } from "react";
import { Card, Button, Flex, AutoComplete, InputNumber, Typography, Select, Divider } from 'antd'; import {
import { PlusOutlined } from '@ant-design/icons'; Card,
import { CatalogSelect } from './CatalogSelect'; Button,
import { CreateContainerModal } from '../invoices/CreateContainerModal'; // Импортируем модалку Flex,
import type { CatalogItem, UnmatchedItem, ProductSearchResult, ProductContainer } from '../../services/types'; AutoComplete,
Input,
InputNumber,
Typography,
Select,
Divider,
Popconfirm,
} from "antd";
import {
PlusOutlined,
DeleteOutlined,
EditOutlined,
CloseOutlined,
} from "@ant-design/icons";
import { CatalogSelect } from "./CatalogSelect";
import { CreateContainerModal } from "../invoices/CreateContainerModal";
import type {
CatalogItem,
UnmatchedItem,
ProductSearchResult,
ProductContainer,
ProductMatch,
} from "../../services/types";
const { Text } = Typography; const { Text } = Typography;
interface Props { interface Props {
catalog: CatalogItem[]; catalog: CatalogItem[];
unmatched?: UnmatchedItem[]; unmatched?: UnmatchedItem[];
onSave: (rawName: string, productId: string, quantity: number, containerId?: string) => void; onSave: (
rawName: string,
productId: string,
quantity: number,
containerId?: string
) => void;
onDeleteUnmatched?: (rawName: string) => void;
isLoading: boolean; isLoading: boolean;
initialValues?: ProductMatch; // Для редактирования
onCancelEdit?: () => void; // Для сброса режима редактирования
} }
export const AddMatchForm: React.FC<Props> = ({ catalog, unmatched = [], onSave, isLoading }) => { export const AddMatchForm: React.FC<Props> = ({
const [rawName, setRawName] = useState(''); catalog,
unmatched = [],
// Состояния товара onSave,
const [selectedProduct, setSelectedProduct] = useState<string | undefined>(undefined); onDeleteUnmatched,
const [selectedProductData, setSelectedProductData] = useState<ProductSearchResult | undefined>(undefined); isLoading,
initialValues,
onCancelEdit,
}) => {
// --- Состояния ---
const [rawName, setRawName] = useState("");
const [selectedProduct, setSelectedProduct] = useState<string | undefined>(
undefined
);
// Храним полный объект товара, чтобы достать из него фасовки и имя для отображения
const [selectedProductData, setSelectedProductData] = useState<
ProductSearchResult | undefined
>(undefined);
const [quantity, setQuantity] = useState<number | null>(1); const [quantity, setQuantity] = useState<number | null>(1);
const [selectedContainer, setSelectedContainer] = useState<string | null>(null); const [selectedContainer, setSelectedContainer] = useState<string | null>(
null
);
// Состояние модалки создания фасовки
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
// --- Эффект для инициализации полей при редактировании ---
useEffect(() => {
if (initialValues) {
// eslint-disable-next-line
setRawName(initialValues.raw_name || "");
const prodId = initialValues.product?.id;
setSelectedProduct(prodId);
// Важно: восстанавливаем объект продукта из initialValues
// Приводим тип, так как DTO могут немного отличаться, но нам нужны containers и name
const prodData = initialValues.product as unknown as ProductSearchResult;
setSelectedProductData(prodData);
setQuantity(Number(initialValues.quantity) || 1);
setSelectedContainer(initialValues.container?.id || null);
} else {
// РЕЖИМ СОЗДАНИЯ (Сброс)
setRawName("");
setSelectedProduct(undefined);
setSelectedProductData(undefined);
setQuantity(1);
setSelectedContainer(null);
}
}, [initialValues]);
// --- Вычисляемые значения --- // --- Вычисляемые значения ---
const unmatchedOptions = useMemo(() => { const unmatchedOptions = useMemo(() => {
return unmatched.map(item => ({ return unmatched.map((item) => ({
value: item.raw_name, value: item.raw_name,
label: item.count ? `${item.raw_name} (${item.count} шт)` : item.raw_name label: item.count ? `${item.raw_name} (${item.count} шт)` : item.raw_name,
})); }));
}, [unmatched]); }, [unmatched]);
// Активный продукт (из поиска или из старого каталога) // Активный продукт: либо то, что выбрали в поиске, либо то, что пришло из редактирования
const activeProduct = useMemo(() => { const activeProduct = useMemo(() => {
if (selectedProductData) return selectedProductData; if (selectedProductData) return selectedProductData;
// Фоллбэк: пытаемся найти в общем каталоге (если он загружен полностью, что редко)
if (selectedProduct && catalog.length > 0) { if (selectedProduct && catalog.length > 0) {
return catalog.find(item => item.id === selectedProduct) as unknown as ProductSearchResult; return catalog.find(
(item) => item.id === selectedProduct
) as unknown as ProductSearchResult;
} }
return undefined; return undefined;
}, [selectedProduct, selectedProductData, catalog]); }, [selectedProduct, selectedProductData, catalog]);
@@ -51,119 +122,201 @@ export const AddMatchForm: React.FC<Props> = ({ catalog, unmatched = [], onSave,
}, [activeProduct]); }, [activeProduct]);
// Базовая единица // Базовая единица
const baseUom = activeProduct?.main_unit?.name || activeProduct?.measure_unit || 'ед.'; const baseUom =
activeProduct?.main_unit?.name || activeProduct?.measure_unit || "ед.";
// Текстовое отображение текущей единицы // Текстовое отображение текущей единицы (для инпута количества)
const currentUomName = useMemo(() => { const currentUomName = useMemo(() => {
if (selectedContainer) { if (selectedContainer) {
const cont = containers.find(c => c.id === selectedContainer); const cont = containers.find((c) => c.id === selectedContainer);
return cont ? cont.name : baseUom; return cont ? cont.name : baseUom;
} }
return baseUom; return baseUom;
}, [selectedContainer, containers, baseUom]); }, [selectedContainer, containers, baseUom]);
const isButtonDisabled = !rawName.trim() || !selectedProduct || !quantity || quantity <= 0 || isLoading; const isButtonDisabled =
!rawName.trim() ||
!selectedProduct ||
quantity === null ||
quantity <= 0 ||
isLoading;
// --- Хендлеры --- // --- Хендлеры ---
const handleProductChange = (val: string, productObj?: ProductSearchResult) => { const handleProductChange = (
val: string,
productObj?: ProductSearchResult
) => {
setSelectedProduct(val); setSelectedProduct(val);
if (productObj) { if (productObj) {
setSelectedProductData(productObj); setSelectedProductData(productObj);
} }
// При смене товара сбрасываем фасовку
setSelectedContainer(null); setSelectedContainer(null);
}; };
const handleSubmit = () => { const handleSubmit = () => {
if (rawName.trim() && selectedProduct && quantity && quantity > 0) { let quantityValue = quantity;
onSave(rawName, selectedProduct, quantity, selectedContainer || undefined);
// Сброс формы // Защита от null/строк
setRawName(''); if (quantityValue === null || quantityValue === undefined) {
setSelectedProduct(undefined); quantityValue = 1;
setSelectedProductData(undefined); } else if (typeof quantityValue === "string") {
setQuantity(1); quantityValue = parseFloat(quantityValue);
setSelectedContainer(null); }
if (isNaN(quantityValue) || quantityValue <= 0) {
quantityValue = 1;
}
if (rawName.trim() && selectedProduct) {
onSave(
rawName,
selectedProduct,
quantityValue,
selectedContainer || undefined
);
// Если это не редактирование, очищаем форму
if (!initialValues) {
setRawName("");
setSelectedProduct(undefined);
setSelectedProductData(undefined);
setQuantity(1);
setSelectedContainer(null);
}
} }
}; };
// Обработка создания новой фасовки
const handleContainerCreated = (newContainer: ProductContainer) => { const handleContainerCreated = (newContainer: ProductContainer) => {
setIsModalOpen(false); setIsModalOpen(false);
// 1. Обновляем локальные данные продукта, добавляя новую фасовку в список // Добавляем созданную фасовку в локальный стейт продукта
if (selectedProductData) { if (selectedProductData) {
setSelectedProductData({ setSelectedProductData({
...selectedProductData, ...selectedProductData,
containers: [...(selectedProductData.containers || []), newContainer] containers: [...(selectedProductData.containers || []), newContainer],
}); });
} else if (activeProduct) { } else if (activeProduct) {
// Если продукт был взят из общего catalog, создаем локальную копию setSelectedProductData({
setSelectedProductData({ ...activeProduct,
...activeProduct, containers: [...(activeProduct.containers || []), newContainer],
containers: [...(activeProduct.containers || []), newContainer] });
});
} }
// 2. Сразу выбираем созданную фасовку // Выбираем новую фасовку
setSelectedContainer(newContainer.id); setSelectedContainer(newContainer.id);
}; };
const handleDeleteUnmatched = () => {
if (onDeleteUnmatched && rawName.trim()) {
onDeleteUnmatched(rawName);
setRawName("");
}
};
// Кнопка "Сбросить" вызывает внешний обработчик отмены редактирования
const handleCancel = () => {
if (onCancelEdit) {
onCancelEdit();
}
};
return ( return (
<Card title="Добавить новую связь" size="small" style={{ marginBottom: 16 }}> <Card
title={
initialValues ? "✏️ Редактирование связи" : " Добавить новую связь"
}
size="small"
style={{
marginBottom: 16,
borderColor: initialValues ? "#1890ff" : undefined, // Подсветка при редактировании
background: initialValues ? "#f0f5ff" : undefined,
}}
>
<Flex vertical gap="middle"> <Flex vertical gap="middle">
{/* Поле текста из чека */} {/* Поле: Текст из чека */}
<div> <div>
<div style={{ marginBottom: 4, fontSize: 12, color: '#888' }}>Текст из чека:</div> <div style={{ marginBottom: 4, fontSize: 12, color: "#888" }}>
<AutoComplete Текст из чека (Raw Name):
placeholder="Например: Масло слив. коробка" </div>
options={unmatchedOptions} <div style={{ display: "flex", gap: 8 }}>
value={rawName} <AutoComplete
onChange={setRawName} options={unmatchedOptions}
filterOption={(inputValue, option) => value={rawName}
!inputValue || (option?.value as string).toLowerCase().includes(inputValue.toLowerCase()) onChange={setRawName}
} filterOption={(inputValue, option) =>
style={{ width: '100%' }} !inputValue ||
/> (option?.value as string)
.toLowerCase()
.includes(inputValue.toLowerCase())
}
style={{ flex: 1 }}
>
<Input.TextArea
placeholder="Например: Масло слив. коробка"
autoSize={{ minRows: 1, maxRows: 4 }}
/>
</AutoComplete>
{onDeleteUnmatched && !initialValues && (
<Popconfirm
title="Удалить строку?"
description="Удалить из списка нераспознанных?"
onConfirm={handleDeleteUnmatched}
okText="Да"
cancelText="Нет"
>
<Button
danger
icon={<DeleteOutlined />}
disabled={!rawName.trim()}
title="Удалить мусорную строку"
/>
</Popconfirm>
)}
</div>
</div> </div>
{/* Выбор товара (Поиск) */} {/* Поле: Товар */}
<div> <div>
<div style={{ marginBottom: 4, fontSize: 12, color: '#888' }}>Товар в iiko:</div> <div style={{ marginBottom: 4, fontSize: 12, color: "#888" }}>
Товар в iiko:
</div>
<CatalogSelect <CatalogSelect
value={selectedProduct} value={selectedProduct}
onChange={handleProductChange} onChange={handleProductChange}
disabled={isLoading} disabled={isLoading}
initialProduct={activeProduct} // Передаем полный объект для правильного отображения!
/> />
</div> </div>
{/* Выбор фасовки (появляется только если есть активный товар) */} {/* Поле: Фасовка */}
{activeProduct && ( {activeProduct && (
<div> <div>
<div style={{ marginBottom: 4, fontSize: 12, color: '#888' }}>Единица измерения / Фасовка:</div> <div style={{ marginBottom: 4, fontSize: 12, color: "#888" }}>
Единица измерения / Фасовка:
</div>
<Select <Select
style={{ width: '100%' }} style={{ width: "100%" }}
value={selectedContainer} value={selectedContainer}
onChange={setSelectedContainer} onChange={setSelectedContainer}
placeholder="Выберите ед. измерения" placeholder="Выберите ед. измерения"
options={[ options={[
{ value: null, label: `Базовая единица (${baseUom})` }, { value: null, label: `Базовая единица (${baseUom})` },
...containers.map(c => ({ ...containers.map((c) => ({
value: c.id, value: c.id,
label: `${c.name} (=${Number(c.count)} ${baseUom})` label: `${c.name} (=${Number(c.count)} ${baseUom})`,
})) })),
]} ]}
// Рендерим кнопку добавления внизу выпадающего списка
dropdownRender={(menu) => ( dropdownRender={(menu) => (
<> <>
{menu} {menu}
<Divider style={{ margin: '4px 0' }} /> <Divider style={{ margin: "4px 0" }} />
<Button <Button
type="text" type="text"
block block
icon={<PlusOutlined />} icon={<PlusOutlined />}
onClick={() => setIsModalOpen(true)} onClick={() => setIsModalOpen(true)}
style={{ textAlign: 'left' }} style={{ textAlign: "left" }}
> >
Добавить фасовку Добавить фасовку
</Button> </Button>
@@ -173,17 +326,17 @@ export const AddMatchForm: React.FC<Props> = ({ catalog, unmatched = [], onSave,
</div> </div>
)} )}
{/* Количество */} {/* Поле: Количество */}
<div> <div>
<div style={{ marginBottom: 4, fontSize: 12, color: '#888' }}> <div style={{ marginBottom: 4, fontSize: 12, color: "#888" }}>
Количество (в выбранных единицах): Коэффициент (сколько товара в одной позиции чека):
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<InputNumber <InputNumber
min={0.001} min={0.001}
step={selectedContainer ? 1 : 0.1} step={selectedContainer ? 1 : 0.1}
value={quantity} value={quantity}
onChange={setQuantity} onChange={(val) => setQuantity(Number(val))}
style={{ flex: 1 }} style={{ flex: 1 }}
placeholder="1" placeholder="1"
/> />
@@ -191,20 +344,32 @@ export const AddMatchForm: React.FC<Props> = ({ catalog, unmatched = [], onSave,
</div> </div>
</div> </div>
{/* Кнопка сохранения */} {/* Кнопки действий */}
<Button <div style={{ display: "flex", gap: 8, marginTop: 4 }}>
type="primary" <Button
icon={<PlusOutlined />} type="primary"
onClick={handleSubmit} icon={initialValues ? <EditOutlined /> : <PlusOutlined />}
loading={isLoading} onClick={handleSubmit}
disabled={isButtonDisabled} loading={isLoading}
block disabled={isButtonDisabled}
> block
Сохранить связь >
</Button> {initialValues ? "Сохранить изменения" : "Добавить связь"}
</Button>
{initialValues && (
<Button
onClick={handleCancel}
icon={<CloseOutlined />}
title="Отменить редактирование"
>
Отмена
</Button>
)}
</div>
</Flex> </Flex>
{/* Модальное окно создания фасовки */} {/* Модалка создания фасовки */}
{activeProduct && ( {activeProduct && (
<CreateContainerModal <CreateContainerModal
visible={isModalOpen} visible={isModalOpen}

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from "react";
import { Select, Spin } from 'antd'; import { Select, Spin } from "antd";
import { api } from '../../services/api'; import { api } from "../../services/api";
import type { CatalogItem, ProductSearchResult } from '../../services/types'; import type { CatalogItem, ProductSearchResult } from "../../services/types";
interface Props { interface Props {
value?: string; value?: string;
@@ -17,7 +17,12 @@ interface SelectOption {
data: ProductSearchResult; data: ProductSearchResult;
} }
export const CatalogSelect: React.FC<Props> = ({ value, onChange, disabled, initialProduct }) => { export const CatalogSelect: React.FC<Props> = ({
value,
onChange,
disabled,
initialProduct,
}) => {
const [options, setOptions] = useState<SelectOption[]>([]); const [options, setOptions] = useState<SelectOption[]>([]);
const [fetching, setFetching] = useState(false); const [fetching, setFetching] = useState(false);
@@ -25,27 +30,31 @@ export const CatalogSelect: React.FC<Props> = ({ value, onChange, disabled, init
useEffect(() => { useEffect(() => {
if (initialProduct && initialProduct.id === value) { if (initialProduct && initialProduct.id === value) {
const name = initialProduct.name; const name = initialProduct.name;
const code = initialProduct.code; const code = initialProduct.code;
setOptions([{ setOptions([
label: code ? `${name} [${code}]` : name, {
value: initialProduct.id, label: code ? `${name} [${code}]` : name,
data: initialProduct as ProductSearchResult value: initialProduct.id,
}]); data: initialProduct as ProductSearchResult,
},
]);
} }
}, [initialProduct, value]); }, [initialProduct, value]);
const fetchProducts = async (search: string) => { const fetchProducts = async (search: string) => {
if (!search) return; if (!search) {
setOptions([]);
return;
}
setFetching(true); setFetching(true);
setOptions([]); // Не сбрасываем options сразу, чтобы не моргало
try { try {
const results = await api.searchProducts(search); const results = await api.searchProducts(search);
const newOptions = results.map(item => ({ const newOptions = results.map((item) => ({
label: item.code ? `${item.name} [${item.code}]` : item.name, label: item.code ? `${item.name} [${item.code}]` : item.name,
value: item.id, value: item.id,
data: item data: item,
})); }));
setOptions(newOptions); setOptions(newOptions);
} catch (e) { } catch (e) {
@@ -59,15 +68,20 @@ export const CatalogSelect: React.FC<Props> = ({ value, onChange, disabled, init
if (fetchRef.current !== null) { if (fetchRef.current !== null) {
window.clearTimeout(fetchRef.current); window.clearTimeout(fetchRef.current);
} }
// Запускаем поиск только если введено хотя бы 2 символа
if (val.length < 2) {
return;
}
fetchRef.current = window.setTimeout(() => { fetchRef.current = window.setTimeout(() => {
fetchProducts(val); fetchProducts(val);
}, 500); }, 500);
}; };
// Исправлено: добавлен | undefined для option const handleChange = (
const handleChange = (val: string, option: SelectOption | SelectOption[] | undefined) => { val: string,
option: SelectOption | SelectOption[] | undefined
) => {
if (onChange) { if (onChange) {
// В single mode option - это один объект или undefined
const opt = Array.isArray(option) ? option[0] : option; const opt = Array.isArray(option) ? option[0] : option;
onChange(val, opt?.data); onChange(val, opt?.data);
} }
@@ -84,9 +98,15 @@ export const CatalogSelect: React.FC<Props> = ({ value, onChange, disabled, init
value={value} value={value}
onChange={handleChange} onChange={handleChange}
disabled={disabled} disabled={disabled}
style={{ width: '100%' }} style={{ width: "100%" }}
listHeight={256} listHeight={256}
allowClear allowClear
// При очистке сбрасываем опции, чтобы при следующем клике не вылезал старый товар
onClear={() => setOptions([])}
// При клике (фокусе), если поле пустое - можно показать дефолтные опции или оставить пустым
onFocus={() => {
if (!value) setOptions([]);
}}
/> />
); );
}; };

View File

@@ -1,21 +1,34 @@
import React from 'react'; import React from "react";
import { List, Typography, Tag, Input, Empty } from 'antd'; import { List, Typography, Tag, Input, Empty, Button, Popconfirm } from "antd";
import { ArrowRightOutlined, SearchOutlined } from '@ant-design/icons'; import {
import type { ProductMatch } from '../../services/types'; ArrowRightOutlined,
SearchOutlined,
DeleteOutlined,
EditOutlined,
} from "@ant-design/icons";
import type { ProductMatch } from "../../services/types";
const { Text } = Typography; const { Text } = Typography;
interface Props { interface Props {
matches: ProductMatch[]; matches: ProductMatch[];
onDeleteMatch?: (rawName: string) => void;
onEditMatch?: (match: ProductMatch) => void;
isDeleting?: boolean;
} }
export const MatchList: React.FC<Props> = ({ matches }) => { export const MatchList: React.FC<Props> = ({
const [searchText, setSearchText] = React.useState(''); matches,
onDeleteMatch,
onEditMatch,
isDeleting = false,
}) => {
const [searchText, setSearchText] = React.useState("");
const filteredData = matches.filter(item => { const filteredData = matches.filter((item) => {
const raw = (item.raw_name || '').toLowerCase(); const raw = (item.raw_name || "").toLowerCase();
const prod = item.product; const prod = item.product;
const prodName = (prod?.name || '').toLowerCase(); const prodName = (prod?.name || "").toLowerCase();
const search = searchText.toLowerCase(); const search = searchText.toLowerCase();
return raw.includes(search) || prodName.includes(search); return raw.includes(search) || prodName.includes(search);
}); });
@@ -24,10 +37,10 @@ export const MatchList: React.FC<Props> = ({ matches }) => {
<div> <div>
<Input <Input
placeholder="Поиск по связям..." placeholder="Поиск по связям..."
prefix={<SearchOutlined style={{ color: '#ccc' }} />} prefix={<SearchOutlined style={{ color: "#ccc" }} />}
style={{ marginBottom: 12 }} style={{ marginBottom: 12 }}
value={searchText} value={searchText}
onChange={e => setSearchText(e.target.value)} onChange={(e) => setSearchText(e.target.value)}
allowClear allowClear
/> />
@@ -38,38 +51,91 @@ export const MatchList: React.FC<Props> = ({ matches }) => {
pagination={{ pageSize: 10, size: "small", simple: true }} pagination={{ pageSize: 10, size: "small", simple: true }}
renderItem={(item) => { renderItem={(item) => {
// Унификация полей (только snake_case) // Унификация полей (только snake_case)
const rawName = item.raw_name || 'Без названия'; const rawName = item.raw_name || "Без названия";
const product = item.product; const product = item.product;
const productName = product?.name || 'Товар не найден'; const productName = product?.name || "Товар не найден";
const qty = item.quantity || 1; const qty = item.quantity || 1;
// Логика отображения Единицы или Фасовки // Логика отображения Единицы или Фасовки
const container = item.container; const container = item.container;
let displayUnit = ''; let displayUnit = "";
if (container) { if (container) {
// Если есть фасовка: "Пачка 180г" // Если есть фасовка: "Пачка 180г"
displayUnit = container.name; displayUnit = container.name;
} else { } else {
// Иначе базовая ед.: "кг" // Иначе базовая ед.: "кг"
displayUnit = product?.measure_unit || 'ед.'; displayUnit = product?.measure_unit || "ед.";
} }
return ( return (
<List.Item style={{ background: '#fff', padding: 12, marginBottom: 8, borderRadius: 8 }}> <List.Item
<div style={{ marginBottom: 4 }}> style={{
background: "#fff",
padding: 12,
marginBottom: 8,
borderRadius: 8,
display: "flex",
flexDirection: "column",
}}
>
<div style={{ flex: 1 }}>
<div style={{ marginBottom: 4 }}>
<Tag color="geekblue">Чек</Tag> <Tag color="geekblue">Чек</Tag>
<Text strong>{rawName}</Text> <Text strong>{rawName}</Text>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: '#888' }}> <div
style={{
display: "flex",
alignItems: "center",
gap: 8,
color: "#888",
}}
>
<ArrowRightOutlined /> <ArrowRightOutlined />
<Text> <Text>
{productName} {productName}
<Text strong style={{ color: '#555', marginLeft: 6 }}> <Text strong style={{ color: "#555", marginLeft: 6 }}>
x {qty} {displayUnit} x {qty} {displayUnit}
</Text> </Text>
</Text> </Text>
</div> </div>
</div>
{(onDeleteMatch || onEditMatch) && (
<div
style={{ display: "flex", justifyContent: "space-between" }}
>
{onEditMatch && (
<Button
type="text"
icon={<EditOutlined />}
onClick={() => onEditMatch(item)}
size="small"
>
Редактировать
</Button>
)}
{onDeleteMatch && (
<Popconfirm
title="Удалить связь?"
description="Это действие нельзя отменить"
onConfirm={() => onDeleteMatch(rawName)}
okText="Да"
cancelText="Нет"
>
<Button
type="text"
danger
icon={<DeleteOutlined />}
loading={isDeleting}
size="small"
>
Удалить
</Button>
</Popconfirm>
)}
</div>
)}
</List.Item> </List.Item>
); );
}} }}

View File

@@ -36,6 +36,29 @@ export const useOcr = () => {
}, },
}); });
const deleteMatchMutation = useMutation({
mutationFn: (rawName: string) => api.deleteMatch(rawName),
onSuccess: () => {
message.success('Связь удалена');
queryClient.invalidateQueries({ queryKey: ['matches'] });
queryClient.invalidateQueries({ queryKey: ['unmatched'] });
},
onError: () => {
message.error('Ошибка при удалении связи');
},
});
const deleteUnmatchedMutation = useMutation({
mutationFn: (rawName: string) => api.deleteUnmatched(rawName),
onSuccess: () => {
message.success('Нераспознанная строка удалена');
queryClient.invalidateQueries({ queryKey: ['unmatched'] });
},
onError: () => {
message.error('Ошибка при удалении нераспознанной строки');
},
});
return { return {
catalog: catalogQuery.data || [], catalog: catalogQuery.data || [],
matches: matchesQuery.data || [], matches: matchesQuery.data || [],
@@ -44,5 +67,9 @@ export const useOcr = () => {
isError: catalogQuery.isError || matchesQuery.isError, isError: catalogQuery.isError || matchesQuery.isError,
createMatch: createMatchMutation.mutate, createMatch: createMatchMutation.mutate,
isCreating: createMatchMutation.isPending, isCreating: createMatchMutation.isPending,
deleteMatch: deleteMatchMutation.mutate,
isDeletingMatch: deleteMatchMutation.isPending,
deleteUnmatched: deleteUnmatchedMutation.mutate,
isDeletingUnmatched: deleteUnmatchedMutation.isPending,
}; };
}; };

View File

@@ -2,22 +2,18 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import { List, Typography, Tag, Spin, Empty, DatePicker, Flex } from "antd";
List,
Typography,
Tag,
Spin,
Empty,
DatePicker,
Flex,
message,
} from "antd";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { import {
ArrowRightOutlined, ArrowRightOutlined,
ThunderboltFilled,
HistoryOutlined, HistoryOutlined,
FileTextOutlined, CheckCircleOutlined,
DeleteOutlined,
PlusOutlined,
ExclamationCircleOutlined,
LoadingOutlined,
CloseCircleOutlined,
StopOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import dayjs, { Dayjs } from "dayjs"; import dayjs, { Dayjs } from "dayjs";
import { api } from "../services/api"; import { api } from "../services/api";
@@ -29,9 +25,9 @@ const { RangePicker } = DatePicker;
export const DraftsList: React.FC = () => { export const DraftsList: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
// Состояние фильтра дат: по умолчанию последние 30 дней // Состояние фильтра дат: по умолчанию последние 7 дней
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs]>([ const [dateRange, setDateRange] = useState<[Dayjs, Dayjs]>([
dayjs().subtract(30, "day"), dayjs().subtract(7, "day"),
dayjs(), dayjs(),
]); ]);
@@ -51,6 +47,9 @@ export const DraftsList: React.FC = () => {
dateRange[0].format("YYYY-MM-DD"), dateRange[0].format("YYYY-MM-DD"),
dateRange[1].format("YYYY-MM-DD") dateRange[1].format("YYYY-MM-DD")
), ),
staleTime: 0,
refetchOnMount: true,
refetchOnWindowFocus: true,
}); });
const getStatusTag = (item: UnifiedInvoice) => { const getStatusTag = (item: UnifiedInvoice) => {
@@ -64,26 +63,64 @@ export const DraftsList: React.FC = () => {
switch (item.status) { switch (item.status) {
case "PROCESSING": case "PROCESSING":
return <Tag color="blue">Обработка</Tag>; return (
<Tag icon={<LoadingOutlined />} color="blue">
Обработка
</Tag>
);
case "READY_TO_VERIFY": case "READY_TO_VERIFY":
return <Tag color="orange">Проверка</Tag>; return (
<Tag icon={<ExclamationCircleOutlined />} color="orange">
Проверка
</Tag>
);
case "COMPLETED": case "COMPLETED":
return <Tag color="green">Готово</Tag>; return (
<Tag icon={<CheckCircleOutlined />} color="green">
Готово
</Tag>
);
case "ERROR": case "ERROR":
return <Tag color="red">Ошибка</Tag>; return (
<Tag icon={<CloseCircleOutlined />} color="red">
Ошибка
</Tag>
);
case "CANCELED": case "CANCELED":
return <Tag color="default">Отменен</Tag>; return (
<Tag icon={<StopOutlined />} color="default">
Отменен
</Tag>
);
case "NEW":
return (
<Tag icon={<PlusOutlined />} color="blue">
Новый
</Tag>
);
case "PROCESSED":
return (
<Tag icon={<CheckCircleOutlined />} color="green">
Обработан
</Tag>
);
case "DELETED":
return (
<Tag icon={<DeleteOutlined />} color="red">
Удален
</Tag>
);
default: default:
return <Tag>{item.status}</Tag>; return <Tag>{item.status}</Tag>;
} }
}; };
const handleInvoiceClick = (item: UnifiedInvoice) => { const handleInvoiceClick = (item: UnifiedInvoice) => {
if (item.type === "SYNCED") { if (item.type === "DRAFT") {
message.info("История доступна только для просмотра"); navigate("/invoice/draft/" + item.id);
return; } else if (item.type === "SYNCED") {
navigate("/invoice/view/" + item.id);
} }
navigate(`/invoice/${item.id}`);
}; };
if (isError) { if (isError) {
@@ -143,7 +180,7 @@ export const DraftsList: React.FC = () => {
padding: 12, padding: 12,
marginBottom: 10, marginBottom: 10,
borderRadius: 12, borderRadius: 12,
cursor: isSynced ? "default" : "pointer", cursor: "pointer",
border: isSynced ? "1px solid #f0f0f0" : "1px solid #e6f7ff", border: isSynced ? "1px solid #f0f0f0" : "1px solid #e6f7ff",
boxShadow: "0 2px 4px rgba(0,0,0,0.02)", boxShadow: "0 2px 4px rgba(0,0,0,0.02)",
display: "block", display: "block",
@@ -158,34 +195,44 @@ export const DraftsList: React.FC = () => {
{item.document_number || "Без номера"} {item.document_number || "Без номера"}
</Text> </Text>
{item.is_app_created && ( {item.is_app_created && (
<ThunderboltFilled <span title="Создано в RMSer">📱</span>
style={{ color: "#faad14" }}
title="Создано в RMSer"
/>
)} )}
</Flex> </Flex>
<Flex vertical gap={2}>
<Text type="secondary" style={{ fontSize: 13 }}>
{item.items_count} поз.
</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
{dayjs(item.date_incoming).format("DD.MM.YYYY")}
</Text>
</Flex>
{item.incoming_number && ( {item.incoming_number && (
<Text type="secondary" style={{ fontSize: 12 }}> <Text type="secondary" style={{ fontSize: 12 }}>
Вх. {item.incoming_number} Вх. {item.incoming_number}
</Text> </Text>
)} )}
</Flex> </Flex>
{getStatusTag(item)} <Flex vertical align="start" gap={4}>
{getStatusTag(item)}
{isSynced && item.items_preview && (
<div>
{item.items_preview
.split(", ")
.map((previewItem, idx) => (
<div
key={idx}
style={{ fontSize: 12, color: "#666" }}
>
{previewItem}
</div>
))}
</div>
)}
</Flex>
</Flex> </Flex>
<Flex justify="space-between" style={{ marginTop: 4 }}> <Flex justify="space-between" align="center">
<Flex gap={8} align="center"> <div></div>
<FileTextOutlined style={{ color: "#888" }} />
<Text type="secondary" style={{ fontSize: 13 }}>
{item.items_count} поз.
</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
{dayjs(item.date_incoming).format("DD.MM.YYYY")}
</Text>
</Flex>
{item.store_name && ( {item.store_name && (
<Tag <Tag
style={{ style={{

View File

@@ -159,6 +159,17 @@ export const InvoiceDraftPage: React.FC = () => {
form.setFieldValue("supplier_id", draft.supplier_id); form.setFieldValue("supplier_id", draft.supplier_id);
if (!currentValues.comment && draft.comment) if (!currentValues.comment && draft.comment)
form.setFieldValue("comment", draft.comment); form.setFieldValue("comment", draft.comment);
// Инициализация входящего номера
if (
!currentValues.incoming_document_number &&
draft.incoming_document_number
)
form.setFieldValue(
"incoming_document_number",
draft.incoming_document_number
);
if (!currentValues.date_incoming) if (!currentValues.date_incoming)
form.setFieldValue( form.setFieldValue(
"date_incoming", "date_incoming",
@@ -204,6 +215,7 @@ export const InvoiceDraftPage: React.FC = () => {
store_id: values.store_id, store_id: values.store_id,
supplier_id: values.supplier_id, supplier_id: values.supplier_id,
comment: values.comment || "", comment: values.comment || "",
incoming_document_number: values.incoming_document_number || "",
}); });
} catch { } catch {
message.error("Заполните обязательные поля (Склад, Поставщик)"); message.error("Заполните обязательные поля (Склад, Поставщик)");
@@ -352,6 +364,18 @@ export const InvoiceDraftPage: React.FC = () => {
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={12}>
{/* Входящий номер */}
<Form.Item
label="Входящий номер"
name="incoming_document_number"
style={{ marginBottom: 8 }}
>
<Input placeholder="№ Документа" size="middle" />
</Form.Item>
</Col>
</Row>
<Row gutter={10}>
<Col span={24}>
<Form.Item <Form.Item
label="Склад" label="Склад"
name="store_id" name="store_id"

View File

@@ -0,0 +1,223 @@
// src/pages/InvoiceViewPage.tsx
import React, { useState } from "react";
import { useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { Spin, Alert, Button, Table, Typography, Tag, Image } from "antd";
import {
ArrowLeftOutlined,
FileImageOutlined,
HistoryOutlined,
} from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
import { api, getStaticUrl } from "../services/api";
import type { DraftStatus } from "../services/types";
const { Title, Text } = Typography;
export const InvoiceViewPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
// Состояние для просмотра фото чека
const [previewVisible, setPreviewVisible] = useState(false);
// Запрос данных накладной
const {
data: invoice,
isLoading,
isError,
} = useQuery({
queryKey: ["invoice", id],
queryFn: () => api.getInvoice(id!),
enabled: !!id,
});
const getStatusTag = (status: DraftStatus) => {
switch (status) {
case "PROCESSING":
return <Tag color="blue">Обработка</Tag>;
case "READY_TO_VERIFY":
return <Tag color="orange">Проверка</Tag>;
case "COMPLETED":
return (
<Tag icon={<HistoryOutlined />} color="success">
Синхронизировано
</Tag>
);
case "ERROR":
return <Tag color="red">Ошибка</Tag>;
case "CANCELED":
return <Tag color="default">Отменен</Tag>;
default:
return <Tag>{status}</Tag>;
}
};
if (isLoading) {
return (
<div style={{ textAlign: "center", padding: 50 }}>
<Spin size="large" />
</div>
);
}
if (isError || !invoice) {
return <Alert type="error" message="Ошибка загрузки накладной" />;
}
const columns = [
{
title: "Товар",
dataIndex: "name",
key: "name",
},
{
title: "Кол-во",
dataIndex: "quantity",
key: "quantity",
align: "right" as const,
},
{
title: "Цена",
dataIndex: "price",
key: "price",
align: "right" as const,
render: (price: number) =>
price.toLocaleString("ru-RU", {
style: "currency",
currency: "RUB",
}),
},
{
title: "Сумма",
dataIndex: "total",
key: "total",
align: "right" as const,
render: (total: number) =>
total.toLocaleString("ru-RU", {
style: "currency",
currency: "RUB",
}),
},
];
const totalSum = (invoice.items || []).reduce(
(acc, item) => acc + item.total,
0
);
return (
<div style={{ paddingBottom: 20 }}>
{/* Header */}
<div
style={{
marginBottom: 16,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
flex: 1,
minWidth: 0,
}}
>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate("/invoices")}
size="small"
/>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 4,
}}
>
<Title level={4} style={{ margin: 0 }}>
{invoice.number}
</Title>
<Text type="secondary" style={{ fontSize: 12 }}>
{invoice.date} {invoice.supplier.name}
</Text>
</div>
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
{getStatusTag(invoice.status)}
{/* Кнопка просмотра чека (только если есть URL) */}
{invoice.photo_url && (
<Button
icon={<FileImageOutlined />}
onClick={() => setPreviewVisible(true)}
size="small"
>
Чек
</Button>
)}
</div>
</div>
{/* Таблица товаров */}
<div
style={{
background: "#fff",
padding: 16,
borderRadius: 8,
marginBottom: 16,
}}
>
<Title level={5} style={{ marginBottom: 16 }}>
Товары ({(invoice.items || []).length} поз.)
</Title>
<Table
columns={columns}
dataSource={invoice.items || []}
pagination={false}
rowKey="name"
size="small"
summary={() => (
<Table.Summary.Row>
<Table.Summary.Cell index={0} colSpan={3}>
<Text strong>Итого:</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={3} align="right">
<Text strong>
{totalSum.toLocaleString("ru-RU", {
style: "currency",
currency: "RUB",
})}
</Text>
</Table.Summary.Cell>
</Table.Summary.Row>
)}
/>
</div>
{/* Скрытый компонент для просмотра изображения */}
{invoice.photo_url && (
<div style={{ display: "none" }}>
<Image.PreviewGroup
preview={{
visible: previewVisible,
onVisibleChange: (vis) => setPreviewVisible(vis),
movable: true,
scaleStep: 0.5,
}}
>
<Image src={getStaticUrl(invoice.photo_url)} />
</Image.PreviewGroup>
</div>
)}
</div>
);
};

View File

@@ -1,8 +1,8 @@
import React from 'react'; import React from "react";
import { Spin, Alert } from 'antd'; import { Spin, Alert } from "antd";
import { useOcr } from '../hooks/useOcr'; import { useOcr } from "../hooks/useOcr";
import { AddMatchForm } from '../components/ocr/AddMatchForm'; import { AddMatchForm } from "../components/ocr/AddMatchForm";
import { MatchList } from '../components/ocr/MatchList'; import { MatchList } from "../components/ocr/MatchList";
export const OcrLearning: React.FC = () => { export const OcrLearning: React.FC = () => {
const { const {
@@ -12,21 +12,48 @@ export const OcrLearning: React.FC = () => {
isLoading, isLoading,
isError, isError,
createMatch, createMatch,
isCreating isCreating,
deleteMatch,
isDeletingMatch,
deleteUnmatched,
} = useOcr(); } = useOcr();
// Состояние для редактирования
const [editingMatch, setEditingMatch] = React.useState<string | null>(null);
// Найти редактируемую связь
const currentEditingMatch = React.useMemo(() => {
if (!editingMatch) return undefined;
return matches.find((match) => match.raw_name === editingMatch);
}, [editingMatch, matches]);
if (isLoading && matches.length === 0) { if (isLoading && matches.length === 0) {
return ( return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '50vh', flexDirection: 'column', gap: 16 }}> <div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "50vh",
flexDirection: "column",
gap: 16,
}}
>
<Spin size="large" /> <Spin size="large" />
<span style={{ color: '#888' }}>Загрузка справочников...</span> <span style={{ color: "#888" }}>Загрузка справочников...</span>
</div> </div>
); );
} }
if (isError) { if (isError) {
return ( return (
<Alert message="Ошибка" description="Не удалось загрузить данные." type="error" showIcon style={{ margin: 16 }} /> <Alert
message="Ошибка"
description="Не удалось загрузить данные."
type="error"
showIcon
style={{ margin: 16 }}
/>
); );
} }
@@ -36,17 +63,42 @@ export const OcrLearning: React.FC = () => {
catalog={catalog} catalog={catalog}
unmatched={unmatched} unmatched={unmatched}
// Передаем containerId // Передаем containerId
onSave={(raw, prodId, qty, contId) => createMatch({ onSave={(raw, prodId, qty, contId) => {
raw_name: raw, if (currentEditingMatch) {
product_id: prodId, // Обновление существующей связи
quantity: qty, createMatch({
container_id: contId raw_name: raw,
})} product_id: prodId,
quantity: qty,
container_id: contId,
});
setEditingMatch(null);
} else {
// Создание новой связи
createMatch({
raw_name: raw,
product_id: prodId,
quantity: qty,
container_id: contId,
});
}
}}
onDeleteUnmatched={deleteUnmatched}
isLoading={isCreating} isLoading={isCreating}
initialValues={currentEditingMatch}
onCancelEdit={() => setEditingMatch(null)}
/>
<h3 style={{ marginLeft: 4, marginBottom: 12 }}>
Обученные позиции ({matches.length})
</h3>
<MatchList
matches={matches}
onDeleteMatch={deleteMatch}
onEditMatch={(match) => {
setEditingMatch(match.raw_name);
}}
isDeleting={isDeletingMatch}
/> />
<h3 style={{ marginLeft: 4, marginBottom: 12 }}>Обученные позиции ({matches.length})</h3>
<MatchList matches={matches} />
</div> </div>
); );
}; };

View File

@@ -24,7 +24,8 @@ import type {
DictionariesResponse, DictionariesResponse,
UnifiedInvoice, UnifiedInvoice,
ServerUser, ServerUser,
UserRole UserRole,
InvoiceDetails
} from './types'; } from './types';
// Базовый URL // Базовый URL
@@ -134,6 +135,18 @@ export const api = {
const { data } = await apiClient.post<{ status: string }>('/ocr/match', payload); const { data } = await apiClient.post<{ status: string }>('/ocr/match', payload);
return data; return data;
}, },
deleteMatch: async (rawName: string): Promise<{ status: string }> => {
const { data } = await apiClient.delete<{ status: string }>('/ocr/match', {
params: { raw_name: rawName }
});
return data;
},
deleteUnmatched: async (rawName: string): Promise<{ status: string }> => {
const { data } = await apiClient.delete<{ status: string }>('/ocr/unmatched', {
params: { raw_name: rawName }
});
return data;
},
createInvoice: async (payload: CreateInvoiceRequest): Promise<InvoiceResponse> => { createInvoice: async (payload: CreateInvoiceRequest): Promise<InvoiceResponse> => {
const { data } = await apiClient.post<InvoiceResponse>('/invoices/send', payload); const { data } = await apiClient.post<InvoiceResponse>('/invoices/send', payload);
@@ -233,4 +246,9 @@ export const api = {
const { data } = await apiClient.delete<{ status: string }>(`/settings/users/${userId}`); const { data } = await apiClient.delete<{ status: string }>(`/settings/users/${userId}`);
return data; return data;
}, },
getInvoice: async (id: string): Promise<InvoiceDetails> => {
const { data } = await apiClient.get<InvoiceDetails>(`/invoices/${id}`);
return data;
},
}; };

View File

@@ -166,7 +166,7 @@ export interface ProductGroup {
// --- Черновик Накладной (Draft) --- // --- Черновик Накладной (Draft) ---
export type DraftStatus = 'PROCESSING' | 'READY_TO_VERIFY' | 'COMPLETED' | 'ERROR' | 'CANCELED'; export type DraftStatus = 'PROCESSING' | 'READY_TO_VERIFY' | 'COMPLETED' | 'ERROR' | 'CANCELED' | 'NEW' | 'PROCESSED' | 'DELETED';
export interface DraftItem { export interface DraftItem {
id: UUID; id: UUID;
@@ -205,6 +205,7 @@ export interface DraftInvoice {
id: UUID; id: UUID;
status: DraftStatus; status: DraftStatus;
document_number: string; document_number: string;
incoming_document_number?: string
date_incoming: string | null; // YYYY-MM-DD date_incoming: string | null; // YYYY-MM-DD
store_id: UUID | null; store_id: UUID | null;
supplier_id: UUID | null; supplier_id: UUID | null;
@@ -228,6 +229,7 @@ export interface CommitDraftRequest {
store_id: UUID; store_id: UUID;
supplier_id: UUID; supplier_id: UUID;
comment: string; comment: string;
incoming_document_number?: string;
} }
export interface MainUnit { export interface MainUnit {
id: UUID; id: UUID;
@@ -249,4 +251,21 @@ export interface UnifiedInvoice {
store_name?: string; store_name?: string;
created_at: string; created_at: string;
is_app_created: boolean; // Создано ли через наше приложение is_app_created: boolean; // Создано ли через наше приложение
items_preview: string; // Краткое содержание товаров
photo_url: string | null; // Ссылка на фото чека
}
export interface InvoiceDetails {
id: UUID;
number: string;
date: string;
status: DraftStatus;
supplier: Supplier;
items: {
name: string;
quantity: number;
price: number;
total: number;
}[];
photo_url: string | null;
} }