Добавил черновики накладных и OCR через Яндекс. LLM для расшифровки универсальный

This commit is contained in:
2025-12-17 03:38:24 +03:00
parent fda30276a5
commit e2df2350f7
32 changed files with 1785 additions and 214 deletions

6
.gitignore vendored
View File

@@ -1,5 +1,9 @@
# Python virtual environment # Python virtual environment
.venv .venv
*.py .env
pack_go_files.py
pack_py_files.py
pack_react_files.py
project_dump.py
node_modules node_modules

View File

@@ -20,6 +20,7 @@ import (
// Репозитории (инфраструктура) // Репозитории (инфраструктура)
catalogPkg "rmser/internal/infrastructure/repository/catalog" catalogPkg "rmser/internal/infrastructure/repository/catalog"
draftsPkg "rmser/internal/infrastructure/repository/drafts"
invoicesPkg "rmser/internal/infrastructure/repository/invoices" invoicesPkg "rmser/internal/infrastructure/repository/invoices"
ocrRepoPkg "rmser/internal/infrastructure/repository/ocr" ocrRepoPkg "rmser/internal/infrastructure/repository/ocr"
opsRepoPkg "rmser/internal/infrastructure/repository/operations" opsRepoPkg "rmser/internal/infrastructure/repository/operations"
@@ -27,6 +28,7 @@ import (
recRepoPkg "rmser/internal/infrastructure/repository/recommendations" recRepoPkg "rmser/internal/infrastructure/repository/recommendations"
"rmser/internal/infrastructure/rms" "rmser/internal/infrastructure/rms"
draftsServicePkg "rmser/internal/services/drafts"
invServicePkg "rmser/internal/services/invoices" // Сервис накладных invServicePkg "rmser/internal/services/invoices" // Сервис накладных
ocrServicePkg "rmser/internal/services/ocr" ocrServicePkg "rmser/internal/services/ocr"
recServicePkg "rmser/internal/services/recommend" recServicePkg "rmser/internal/services/recommend"
@@ -67,14 +69,17 @@ func main() {
opsRepo := opsRepoPkg.NewRepository(database) opsRepo := opsRepoPkg.NewRepository(database)
recRepo := recRepoPkg.NewRepository(database) recRepo := recRepoPkg.NewRepository(database)
ocrRepo := ocrRepoPkg.NewRepository(database) ocrRepo := ocrRepoPkg.NewRepository(database)
draftsRepo := draftsPkg.NewRepository(database)
syncService := sync.NewService(rmsClient, catalogRepo, recipesRepo, invoicesRepo, opsRepo) syncService := sync.NewService(rmsClient, catalogRepo, recipesRepo, invoicesRepo, opsRepo)
recService := recServicePkg.NewService(recRepo) 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) invoiceService := invServicePkg.NewService(rmsClient)
// --- Инициализация Handler'ов --- // --- Инициализация Handler'ов ---
invoiceHandler := handlers.NewInvoiceHandler(invoiceService) invoiceHandler := handlers.NewInvoiceHandler(invoiceService)
draftsHandler := handlers.NewDraftsHandler(draftsService)
ocrHandler := handlers.NewOCRHandler(ocrService) ocrHandler := handlers.NewOCRHandler(ocrService)
recommendHandler := handlers.NewRecommendationsHandler(recService) recommendHandler := handlers.NewRecommendationsHandler(recService)
@@ -154,6 +159,14 @@ func main() {
// Invoices // Invoices
api.POST("/invoices/send", invoiceHandler.SendInvoice) 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 // Recommendations
api.GET("/recommendations", recommendHandler.GetRecommendations) api.GET("/recommendations", recommendHandler.GetRecommendations)
@@ -161,6 +174,7 @@ func main() {
api.GET("/ocr/catalog", ocrHandler.GetCatalog) api.GET("/ocr/catalog", ocrHandler.GetCatalog)
api.GET("/ocr/matches", ocrHandler.GetMatches) api.GET("/ocr/matches", ocrHandler.GetMatches)
api.POST("/ocr/match", ocrHandler.SaveMatch) api.POST("/ocr/match", ocrHandler.SaveMatch)
api.DELETE("/ocr/match", ocrHandler.DeleteMatch)
api.GET("/ocr/unmatched", ocrHandler.GetUnmatched) api.GET("/ocr/unmatched", ocrHandler.GetUnmatched)
} }

View File

@@ -22,3 +22,4 @@ ocr:
telegram: telegram:
token: "7858843765:AAE5HBQHbef4fGLoMDV91bhHZQFcnsBDcv4" token: "7858843765:AAE5HBQHbef4fGLoMDV91bhHZQFcnsBDcv4"
admin_ids: [665599275] admin_ids: [665599275]
web_app_url: "https://rmser.serty.top"

View File

@@ -43,8 +43,9 @@ type RMSConfig struct {
} }
type TelegramConfig struct { type TelegramConfig struct {
Token string `mapstructure:"token"` Token string `mapstructure:"token"`
AdminIDs []int64 `mapstructure:"admin_ids"` AdminIDs []int64 `mapstructure:"admin_ids"`
WebAppURL string `mapstructure:"web_app_url"`
} }
// LoadConfig загружает конфигурацию из файла и переменных окружения // LoadConfig загружает конфигурацию из файла и переменных окружения

View File

@@ -30,6 +30,8 @@ services:
- "5005:5000" - "5005:5000"
environment: environment:
- LOG_LEVEL=INFO - LOG_LEVEL=INFO
- YANDEX_OAUTH_TOKEN=y0__xDK_988GMHdEyDc2M_XFTDIv-CCCP0kok1p0yRYJCgQrj8b9Kwylo25
- YANDEX_FOLDER_ID=b1gas1sh12oui8cskgcm
# 4. Go Application (Основной сервис) # 4. Go Application (Основной сервис)
app: app:

View File

@@ -55,4 +55,7 @@ type Repository interface {
SaveProducts(products []Product) error SaveProducts(products []Product) error
GetAll() ([]Product, error) GetAll() ([]Product, error)
GetActiveGoods() ([]Product, error) GetActiveGoods() ([]Product, error)
// --- Stores ---
SaveStores(stores []Store) error
GetActiveStores() ([]Store, error)
} }

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

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

