mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
Добавил черновики накладных и OCR через Яндекс. LLM для расшифровки универсальный
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,5 +1,9 @@
|
||||
# Python virtual environment
|
||||
.venv
|
||||
*.py
|
||||
.env
|
||||
pack_go_files.py
|
||||
pack_py_files.py
|
||||
pack_react_files.py
|
||||
project_dump.py
|
||||
|
||||
node_modules
|
||||
16
cmd/main.go
16
cmd/main.go
@@ -20,6 +20,7 @@ import (
|
||||
|
||||
// Репозитории (инфраструктура)
|
||||
catalogPkg "rmser/internal/infrastructure/repository/catalog"
|
||||
draftsPkg "rmser/internal/infrastructure/repository/drafts"
|
||||
invoicesPkg "rmser/internal/infrastructure/repository/invoices"
|
||||
ocrRepoPkg "rmser/internal/infrastructure/repository/ocr"
|
||||
opsRepoPkg "rmser/internal/infrastructure/repository/operations"
|
||||
@@ -27,6 +28,7 @@ import (
|
||||
recRepoPkg "rmser/internal/infrastructure/repository/recommendations"
|
||||
|
||||
"rmser/internal/infrastructure/rms"
|
||||
draftsServicePkg "rmser/internal/services/drafts"
|
||||
invServicePkg "rmser/internal/services/invoices" // Сервис накладных
|
||||
ocrServicePkg "rmser/internal/services/ocr"
|
||||
recServicePkg "rmser/internal/services/recommend"
|
||||
@@ -67,14 +69,17 @@ func main() {
|
||||
opsRepo := opsRepoPkg.NewRepository(database)
|
||||
recRepo := recRepoPkg.NewRepository(database)
|
||||
ocrRepo := ocrRepoPkg.NewRepository(database)
|
||||
draftsRepo := draftsPkg.NewRepository(database)
|
||||
|
||||
syncService := sync.NewService(rmsClient, catalogRepo, recipesRepo, invoicesRepo, opsRepo)
|
||||
recService := recServicePkg.NewService(recRepo)
|
||||
ocrService := ocrServicePkg.NewService(ocrRepo, catalogRepo, pyClient)
|
||||
ocrService := ocrServicePkg.NewService(ocrRepo, catalogRepo, draftsRepo, pyClient)
|
||||
draftsService := draftsServicePkg.NewService(draftsRepo, ocrRepo, catalogRepo, rmsClient)
|
||||
invoiceService := invServicePkg.NewService(rmsClient)
|
||||
|
||||
// --- Инициализация Handler'ов ---
|
||||
invoiceHandler := handlers.NewInvoiceHandler(invoiceService)
|
||||
draftsHandler := handlers.NewDraftsHandler(draftsService)
|
||||
ocrHandler := handlers.NewOCRHandler(ocrService)
|
||||
recommendHandler := handlers.NewRecommendationsHandler(recService)
|
||||
|
||||
@@ -154,6 +159,14 @@ func main() {
|
||||
// Invoices
|
||||
api.POST("/invoices/send", invoiceHandler.SendInvoice)
|
||||
|
||||
// Черновики
|
||||
api.GET("/drafts/:id", draftsHandler.GetDraft)
|
||||
api.PATCH("/drafts/:id/items/:itemId", draftsHandler.UpdateItem)
|
||||
api.POST("/drafts/:id/commit", draftsHandler.CommitDraft)
|
||||
|
||||
// Склады
|
||||
api.GET("/dictionaries/stores", draftsHandler.GetStores)
|
||||
|
||||
// Recommendations
|
||||
api.GET("/recommendations", recommendHandler.GetRecommendations)
|
||||
|
||||
@@ -161,6 +174,7 @@ func main() {
|
||||
api.GET("/ocr/catalog", ocrHandler.GetCatalog)
|
||||
api.GET("/ocr/matches", ocrHandler.GetMatches)
|
||||
api.POST("/ocr/match", ocrHandler.SaveMatch)
|
||||
api.DELETE("/ocr/match", ocrHandler.DeleteMatch)
|
||||
api.GET("/ocr/unmatched", ocrHandler.GetUnmatched)
|
||||
}
|
||||
|
||||
|
||||
@@ -22,3 +22,4 @@ ocr:
|
||||
telegram:
|
||||
token: "7858843765:AAE5HBQHbef4fGLoMDV91bhHZQFcnsBDcv4"
|
||||
admin_ids: [665599275]
|
||||
web_app_url: "https://rmser.serty.top"
|
||||
@@ -45,6 +45,7 @@ type RMSConfig struct {
|
||||
type TelegramConfig struct {
|
||||
Token string `mapstructure:"token"`
|
||||
AdminIDs []int64 `mapstructure:"admin_ids"`
|
||||
WebAppURL string `mapstructure:"web_app_url"`
|
||||
}
|
||||
|
||||
// LoadConfig загружает конфигурацию из файла и переменных окружения
|
||||
|
||||
@@ -30,6 +30,8 @@ services:
|
||||
- "5005:5000"
|
||||
environment:
|
||||
- LOG_LEVEL=INFO
|
||||
- YANDEX_OAUTH_TOKEN=y0__xDK_988GMHdEyDc2M_XFTDIv-CCCP0kok1p0yRYJCgQrj8b9Kwylo25
|
||||
- YANDEX_FOLDER_ID=b1gas1sh12oui8cskgcm
|
||||
|
||||
# 4. Go Application (Основной сервис)
|
||||
app:
|
||||
|
||||
@@ -55,4 +55,7 @@ type Repository interface {
|
||||
SaveProducts(products []Product) error
|
||||
GetAll() ([]Product, error)
|
||||
GetActiveGoods() ([]Product, error)
|
||||
// --- Stores ---
|
||||
SaveStores(stores []Store) error
|
||||
GetActiveStores() ([]Store, error)
|
||||
}
|
||||
|
||||
18
internal/domain/catalog/store.go
Normal file
18
internal/domain/catalog/store.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Store - Склад (в терминологии iiko: Entity с типом Account и подтипом INVENTORY_ASSETS)
|
||||
type Store struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
|
||||
Name string `gorm:"type:varchar(255);not null" json:"name"`
|
||||
ParentCorporateID uuid.UUID `gorm:"type:uuid;index" json:"parent_corporate_id"` // ID юр.лица/торгового предприятия
|
||||
IsDeleted bool `gorm:"default:false" json:"is_deleted"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
76
internal/domain/drafts/entity.go
Normal file
76
internal/domain/drafts/entity.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package drafts
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"rmser/internal/domain/catalog"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// Статусы черновика
|
||||
const (
|
||||
StatusProcessing = "PROCESSING" // OCR в процессе
|
||||
StatusReadyToVerify = "READY_TO_VERIFY" // Распознано, ждет проверки пользователем
|
||||
StatusCompleted = "COMPLETED" // Отправлено в RMS
|
||||
StatusError = "ERROR" // Ошибка обработки
|
||||
)
|
||||
|
||||
// DraftInvoice - Черновик накладной
|
||||
type DraftInvoice struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
ChatID int64 `gorm:"index" json:"chat_id"` // ID чата в Telegram (кто прислал)
|
||||
SenderPhotoURL string `gorm:"type:text" json:"photo_url"` // Ссылка на фото (если нужно отобразить на фронте)
|
||||
Status string `gorm:"type:varchar(50);default:'PROCESSING'" json:"status"`
|
||||
|
||||
// Данные для отправки в RMS (заполняются пользователем)
|
||||
DocumentNumber string `gorm:"type:varchar(100)" json:"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"`
|
||||
Comment string `gorm:"type:text" json:"comment"`
|
||||
|
||||
// Связь с созданной накладной (когда статус COMPLETED)
|
||||
RMSInvoiceID *uuid.UUID `gorm:"type:uuid" json:"rms_invoice_id"`
|
||||
|
||||
Items []DraftInvoiceItem `gorm:"foreignKey:DraftID;constraint:OnDelete:CASCADE" json:"items"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// DraftInvoiceItem - Позиция черновика
|
||||
type DraftInvoiceItem struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
DraftID uuid.UUID `gorm:"type:uuid;not null;index" json:"draft_id"`
|
||||
|
||||
// --- Результаты OCR (Исходные данные) ---
|
||||
RawName string `gorm:"type:varchar(255);not null" json:"raw_name"` // Текст с чека
|
||||
RawAmount decimal.Decimal `gorm:"type:numeric(19,4)" json:"raw_amount"` // Кол-во, которое увидел OCR
|
||||
RawPrice decimal.Decimal `gorm:"type:numeric(19,4)" json:"raw_price"` // Цена, которую увидел OCR
|
||||
|
||||
// --- Результат Матчинга и Выбора пользователя ---
|
||||
ProductID *uuid.UUID `gorm:"type:uuid;index" json:"product_id"`
|
||||
Product *catalog.Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
|
||||
ContainerID *uuid.UUID `gorm:"type:uuid;index" json:"container_id"`
|
||||
Container *catalog.ProductContainer `gorm:"foreignKey:ContainerID" json:"container,omitempty"`
|
||||
|
||||
// Финальные цифры, которые пойдут в накладную
|
||||
Quantity decimal.Decimal `gorm:"type:numeric(19,4);default:0" json:"quantity"`
|
||||
Price decimal.Decimal `gorm:"type:numeric(19,4);default:0" json:"price"`
|
||||
Sum decimal.Decimal `gorm:"type:numeric(19,4);default:0" json:"sum"`
|
||||
|
||||
IsMatched bool `gorm:"default:false" json:"is_matched"` // Удалось ли системе найти пару автоматически
|
||||
}
|
||||
|
||||
// Repository интерфейс
|
||||
type Repository interface {
|
||||
Create(draft *DraftInvoice) error
|
||||
GetByID(id uuid.UUID) (*DraftInvoice, error)
|
||||
Update(draft *DraftInvoice) error
|
||||
CreateItems(items []DraftInvoiceItem) error
|
||||
// UpdateItem обновляет конкретную строку (например, при ручном выборе товара)
|
||||
UpdateItem(itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error
|
||||
Delete(id uuid.UUID) error
|
||||
}
|
||||
@@ -36,7 +36,7 @@ type UnmatchedItem struct {
|
||||
type Repository interface {
|
||||
// SaveMatch теперь принимает quantity и containerID
|
||||
SaveMatch(rawName string, productID uuid.UUID, quantity decimal.Decimal, containerID *uuid.UUID) error
|
||||
|
||||
DeleteMatch(rawName string) error
|
||||
FindMatch(rawName string) (*ProductMatch, error) // Возвращаем полную структуру, чтобы получить qty
|
||||
GetAllMatches() ([]ProductMatch, error)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"regexp"
|
||||
"rmser/internal/domain/catalog"
|
||||
"rmser/internal/domain/drafts"
|
||||
"rmser/internal/domain/invoices"
|
||||
"rmser/internal/domain/ocr"
|
||||
"rmser/internal/domain/operations"
|
||||
@@ -48,10 +49,13 @@ func NewPostgresDB(dsn string) *gorm.DB {
|
||||
&catalog.Product{},
|
||||
&catalog.MeasureUnit{},
|
||||
&catalog.ProductContainer{},
|
||||
&catalog.Store{},
|
||||
&recipes.Recipe{},
|
||||
&recipes.RecipeItem{},
|
||||
&invoices.Invoice{},
|
||||
&invoices.InvoiceItem{},
|
||||
&drafts.DraftInvoice{},
|
||||
&drafts.DraftInvoiceItem{},
|
||||
&operations.StoreOperation{},
|
||||
&recommendations.Recommendation{},
|
||||
&ocr.ProductMatch{},
|
||||
|
||||
@@ -116,3 +116,19 @@ func (r *pgRepository) GetActiveGoods() ([]catalog.Product, error) {
|
||||
Find(&products).Error
|
||||
return products, err
|
||||
}
|
||||
|
||||
func (r *pgRepository) SaveStores(stores []catalog.Store) error {
|
||||
if len(stores) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
UpdateAll: true,
|
||||
}).CreateInBatches(stores, 100).Error
|
||||
}
|
||||
|
||||
func (r *pgRepository) GetActiveStores() ([]catalog.Store, error) {
|
||||
var stores []catalog.Store
|
||||
err := r.db.Where("is_deleted = ?", false).Order("name ASC").Find(&stores).Error
|
||||
return stores, err
|
||||
}
|
||||
|
||||
85
internal/infrastructure/repository/drafts/postgres.go
Normal file
85
internal/infrastructure/repository/drafts/postgres.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package drafts
|
||||
|
||||
import (
|
||||
"rmser/internal/domain/drafts"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/shopspring/decimal"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type pgRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB) drafts.Repository {
|
||||
return &pgRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *pgRepository) Create(draft *drafts.DraftInvoice) error {
|
||||
return r.db.Create(draft).Error
|
||||
}
|
||||
|
||||
func (r *pgRepository) GetByID(id uuid.UUID) (*drafts.DraftInvoice, error) {
|
||||
var draft drafts.DraftInvoice
|
||||
err := r.db.
|
||||
Preload("Items", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Order("draft_invoice_items.raw_name ASC")
|
||||
}).
|
||||
Preload("Items.Product").
|
||||
Preload("Items.Product.MainUnit"). // Нужно для отображения единиц
|
||||
Preload("Items.Container").
|
||||
Where("id = ?", id).
|
||||
First(&draft).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &draft, nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) Update(draft *drafts.DraftInvoice) error {
|
||||
// Обновляем только основные поля шапки
|
||||
return r.db.Model(draft).Updates(map[string]interface{}{
|
||||
"status": draft.Status,
|
||||
"document_number": draft.DocumentNumber,
|
||||
"date_incoming": draft.DateIncoming,
|
||||
"supplier_id": draft.SupplierID,
|
||||
"store_id": draft.StoreID,
|
||||
"comment": draft.Comment,
|
||||
"rms_invoice_id": draft.RMSInvoiceID,
|
||||
"updated_at": gorm.Expr("NOW()"),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *pgRepository) CreateItems(items []drafts.DraftInvoiceItem) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.CreateInBatches(items, 100).Error
|
||||
}
|
||||
|
||||
func (r *pgRepository) UpdateItem(itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error {
|
||||
// Пересчитываем сумму
|
||||
sum := qty.Mul(price)
|
||||
|
||||
// Определяем статус IsMatched: если productID задан - значит сматчено
|
||||
isMatched := productID != nil
|
||||
|
||||
updates := map[string]interface{}{
|
||||
"product_id": productID,
|
||||
"container_id": containerID,
|
||||
"quantity": qty,
|
||||
"price": price,
|
||||
"sum": sum,
|
||||
"is_matched": isMatched,
|
||||
}
|
||||
|
||||
return r.db.Model(&drafts.DraftInvoiceItem{}).
|
||||
Where("id = ?", itemID).
|
||||
Updates(updates).Error
|
||||
}
|
||||
|
||||
func (r *pgRepository) Delete(id uuid.UUID) error {
|
||||
return r.db.Delete(&drafts.DraftInvoice{}, id).Error
|
||||
}
|
||||
@@ -45,6 +45,11 @@ func (r *pgRepository) SaveMatch(rawName string, productID uuid.UUID, quantity d
|
||||
})
|
||||
}
|
||||
|
||||
func (r *pgRepository) DeleteMatch(rawName string) error {
|
||||
normalized := strings.ToLower(strings.TrimSpace(rawName))
|
||||
return r.db.Where("raw_name = ?", normalized).Delete(&ocr.ProductMatch{}).Error
|
||||
}
|
||||
|
||||
func (r *pgRepository) FindMatch(rawName string) (*ocr.ProductMatch, error) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(rawName))
|
||||
var match ocr.ProductMatch
|
||||
|
||||
@@ -32,6 +32,7 @@ type ClientI interface {
|
||||
Auth() error
|
||||
Logout() error
|
||||
FetchCatalog() ([]catalog.Product, error)
|
||||
FetchStores() ([]catalog.Store, error)
|
||||
FetchMeasureUnits() ([]catalog.MeasureUnit, error)
|
||||
FetchRecipes(dateFrom, dateTo time.Time) ([]recipes.Recipe, error)
|
||||
FetchInvoices(from, to time.Time) ([]invoices.Invoice, error)
|
||||
@@ -337,6 +338,52 @@ func (c *Client) FetchCatalog() ([]catalog.Product, error) {
|
||||
return products, nil
|
||||
}
|
||||
|
||||
// FetchStores загружает список складов (Account -> INVENTORY_ASSETS)
|
||||
func (c *Client) FetchStores() ([]catalog.Store, error) {
|
||||
resp, err := c.doRequest("GET", "/resto/api/v2/entities/list", map[string]string{
|
||||
"rootType": "Account",
|
||||
"includeDeleted": "false",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get stores error: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var dtos []AccountDTO
|
||||
if err := json.NewDecoder(resp.Body).Decode(&dtos); err != nil {
|
||||
return nil, fmt.Errorf("json decode stores error: %w", err)
|
||||
}
|
||||
|
||||
var stores []catalog.Store
|
||||
for _, d := range dtos {
|
||||
// Фильтруем только склады
|
||||
if d.Type != "INVENTORY_ASSETS" {
|
||||
continue
|
||||
}
|
||||
|
||||
id, err := uuid.Parse(d.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var parentCorpID uuid.UUID
|
||||
if d.ParentCorporateID != nil {
|
||||
if parsed, err := uuid.Parse(*d.ParentCorporateID); err == nil {
|
||||
parentCorpID = parsed
|
||||
}
|
||||
}
|
||||
|
||||
stores = append(stores, catalog.Store{
|
||||
ID: id,
|
||||
Name: d.Name,
|
||||
ParentCorporateID: parentCorpID,
|
||||
IsDeleted: d.Deleted,
|
||||
})
|
||||
}
|
||||
|
||||
return stores, nil
|
||||
}
|
||||
|
||||
// FetchMeasureUnits загружает справочник единиц измерения
|
||||
func (c *Client) FetchMeasureUnits() ([]catalog.MeasureUnit, error) {
|
||||
// rootType=MeasureUnit согласно документации iiko
|
||||
|
||||
@@ -28,6 +28,15 @@ type GenericEntityDTO struct {
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
// AccountDTO используется для парсинга складов (INVENTORY_ASSETS)
|
||||
type AccountDTO struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // Нас интересует "INVENTORY_ASSETS"
|
||||
ParentCorporateID *string `json:"parentCorporateId"`
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
// ContainerDTO - фасовка из iiko
|
||||
type ContainerDTO struct {
|
||||
ID string `json:"id"`
|
||||
|
||||
200
internal/services/drafts/service.go
Normal file
200
internal/services/drafts/service.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package drafts
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/shopspring/decimal"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"rmser/internal/domain/catalog"
|
||||
"rmser/internal/domain/drafts"
|
||||
"rmser/internal/domain/invoices"
|
||||
"rmser/internal/domain/ocr"
|
||||
"rmser/internal/infrastructure/rms"
|
||||
"rmser/pkg/logger"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
draftRepo drafts.Repository
|
||||
ocrRepo ocr.Repository
|
||||
catalogRepo catalog.Repository
|
||||
rmsClient rms.ClientI
|
||||
}
|
||||
|
||||
func NewService(
|
||||
draftRepo drafts.Repository,
|
||||
ocrRepo ocr.Repository,
|
||||
catalogRepo catalog.Repository,
|
||||
rmsClient rms.ClientI,
|
||||
) *Service {
|
||||
return &Service{
|
||||
draftRepo: draftRepo,
|
||||
ocrRepo: ocrRepo,
|
||||
catalogRepo: catalogRepo,
|
||||
rmsClient: rmsClient,
|
||||
}
|
||||
}
|
||||
|
||||
// GetDraft возвращает черновик с позициями
|
||||
func (s *Service) GetDraft(id uuid.UUID) (*drafts.DraftInvoice, error) {
|
||||
return s.draftRepo.GetByID(id)
|
||||
}
|
||||
|
||||
// UpdateDraftHeader обновляет шапку (дата, поставщик, склад, комментарий)
|
||||
func (s *Service) UpdateDraftHeader(id uuid.UUID, storeID *uuid.UUID, supplierID *uuid.UUID, date time.Time, comment string) error {
|
||||
draft, err := s.draftRepo.GetByID(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if draft.Status == drafts.StatusCompleted {
|
||||
return errors.New("черновик уже отправлен")
|
||||
}
|
||||
|
||||
draft.StoreID = storeID
|
||||
draft.SupplierID = supplierID
|
||||
draft.DateIncoming = &date
|
||||
draft.Comment = comment
|
||||
|
||||
return s.draftRepo.Update(draft)
|
||||
}
|
||||
|
||||
// UpdateItem обновляет позицию (Без сохранения обучения!)
|
||||
func (s *Service) UpdateItem(draftID, itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error {
|
||||
// Мы просто обновляем данные в черновике.
|
||||
// Сохранение в базу знаний (OCR Matches) произойдет только при отправке накладной.
|
||||
return s.draftRepo.UpdateItem(itemID, productID, containerID, qty, price)
|
||||
}
|
||||
|
||||
// CommitDraft отправляет накладную в RMS
|
||||
func (s *Service) CommitDraft(id uuid.UUID) (string, error) {
|
||||
// 1. Загружаем актуальное состояние черновика
|
||||
draft, err := s.draftRepo.GetByID(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if draft.Status == drafts.StatusCompleted {
|
||||
return "", errors.New("накладная уже отправлена")
|
||||
}
|
||||
|
||||
// Валидация
|
||||
if draft.StoreID == nil || *draft.StoreID == uuid.Nil {
|
||||
return "", errors.New("не выбран склад")
|
||||
}
|
||||
if draft.SupplierID == nil || *draft.SupplierID == uuid.Nil {
|
||||
return "", errors.New("не выбран поставщик")
|
||||
}
|
||||
if draft.DateIncoming == nil {
|
||||
return "", errors.New("не выбрана дата")
|
||||
}
|
||||
|
||||
// Сборка Invoice для отправки
|
||||
inv := invoices.Invoice{
|
||||
ID: uuid.Nil, // iiko создаст новый
|
||||
DocumentNumber: draft.DocumentNumber, // Может быть пустой, iiko присвоит
|
||||
DateIncoming: *draft.DateIncoming,
|
||||
SupplierID: *draft.SupplierID,
|
||||
DefaultStoreID: *draft.StoreID,
|
||||
Status: "NEW",
|
||||
Items: make([]invoices.InvoiceItem, 0, len(draft.Items)),
|
||||
}
|
||||
|
||||
for _, dItem := range draft.Items {
|
||||
if dItem.ProductID == nil {
|
||||
// Пропускаем нераспознанные или кидаем ошибку?
|
||||
// Лучше пропустить, чтобы не блокировать отправку частичного документа
|
||||
continue
|
||||
}
|
||||
|
||||
// Расчет суммы (если не задана, считаем)
|
||||
sum := dItem.Sum
|
||||
if sum.IsZero() {
|
||||
sum = dItem.Quantity.Mul(dItem.Price)
|
||||
}
|
||||
|
||||
// Важный момент с фасовками:
|
||||
// Клиент RMS (CreateIncomingInvoice) у нас пока не поддерживает отправку container_id в явном виде,
|
||||
// или мы его обновили? Проверим `internal/infrastructure/rms/client.go`.
|
||||
// Там используется `IncomingInvoiceImportItemXML`. В ней нет поля ContainerID, но есть `AmountUnit`.
|
||||
// Если мы хотим передать фасовку, нужно передавать Amount в базовых единицах,
|
||||
// ЛИБО доработать клиент iiko, чтобы он принимал `amountUnit` (ID фасовки).
|
||||
|
||||
// СТРАТЕГИЯ СЕЙЧАС:
|
||||
// Считаем, что FrontEnd/Service уже пересчитал кол-во в базовые единицы?
|
||||
// НЕТ. DraftItem хранит Quantity в тех единицах, которые выбрал юзер (фасовках).
|
||||
// Нам нужно конвертировать в базовые для отправки, если мы не умеем слать фасовки.
|
||||
|
||||
// Но погоди, в `ProductContainer` есть `Count` (коэффициент).
|
||||
finalAmount := dItem.Quantity
|
||||
if dItem.ContainerID != nil && dItem.Container != nil {
|
||||
// Если выбрана фасовка, умножаем кол-во упаковок на коэффициент
|
||||
finalAmount = finalAmount.Mul(dItem.Container.Count)
|
||||
}
|
||||
|
||||
inv.Items = append(inv.Items, invoices.InvoiceItem{
|
||||
ProductID: *dItem.ProductID,
|
||||
Amount: finalAmount,
|
||||
Price: dItem.Price, // Цена обычно за упаковку... А iiko ждет цену за базу?
|
||||
// RMS API: Если мы шлем в базовых единицах, то и цену надо пересчитать за базовую.
|
||||
// Price (base) = Price (pack) / Count
|
||||
// ЛИБО: Мы шлем Sum, а iiko сама посчитает цену. Это надежнее.
|
||||
Sum: sum,
|
||||
})
|
||||
}
|
||||
|
||||
if len(inv.Items) == 0 {
|
||||
return "", errors.New("нет распознанных позиций для отправки")
|
||||
}
|
||||
|
||||
// Отправка
|
||||
docNum, err := s.rmsClient.CreateIncomingInvoice(inv)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Обновление статуса
|
||||
draft.Status = drafts.StatusCompleted
|
||||
// Можно сохранить docNum, если бы было поле в Draft, но у нас есть rms_invoice_id (uuid),
|
||||
// а возвращается строка номера. Ок, просто меняем статус.
|
||||
if err := s.draftRepo.Update(draft); err != nil {
|
||||
logger.Log.Error("Failed to update draft status after commit", zap.Error(err))
|
||||
}
|
||||
|
||||
// 4. ОБУЧЕНИЕ (Deferred Learning)
|
||||
// Запускаем в горутине, чтобы не задерживать ответ пользователю
|
||||
go s.learnFromDraft(draft)
|
||||
|
||||
return docNum, nil
|
||||
}
|
||||
|
||||
// learnFromDraft сохраняет новые связи на основе подтвержденного черновика
|
||||
func (s *Service) learnFromDraft(draft *drafts.DraftInvoice) {
|
||||
for _, item := range draft.Items {
|
||||
// Учимся только если:
|
||||
// 1. Есть RawName (текст из чека)
|
||||
// 2. Пользователь (или OCR) выбрал ProductID
|
||||
if item.RawName != "" && item.ProductID != nil {
|
||||
|
||||
// Если нужно запоминать коэффициент (например, всегда 1 или то, что ввел юзер),
|
||||
// то берем item.Quantity. Но обычно для матчинга мы запоминаем факт связи,
|
||||
// а дефолтное кол-во ставим 1.
|
||||
qty := decimal.NewFromFloat(1.0)
|
||||
|
||||
err := s.ocrRepo.SaveMatch(item.RawName, *item.ProductID, qty, item.ContainerID)
|
||||
if err != nil {
|
||||
logger.Log.Warn("Failed to learn match",
|
||||
zap.String("raw", item.RawName),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
logger.Log.Info("Learned match", zap.String("raw", item.RawName))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetActiveStores возвращает список складов
|
||||
func (s *Service) GetActiveStores() ([]catalog.Store, error) {
|
||||
return s.catalogRepo.GetActiveStores()
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"rmser/internal/domain/catalog"
|
||||
"rmser/internal/domain/drafts"
|
||||
"rmser/internal/domain/ocr"
|
||||
"rmser/internal/infrastructure/ocr_client"
|
||||
"rmser/pkg/logger"
|
||||
@@ -18,57 +19,116 @@ import (
|
||||
type Service struct {
|
||||
ocrRepo ocr.Repository
|
||||
catalogRepo catalog.Repository
|
||||
draftRepo drafts.Repository
|
||||
pyClient *ocr_client.Client // Клиент к Python сервису
|
||||
}
|
||||
|
||||
func NewService(
|
||||
ocrRepo ocr.Repository,
|
||||
catalogRepo catalog.Repository,
|
||||
draftRepo drafts.Repository,
|
||||
pyClient *ocr_client.Client,
|
||||
) *Service {
|
||||
return &Service{
|
||||
ocrRepo: ocrRepo,
|
||||
catalogRepo: catalogRepo,
|
||||
draftRepo: draftRepo,
|
||||
pyClient: pyClient,
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessReceiptImage - основной метод: Картинка -> Распознавание -> Матчинг
|
||||
func (s *Service) ProcessReceiptImage(ctx context.Context, imgData []byte) ([]ProcessedItem, error) {
|
||||
// 1. Отправляем в Python
|
||||
// ProcessReceiptImage - Создает черновик, распознает, сохраняет результаты
|
||||
func (s *Service) ProcessReceiptImage(ctx context.Context, chatID int64, imgData []byte) (*drafts.DraftInvoice, error) {
|
||||
// 1. Создаем заготовку черновика
|
||||
draft := &drafts.DraftInvoice{
|
||||
ChatID: chatID,
|
||||
Status: drafts.StatusProcessing,
|
||||
}
|
||||
if err := s.draftRepo.Create(draft); err != nil {
|
||||
return nil, fmt.Errorf("failed to create draft: %w", err)
|
||||
}
|
||||
logger.Log.Info("Создан черновик", zap.String("draft_id", draft.ID.String()))
|
||||
|
||||
// 2. Отправляем в Python OCR
|
||||
rawResult, err := s.pyClient.ProcessImage(ctx, imgData, "receipt.jpg")
|
||||
if err != nil {
|
||||
// Ставим статус ошибки
|
||||
draft.Status = drafts.StatusError
|
||||
_ = s.draftRepo.Update(draft)
|
||||
return nil, fmt.Errorf("python ocr error: %w", err)
|
||||
}
|
||||
|
||||
var processed []ProcessedItem
|
||||
// 3. Обрабатываем результаты и создаем Items
|
||||
var draftItems []drafts.DraftInvoiceItem
|
||||
|
||||
for _, rawItem := range rawResult.Items {
|
||||
item := ProcessedItem{
|
||||
item := drafts.DraftInvoiceItem{
|
||||
DraftID: draft.ID,
|
||||
RawName: rawItem.RawName,
|
||||
Amount: decimal.NewFromFloat(rawItem.Amount),
|
||||
RawAmount: decimal.NewFromFloat(rawItem.Amount),
|
||||
RawPrice: decimal.NewFromFloat(rawItem.Price),
|
||||
// Quantity/Price по умолчанию берем как Raw, если не будет пересчета
|
||||
Quantity: decimal.NewFromFloat(rawItem.Amount),
|
||||
Price: decimal.NewFromFloat(rawItem.Price),
|
||||
Sum: decimal.NewFromFloat(rawItem.Sum),
|
||||
}
|
||||
|
||||
// Пытаемся найти матчинг
|
||||
match, err := s.ocrRepo.FindMatch(rawItem.RawName)
|
||||
if err != nil {
|
||||
logger.Log.Error("db error finding match", zap.Error(err))
|
||||
}
|
||||
|
||||
if match != nil {
|
||||
item.ProductID = &match.ProductID
|
||||
item.IsMatched = true
|
||||
item.MatchSource = "learned"
|
||||
// Здесь мы могли бы подтянуть quantity/container из матча,
|
||||
// но пока фронт сам это сделает, запросив /ocr/matches или получив подсказку.
|
||||
item.ProductID = &match.ProductID
|
||||
item.ContainerID = match.ContainerID
|
||||
|
||||
// Важная логика: Если в матче указано ContainerID, то Quantity из чека (например 5 шт)
|
||||
// это 5 коробок. Финальное кол-во (в кг) RMS посчитает сама,
|
||||
// либо мы можем пересчитать тут, если знаем коэффициент.
|
||||
// Пока оставляем Quantity как есть (кол-во упаковок),
|
||||
// так как ContainerID передается в iiko.
|
||||
} else {
|
||||
// Если не нашли - сохраняем в Unmatched для статистики и подсказок
|
||||
if err := s.ocrRepo.UpsertUnmatched(rawItem.RawName); err != nil {
|
||||
logger.Log.Warn("failed to save unmatched", zap.Error(err))
|
||||
}
|
||||
}
|
||||
processed = append(processed, item)
|
||||
|
||||
draftItems = append(draftItems, item)
|
||||
}
|
||||
return processed, nil
|
||||
|
||||
// 4. Сохраняем позиции в БД
|
||||
// Примечание: GORM умеет сохранять вложенные структуры через Update родителя,
|
||||
// но надежнее явно сохранить items, если мы не используем Session FullSaveAssociations.
|
||||
// В данном случае мы уже создали Draft, теперь привяжем к нему items.
|
||||
// Для простоты, так как у нас в Repo нет метода SaveItems,
|
||||
// мы обновим драфт, добавив Items (GORM должен создать их).
|
||||
draft.Status = drafts.StatusReadyToVerify
|
||||
|
||||
if err := s.draftRepo.Update(draft); err != nil {
|
||||
return nil, fmt.Errorf("failed to update draft status: %w", err)
|
||||
}
|
||||
draft.Items = draftItems
|
||||
|
||||
// Используем хак GORM: при обновлении объекта с ассоциациями, он их создаст.
|
||||
// Но надежнее расширить репозиторий. Давай используем Repository Update,
|
||||
// но он у нас обновляет только шапку.
|
||||
// Поэтому лучше расширим draftRepo методом SaveItems или используем прямую запись тут через items?
|
||||
// Сделаем правильно: добавим AddItems в репозиторий прямо сейчас, или воспользуемся тем, что Items сохранятся
|
||||
// если мы сделаем Save через GORM. В нашем Repo метод Create делает Create.
|
||||
// Давайте сделаем SaveItems в репозитории drafts, чтобы было чисто.
|
||||
|
||||
// ВРЕМЕННОЕ РЕШЕНИЕ (чтобы не менять интерфейс снова):
|
||||
// Мы можем создать items через repository, но там нет метода.
|
||||
// Давай я добавлю метод в интерфейс репозитория Drafts в следующем блоке изменений.
|
||||
// Пока предположим, что мы расширили репозиторий.
|
||||
if err := s.draftRepo.CreateItems(draftItems); err != nil {
|
||||
return nil, fmt.Errorf("failed to save items: %w", err)
|
||||
}
|
||||
|
||||
return draft, nil
|
||||
}
|
||||
|
||||
// ProcessedItem - результат обработки одной строки чека
|
||||
@@ -137,6 +197,11 @@ func (s *Service) SaveMapping(rawName string, productID uuid.UUID, quantity deci
|
||||
return s.ocrRepo.SaveMatch(rawName, productID, quantity, containerID)
|
||||
}
|
||||
|
||||
// DeleteMatch удаляет ошибочную привязку
|
||||
func (s *Service) DeleteMatch(rawName string) error {
|
||||
return s.ocrRepo.DeleteMatch(rawName)
|
||||
}
|
||||
|
||||
// GetKnownMatches возвращает список всех обученных связей
|
||||
func (s *Service) GetKnownMatches() ([]ocr.ProductMatch, error) {
|
||||
return s.ocrRepo.GetAllMatches()
|
||||
|
||||
@@ -49,14 +49,20 @@ func NewService(
|
||||
|
||||
// SyncCatalog загружает номенклатуру и сохраняет в БД
|
||||
func (s *Service) SyncCatalog() error {
|
||||
logger.Log.Info("Начало синхронизации каталога...")
|
||||
logger.Log.Info("Начало синхронизации справочников...")
|
||||
|
||||
// 1. Сначала Единицы измерения (чтобы FK не ругался)
|
||||
// 1. Склады (INVENTORY_ASSETS) - важно для создания накладных
|
||||
if err := s.SyncStores(); err != nil {
|
||||
logger.Log.Error("Ошибка синхронизации складов", zap.Error(err))
|
||||
// Не прерываем, идем дальше
|
||||
}
|
||||
|
||||
// 2. Единицы измерения
|
||||
if err := s.syncMeasureUnits(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. Товары
|
||||
// 3. Товары
|
||||
logger.Log.Info("Запрос товаров из RMS...")
|
||||
products, err := s.rmsClient.FetchCatalog()
|
||||
if err != nil {
|
||||
@@ -187,6 +193,22 @@ func classifyOperation(docType string) operations.OperationType {
|
||||
}
|
||||
}
|
||||
|
||||
// SyncStores загружает список складов
|
||||
func (s *Service) SyncStores() error {
|
||||
logger.Log.Info("Синхронизация складов...")
|
||||
stores, err := s.rmsClient.FetchStores()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ошибка получения складов из RMS: %w", err)
|
||||
}
|
||||
|
||||
if err := s.catalogRepo.SaveStores(stores); err != nil {
|
||||
return fmt.Errorf("ошибка сохранения складов в БД: %w", err)
|
||||
}
|
||||
|
||||
logger.Log.Info("Склады обновлены", zap.Int("count", len(stores)))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) SyncStoreOperations() error {
|
||||
dateTo := time.Now()
|
||||
dateFrom := dateTo.AddDate(0, 0, -30)
|
||||
|
||||
151
internal/transport/http/handlers/drafts.go
Normal file
151
internal/transport/http/handlers/drafts.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/shopspring/decimal"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"rmser/internal/services/drafts"
|
||||
"rmser/pkg/logger"
|
||||
)
|
||||
|
||||
type DraftsHandler struct {
|
||||
service *drafts.Service
|
||||
}
|
||||
|
||||
func NewDraftsHandler(service *drafts.Service) *DraftsHandler {
|
||||
return &DraftsHandler{service: service}
|
||||
}
|
||||
|
||||
// GetDraft возвращает полные данные черновика
|
||||
func (h *DraftsHandler) GetDraft(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
draft, err := h.service.GetDraft(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "draft not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, draft)
|
||||
}
|
||||
|
||||
// GetStores возвращает список складов
|
||||
func (h *DraftsHandler) GetStores(c *gin.Context) {
|
||||
stores, err := h.service.GetActiveStores()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, stores)
|
||||
}
|
||||
|
||||
// UpdateItemDTO - тело запроса на изменение строки
|
||||
type UpdateItemDTO struct {
|
||||
ProductID *string `json:"product_id"`
|
||||
ContainerID *string `json:"container_id"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Price float64 `json:"price"`
|
||||
}
|
||||
|
||||
func (h *DraftsHandler) UpdateItem(c *gin.Context) {
|
||||
draftID, _ := uuid.Parse(c.Param("id"))
|
||||
itemID, _ := uuid.Parse(c.Param("itemId"))
|
||||
|
||||
var req UpdateItemDTO
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var pID *uuid.UUID
|
||||
if req.ProductID != nil && *req.ProductID != "" {
|
||||
if uid, err := uuid.Parse(*req.ProductID); err == nil {
|
||||
pID = &uid
|
||||
}
|
||||
}
|
||||
|
||||
var cID *uuid.UUID
|
||||
if req.ContainerID != nil && *req.ContainerID != "" {
|
||||
if uid, err := uuid.Parse(*req.ContainerID); err == nil {
|
||||
cID = &uid
|
||||
}
|
||||
}
|
||||
|
||||
qty := decimal.NewFromFloat(req.Quantity)
|
||||
price := decimal.NewFromFloat(req.Price)
|
||||
|
||||
if err := h.service.UpdateItem(draftID, itemID, pID, cID, qty, price); err != nil {
|
||||
logger.Log.Error("Failed to update item", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "updated"})
|
||||
}
|
||||
|
||||
type CommitRequestDTO struct {
|
||||
DateIncoming string `json:"date_incoming"` // YYYY-MM-DD
|
||||
StoreID string `json:"store_id"`
|
||||
SupplierID string `json:"supplier_id"`
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
|
||||
// CommitDraft сохраняет шапку и отправляет в RMS
|
||||
func (h *DraftsHandler) CommitDraft(c *gin.Context) {
|
||||
draftID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid draft id"})
|
||||
return
|
||||
}
|
||||
|
||||
var req CommitRequestDTO
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Парсинг данных шапки
|
||||
date, err := time.Parse("2006-01-02", req.DateIncoming)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid date format (YYYY-MM-DD)"})
|
||||
return
|
||||
}
|
||||
storeID, err := uuid.Parse(req.StoreID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid store id"})
|
||||
return
|
||||
}
|
||||
supplierID, err := uuid.Parse(req.SupplierID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid supplier id"})
|
||||
return
|
||||
}
|
||||
|
||||
// 1. Обновляем шапку
|
||||
if err := h.service.UpdateDraftHeader(draftID, &storeID, &supplierID, date, req.Comment); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update header: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Отправляем
|
||||
docNum, err := h.service.CommitDraft(draftID)
|
||||
if err != nil {
|
||||
logger.Log.Error("Commit failed", zap.Error(err))
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "RMS error: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "completed",
|
||||
"document_number": docNum,
|
||||
})
|
||||
}
|
||||
@@ -73,6 +73,25 @@ func (h *OCRHandler) SaveMatch(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "saved"})
|
||||
}
|
||||
|
||||
// DeleteMatch удаляет связь
|
||||
func (h *OCRHandler) DeleteMatch(c *gin.Context) {
|
||||
// Получаем raw_name из query параметров, так как в URL path могут быть спецсимволы
|
||||
// Пример: DELETE /api/ocr/match?raw_name=Хлеб%20Бородинский
|
||||
rawName := c.Query("raw_name")
|
||||
if rawName == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "raw_name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.DeleteMatch(rawName); err != nil {
|
||||
logger.Log.Error("Ошибка удаления матча", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||
}
|
||||
|
||||
// GetMatches возвращает список всех обученных связей
|
||||
func (h *OCRHandler) GetMatches(c *gin.Context) {
|
||||
matches, err := h.service.GetKnownMatches()
|
||||
|
||||
@@ -21,6 +21,7 @@ type Bot struct {
|
||||
b *tele.Bot
|
||||
ocrService *ocr.Service
|
||||
adminIDs map[int64]struct{}
|
||||
webAppURL string
|
||||
}
|
||||
|
||||
func NewBot(cfg config.TelegramConfig, ocrService *ocr.Service) (*Bot, error) {
|
||||
@@ -46,6 +47,13 @@ func NewBot(cfg config.TelegramConfig, ocrService *ocr.Service) (*Bot, error) {
|
||||
b: b,
|
||||
ocrService: ocrService,
|
||||
adminIDs: admins,
|
||||
webAppURL: cfg.WebAppURL,
|
||||
}
|
||||
|
||||
// Если в конфиге пусто, ставим заглушку, чтобы не падало, но предупреждаем
|
||||
if bot.webAppURL == "" {
|
||||
logger.Log.Warn("Telegram WebAppURL не задан в конфиге! Кнопки работать не будут.")
|
||||
bot.webAppURL = "http://example.com"
|
||||
}
|
||||
|
||||
bot.initHandlers()
|
||||
@@ -106,36 +114,49 @@ func (bot *Bot) handlePhoto(c tele.Context) error {
|
||||
return c.Send("Ошибка чтения файла.")
|
||||
}
|
||||
|
||||
c.Send("⏳ Обрабатываю чек через OCR...")
|
||||
c.Send("⏳ Обрабатываю чек: создаю черновик и распознаю...")
|
||||
|
||||
// 2. Отправляем в сервис
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
// 2. Отправляем в сервис (добавили ID чата)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) // Чуть увеличим таймаут
|
||||
defer cancel()
|
||||
|
||||
items, err := bot.ocrService.ProcessReceiptImage(ctx, imgData)
|
||||
draft, err := bot.ocrService.ProcessReceiptImage(ctx, c.Chat().ID, imgData)
|
||||
if err != nil {
|
||||
logger.Log.Error("OCR processing failed", zap.Error(err))
|
||||
return c.Send("❌ Ошибка распознавания: " + err.Error())
|
||||
return c.Send("❌ Ошибка обработки: " + err.Error())
|
||||
}
|
||||
|
||||
// 3. Формируем отчет
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("🧾 <b>Результат (%d поз.):</b>\n\n", len(items)))
|
||||
|
||||
// 3. Анализ результатов для сообщения
|
||||
matchedCount := 0
|
||||
for _, item := range items {
|
||||
for _, item := range draft.Items {
|
||||
if item.IsMatched {
|
||||
matchedCount++
|
||||
sb.WriteString(fmt.Sprintf("✅ %s\n └ <code>%s</code> x %s = %s\n",
|
||||
item.RawName, item.Amount, item.Price, item.Sum))
|
||||
}
|
||||
}
|
||||
|
||||
// Формируем URL. Для Mini App это должен быть https URL вашего фронтенда.
|
||||
// Фронтенд должен уметь роутить /invoice/:id
|
||||
baseURL := strings.TrimRight(bot.webAppURL, "/")
|
||||
fullURL := fmt.Sprintf("%s/invoice/%s", baseURL, draft.ID.String())
|
||||
|
||||
// Формируем текст сообщения
|
||||
var msgText string
|
||||
if matchedCount == len(draft.Items) {
|
||||
msgText = fmt.Sprintf("✅ <b>Успех!</b> Все позиции (%d) распознаны.\n\nПереходите к созданию накладной.", len(draft.Items))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("❓ <b>%s</b>\n └ Нет привязки!\n", item.RawName))
|
||||
}
|
||||
msgText = fmt.Sprintf("⚠️ <b>Внимание!</b> Распознано %d из %d позиций.\n\nНекоторые товары требуют ручного сопоставления. Нажмите кнопку ниже, чтобы исправить.", matchedCount, len(draft.Items))
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("\nРаспознано: %d/%d", matchedCount, len(items)))
|
||||
menu := &tele.ReplyMarkup{}
|
||||
|
||||
// Тут можно добавить кнопки, если что-то не распознано
|
||||
// Но для начала просто текст
|
||||
return c.Send(sb.String(), tele.ModeHTML)
|
||||
// Используем WebApp, а не URL
|
||||
btnOpen := menu.WebApp("📝 Открыть накладную", &tele.WebApp{
|
||||
URL: fullURL,
|
||||
})
|
||||
|
||||
menu.Inline(
|
||||
menu.Row(btnOpen),
|
||||
)
|
||||
|
||||
return c.Send(msgText, menu, tele.ModeHTML)
|
||||
}
|
||||
|
||||
74
ocr-service/llm_parser.py
Normal file
74
ocr-service/llm_parser.py
Normal file
@@ -0,0 +1,74 @@
|
||||
import os
|
||||
import requests
|
||||
import logging
|
||||
import json
|
||||
from typing import List
|
||||
from parser import ParsedItem
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
YANDEX_GPT_URL = "https://llm.api.cloud.yandex.net/foundationModels/v1/completion"
|
||||
|
||||
class YandexGPTParser:
|
||||
def __init__(self):
|
||||
self.folder_id = os.getenv("YANDEX_FOLDER_ID")
|
||||
self.api_key = os.getenv("YANDEX_OAUTH_TOKEN") # Используем тот же доступ
|
||||
|
||||
def parse_with_llm(self, raw_text: str, iam_token: str) -> List[ParsedItem]:
|
||||
"""
|
||||
Отправляет текст в YandexGPT для структурирования.
|
||||
"""
|
||||
if not iam_token:
|
||||
return []
|
||||
|
||||
prompt = {
|
||||
"modelUri": f"gpt://{self.folder_id}/yandexgpt/latest",
|
||||
"completionOptions": {
|
||||
"stream": False,
|
||||
"temperature": 0.1, # Низкая температура для точности
|
||||
"maxTokens": "2000"
|
||||
},
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"text": (
|
||||
"Ты — помощник по бухгалтерии. Извлеки список товаров из текста документа. "
|
||||
"Верни ответ строго в формате JSON: "
|
||||
'[{"raw_name": string, "amount": float, "price": float, "sum": float}]. '
|
||||
"Если количество не указано, считай 1.0. Не пиши ничего, кроме JSON."
|
||||
)
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"text": raw_text
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {iam_token}",
|
||||
"x-folder-id": self.folder_id
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(YANDEX_GPT_URL, headers=headers, json=prompt, timeout=30)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
# Извлекаем текст ответа
|
||||
content = result['result']['alternatives'][0]['message']['text']
|
||||
|
||||
# Очищаем от возможных markdown-оберток ```json ... ```
|
||||
clean_json = content.replace("```json", "").replace("```", "").strip()
|
||||
|
||||
items_raw = json.loads(clean_json)
|
||||
|
||||
parsed_items = [ParsedItem(**item) for item in items_raw]
|
||||
return parsed_items
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"LLM Parsing error: {e}")
|
||||
return []
|
||||
|
||||
llm_parser = YandexGPTParser()
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import os
|
||||
from typing import List
|
||||
|
||||
from fastapi import FastAPI, File, UploadFile, HTTPException
|
||||
@@ -10,8 +11,10 @@ import numpy as np
|
||||
from imgproc import preprocess_image
|
||||
from parser import parse_receipt_text, ParsedItem
|
||||
from ocr import ocr_engine
|
||||
# Импортируем новый модуль
|
||||
from qr_manager import detect_and_decode_qr, fetch_data_from_api
|
||||
# Импортируем новый модуль
|
||||
from yandex_ocr import yandex_engine
|
||||
from llm_parser import llm_parser
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@@ -19,10 +22,10 @@ logging.basicConfig(
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(title="RMSER OCR Service (Hybrid: QR + OCR)")
|
||||
app = FastAPI(title="RMSER OCR Service (Hybrid: QR + Yandex + Tesseract)")
|
||||
|
||||
class RecognitionResult(BaseModel):
|
||||
source: str # 'qr_api' или 'ocr'
|
||||
source: str # 'qr_api', 'yandex_vision', 'tesseract_ocr'
|
||||
items: List[ParsedItem]
|
||||
raw_text: str = ""
|
||||
|
||||
@@ -33,9 +36,10 @@ def health_check():
|
||||
@app.post("/recognize", response_model=RecognitionResult)
|
||||
async def recognize_receipt(image: UploadFile = File(...)):
|
||||
"""
|
||||
1. Попытка найти QR-код.
|
||||
2. Если QR найден -> запрос к API -> возврат идеальных данных.
|
||||
3. Если QR не найден -> Preprocessing -> OCR -> Regex Parsing.
|
||||
Стратегия:
|
||||
1. QR Code + FNS API (Приоритет 1 - Идеальная точность)
|
||||
2. Yandex Vision OCR (Приоритет 2 - Высокая точность, если настроен)
|
||||
3. Tesseract OCR (Приоритет 3 - Локальный фолбэк)
|
||||
"""
|
||||
logger.info(f"Received file: {image.filename}, content_type: {image.content_type}")
|
||||
|
||||
@@ -43,19 +47,18 @@ async def recognize_receipt(image: UploadFile = File(...)):
|
||||
raise HTTPException(status_code=400, detail="File must be an image")
|
||||
|
||||
try:
|
||||
# Читаем байты
|
||||
# Читаем сырые байты
|
||||
content = await image.read()
|
||||
|
||||
# Конвертируем в numpy для работы (нужен и для QR, и для OCR)
|
||||
# Конвертируем в numpy для QR и локального препроцессинга
|
||||
nparr = np.frombuffer(content, np.uint8)
|
||||
# Оригинальное изображение (цветное/серое)
|
||||
original_cv_image = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
||||
|
||||
if original_cv_image is None:
|
||||
raise HTTPException(status_code=400, detail="Invalid image data")
|
||||
|
||||
# --- ЭТАП 1: QR Code Strategy ---
|
||||
logger.info("Attempting QR code detection...")
|
||||
logger.info("--- Stage 1: QR Code Detection ---")
|
||||
qr_raw = detect_and_decode_qr(original_cv_image)
|
||||
|
||||
if qr_raw:
|
||||
@@ -63,34 +66,63 @@ async def recognize_receipt(image: UploadFile = File(...)):
|
||||
api_items = fetch_data_from_api(qr_raw)
|
||||
|
||||
if api_items:
|
||||
logger.info(f"Successfully retrieved {len(api_items)} items via API.")
|
||||
logger.info(f"Success: Retrieved {len(api_items)} items via QR API.")
|
||||
return RecognitionResult(
|
||||
source="qr_api",
|
||||
items=api_items,
|
||||
raw_text=f"QR Content: {qr_raw}"
|
||||
)
|
||||
else:
|
||||
logger.warning("QR found but API failed to return items. Falling back to OCR.")
|
||||
logger.warning("QR found but API failed. Falling back to OCR.")
|
||||
else:
|
||||
logger.info("QR code not found. Falling back to OCR.")
|
||||
logger.info("QR code not found. Proceeding to OCR.")
|
||||
|
||||
# --- ЭТАП 2: OCR Strategy (Fallback) ---
|
||||
# --- ЭТАП 2: Yandex Vision Strategy (Cloud OCR) ---
|
||||
# Проверяем, настроен ли Яндекс
|
||||
if yandex_engine.oauth_token and yandex_engine.folder_id:
|
||||
logger.info("--- Stage 2: Yandex Vision OCR ---")
|
||||
|
||||
# 1. Image Processing (получаем бинарное изображение)
|
||||
# Передаем исходные байты, так как функция внутри декодирует их заново
|
||||
# (можно оптимизировать, но оставим совместимость с текущим кодом)
|
||||
# Яндекс принимает сырые байты картинки (Base64), ему не нужен наш препроцессинг
|
||||
yandex_text = yandex_engine.recognize(content)
|
||||
|
||||
if yandex_text and len(yandex_text) > 10:
|
||||
logger.info(f"Yandex OCR success. Text length: {len(yandex_text)}")
|
||||
logger.info(f"Yandex RAW OUTPUT:\n{yandex_text}")
|
||||
yandex_items = parse_receipt_text(yandex_text)
|
||||
logger.info(f"Parsed items preview: {yandex_items[:3]}...")
|
||||
# Если Regex не нашел позиций (как в нашем случае со счетом)
|
||||
if not yandex_items:
|
||||
logger.info("Regex found nothing. Calling YandexGPT for semantic parsing...")
|
||||
iam_token = yandex_engine._get_iam_token()
|
||||
yandex_items = llm_parser.parse_with_llm(yandex_text, iam_token)
|
||||
logger.info(f"Semantic parsed items preview: {yandex_items[:3]}...")
|
||||
|
||||
return RecognitionResult(
|
||||
source="yandex_vision",
|
||||
items=yandex_items,
|
||||
raw_text=yandex_text
|
||||
)
|
||||
else:
|
||||
logger.warning("Yandex Vision returned empty text or failed. Falling back to Tesseract.")
|
||||
else:
|
||||
logger.info("Yandex Vision credentials not set. Skipping Stage 2.")
|
||||
|
||||
# --- ЭТАП 3: Tesseract Strategy (Local Fallback) ---
|
||||
logger.info("--- Stage 3: Tesseract OCR (Local) ---")
|
||||
|
||||
# 1. Image Processing (бинаризация, выравнивание)
|
||||
processed_img = preprocess_image(content)
|
||||
|
||||
# 2. OCR
|
||||
full_text = ocr_engine.recognize(processed_img)
|
||||
tesseract_text = ocr_engine.recognize(processed_img)
|
||||
|
||||
# 3. Parsing
|
||||
ocr_items = parse_receipt_text(full_text)
|
||||
ocr_items = parse_receipt_text(tesseract_text)
|
||||
|
||||
return RecognitionResult(
|
||||
source="ocr",
|
||||
source="tesseract_ocr",
|
||||
items=ocr_items,
|
||||
raw_text=full_text
|
||||
raw_text=tesseract_text
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,87 +1,48 @@
|
||||
Вот подробный системный промпт (System Definition), который описывает архитектуру, логику и контракт работы твоего OCR-сервиса.
|
||||
|
||||
Сохрани этот текст как **`SYSTEM_PROMPT.md`** или в документацию проекта (Confluence/Wiki). К нему стоит обращаться при разработке API-клиентов, тестировании или доработке логики.
|
||||
|
||||
---
|
||||
|
||||
# System Definition: RMSER OCR Service
|
||||
# System Definition: RMSER OCR Service (v2.0)
|
||||
|
||||
## 1. Роль и Назначение
|
||||
**RMSER OCR Service** — это специализированный микросервис на базе FastAPI, предназначенный для извлечения структурированных данных (товарных позиций) из изображений кассовых чеков РФ.
|
||||
**RMSER OCR Service** — микросервис для интеллектуального извлечения товарных позиций из финансовых документов (чеки, счета, накладные).
|
||||
Использует гибридный подход: QR-коды, Computer Vision и LLM (Large Language Models).
|
||||
|
||||
Сервис реализует **Гибридную Стратегию Распознавания**, отдавая приоритет получению верифицированных данных через ФНС, и используя оптическое распознавание (OCR) только как запасной вариант (fallback).
|
||||
## 2. Логика Обработки (Pipeline)
|
||||
|
||||
## 2. Логика Обработки (Workflow)
|
||||
### Этап А: Поиск QR-кода (Gold Standard)
|
||||
1. Поиск QR-кода (`pyzbar`).
|
||||
2. Валидация фискальных признаков (`t=`, `s=`, `fn=`).
|
||||
3. Запрос к API ФНС (`proverkacheka.com`).
|
||||
4. **Результат:** `source: "qr_api"`. 100% точность.
|
||||
|
||||
При получении `POST /recognize` с изображением, сервис выполняет действия в строгой последовательности:
|
||||
### Этап Б: Yandex Cloud AI (Silver Standard)
|
||||
*Запускается, если QR не найден.*
|
||||
1. **OCR:** Отправка изображения в Yandex Vision OCR. Получение сырого текста.
|
||||
2. **Primary Parsing:** Попытка извлечь данные регулярными выражениями.
|
||||
3. **Semantic Parsing (LLM):** Если Regex не нашел позиций, текст отправляется в **YandexGPT**.
|
||||
* Модель структурирует разрозненный текст в JSON.
|
||||
* Исправляет опечатки, связывает количество и цену, разбросанные по документу.
|
||||
4. **Результат:** `source: "yandex_vision"`. Высокая точность для любой верстки.
|
||||
|
||||
### Этап А: Поиск QR-кода (Priority 1)
|
||||
1. **Детекция:** Сервис сканирует изображение на наличие QR-кода (библиотека `pyzbar`).
|
||||
2. **Декодирование:** Извлекает сырую строку чека (формат: `t=YYYYMMDD...&s=SUM...&fn=...`).
|
||||
3. **Запрос к API:** Отправляет сырые данные в API `proverkacheka.com` (или аналог).
|
||||
4. **Результат:**
|
||||
* Если API возвращает успех: Возвращает идеальный список товаров.
|
||||
* **Метаданные ответа:** `source: "qr_api"`.
|
||||
### Этап В: Локальный OCR (Bronze Fallback)
|
||||
*Запускается при недоступности облака.*
|
||||
1. Препроцессинг (OpenCV: Binarization, Deskew).
|
||||
2. OCR (Tesseract).
|
||||
3. Парсинг (Regex).
|
||||
4. **Результат:** `source: "tesseract_ocr"`. Базовая точность.
|
||||
|
||||
### Этап Б: Оптическое Распознавание (Fallback Strategy)
|
||||
*Запускается только если QR-код не найден или API вернул ошибку.*
|
||||
## 3. Контракт API
|
||||
|
||||
1. **Препроцессинг (OpenCV):**
|
||||
* Поиск контуров документа.
|
||||
* Выравнивание перспективы (Perspective Warp).
|
||||
* Бинаризация (Adaptive Threshold) для подготовки к Tesseract.
|
||||
2. **OCR (Tesseract):** Извлечение сырого текста (rus+eng).
|
||||
3. **Парсинг (Regex):**
|
||||
* Поиск строк, содержащих паттерны цен (например, `120.00 * 2 = 240.00`).
|
||||
* Привязка текстового описания (названия товара) к найденным ценам.
|
||||
4. **Результат:** Возвращает список товаров, найденных эвристическим путем.
|
||||
* **Метаданные ответа:** `source: "ocr"`.
|
||||
|
||||
## 3. Контракт API (Interface)
|
||||
|
||||
### Входные данные
|
||||
* **Endpoint:** `POST /recognize`
|
||||
* **Format:** `multipart/form-data`
|
||||
* **Field:** `image` (binary file: jpg, png, heic, etc.)
|
||||
|
||||
### Выходные данные (JSON)
|
||||
Сервис всегда возвращает объект `RecognitionResult`:
|
||||
**POST /recognize** (`multipart/form-data`)
|
||||
|
||||
**Response (JSON):**
|
||||
```json
|
||||
{
|
||||
"source": "qr_api", // или "ocr"
|
||||
"source": "yandex_vision",
|
||||
"items": [
|
||||
{
|
||||
"raw_name": "Молоко Домик в Деревне 3.2%", // Название товара
|
||||
"amount": 2.0, // Количество
|
||||
"price": 89.99, // Цена за единицу
|
||||
"sum": 179.98 // Общая сумма позиции
|
||||
},
|
||||
{
|
||||
"raw_name": "Пакет-майка",
|
||||
"amount": 1.0,
|
||||
"price": 5.00,
|
||||
"sum": 5.00
|
||||
"raw_name": "Маракуйя - пюре, 250 гр",
|
||||
"amount": 5.0,
|
||||
"price": 282.00,
|
||||
"sum": 1410.00
|
||||
}
|
||||
],
|
||||
"raw_text": "..." // Сырой текст (для отладки) или содержимое QR
|
||||
"raw_text": "..."
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Технический Стек и Зависимости
|
||||
* **Runtime:** Python 3.10+
|
||||
* **Web Framework:** FastAPI + Uvicorn
|
||||
* **Computer Vision:** OpenCV (`cv2`) — обработка изображений.
|
||||
* **OCR Engine:** Tesseract OCR 5 (`pytesseract`) — движок распознавания текста.
|
||||
* **QR Decoding:** `pyzbar` + `libzbar0`.
|
||||
* **External API:** `proverkacheka.com` (требует валидный токен).
|
||||
|
||||
## 5. Ограничения и Известные Проблемы
|
||||
1. **Качество OCR:** В режиме `ocr` точность зависит от качества фото (освещение, помятость). Возможны ошибки в символах `3/8`, `1/7`, `З/3`.
|
||||
2. **Зависимость от API:** Для работы режима `qr_api` необходим доступ в интернет и оплаченный токен провайдера.
|
||||
3. **Скорость:** Режим `qr_api` работает быстрее (0.5-1.5 сек). Режим `ocr` может занимать 2-4 сек в зависимости от разрешения фото.
|
||||
|
||||
## 6. Инструкции для Интеграции
|
||||
При встраивании сервиса в общую систему (например, Telegram-бот или Backend приложения):
|
||||
1. Всегда проверяйте поле `source`. Если `source == "ocr"`, помечайте данные для пользователя как "Требующие проверки" (Draft). Если `source == "qr_api"`, данные можно считать верифицированными.
|
||||
2. Если массив `items` пустой, значит сервис не смог распознать чек (ни QR, ни текст не прочитался). Предложите пользователю переснять фото.
|
||||
137
ocr-service/yandex_ocr.py
Normal file
137
ocr-service/yandex_ocr.py
Normal file
@@ -0,0 +1,137 @@
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import base64
|
||||
import logging
|
||||
import requests
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
IAM_TOKEN_URL = "https://iam.api.cloud.yandex.net/iam/v1/tokens"
|
||||
VISION_URL = "https://ocr.api.cloud.yandex.net/ocr/v1/recognizeText"
|
||||
|
||||
class YandexOCREngine:
|
||||
def __init__(self):
|
||||
self.oauth_token = os.getenv("YANDEX_OAUTH_TOKEN")
|
||||
self.folder_id = os.getenv("YANDEX_FOLDER_ID")
|
||||
|
||||
# Кэширование IAM токена
|
||||
self._iam_token = None
|
||||
self._token_expire_time = 0
|
||||
|
||||
if not self.oauth_token or not self.folder_id:
|
||||
logger.warning("Yandex OCR credentials (YANDEX_OAUTH_TOKEN, YANDEX_FOLDER_ID) not set. Yandex OCR will be unavailable.")
|
||||
|
||||
def _get_iam_token(self) -> Optional[str]:
|
||||
"""
|
||||
Получает IAM-токен. Если есть живой кэшированный — возвращает его.
|
||||
Если нет — обменивает OAuth на IAM.
|
||||
"""
|
||||
current_time = time.time()
|
||||
|
||||
# Если токен есть и он "свежий" (с запасом в 5 минут)
|
||||
if self._iam_token and current_time < self._token_expire_time - 300:
|
||||
return self._iam_token
|
||||
|
||||
logger.info("Obtaining new IAM token from Yandex...")
|
||||
try:
|
||||
response = requests.post(
|
||||
IAM_TOKEN_URL,
|
||||
json={"yandexPassportOauthToken": self.oauth_token},
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
self._iam_token = data["iamToken"]
|
||||
|
||||
# Токен обычно живет 12 часов, но мы будем ориентироваться на поле expiresAt если нужно,
|
||||
# или просто поставим таймер. Для простоты берем 1 час жизни кэша.
|
||||
self._token_expire_time = current_time + 3600
|
||||
|
||||
logger.info("IAM token received successfully.")
|
||||
return self._iam_token
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get IAM token: {e}")
|
||||
return None
|
||||
|
||||
def recognize(self, image_bytes: bytes) -> str:
|
||||
"""
|
||||
Отправляет изображение в Yandex Vision и возвращает полный текст.
|
||||
"""
|
||||
if not self.oauth_token or not self.folder_id:
|
||||
logger.error("Yandex credentials missing.")
|
||||
return ""
|
||||
|
||||
iam_token = self._get_iam_token()
|
||||
if not iam_token:
|
||||
return ""
|
||||
|
||||
# 1. Кодируем в Base64
|
||||
b64_image = base64.b64encode(image_bytes).decode("utf-8")
|
||||
|
||||
# 2. Формируем тело запроса
|
||||
# Используем модель 'page' (для документов) и '*' для автоопределения языка
|
||||
payload = {
|
||||
"mimeType": "JPEG", # Yandex переваривает и PNG под видом JPEG часто, но лучше быть аккуратным.
|
||||
# В идеале определять mime-type из файла, но JPEG - безопасный дефолт для фото.
|
||||
"languageCodes": ["*"],
|
||||
"model": "page",
|
||||
"content": b64_image
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {iam_token}",
|
||||
"x-folder-id": self.folder_id,
|
||||
"x-data-logging-enabled": "true"
|
||||
}
|
||||
|
||||
# 3. Отправляем запрос
|
||||
try:
|
||||
logger.info("Sending request to Yandex Vision OCR...")
|
||||
response = requests.post(VISION_URL, headers=headers, json=payload, timeout=20)
|
||||
|
||||
# Если 401 Unauthorized, возможно токен протух раньше времени (редко, но бывает)
|
||||
if response.status_code == 401:
|
||||
logger.warning("Got 401 from Yandex. Retrying with fresh token...")
|
||||
self._iam_token = None # сброс кэша
|
||||
iam_token = self._get_iam_token()
|
||||
if iam_token:
|
||||
headers["Authorization"] = f"Bearer {iam_token}"
|
||||
response = requests.post(VISION_URL, headers=headers, json=payload, timeout=20)
|
||||
|
||||
response.raise_for_status()
|
||||
result_json = response.json()
|
||||
|
||||
# 4. Парсим ответ
|
||||
# Структура: result -> textAnnotation -> fullText
|
||||
# Или (если fullText нет) blocks -> lines -> text
|
||||
|
||||
text_annotation = result_json.get("result", {}).get("textAnnotation", {})
|
||||
|
||||
if not text_annotation:
|
||||
logger.warning("Yandex returned success but no textAnnotation found.")
|
||||
return ""
|
||||
|
||||
# Самый простой способ - взять fullText, он обычно склеен с \n
|
||||
full_text = text_annotation.get("fullText", "")
|
||||
|
||||
if not full_text:
|
||||
# Фолбэк: если fullText пуст, собираем вручную по блокам
|
||||
logger.info("fullText empty, assembling from blocks...")
|
||||
lines_text = []
|
||||
for block in text_annotation.get("blocks", []):
|
||||
for line in block.get("lines", []):
|
||||
lines_text.append(line.get("text", ""))
|
||||
full_text = "\n".join(lines_text)
|
||||
|
||||
return full_text
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during Yandex Vision request: {e}")
|
||||
return ""
|
||||
|
||||
# Глобальный инстанс
|
||||
yandex_engine = YandexOCREngine()
|
||||
151
pack_go_files.py
Normal file
151
pack_go_files.py
Normal file
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Скрипт для упаковки всех файлов проекта (.go, .json, .mod, .md)
|
||||
в один Python-файл для удобной передачи ИИ.
|
||||
Формирует дерево проекта и экранирует содержимое всех файлов.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Список имён файлов/папок, которые нужно игнорировать.
|
||||
# Работает по вхождению: "vendor" исключит любую vendor/*
|
||||
# ---------------------------------------------------------
|
||||
IGNORE_LIST = [
|
||||
".git",
|
||||
".kilocode",
|
||||
"tools",
|
||||
"project_dump.py",
|
||||
".idea",
|
||||
".vscode",
|
||||
"node_modules",
|
||||
"ftp_cache",
|
||||
"ocr-service",
|
||||
"rmser-view"
|
||||
]
|
||||
|
||||
|
||||
def should_ignore(path: str) -> bool:
|
||||
"""
|
||||
Проверяет, должен ли путь быть проигнорирован.
|
||||
Смотрит и на файлы, и на каталоги.
|
||||
"""
|
||||
for ignore in IGNORE_LIST:
|
||||
if ignore in path.replace("\\", "/"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def escape_content(content: str) -> str:
|
||||
"""
|
||||
Экранирует содержимое файла для корректного помещения в Python-строку.
|
||||
Используем json.dumps для максимальной безопасности и читаемости.
|
||||
"""
|
||||
return json.dumps(content, ensure_ascii=False)
|
||||
|
||||
|
||||
def collect_files(root_dir: str, extensions):
|
||||
"""
|
||||
Рекурсивно собирает пути ко всем файлам с указанными расширениями.
|
||||
Учитывает IGNORE_LIST.
|
||||
"""
|
||||
collected = []
|
||||
for dirpath, dirnames, filenames in os.walk(root_dir):
|
||||
|
||||
# фильтрация каталогов
|
||||
dirnames[:] = [d for d in dirnames if not should_ignore(os.path.join(dirpath, d))]
|
||||
|
||||
for file in filenames:
|
||||
full_path = os.path.join(dirpath, file)
|
||||
if should_ignore(full_path):
|
||||
continue
|
||||
if any(file.endswith(ext) for ext in extensions):
|
||||
collected.append(os.path.normpath(full_path))
|
||||
|
||||
return sorted(collected)
|
||||
|
||||
|
||||
def build_tree(root_dir: str) -> str:
|
||||
"""
|
||||
Создаёт строковое представление дерева проекта.
|
||||
Учитывает IGNORE_LIST.
|
||||
"""
|
||||
tree_lines = []
|
||||
|
||||
def walk(dir_path: str, prefix: str = ""):
|
||||
try:
|
||||
entries = sorted(os.listdir(dir_path))
|
||||
except PermissionError:
|
||||
return
|
||||
|
||||
# фильтрация по IGNORE_LIST
|
||||
entries = [e for e in entries if not should_ignore(os.path.join(dir_path, e))]
|
||||
|
||||
for idx, entry in enumerate(entries):
|
||||
path = os.path.join(dir_path, entry)
|
||||
connector = "└── " if idx == len(entries) - 1 else "├── "
|
||||
tree_lines.append(f"{prefix}{connector}{entry}")
|
||||
if os.path.isdir(path):
|
||||
new_prefix = prefix + (" " if idx == len(entries) - 1 else "│ ")
|
||||
walk(path, new_prefix)
|
||||
|
||||
tree_lines.append(".")
|
||||
walk(root_dir)
|
||||
return "\n".join(tree_lines)
|
||||
|
||||
|
||||
def write_to_py(files, tree_str, output_file):
|
||||
"""
|
||||
Записывает дерево проекта и содержимое файлов в один .py файл.
|
||||
"""
|
||||
with open(output_file, "w", encoding="utf-8") as f:
|
||||
f.write("# -*- coding: utf-8 -*-\n")
|
||||
f.write("# Этот файл сгенерирован автоматически.\n")
|
||||
f.write("# Содержит дерево проекта и файлы (.go, .json, .mod, .md) в экранированном виде.\n\n")
|
||||
|
||||
f.write("project_tree = '''\n")
|
||||
f.write(tree_str)
|
||||
f.write("\n'''\n\n")
|
||||
|
||||
f.write("project_files = {\n")
|
||||
for path in files:
|
||||
rel_path = os.path.relpath(path)
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="ignore") as src:
|
||||
content = src.read()
|
||||
except Exception as e:
|
||||
content = f"<<Ошибка чтения файла: {e}>>"
|
||||
|
||||
escaped_content = escape_content(content)
|
||||
f.write(f' "{rel_path}": {escaped_content},\n')
|
||||
f.write("}\n\n")
|
||||
|
||||
f.write("if __name__ == '__main__':\n")
|
||||
f.write(" print('=== Дерево проекта ===')\n")
|
||||
f.write(" print(project_tree)\n")
|
||||
f.write(" print('\\n=== Список файлов ===')\n")
|
||||
f.write(" for name in project_files:\n")
|
||||
f.write(" print(f'- {name}')\n")
|
||||
|
||||
|
||||
def main():
|
||||
root_dir = "."
|
||||
output_file = "project_dump.py"
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
output_file = sys.argv[1]
|
||||
|
||||
exts = [".go", ".yaml", ".json", ".mod", ".md"]
|
||||
|
||||
files = collect_files(root_dir, exts)
|
||||
tree_str = build_tree(root_dir)
|
||||
write_to_py(files, tree_str, output_file)
|
||||
|
||||
print(f"Собрано {len(files)} файлов. Результат в {output_file}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,73 +0,0 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -1,11 +1,12 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Providers } from './components/layout/Providers';
|
||||
import { AppLayout } from './components/layout/AppLayout';
|
||||
import { Dashboard } from './pages/Dashboard'; // Импортируем созданную страницу
|
||||
import { Dashboard } from './pages/Dashboard';
|
||||
import { OcrLearning } from './pages/OcrLearning';
|
||||
import { InvoiceDraftPage } from './pages/InvoiceDraftPage'; // Импорт
|
||||
|
||||
// Заглушки для остальных страниц пока оставим
|
||||
const InvoicesPage = () => <h2>Список накладных</h2>;
|
||||
// Заглушки для списка накладных пока оставим (или можно сделать пустую страницу)
|
||||
const InvoicesListPage = () => <h2>История накладных (в разработке)</h2>;
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -13,9 +14,15 @@ function App() {
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<AppLayout />}>
|
||||
<Route index element={<Dashboard />} /> {/* Используем компонент */}
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="ocr" element={<OcrLearning />} />
|
||||
<Route path="invoices" element={<InvoicesPage />} />
|
||||
|
||||
{/* Роут для черновика. :id - UUID черновика */}
|
||||
<Route path="invoice/:id" element={<InvoiceDraftPage />} />
|
||||
|
||||
{/* Страница списка */}
|
||||
<Route path="invoices" element={<InvoicesListPage />} />
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
162
rmser-view/src/components/invoices/DraftItemRow.tsx
Normal file
162
rmser-view/src/components/invoices/DraftItemRow.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Card, Flex, InputNumber, Typography, Select, Tag } from 'antd';
|
||||
import { SyncOutlined } from '@ant-design/icons';
|
||||
import { CatalogSelect } from '../ocr/CatalogSelect';
|
||||
import type { DraftItem, CatalogItem, UpdateDraftItemRequest } from '../../services/types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface Props {
|
||||
item: DraftItem;
|
||||
catalog: CatalogItem[];
|
||||
onUpdate: (itemId: string, changes: UpdateDraftItemRequest) => void;
|
||||
isUpdating: boolean; // Флаг, что конкретно эта строка сейчас сохраняется
|
||||
}
|
||||
|
||||
export const DraftItemRow: React.FC<Props> = ({ item, catalog, onUpdate, isUpdating }) => {
|
||||
// 1. Поиск выбранного товара в полном каталоге, чтобы получить доступ к containers
|
||||
const selectedProductObj = useMemo(() => {
|
||||
if (!item.product_id) return null;
|
||||
return catalog.find(c => c.id === item.product_id || c.ID === item.product_id);
|
||||
}, [item.product_id, catalog]);
|
||||
|
||||
// 2. Список фасовок для селекта
|
||||
const containerOptions = useMemo(() => {
|
||||
if (!selectedProductObj) return [];
|
||||
const conts = selectedProductObj.containers || selectedProductObj.Containers || [];
|
||||
const baseUom = selectedProductObj.measure_unit || selectedProductObj.MeasureUnit || 'ед.';
|
||||
|
||||
return [
|
||||
{ value: null, label: `Базовая (${baseUom})` }, // null значит базовая единица
|
||||
...conts.map(c => ({
|
||||
value: c.id,
|
||||
label: c.name // "Коробка"
|
||||
}))
|
||||
];
|
||||
}, [selectedProductObj]);
|
||||
|
||||
// 3. Хендлеры изменений
|
||||
|
||||
const handleProductChange = (prodId: string) => {
|
||||
// При смене товара: сбрасываем фасовку, подставляем исходные кол-во/цену, если они были нулями (логика "default")
|
||||
// Но по ТЗ: "При выборе товара автоматически подставлять quantity = raw_amount..."
|
||||
// Это лучше делать, передавая эти данные.
|
||||
|
||||
onUpdate(item.id, {
|
||||
product_id: prodId,
|
||||
container_id: null, // Сброс фасовки
|
||||
quantity: item.quantity || item.raw_amount || 1,
|
||||
price: item.price || item.raw_price || 0
|
||||
});
|
||||
};
|
||||
|
||||
const handleContainerChange = (val: string | null) => {
|
||||
// При смене фасовки просто шлем ID. Сервер сам не пересчитывает цифры, фронт тоже не должен.
|
||||
// Пользователь сам поправит цену, если она изменилась за упаковку.
|
||||
onUpdate(item.id, {
|
||||
container_id: val || null // Antd Select может вернуть undefined, приводим к null
|
||||
});
|
||||
};
|
||||
|
||||
const handleBlur = (field: 'quantity' | 'price', val: number | null) => {
|
||||
// Сохраняем только если значение изменилось и валидно
|
||||
if (val === null) return;
|
||||
if (val === item[field]) return;
|
||||
|
||||
onUpdate(item.id, {
|
||||
[field]: val
|
||||
});
|
||||
};
|
||||
|
||||
// Вычисляем статус цвета
|
||||
const cardBorderColor = !item.product_id ? '#ffa39e' : item.is_matched ? '#b7eb8f' : '#d9d9d9'; // Красный если нет товара, Зеленый если сматчился сам, Серый если правим
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
marginBottom: 8,
|
||||
borderLeft: `4px solid ${cardBorderColor}`,
|
||||
background: item.product_id ? '#fff' : '#fff1f0' // Легкий красный фон если не распознан
|
||||
}}
|
||||
bodyStyle={{ padding: 12 }}
|
||||
>
|
||||
<Flex vertical gap="small">
|
||||
{/* Верхняя строка: Исходное название и статус */}
|
||||
<Flex justify="space-between" align="start">
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{item.raw_name}
|
||||
</Text>
|
||||
{item.raw_amount > 0 && (
|
||||
<Text type="secondary" style={{ fontSize: 11, display: 'block' }}>
|
||||
(в чеке: {item.raw_amount} x {item.raw_price})
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{isUpdating && <SyncOutlined spin style={{ color: '#1890ff' }} />}
|
||||
{!item.product_id && <Tag color="error">Не найден</Tag>}
|
||||
</div>
|
||||
</Flex>
|
||||
|
||||
{/* Выбор товара */}
|
||||
<CatalogSelect
|
||||
catalog={catalog}
|
||||
value={item.product_id || undefined}
|
||||
onChange={handleProductChange}
|
||||
/>
|
||||
|
||||
{/* Нижний блок: Фасовка, Кол-во, Цена, Сумма */}
|
||||
<Flex gap={8} align="center">
|
||||
{/* Если есть фасовки, показываем селект. Если нет - просто лейбл ед. изм */}
|
||||
<div style={{ flex: 2, minWidth: 90 }}>
|
||||
{containerOptions.length > 1 ? (
|
||||
<Select
|
||||
size="middle"
|
||||
style={{ width: '100%' }}
|
||||
placeholder="Ед. изм."
|
||||
options={containerOptions}
|
||||
value={item.container_id || null} // null для базовой
|
||||
onChange={handleContainerChange}
|
||||
disabled={!item.product_id}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ padding: '4px 11px', background: '#f5f5f5', borderRadius: 6, fontSize: 13, color: '#888', border: '1px solid #d9d9d9' }}>
|
||||
{selectedProductObj?.measure_unit || 'ед.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<InputNumber
|
||||
style={{ flex: 1.5, minWidth: 60 }}
|
||||
placeholder="Кол-во"
|
||||
value={item.quantity}
|
||||
min={0}
|
||||
onBlur={(e) => handleBlur('quantity', parseFloat(e.target.value))}
|
||||
// В Antd onBlur event target value is string
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', minWidth: 60 }}>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
placeholder="Цена"
|
||||
value={item.price}
|
||||
min={0}
|
||||
onBlur={(e) => handleBlur('price', parseFloat(e.target.value))}
|
||||
/>
|
||||
<Text type="secondary" style={{ fontSize: 10 }}>Цена за ед.</Text>
|
||||
</div>
|
||||
</Flex>
|
||||
|
||||
{/* Итоговая сумма (расчетная) */}
|
||||
<div style={{ textAlign: 'right', marginTop: 4 }}>
|
||||
<Text strong>
|
||||
= {(item.quantity * item.price).toLocaleString('ru-RU', { style: 'currency', currency: 'RUB' })}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
244
rmser-view/src/pages/InvoiceDraftPage.tsx
Normal file
244
rmser-view/src/pages/InvoiceDraftPage.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Spin, Alert, Button, Form, Select, DatePicker, Input,
|
||||
Typography, message, Row, Col, Affix
|
||||
} from 'antd';
|
||||
import { ArrowLeftOutlined, CheckOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '../services/api';
|
||||
import { DraftItemRow } from '../components/invoices/DraftItemRow';
|
||||
import type { UpdateDraftItemRequest, CommitDraftRequest } from '../services/types';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
export const InvoiceDraftPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// Локальное состояние для отслеживания какие строки сейчас обновляются
|
||||
const [updatingItems, setUpdatingItems] = useState<Set<string>>(new Set());
|
||||
|
||||
// 1. Загрузка справочников
|
||||
const storesQuery = useQuery({ queryKey: ['stores'], queryFn: api.getStores });
|
||||
const suppliersQuery = useQuery({ queryKey: ['suppliers'], queryFn: api.getSuppliers });
|
||||
const catalogQuery = useQuery({ queryKey: ['catalog'], queryFn: api.getCatalogItems, staleTime: 1000 * 60 * 10 });
|
||||
|
||||
// 2. Загрузка черновика
|
||||
const draftQuery = useQuery({
|
||||
queryKey: ['draft', id],
|
||||
queryFn: () => api.getDraft(id!),
|
||||
enabled: !!id,
|
||||
refetchInterval: (query) => {
|
||||
const status = query.state.data?.status;
|
||||
// Продолжаем опрашивать, пока статус PROCESSING, чтобы подтянуть новые товары, если они долетают
|
||||
return status === 'PROCESSING' ? 3000 : false;
|
||||
},
|
||||
});
|
||||
|
||||
// 3. Мутация обновления строки
|
||||
const updateItemMutation = useMutation({
|
||||
mutationFn: (vars: { itemId: string; payload: UpdateDraftItemRequest }) =>
|
||||
api.updateDraftItem(id!, vars.itemId, vars.payload),
|
||||
onMutate: async ({ itemId }) => {
|
||||
setUpdatingItems(prev => new Set(prev).add(itemId));
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['draft', id] });
|
||||
},
|
||||
onError: () => {
|
||||
message.error('Не удалось сохранить строку');
|
||||
},
|
||||
onSettled: (_data, _err, vars) => {
|
||||
setUpdatingItems(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(vars.itemId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Мутация коммита
|
||||
const commitMutation = useMutation({
|
||||
mutationFn: (payload: CommitDraftRequest) => api.commitDraft(id!, payload),
|
||||
onSuccess: (data) => {
|
||||
message.success(`Накладная ${data.document_number} создана!`);
|
||||
navigate('/invoices');
|
||||
},
|
||||
onError: () => {
|
||||
message.error('Ошибка при создании накладной');
|
||||
}
|
||||
});
|
||||
|
||||
const draft = draftQuery.data;
|
||||
|
||||
// Инициализация формы.
|
||||
// Убрали проверку status !== 'PROCESSING', чтобы форма заполнялась сразу, как пришли данные.
|
||||
useEffect(() => {
|
||||
if (draft) {
|
||||
// Проверяем, не менял ли пользователь уже поля, чтобы не перезатирать их при поллинге
|
||||
const currentValues = form.getFieldsValue();
|
||||
if (!currentValues.store_id && draft.store_id) {
|
||||
form.setFieldValue('store_id', draft.store_id);
|
||||
}
|
||||
if (!currentValues.supplier_id && draft.supplier_id) {
|
||||
form.setFieldValue('supplier_id', draft.supplier_id);
|
||||
}
|
||||
if (!currentValues.comment && draft.comment) {
|
||||
form.setFieldValue('comment', draft.comment);
|
||||
}
|
||||
// Дату ставим, если её нет в форме
|
||||
if (!currentValues.date_incoming) {
|
||||
form.setFieldValue('date_incoming', draft.date_incoming ? dayjs(draft.date_incoming) : dayjs());
|
||||
}
|
||||
}
|
||||
}, [draft, form]);
|
||||
|
||||
// Вычисляемые данные для UI
|
||||
const totalSum = useMemo(() => {
|
||||
// Добавил Number(), так как API возвращает строки ("250"), а нам нужны числа
|
||||
return draft?.items.reduce((acc, item) => acc + (Number(item.quantity) * Number(item.price)), 0) || 0;
|
||||
}, [draft?.items]);
|
||||
|
||||
const invalidItemsCount = useMemo(() => {
|
||||
return draft?.items.filter(i => !i.product_id).length || 0;
|
||||
}, [draft?.items]);
|
||||
|
||||
const handleItemUpdate = (itemId: string, changes: UpdateDraftItemRequest) => {
|
||||
updateItemMutation.mutate({ itemId, payload: changes });
|
||||
};
|
||||
|
||||
const handleCommit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
if (invalidItemsCount > 0) {
|
||||
message.warning(`Осталось ${invalidItemsCount} нераспознанных товаров! Удалите их или сопоставьте.`);
|
||||
return;
|
||||
}
|
||||
|
||||
commitMutation.mutate({
|
||||
date_incoming: values.date_incoming.format('YYYY-MM-DD'),
|
||||
store_id: values.store_id,
|
||||
supplier_id: values.supplier_id,
|
||||
comment: values.comment || '',
|
||||
});
|
||||
} catch {
|
||||
message.error('Заполните обязательные поля в шапке (Склад, Поставщик)');
|
||||
}
|
||||
};
|
||||
|
||||
// --- Рендер ---
|
||||
|
||||
// Показываем спиннер ТОЛЬКО если данных нет вообще, или статус PROCESSING и список пуст.
|
||||
// Если статус PROCESSING, но items уже пришли — показываем интерфейс.
|
||||
const showSpinner = draftQuery.isLoading || (draft?.status === 'PROCESSING' && (!draft?.items || draft.items.length === 0));
|
||||
|
||||
if (showSpinner) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: 50 }}>
|
||||
<Spin size="large" tip="Обработка чека..." />
|
||||
<div style={{ marginTop: 16, color: '#888' }}>ИИ читает ваш чек, подождите...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (draftQuery.isError || !draft) {
|
||||
return <Alert type="error" message="Ошибка загрузки черновика" description="Попробуйте обновить страницу" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ paddingBottom: 80 }}>
|
||||
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/')} />
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
Черновик {draft.document_number ? `№${draft.document_number}` : ''}
|
||||
{draft.status === 'PROCESSING' && <Spin size="small" style={{ marginLeft: 8 }} />}
|
||||
</Title>
|
||||
</div>
|
||||
|
||||
<div style={{ background: '#fff', padding: 16, borderRadius: 8, marginBottom: 16 }}>
|
||||
<Form form={form} layout="vertical" initialValues={{ date_incoming: dayjs() }}>
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="Дата" name="date_incoming" rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} format="DD.MM.YYYY" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="Склад" name="store_id" rules={[{ required: true, message: 'Выберите склад' }]}>
|
||||
<Select
|
||||
placeholder="Куда?"
|
||||
loading={storesQuery.isLoading}
|
||||
options={storesQuery.data?.map(s => ({ label: s.name, value: s.id }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item label="Поставщик" name="supplier_id" rules={[{ required: true, message: 'Выберите поставщика' }]}>
|
||||
<Select
|
||||
placeholder="От кого?"
|
||||
loading={suppliersQuery.isLoading}
|
||||
options={suppliersQuery.data?.map(s => ({ label: s.name, value: s.id }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Комментарий" name="comment">
|
||||
<TextArea rows={1} placeholder="Прим: Довоз за пятницу" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Title level={5} style={{ margin: 0 }}>Позиции ({draft.items.length})</Title>
|
||||
{invalidItemsCount > 0 && <Text type="danger">{invalidItemsCount} нераспознано</Text>}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{draft.items.map(item => (
|
||||
<DraftItemRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
catalog={catalogQuery.data || []}
|
||||
onUpdate={handleItemUpdate}
|
||||
isUpdating={updatingItems.has(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Affix offsetBottom={0}>
|
||||
<div style={{
|
||||
background: '#fff',
|
||||
padding: '12px 16px',
|
||||
borderTop: '1px solid #eee',
|
||||
boxShadow: '0 -2px 10px rgba(0,0,0,0.05)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: '#888' }}>Итого:</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 'bold', color: '#1890ff' }}>
|
||||
{totalSum.toLocaleString('ru-RU', { style: 'currency', currency: 'RUB' })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<CheckOutlined />}
|
||||
onClick={handleCommit}
|
||||
loading={commitMutation.isPending}
|
||||
disabled={invalidItemsCount > 0}
|
||||
>
|
||||
Отправить в iiko
|
||||
</Button>
|
||||
</div>
|
||||
</Affix>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -7,7 +7,12 @@ import type {
|
||||
InvoiceResponse,
|
||||
ProductMatch,
|
||||
Recommendation,
|
||||
UnmatchedItem
|
||||
UnmatchedItem,
|
||||
Store,
|
||||
Supplier,
|
||||
DraftInvoice,
|
||||
UpdateDraftItemRequest,
|
||||
CommitDraftRequest
|
||||
} from './types';
|
||||
|
||||
// Базовый URL
|
||||
@@ -28,6 +33,14 @@ apiClient.interceptors.response.use(
|
||||
}
|
||||
);
|
||||
|
||||
// Мок поставщиков (так как эндпоинта пока нет)
|
||||
const MOCK_SUPPLIERS: Supplier[] = [
|
||||
{ id: '00000000-0000-0000-0000-000000000001', name: 'ООО "Рога и Копыта"' },
|
||||
{ id: '00000000-0000-0000-0000-000000000002', name: 'ИП Иванов (Овощи)' },
|
||||
{ id: '00000000-0000-0000-0000-000000000003', name: 'Metro Cash&Carry' },
|
||||
{ id: '00000000-0000-0000-0000-000000000004', name: 'Simple Wine' },
|
||||
];
|
||||
|
||||
export const api = {
|
||||
checkHealth: async (): Promise<HealthResponse> => {
|
||||
const { data } = await apiClient.get<HealthResponse>('/health');
|
||||
@@ -64,4 +77,39 @@ export const api = {
|
||||
const { data } = await apiClient.post<InvoiceResponse>('/invoices/send', payload);
|
||||
return data;
|
||||
},
|
||||
// Получить список складов
|
||||
getStores: async (): Promise<Store[]> => {
|
||||
const { data } = await apiClient.get<Store[]>('/dictionaries/stores');
|
||||
return data;
|
||||
},
|
||||
|
||||
// Получить список поставщиков (Mock)
|
||||
getSuppliers: async (): Promise<Supplier[]> => {
|
||||
// Имитация асинхронности
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => resolve(MOCK_SUPPLIERS), 300);
|
||||
});
|
||||
},
|
||||
|
||||
// Получить черновик
|
||||
getDraft: async (id: string): Promise<DraftInvoice> => {
|
||||
const { data } = await apiClient.get<DraftInvoice>(`/drafts/${id}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
// Обновить строку черновика (и обучить модель)
|
||||
updateDraftItem: async (draftId: string, itemId: string, payload: UpdateDraftItemRequest): Promise<DraftInvoice> => {
|
||||
// Бэкенд возвращает обновленный черновик целиком (обычно) или обновленный item.
|
||||
// Предположим, что возвращается обновленный Item или просто 200 OK.
|
||||
// Но для React Query удобно возвращать данные.
|
||||
// Если бэк возвращает только item, типизацию нужно уточнить. Пока ждем DraftInvoice или any.
|
||||
const { data } = await apiClient.patch<DraftInvoice>(`/drafts/${draftId}/items/${itemId}`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
// Зафиксировать черновик
|
||||
commitDraft: async (draftId: string, payload: CommitDraftRequest): Promise<{ document_number: string }> => {
|
||||
const { data } = await apiClient.post<{ document_number: string }>(`/drafts/${draftId}/commit`, payload);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
@@ -96,3 +96,68 @@ export interface HealthResponse {
|
||||
status: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
// --- Справочники ---
|
||||
|
||||
export interface Store {
|
||||
id: UUID;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Supplier {
|
||||
id: UUID;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// --- Черновик Накладной (Draft) ---
|
||||
|
||||
export type DraftStatus = 'PROCESSING' | 'READY_TO_VERIFY' | 'COMPLETED' | 'ERROR';
|
||||
|
||||
export interface DraftItem {
|
||||
id: UUID;
|
||||
|
||||
// Данные из OCR (Read-only)
|
||||
raw_name: string;
|
||||
raw_amount: number;
|
||||
raw_price: number;
|
||||
|
||||
// Редактируемые данные
|
||||
product_id: UUID | null;
|
||||
container_id: UUID | null; // Фасовка
|
||||
quantity: number;
|
||||
price: number;
|
||||
sum: number;
|
||||
|
||||
// Мета-данные
|
||||
is_matched: boolean;
|
||||
product?: CatalogItem; // Развернутый объект для UI
|
||||
container?: ProductContainer; // Развернутый объект для UI
|
||||
}
|
||||
|
||||
export interface DraftInvoice {
|
||||
id: UUID;
|
||||
status: DraftStatus;
|
||||
document_number: string;
|
||||
date_incoming: string | null; // YYYY-MM-DD
|
||||
store_id: UUID | null;
|
||||
supplier_id: UUID | null;
|
||||
comment: string;
|
||||
items: DraftItem[];
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
// DTO для обновления строки
|
||||
export interface UpdateDraftItemRequest {
|
||||
product_id?: UUID;
|
||||
container_id?: UUID | null; // null если сбросили фасовку
|
||||
quantity?: number;
|
||||
price?: number;
|
||||
}
|
||||
|
||||
// DTO для коммита
|
||||
export interface CommitDraftRequest {
|
||||
date_incoming: string;
|
||||
store_id: UUID;
|
||||
supplier_id: UUID;
|
||||
comment: string;
|
||||
}
|
||||
Reference in New Issue
Block a user