diff --git a/.gitignore b/.gitignore
index 0ba42dc..df54f37 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
\ No newline at end of file
diff --git a/cmd/main.go b/cmd/main.go
index 0d84203..4d83176 100644
--- a/cmd/main.go
+++ b/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)
}
diff --git a/config.yaml b/config.yaml
index 3769a53..5294211 100644
--- a/config.yaml
+++ b/config.yaml
@@ -21,4 +21,5 @@ ocr:
telegram:
token: "7858843765:AAE5HBQHbef4fGLoMDV91bhHZQFcnsBDcv4"
- admin_ids: [665599275]
\ No newline at end of file
+ admin_ids: [665599275]
+ web_app_url: "https://rmser.serty.top"
\ No newline at end of file
diff --git a/config/config.go b/config/config.go
index 0b62136..a67baaf 100644
--- a/config/config.go
+++ b/config/config.go
@@ -43,8 +43,9 @@ type RMSConfig struct {
}
type TelegramConfig struct {
- Token string `mapstructure:"token"`
- AdminIDs []int64 `mapstructure:"admin_ids"`
+ Token string `mapstructure:"token"`
+ AdminIDs []int64 `mapstructure:"admin_ids"`
+ WebAppURL string `mapstructure:"web_app_url"`
}
// LoadConfig загружает конфигурацию из файла и переменных окружения
diff --git a/docker-compose.yml b/docker-compose.yml
index 267f4f3..03b79a7 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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:
diff --git a/internal/domain/catalog/entity.go b/internal/domain/catalog/entity.go
index c46a27e..cb63163 100644
--- a/internal/domain/catalog/entity.go
+++ b/internal/domain/catalog/entity.go
@@ -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)
}
diff --git a/internal/domain/catalog/store.go b/internal/domain/catalog/store.go
new file mode 100644
index 0000000..98ddf74
--- /dev/null
+++ b/internal/domain/catalog/store.go
@@ -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"`
+}
diff --git a/internal/domain/drafts/entity.go b/internal/domain/drafts/entity.go
new file mode 100644
index 0000000..a999289
--- /dev/null
+++ b/internal/domain/drafts/entity.go
@@ -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
+}
diff --git a/internal/domain/ocr/entity.go b/internal/domain/ocr/entity.go
index 2fbdadf..156652f 100644
--- a/internal/domain/ocr/entity.go
+++ b/internal/domain/ocr/entity.go
@@ -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)
diff --git a/internal/infrastructure/db/postgres.go b/internal/infrastructure/db/postgres.go
index 70efb4f..1f3100b 100644
--- a/internal/infrastructure/db/postgres.go
+++ b/internal/infrastructure/db/postgres.go
@@ -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{},
diff --git a/internal/infrastructure/repository/catalog/postgres.go b/internal/infrastructure/repository/catalog/postgres.go
index ec1055f..3092ac3 100644
--- a/internal/infrastructure/repository/catalog/postgres.go
+++ b/internal/infrastructure/repository/catalog/postgres.go
@@ -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
+}
diff --git a/internal/infrastructure/repository/drafts/postgres.go b/internal/infrastructure/repository/drafts/postgres.go
new file mode 100644
index 0000000..67572dc
--- /dev/null
+++ b/internal/infrastructure/repository/drafts/postgres.go
@@ -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
+}
diff --git a/internal/infrastructure/repository/ocr/postgres.go b/internal/infrastructure/repository/ocr/postgres.go
index be8fca4..5c76d8c 100644
--- a/internal/infrastructure/repository/ocr/postgres.go
+++ b/internal/infrastructure/repository/ocr/postgres.go
@@ -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
diff --git a/internal/infrastructure/rms/client.go b/internal/infrastructure/rms/client.go
index bc80413..71953c1 100644
--- a/internal/infrastructure/rms/client.go
+++ b/internal/infrastructure/rms/client.go
@@ -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
diff --git a/internal/infrastructure/rms/dto.go b/internal/infrastructure/rms/dto.go
index 97f20af..ece2e20 100644
--- a/internal/infrastructure/rms/dto.go
+++ b/internal/infrastructure/rms/dto.go
@@ -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"`
diff --git a/internal/services/drafts/service.go b/internal/services/drafts/service.go
new file mode 100644
index 0000000..7390e98
--- /dev/null
+++ b/internal/services/drafts/service.go
@@ -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()
+}
diff --git a/internal/services/ocr/service.go b/internal/services/ocr/service.go
index 24ead05..58426ec 100644
--- a/internal/services/ocr/service.go
+++ b/internal/services/ocr/service.go
@@ -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{
- RawName: rawItem.RawName,
- Amount: decimal.NewFromFloat(rawItem.Amount),
- Price: decimal.NewFromFloat(rawItem.Price),
- Sum: decimal.NewFromFloat(rawItem.Sum),
+ item := drafts.DraftInvoiceItem{
+ DraftID: draft.ID,
+ RawName: rawItem.RawName,
+ 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()
diff --git a/internal/services/sync/service.go b/internal/services/sync/service.go
index 7dcb02d..33e0898 100644
--- a/internal/services/sync/service.go
+++ b/internal/services/sync/service.go
@@ -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)
diff --git a/internal/transport/http/handlers/drafts.go b/internal/transport/http/handlers/drafts.go
new file mode 100644
index 0000000..f726694
--- /dev/null
+++ b/internal/transport/http/handlers/drafts.go
@@ -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,
+ })
+}
diff --git a/internal/transport/http/handlers/ocr.go b/internal/transport/http/handlers/ocr.go
index 9614d7e..4174d94 100644
--- a/internal/transport/http/handlers/ocr.go
+++ b/internal/transport/http/handlers/ocr.go
@@ -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()
diff --git a/internal/transport/telegram/bot.go b/internal/transport/telegram/bot.go
index 9780fce..943593a 100644
--- a/internal/transport/telegram/bot.go
+++ b/internal/transport/telegram/bot.go
@@ -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("🧾 Результат (%d поз.):\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 └ %s x %s = %s\n",
- item.RawName, item.Amount, item.Price, item.Sum))
- } else {
- sb.WriteString(fmt.Sprintf("❓ %s\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())
- // Тут можно добавить кнопки, если что-то не распознано
- // Но для начала просто текст
- return c.Send(sb.String(), tele.ModeHTML)
+ // Формируем текст сообщения
+ var msgText string
+ if matchedCount == len(draft.Items) {
+ msgText = fmt.Sprintf("✅ Успех! Все позиции (%d) распознаны.\n\nПереходите к созданию накладной.", len(draft.Items))
+ } else {
+ msgText = fmt.Sprintf("⚠️ Внимание! Распознано %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)
}
diff --git a/ocr-service/llm_parser.py b/ocr-service/llm_parser.py
new file mode 100644
index 0000000..2769bb3
--- /dev/null
+++ b/ocr-service/llm_parser.py
@@ -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()
\ No newline at end of file
diff --git a/ocr-service/main.py b/ocr-service/main.py
index c3fde5b..97a8946 100644
--- a/ocr-service/main.py
+++ b/ocr-service/main.py
@@ -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 ---")
+
+ # Яндекс принимает сырые байты картинки (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 (получаем бинарное изображение)
- # Передаем исходные байты, так как функция внутри декодирует их заново
- # (можно оптимизировать, но оставим совместимость с текущим кодом)
+ # 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:
diff --git a/ocr-service/system-prompt.md b/ocr-service/system-prompt.md
index ba9f664..6745b89 100644
--- a/ocr-service/system-prompt.md
+++ b/ocr-service/system-prompt.md
@@ -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
-}
-```
-
-## 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, ни текст не прочитался). Предложите пользователю переснять фото.
\ No newline at end of file
+ "raw_text": "..."
+}
\ No newline at end of file
diff --git a/ocr-service/yandex_ocr.py b/ocr-service/yandex_ocr.py
new file mode 100644
index 0000000..6d8417b
--- /dev/null
+++ b/ocr-service/yandex_ocr.py
@@ -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()
\ No newline at end of file
diff --git a/pack_go_files.py b/pack_go_files.py
new file mode 100644
index 0000000..2968e32
--- /dev/null
+++ b/pack_go_files.py
@@ -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()
diff --git a/rmser-view/README.md b/rmser-view/README.md
deleted file mode 100644
index d2e7761..0000000
--- a/rmser-view/README.md
+++ /dev/null
@@ -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...
- },
- },
-])
-```
diff --git a/rmser-view/src/App.tsx b/rmser-view/src/App.tsx
index c03b02f..dc52661 100644
--- a/rmser-view/src/App.tsx
+++ b/rmser-view/src/App.tsx
@@ -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 = () =>