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 = () =>

Список накладных

; +// Заглушки для списка накладных пока оставим (или можно сделать пустую страницу) +const InvoicesListPage = () =>

История накладных (в разработке)

; function App() { return ( @@ -13,9 +14,15 @@ function App() { }> - } /> {/* Используем компонент */} + } /> } /> - } /> + + {/* Роут для черновика. :id - UUID черновика */} + } /> + + {/* Страница списка */} + } /> + } /> diff --git a/rmser-view/src/components/invoices/DraftItemRow.tsx b/rmser-view/src/components/invoices/DraftItemRow.tsx new file mode 100644 index 0000000..6c22de1 --- /dev/null +++ b/rmser-view/src/components/invoices/DraftItemRow.tsx @@ -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 = ({ 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 ( + + + {/* Верхняя строка: Исходное название и статус */} + +
+ + {item.raw_name} + + {item.raw_amount > 0 && ( + + (в чеке: {item.raw_amount} x {item.raw_price}) + + )} +
+
+ {isUpdating && } + {!item.product_id && Не найден} +
+
+ + {/* Выбор товара */} + + + {/* Нижний блок: Фасовка, Кол-во, Цена, Сумма */} + + {/* Если есть фасовки, показываем селект. Если нет - просто лейбл ед. изм */} +
+ {containerOptions.length > 1 ? ( + ({ label: s.name, value: s.id }))} + /> + + + + + +