View File

@@ -36,7 +36,7 @@ type UnmatchedItem struct {
type Repository interface { type Repository interface {
// SaveMatch теперь принимает quantity и containerID // SaveMatch теперь принимает quantity и containerID
SaveMatch(rawName string, productID uuid.UUID, quantity decimal.Decimal, containerID *uuid.UUID) error SaveMatch(rawName string, productID uuid.UUID, quantity decimal.Decimal, containerID *uuid.UUID) error
DeleteMatch(rawName string) error
FindMatch(rawName string) (*ProductMatch, error) // Возвращаем полную структуру, чтобы получить qty FindMatch(rawName string) (*ProductMatch, error) // Возвращаем полную структуру, чтобы получить qty
GetAllMatches() ([]ProductMatch, error) GetAllMatches() ([]ProductMatch, error)

View File

@@ -7,6 +7,7 @@ import (
"os" "os"
"regexp" "regexp"
"rmser/internal/domain/catalog" "rmser/internal/domain/catalog"
"rmser/internal/domain/drafts"
"rmser/internal/domain/invoices" "rmser/internal/domain/invoices"
"rmser/internal/domain/ocr" "rmser/internal/domain/ocr"
"rmser/internal/domain/operations" "rmser/internal/domain/operations"
@@ -48,10 +49,13 @@ func NewPostgresDB(dsn string) *gorm.DB {
&catalog.Product{}, &catalog.Product{},
&catalog.MeasureUnit{}, &catalog.MeasureUnit{},
&catalog.ProductContainer{}, &catalog.ProductContainer{},
&catalog.Store{},
&recipes.Recipe{}, &recipes.Recipe{},
&recipes.RecipeItem{}, &recipes.RecipeItem{},
&invoices.Invoice{}, &invoices.Invoice{},
&invoices.InvoiceItem{}, &invoices.InvoiceItem{},
&drafts.DraftInvoice{},
&drafts.DraftInvoiceItem{},
&operations.StoreOperation{}, &operations.StoreOperation{},
&recommendations.Recommendation{}, &recommendations.Recommendation{},
&ocr.ProductMatch{}, &ocr.ProductMatch{},

View File

@@ -116,3 +116,19 @@ func (r *pgRepository) GetActiveGoods() ([]catalog.Product, error) {
Find(&products).Error Find(&products).Error
return products, err 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
}

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

View File

@@ -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) { func (r *pgRepository) FindMatch(rawName string) (*ocr.ProductMatch, error) {
normalized := strings.ToLower(strings.TrimSpace(rawName)) normalized := strings.ToLower(strings.TrimSpace(rawName))
var match ocr.ProductMatch var match ocr.ProductMatch

View File

@@ -32,6 +32,7 @@ type ClientI interface {
Auth() error Auth() error
Logout() error Logout() error
FetchCatalog() ([]catalog.Product, error) FetchCatalog() ([]catalog.Product, error)
FetchStores() ([]catalog.Store, error)
FetchMeasureUnits() ([]catalog.MeasureUnit, error) FetchMeasureUnits() ([]catalog.MeasureUnit, error)
FetchRecipes(dateFrom, dateTo time.Time) ([]recipes.Recipe, error) FetchRecipes(dateFrom, dateTo time.Time) ([]recipes.Recipe, error)
FetchInvoices(from, to time.Time) ([]invoices.Invoice, error) FetchInvoices(from, to time.Time) ([]invoices.Invoice, error)
@@ -337,6 +338,52 @@ func (c *Client) FetchCatalog() ([]catalog.Product, error) {
return products, nil 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 загружает справочник единиц измерения // FetchMeasureUnits загружает справочник единиц измерения
func (c *Client) FetchMeasureUnits() ([]catalog.MeasureUnit, error) { func (c *Client) FetchMeasureUnits() ([]catalog.MeasureUnit, error) {
// rootType=MeasureUnit согласно документации iiko // rootType=MeasureUnit согласно документации iiko

View File

@@ -28,6 +28,15 @@ type GenericEntityDTO struct {
Deleted bool `json:"deleted"` 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 // ContainerDTO - фасовка из iiko
type ContainerDTO struct { type ContainerDTO struct {
ID string `json:"id"` ID string `json:"id"`

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

View File

@@ -10,6 +10,7 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
"rmser/internal/domain/catalog" "rmser/internal/domain/catalog"
"rmser/internal/domain/drafts"
"rmser/internal/domain/ocr" "rmser/internal/domain/ocr"
"rmser/internal/infrastructure/ocr_client" "rmser/internal/infrastructure/ocr_client"
"rmser/pkg/logger" "rmser/pkg/logger"
@@ -18,57 +19,116 @@ import (
type Service struct { type Service struct {
ocrRepo ocr.Repository ocrRepo ocr.Repository
catalogRepo catalog.Repository catalogRepo catalog.Repository
draftRepo drafts.Repository
pyClient *ocr_client.Client // Клиент к Python сервису pyClient *ocr_client.Client // Клиент к Python сервису
} }
func NewService( func NewService(
ocrRepo ocr.Repository, ocrRepo ocr.Repository,
catalogRepo catalog.Repository, catalogRepo catalog.Repository,
draftRepo drafts.Repository,
pyClient *ocr_client.Client, pyClient *ocr_client.Client,
) *Service { ) *Service {
return &Service{ return &Service{
ocrRepo: ocrRepo, ocrRepo: ocrRepo,
catalogRepo: catalogRepo, catalogRepo: catalogRepo,
draftRepo: draftRepo,
pyClient: pyClient, pyClient: pyClient,
} }
} }
// ProcessReceiptImage - основной метод: Картинка -> Распознавание -> Матчинг // ProcessReceiptImage - Создает черновик, распознает, сохраняет результаты
func (s *Service) ProcessReceiptImage(ctx context.Context, imgData []byte) ([]ProcessedItem, error) { func (s *Service) ProcessReceiptImage(ctx context.Context, chatID int64, imgData []byte) (*drafts.DraftInvoice, error) {
// 1. Отправляем в Python // 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") rawResult, err := s.pyClient.ProcessImage(ctx, imgData, "receipt.jpg")
if err != nil { if err != nil {
// Ставим статус ошибки
draft.Status = drafts.StatusError
_ = s.draftRepo.Update(draft)
return nil, fmt.Errorf("python ocr error: %w", err) return nil, fmt.Errorf("python ocr error: %w", err)
} }
var processed []ProcessedItem // 3. Обрабатываем результаты и создаем Items
var draftItems []drafts.DraftInvoiceItem
for _, rawItem := range rawResult.Items { for _, rawItem := range rawResult.Items {
item := ProcessedItem{ item := drafts.DraftInvoiceItem{
RawName: rawItem.RawName, DraftID: draft.ID,
Amount: decimal.NewFromFloat(rawItem.Amount), RawName: rawItem.RawName,
Price: decimal.NewFromFloat(rawItem.Price), RawAmount: decimal.NewFromFloat(rawItem.Amount),
Sum: decimal.NewFromFloat(rawItem.Sum), 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) match, err := s.ocrRepo.FindMatch(rawItem.RawName)
if err != nil { if err != nil {
logger.Log.Error("db error finding match", zap.Error(err)) logger.Log.Error("db error finding match", zap.Error(err))
} }
if match != nil { if match != nil {
item.ProductID = &match.ProductID
item.IsMatched = true item.IsMatched = true
item.MatchSource = "learned" item.ProductID = &match.ProductID
// Здесь мы могли бы подтянуть quantity/container из матча, item.ContainerID = match.ContainerID
// но пока фронт сам это сделает, запросив /ocr/matches или получив подсказку.
// Важная логика: Если в матче указано ContainerID, то Quantity из чека (например 5 шт)
// это 5 коробок. Финальное кол-во (в кг) RMS посчитает сама,
// либо мы можем пересчитать тут, если знаем коэффициент.
// Пока оставляем Quantity как есть (кол-во упаковок),
// так как ContainerID передается в iiko.
} else { } else {
// Если не нашли - сохраняем в Unmatched для статистики и подсказок
if err := s.ocrRepo.UpsertUnmatched(rawItem.RawName); err != nil { if err := s.ocrRepo.UpsertUnmatched(rawItem.RawName); err != nil {
logger.Log.Warn("failed to save unmatched", zap.Error(err)) 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 - результат обработки одной строки чека // ProcessedItem - результат обработки одной строки чека
@@ -137,6 +197,11 @@ func (s *Service) SaveMapping(rawName string, productID uuid.UUID, quantity deci
return s.ocrRepo.SaveMatch(rawName, productID, quantity, containerID) return s.ocrRepo.SaveMatch(rawName, productID, quantity, containerID)
} }
// DeleteMatch удаляет ошибочную привязку
func (s *Service) DeleteMatch(rawName string) error {
return s.ocrRepo.DeleteMatch(rawName)
}
// GetKnownMatches возвращает список всех обученных связей // GetKnownMatches возвращает список всех обученных связей
func (s *Service) GetKnownMatches() ([]ocr.ProductMatch, error) { func (s *Service) GetKnownMatches() ([]ocr.ProductMatch, error) {
return s.ocrRepo.GetAllMatches() return s.ocrRepo.GetAllMatches()

View File

@@ -49,14 +49,20 @@ func NewService(
// SyncCatalog загружает номенклатуру и сохраняет в БД // SyncCatalog загружает номенклатуру и сохраняет в БД
func (s *Service) SyncCatalog() error { 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 { if err := s.syncMeasureUnits(); err != nil {
return err return err
} }
// 2. Товары // 3. Товары
logger.Log.Info("Запрос товаров из RMS...") logger.Log.Info("Запрос товаров из RMS...")
products, err := s.rmsClient.FetchCatalog() products, err := s.rmsClient.FetchCatalog()
if err != nil { 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 { func (s *Service) SyncStoreOperations() error {
dateTo := time.Now() dateTo := time.Now()
dateFrom := dateTo.AddDate(0, 0, -30) dateFrom := dateTo.AddDate(0, 0, -30)

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

View File

@@ -73,6 +73,25 @@ func (h *OCRHandler) SaveMatch(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "saved"}) 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 возвращает список всех обученных связей // GetMatches возвращает список всех обученных связей
func (h *OCRHandler) GetMatches(c *gin.Context) { func (h *OCRHandler) GetMatches(c *gin.Context) {
matches, err := h.service.GetKnownMatches() matches, err := h.service.GetKnownMatches()

View File

@@ -21,6 +21,7 @@ type Bot struct {
b *tele.Bot b *tele.Bot
ocrService *ocr.Service ocrService *ocr.Service
adminIDs map[int64]struct{} adminIDs map[int64]struct{}
webAppURL string
} }
func NewBot(cfg config.TelegramConfig, ocrService *ocr.Service) (*Bot, error) { 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, b: b,
ocrService: ocrService, ocrService: ocrService,
adminIDs: admins, adminIDs: admins,
webAppURL: cfg.WebAppURL,
}
// Если в конфиге пусто, ставим заглушку, чтобы не падало, но предупреждаем
if bot.webAppURL == "" {
logger.Log.Warn("Telegram WebAppURL не задан в конфиге! Кнопки работать не будут.")
bot.webAppURL = "http://example.com"
} }
bot.initHandlers() bot.initHandlers()
@@ -106,36 +114,49 @@ func (bot *Bot) handlePhoto(c tele.Context) error {
return c.Send("Ошибка чтения файла.") return c.Send("Ошибка чтения файла.")
} }
c.Send("⏳ Обрабатываю чек через OCR...") c.Send("⏳ Обрабатываю чек: создаю черновик и распознаю...")
// 2. Отправляем в сервис // 2. Отправляем в сервис (добавили ID чата)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) // Чуть увеличим таймаут
defer cancel() defer cancel()
items, err := bot.ocrService.ProcessReceiptImage(ctx, imgData) draft, err := bot.ocrService.ProcessReceiptImage(ctx, c.Chat().ID, imgData)
if err != nil { if err != nil {
logger.Log.Error("OCR processing failed", zap.Error(err)) logger.Log.Error("OCR processing failed", zap.Error(err))
return c.Send("❌ Ошибка распознавания: " + err.Error()) return c.Send("❌ Ошибка обработки: " + err.Error())
} }
// 3. Формируем отчет // 3. Анализ результатов для сообщения
var sb strings.Builder
sb.WriteString(fmt.Sprintf("🧾 <b>Результат (%d поз.):</b>\n\n", len(items)))
matchedCount := 0 matchedCount := 0
for _, item := range items { for _, item := range draft.Items {
if item.IsMatched { if item.IsMatched {
matchedCount++ matchedCount++
sb.WriteString(fmt.Sprintf("✅ %s\n └ <code>%s</code> x %s = %s\n",
item.RawName, item.Amount, item.Price, item.Sum))
} else {
sb.WriteString(fmt.Sprintf("❓ <b>%s</b>\n └ Нет привязки!\n", item.RawName))
} }
} }
sb.WriteString(fmt.Sprintf("\nРаспознано: %d/%d", matchedCount, len(items))) // Формируем 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
return c.Send(sb.String(), tele.ModeHTML) if matchedCount == len(draft.Items) {
msgText = fmt.Sprintf("✅ <b>Успех!</b> Все позиции (%d) распознаны.\n\nПереходите к созданию накладной.", len(draft.Items))
} else {
msgText = fmt.Sprintf("⚠️ <b>Внимание!</b> Распознано %d из %d позиций.\n\nНекоторые товары требуют ручного сопоставления. Нажмите кнопку ниже, чтобы исправить.", matchedCount, len(draft.Items))
}
menu := &tele.ReplyMarkup{}
// Используем 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
View 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()

View File

@@ -1,4 +1,5 @@
import logging import logging
import os
from typing import List from typing import List
from fastapi import FastAPI, File, UploadFile, HTTPException from fastapi import FastAPI, File, UploadFile, HTTPException
@@ -10,8 +11,10 @@ import numpy as np
from imgproc import preprocess_image from imgproc import preprocess_image
from parser import parse_receipt_text, ParsedItem from parser import parse_receipt_text, ParsedItem
from ocr import ocr_engine from ocr import ocr_engine
# Импортируем новый модуль
from qr_manager import detect_and_decode_qr, fetch_data_from_api 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( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@@ -19,10 +22,10 @@ logging.basicConfig(
) )
logger = logging.getLogger(__name__) 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): class RecognitionResult(BaseModel):
source: str # 'qr_api' или 'ocr' source: str # 'qr_api', 'yandex_vision', 'tesseract_ocr'
items: List[ParsedItem] items: List[ParsedItem]
raw_text: str = "" raw_text: str = ""
@@ -33,9 +36,10 @@ def health_check():
@app.post("/recognize", response_model=RecognitionResult) @app.post("/recognize", response_model=RecognitionResult)
async def recognize_receipt(image: UploadFile = File(...)): async def recognize_receipt(image: UploadFile = File(...)):
""" """
1. Попытка найти QR-код. Стратегия:
2. Если QR найден -> запрос к API -> возврат идеальных данных. 1. QR Code + FNS API (Приоритет 1 - Идеальная точность)
3. Если QR не найден -> Preprocessing -> OCR -> Regex Parsing. 2. Yandex Vision OCR (Приоритет 2 - Высокая точность, если настроен)
3. Tesseract OCR (Приоритет 3 - Локальный фолбэк)
""" """
logger.info(f"Received file: {image.filename}, content_type: {image.content_type}") 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") raise HTTPException(status_code=400, detail="File must be an image")
try: try:
# Читаем байты # Читаем сырые байты
content = await image.read() content = await image.read()
# Конвертируем в numpy для работы (нужен и для QR, и для OCR) # Конвертируем в numpy для QR и локального препроцессинга
nparr = np.frombuffer(content, np.uint8) nparr = np.frombuffer(content, np.uint8)
# Оригинальное изображение (цветное/серое)
original_cv_image = cv2.imdecode(nparr, cv2.IMREAD_COLOR) original_cv_image = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
if original_cv_image is None: if original_cv_image is None:
raise HTTPException(status_code=400, detail="Invalid image data") raise HTTPException(status_code=400, detail="Invalid image data")
# --- ЭТАП 1: QR Code Strategy --- # --- ЭТАП 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) qr_raw = detect_and_decode_qr(original_cv_image)
if qr_raw: if qr_raw:
@@ -63,34 +66,63 @@ async def recognize_receipt(image: UploadFile = File(...)):
api_items = fetch_data_from_api(qr_raw) api_items = fetch_data_from_api(qr_raw)
if api_items: 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( return RecognitionResult(
source="qr_api", source="qr_api",
items=api_items, items=api_items,
raw_text=f"QR Content: {qr_raw}" raw_text=f"QR Content: {qr_raw}"
) )
else: 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: 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) processed_img = preprocess_image(content)
# 2. OCR # 2. OCR
full_text = ocr_engine.recognize(processed_img) tesseract_text = ocr_engine.recognize(processed_img)
# 3. Parsing # 3. Parsing
ocr_items = parse_receipt_text(full_text) ocr_items = parse_receipt_text(tesseract_text)
return RecognitionResult( return RecognitionResult(
source="ocr", source="tesseract_ocr",
items=ocr_items, items=ocr_items,
raw_text=full_text raw_text=tesseract_text
) )
except Exception as e: except Exception as e:

View File

@@ -1,87 +1,48 @@
Вот подробный системный промпт (System Definition), который описывает архитектуру, логику и контракт работы твоего OCR-сервиса. # System Definition: RMSER OCR Service (v2.0)
Сохрани этот текст как **`SYSTEM_PROMPT.md`** или в документацию проекта (Confluence/Wiki). К нему стоит обращаться при разработке API-клиентов, тестировании или доработке логики.
---
# System Definition: RMSER OCR Service
## 1. Роль и Назначение ## 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) ### Этап В: Локальный OCR (Bronze Fallback)
1. **Детекция:** Сервис сканирует изображение на наличие QR-кода (библиотека `pyzbar`). *Запускается при недоступности облака.*
2. **Декодирование:** Извлекает сырую строку чека (формат: `t=YYYYMMDD...&s=SUM...&fn=...`). 1. Препроцессинг (OpenCV: Binarization, Deskew).
3. **Запрос к API:** Отправляет сырые данные в API `proverkacheka.com` (или аналог). 2. OCR (Tesseract).
4. **Результат:** 3. Парсинг (Regex).
* Если API возвращает успех: Возвращает идеальный список товаров. 4. **Результат:** `source: "tesseract_ocr"`. Базовая точность.
* **Метаданные ответа:** `source: "qr_api"`.
### Этап Б: Оптическое Распознавание (Fallback Strategy) ## 3. Контракт API
*Запускается только если QR-код не найден или API вернул ошибку.*
1. **Препроцессинг (OpenCV):** **POST /recognize** (`multipart/form-data`)
* Поиск контуров документа.
* Выравнивание перспективы (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`:
**Response (JSON):**
```json ```json
{ {
"source": "qr_api", // или "ocr" "source": "yandex_vision",
"items": [ "items": [
{ {
"raw_name": "Молоко Домик в Деревне 3.2%", // Название товара "raw_name": "Маракуйя - пюре, 250 гр",
"amount": 2.0, // Количество "amount": 5.0,
"price": 89.99, // Цена за единицу "price": 282.00,
"sum": 179.98 // Общая сумма позиции "sum": 1410.00
},
{
"raw_name": "Пакет-майка",
"amount": 1.0,
"price": 5.00,
"sum": 5.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
View 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
View 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()

View File

@@ -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...
},
},
])
```

View File

@@ -1,11 +1,12 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { Providers } from './components/layout/Providers'; import { Providers } from './components/layout/Providers';
import { AppLayout } from './components/layout/AppLayout'; import { AppLayout } from './components/layout/AppLayout';
import { Dashboard } from './pages/Dashboard'; // Импортируем созданную страницу import { Dashboard } from './pages/Dashboard';
import { OcrLearning } from './pages/OcrLearning'; import { OcrLearning } from './pages/OcrLearning';
import { InvoiceDraftPage } from './pages/InvoiceDraftPage'; // Импорт
// Заглушки для остальных страниц пока оставим // Заглушки для списка накладных пока оставим (или можно сделать пустую страницу)
const InvoicesPage = () => <h2>Список накладных</h2>; const InvoicesListPage = () => <h2>История накладных (в разработке)</h2>;
function App() { function App() {
return ( return (
@@ -13,9 +14,15 @@ function App() {
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="/" element={<AppLayout />}> <Route path="/" element={<AppLayout />}>
<Route index element={<Dashboard />} /> {/* Используем компонент */} <Route index element={<Dashboard />} />
<Route path="ocr" element={<OcrLearning />} /> <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 path="*" element={<Navigate to="/" replace />} />
</Route> </Route>
</Routes> </Routes>

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

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

View File

@@ -7,7 +7,12 @@ import type {
InvoiceResponse, InvoiceResponse,
ProductMatch, ProductMatch,
Recommendation, Recommendation,
UnmatchedItem UnmatchedItem,
Store,
Supplier,
DraftInvoice,
UpdateDraftItemRequest,
CommitDraftRequest
} from './types'; } from './types';
// Базовый URL // Базовый 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 = { export const api = {
checkHealth: async (): Promise<HealthResponse> => { checkHealth: async (): Promise<HealthResponse> => {
const { data } = await apiClient.get<HealthResponse>('/health'); const { data } = await apiClient.get<HealthResponse>('/health');
@@ -64,4 +77,39 @@ export const api = {
const { data } = await apiClient.post<InvoiceResponse>('/invoices/send', payload); const { data } = await apiClient.post<InvoiceResponse>('/invoices/send', payload);
return data; 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;
},
}; };

View File

@@ -96,3 +96,68 @@ export interface HealthResponse {
status: string; status: string;
time: 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;
}