added front - react+ts

ocr improved
This commit is contained in:
2025-12-11 05:20:53 +03:00
parent 73b1477368
commit 02681340c5
39 changed files with 6286 additions and 267 deletions

View File

@@ -7,27 +7,51 @@ import (
"github.com/shopspring/decimal"
)
// MeasureUnit - Единица измерения (kg, l, pcs)
type MeasureUnit struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
Name string `gorm:"type:varchar(50);not null" json:"name"`
Code string `gorm:"type:varchar(50)" json:"code"`
}
// ProductContainer - Фасовка (упаковка) товара
type ProductContainer struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
ProductID uuid.UUID `gorm:"type:uuid;index;not null" json:"product_id"`
Name string `gorm:"type:varchar(100);not null" json:"name"`
Count decimal.Decimal `gorm:"type:numeric(19,4);not null" json:"count"` // Коэфф. пересчета
}
// Product - Номенклатура
type Product struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;"`
ParentID *uuid.UUID `gorm:"type:uuid;index"`
Name string `gorm:"type:varchar(255);not null"`
Type string `gorm:"type:varchar(50);index"` // GOODS, DISH, PREPARED, etc.
Num string `gorm:"type:varchar(50)"`
Code string `gorm:"type:varchar(50)"`
UnitWeight decimal.Decimal `gorm:"type:numeric(19,4)"`
UnitCapacity decimal.Decimal `gorm:"type:numeric(19,4)"`
IsDeleted bool `gorm:"default:false"`
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
ParentID *uuid.UUID `gorm:"type:uuid;index" json:"parent_id"`
Name string `gorm:"type:varchar(255);not null" json:"name"`
Type string `gorm:"type:varchar(50);index" json:"type"` // GOODS, DISH, PREPARED
Num string `gorm:"type:varchar(50)" json:"num"`
Code string `gorm:"type:varchar(50)" json:"code"`
UnitWeight decimal.Decimal `gorm:"type:numeric(19,4)" json:"unit_weight"`
UnitCapacity decimal.Decimal `gorm:"type:numeric(19,4)" json:"unit_capacity"`
Parent *Product `gorm:"foreignKey:ParentID"`
Children []*Product `gorm:"foreignKey:ParentID"`
// Связь с единицей измерения
MainUnitID *uuid.UUID `gorm:"type:uuid;index" json:"main_unit_id"`
MainUnit *MeasureUnit `gorm:"foreignKey:MainUnitID" json:"main_unit,omitempty"`
CreatedAt time.Time
UpdatedAt time.Time
// Фасовки
Containers []ProductContainer `gorm:"foreignKey:ProductID;constraint:OnDelete:CASCADE" json:"containers,omitempty"`
IsDeleted bool `gorm:"default:false" json:"is_deleted"`
Parent *Product `gorm:"foreignKey:ParentID" json:"-"`
Children []*Product `gorm:"foreignKey:ParentID" json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Repository интерфейс для каталога
type Repository interface {
SaveMeasureUnits(units []MeasureUnit) error
SaveProducts(products []Product) error
GetAll() ([]Product, error)
GetActiveGoods() ([]Product, error)

View File

@@ -3,32 +3,44 @@ package ocr
import (
"time"
"github.com/google/uuid"
"rmser/internal/domain/catalog"
"github.com/google/uuid"
"github.com/shopspring/decimal"
)
// ProductMatch связывает текст из чека с конкретным товаром в iiko
type ProductMatch struct {
// RawName - распознанный текст (ключ).
// Лучше хранить в нижнем регистре и без лишних пробелов.
RawName string `gorm:"type:varchar(255);primary_key"`
RawName string `gorm:"type:varchar(255);primary_key" json:"raw_name"`
ProductID uuid.UUID `gorm:"type:uuid;not null;index" json:"product_id"`
Product catalog.Product `gorm:"foreignKey:ProductID" json:"product"`
ProductID uuid.UUID `gorm:"type:uuid;not null;index"`
// Количество и фасовки
Quantity decimal.Decimal `gorm:"type:numeric(19,4);default:1" json:"quantity"`
ContainerID *uuid.UUID `gorm:"type:uuid;index" json:"container_id"`
// Product - связь для GORM
Product catalog.Product `gorm:"foreignKey:ProductID"`
// Для подгрузки данных о фасовке при чтении
Container *catalog.ProductContainer `gorm:"foreignKey:ContainerID" json:"container,omitempty"`
UpdatedAt time.Time
CreatedAt time.Time
UpdatedAt time.Time `json:"updated_at"`
CreatedAt time.Time `json:"created_at"`
}
// UnmatchedItem хранит строки, которые не удалось распознать, для подсказок
type UnmatchedItem struct {
RawName string `gorm:"type:varchar(255);primary_key" json:"raw_name"`
Count int `gorm:"default:1" json:"count"` // Сколько раз встречалось
LastSeen time.Time `json:"last_seen"`
}
type Repository interface {
// SaveMatch сохраняет или обновляет привязку
SaveMatch(rawName string, productID uuid.UUID) error
// SaveMatch теперь принимает quantity и containerID
SaveMatch(rawName string, productID uuid.UUID, quantity decimal.Decimal, containerID *uuid.UUID) error
// FindMatch ищет товар по точному совпадению названия
FindMatch(rawName string) (*uuid.UUID, error)
// GetAllMatches возвращает все существующие привязки
FindMatch(rawName string) (*ProductMatch, error) // Возвращаем полную структуру, чтобы получить qty
GetAllMatches() ([]ProductMatch, error)
UpsertUnmatched(rawName string) error
GetTopUnmatched(limit int) ([]UnmatchedItem, error)
DeleteUnmatched(rawName string) error
}

View File

@@ -46,6 +46,8 @@ func NewPostgresDB(dsn string) *gorm.DB {
// 4. Автомиграция
err = db.AutoMigrate(
&catalog.Product{},
&catalog.MeasureUnit{},
&catalog.ProductContainer{},
&recipes.Recipe{},
&recipes.RecipeItem{},
&invoices.Invoice{},
@@ -53,6 +55,7 @@ func NewPostgresDB(dsn string) *gorm.DB {
&operations.StoreOperation{},
&recommendations.Recommendation{},
&ocr.ProductMatch{},
&ocr.UnmatchedItem{},
)
if err != nil {
panic(fmt.Sprintf("ошибка миграции БД: %v", err))

View File

@@ -16,22 +16,53 @@ func NewRepository(db *gorm.DB) catalog.Repository {
return &pgRepository{db: db}
}
func (r *pgRepository) SaveProducts(products []catalog.Product) error {
// Сортировка (родители -> дети)
sorted := sortProductsByHierarchy(products)
func (r *pgRepository) SaveMeasureUnits(units []catalog.MeasureUnit) error {
if len(units) == 0 {
return nil
}
return r.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
UpdateAll: true,
}).CreateInBatches(sorted, 100).Error
}).CreateInBatches(units, 100).Error
}
func (r *pgRepository) SaveProducts(products []catalog.Product) error {
sorted := sortProductsByHierarchy(products)
return r.db.Transaction(func(tx *gorm.DB) error {
// 1. Сохраняем продукты (без контейнеров, чтобы ускорить и не дублировать)
if err := tx.Omit("Containers").Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
UpdateAll: true,
}).CreateInBatches(sorted, 100).Error; err != nil {
return err
}
// 2. Собираем все контейнеры в один слайс
var allContainers []catalog.ProductContainer
for _, p := range products {
allContainers = append(allContainers, p.Containers...)
}
// 3. Сохраняем контейнеры
if len(allContainers) > 0 {
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
UpdateAll: true,
}).CreateInBatches(allContainers, 100).Error; err != nil {
return err
}
}
return nil
})
}
func (r *pgRepository) GetAll() ([]catalog.Product, error) {
var products []catalog.Product
err := r.db.Find(&products).Error
err := r.db.Preload("MainUnit").Find(&products).Error
return products, err
}
// Вспомогательная функция сортировки
// Вспомогательная функция сортировки (оставляем как была)
func sortProductsByHierarchy(products []catalog.Product) []catalog.Product {
if len(products) == 0 {
return products
@@ -73,11 +104,14 @@ func sortProductsByHierarchy(products []catalog.Product) []catalog.Product {
return result
}
// GetActiveGoods возвращает только активные товары (не удаленные, тип GOODS)
// GetActiveGoods возвращает только активные товары c подгруженной единицей измерения
// GetActiveGoods оптимизирован: подгружаем Units и Containers
func (r *pgRepository) GetActiveGoods() ([]catalog.Product, error) {
var products []catalog.Product
// iikoRMS: GOODS - товары, PREPARED - заготовки (иногда их тоже покупают)
err := r.db.Where("is_deleted = ? AND type IN ?", false, []string{"GOODS"}).
err := r.db.
Preload("MainUnit").
Preload("Containers"). // <-- Подгружаем фасовки
Where("is_deleted = ? AND type IN ?", false, []string{"GOODS"}).
Order("name ASC").
Find(&products).Error
return products, err

View File

@@ -2,6 +2,7 @@ package ocr
import (
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -9,6 +10,7 @@ import (
"rmser/internal/domain/ocr"
"github.com/google/uuid"
"github.com/shopspring/decimal"
)
type pgRepository struct {
@@ -19,39 +21,88 @@ func NewRepository(db *gorm.DB) ocr.Repository {
return &pgRepository{db: db}
}
func (r *pgRepository) SaveMatch(rawName string, productID uuid.UUID) error {
func (r *pgRepository) SaveMatch(rawName string, productID uuid.UUID, quantity decimal.Decimal, containerID *uuid.UUID) error {
normalized := strings.ToLower(strings.TrimSpace(rawName))
match := ocr.ProductMatch{
RawName: normalized,
ProductID: productID,
RawName: normalized,
ProductID: productID,
Quantity: quantity,
ContainerID: containerID,
}
// Upsert: если такая строка уже была, обновляем ссылку на товар
return r.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "raw_name"}},
DoUpdates: clause.AssignmentColumns([]string{"product_id", "updated_at"}),
}).Create(&match).Error
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "raw_name"}},
DoUpdates: clause.AssignmentColumns([]string{"product_id", "quantity", "container_id", "updated_at"}),
}).Create(&match).Error; err != nil {
return err
}
if err := tx.Where("raw_name = ?", normalized).Delete(&ocr.UnmatchedItem{}).Error; err != nil {
return err
}
return nil
})
}
func (r *pgRepository) FindMatch(rawName string) (*uuid.UUID, error) {
func (r *pgRepository) FindMatch(rawName string) (*ocr.ProductMatch, error) {
normalized := strings.ToLower(strings.TrimSpace(rawName))
var match ocr.ProductMatch
err := r.db.Where("raw_name = ?", normalized).First(&match).Error
// Preload Container на случай, если нам сразу нужна инфа
err := r.db.Preload("Container").Where("raw_name = ?", normalized).First(&match).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return &match.ProductID, nil
return &match, nil
}
func (r *pgRepository) GetAllMatches() ([]ocr.ProductMatch, error) {
var matches []ocr.ProductMatch
// Preload("Product") загружает связанную сущность товара,
// чтобы мы видели не только ID, но и название товара из каталога.
err := r.db.Preload("Product").Order("updated_at DESC").Find(&matches).Error
// Подгружаем Товар, Единицу и Фасовку
err := r.db.
Preload("Product").
Preload("Product.MainUnit").
Preload("Container").
Order("updated_at DESC").
Find(&matches).Error
return matches, err
}
// UpsertUnmatched увеличивает счетчик встречаемости
func (r *pgRepository) UpsertUnmatched(rawName string) error {
normalized := strings.ToLower(strings.TrimSpace(rawName))
if normalized == "" {
return nil
}
// Используем сырой SQL или GORM upsert expression
// PostgreSQL: INSERT ... ON CONFLICT DO UPDATE SET count = count + 1
item := ocr.UnmatchedItem{
RawName: normalized,
Count: 1,
LastSeen: time.Now(),
}
return r.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "raw_name"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"count": gorm.Expr("unmatched_items.count + 1"),
"last_seen": time.Now(),
}),
}).Create(&item).Error
}
func (r *pgRepository) GetTopUnmatched(limit int) ([]ocr.UnmatchedItem, error) {
var items []ocr.UnmatchedItem
err := r.db.Order("count DESC, last_seen DESC").Limit(limit).Find(&items).Error
return items, err
}
func (r *pgRepository) DeleteUnmatched(rawName string) error {
normalized := strings.ToLower(strings.TrimSpace(rawName))
return r.db.Where("raw_name = ?", normalized).Delete(&ocr.UnmatchedItem{}).Error
}

View File

@@ -32,6 +32,7 @@ type ClientI interface {
Auth() error
Logout() error
FetchCatalog() ([]catalog.Product, error)
FetchMeasureUnits() ([]catalog.MeasureUnit, error)
FetchRecipes(dateFrom, dateTo time.Time) ([]recipes.Recipe, error)
FetchInvoices(from, to time.Time) ([]invoices.Invoice, error)
FetchStoreOperations(presetID string, from, to time.Time) ([]StoreReportItemXML, error)
@@ -295,6 +296,29 @@ func (c *Client) FetchCatalog() ([]catalog.Product, error) {
parentID = &pid
}
}
// Обработка MainUnit
var mainUnitID *uuid.UUID
if p.MainUnit != nil {
if uid, err := uuid.Parse(*p.MainUnit); err == nil {
mainUnitID = &uid
}
}
// Маппинг фасовок
var containers []catalog.ProductContainer
for _, contDto := range p.Containers {
cID, err := uuid.Parse(contDto.ID)
if err == nil {
containers = append(containers, catalog.ProductContainer{
ID: cID,
ProductID: id,
Name: contDto.Name,
Count: decimal.NewFromFloat(contDto.Count),
})
}
}
products = append(products, catalog.Product{
ID: id,
ParentID: parentID,
@@ -304,6 +328,8 @@ func (c *Client) FetchCatalog() ([]catalog.Product, error) {
Type: p.Type,
UnitWeight: decimal.NewFromFloat(p.UnitWeight),
UnitCapacity: decimal.NewFromFloat(p.UnitCapacity),
MainUnitID: mainUnitID,
Containers: containers,
IsDeleted: p.Deleted,
})
}
@@ -311,6 +337,38 @@ func (c *Client) FetchCatalog() ([]catalog.Product, error) {
return products, nil
}
// FetchMeasureUnits загружает справочник единиц измерения
func (c *Client) FetchMeasureUnits() ([]catalog.MeasureUnit, error) {
// rootType=MeasureUnit согласно документации iiko
resp, err := c.doRequest("GET", "/resto/api/v2/entities/list", map[string]string{
"rootType": "MeasureUnit",
"includeDeleted": "false",
})
if err != nil {
return nil, fmt.Errorf("get measure units error: %w", err)
}
defer resp.Body.Close()
var dtos []GenericEntityDTO
if err := json.NewDecoder(resp.Body).Decode(&dtos); err != nil {
return nil, fmt.Errorf("json decode error: %w", err)
}
var result []catalog.MeasureUnit
for _, d := range dtos {
id, err := uuid.Parse(d.ID)
if err != nil {
continue
}
result = append(result, catalog.MeasureUnit{
ID: id,
Name: d.Name,
Code: d.Code,
})
}
return result, nil
}
func (c *Client) FetchRecipes(dateFrom, dateTo time.Time) ([]recipes.Recipe, error) {
params := map[string]string{
"dateFrom": dateFrom.Format("2006-01-02"),

View File

@@ -7,15 +7,32 @@ import (
// --- JSON DTOs (V2 API) ---
type ProductDTO struct {
ID string `json:"id"`
ParentID *string `json:"parent"` // Может быть null
Name string `json:"name"`
Num string `json:"num"` // Артикул
Code string `json:"code"` // Код быстрого набора
Type string `json:"type"` // GOODS, DISH, PREPARED, etc.
UnitWeight float64 `json:"unitWeight"`
UnitCapacity float64 `json:"unitCapacity"`
Deleted bool `json:"deleted"`
ID string `json:"id"`
ParentID *string `json:"parent"` // Может быть null
Name string `json:"name"`
Num string `json:"num"` // Артикул
Code string `json:"code"` // Код быстрого набора
Type string `json:"type"` // GOODS, DISH, PREPARED, etc.
UnitWeight float64 `json:"unitWeight"`
UnitCapacity float64 `json:"unitCapacity"`
MainUnit *string `json:"mainUnit"`
Containers []ContainerDTO `json:"containers"`
Deleted bool `json:"deleted"`
}
// GenericEntityDTO используется для простых справочников (MeasureUnit и др.)
type GenericEntityDTO struct {
ID string `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
Deleted bool `json:"deleted"`
}
// ContainerDTO - фасовка из iiko
type ContainerDTO struct {
ID string `json:"id"`
Name string `json:"name"` // Название фасовки (напр. "Коробка")
Count float64 `json:"count"` // Сколько базовых единиц в фасовке
}
type GroupDTO struct {

View File

@@ -42,8 +42,6 @@ func (s *Service) ProcessReceiptImage(ctx context.Context, imgData []byte) ([]Pr
}
var processed []ProcessedItem
// 2. Обрабатываем каждую строку
for _, rawItem := range rawResult.Items {
item := ProcessedItem{
RawName: rawItem.RawName,
@@ -52,26 +50,24 @@ func (s *Service) ProcessReceiptImage(ctx context.Context, imgData []byte) ([]Pr
Sum: decimal.NewFromFloat(rawItem.Sum),
}
// 3. Ищем соответствие
// Сначала проверяем таблицу ручного обучения (product_matches)
matchID, err := s.ocrRepo.FindMatch(rawItem.RawName)
match, err := s.ocrRepo.FindMatch(rawItem.RawName)
if err != nil {
logger.Log.Error("db error finding match", zap.Error(err))
}
if matchID != nil {
// Нашли в обучении
item.ProductID = matchID
if match != nil {
item.ProductID = &match.ProductID
item.IsMatched = true
item.MatchSource = "learned"
// Здесь мы могли бы подтянуть quantity/container из матча,
// но пока фронт сам это сделает, запросив /ocr/matches или получив подсказку.
} else {
// Если не нашли, пробуем найти точное совпадение по имени в каталоге (на всякий случай)
// (В реальном проекте тут может быть нечеткий поиск, но пока точный)
if err := s.ocrRepo.UpsertUnmatched(rawItem.RawName); err != nil {
logger.Log.Warn("failed to save unmatched", zap.Error(err))
}
}
processed = append(processed, item)
}
return processed, nil
}
@@ -87,14 +83,21 @@ type ProcessedItem struct {
MatchSource string // "learned", "auto", "manual"
}
// ProductForIndex DTO для внешнего сервиса
type ProductForIndex struct {
ID string `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
type ContainerForIndex struct {
ID string `json:"id"`
Name string `json:"name"`
Count float64 `json:"count"`
}
// GetCatalogForIndexing возвращает список товаров для построения индекса
type ProductForIndex struct {
ID string `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
MeasureUnit string `json:"measure_unit"`
Containers []ContainerForIndex `json:"containers"`
}
// GetCatalogForIndexing - возвращает облегченный каталог
func (s *Service) GetCatalogForIndexing() ([]ProductForIndex, error) {
products, err := s.catalogRepo.GetActiveGoods()
if err != nil {
@@ -103,18 +106,35 @@ func (s *Service) GetCatalogForIndexing() ([]ProductForIndex, error) {
result := make([]ProductForIndex, 0, len(products))
for _, p := range products {
uom := ""
if p.MainUnit != nil {
uom = p.MainUnit.Name
}
var conts []ContainerForIndex
for _, c := range p.Containers {
cnt, _ := c.Count.Float64()
conts = append(conts, ContainerForIndex{
ID: c.ID.String(),
Name: c.Name,
Count: cnt,
})
}
result = append(result, ProductForIndex{
ID: p.ID.String(),
Name: p.Name,
Code: p.Code,
ID: p.ID.String(),
Name: p.Name,
Code: p.Code,
MeasureUnit: uom,
Containers: conts,
})
}
return result, nil
}
// SaveMapping сохраняет связь "Текст из чека" -> "Наш товар"
func (s *Service) SaveMapping(rawName string, productID uuid.UUID) error {
return s.ocrRepo.SaveMatch(rawName, productID)
// SaveMapping сохраняет привязку с количеством и фасовкой
func (s *Service) SaveMapping(rawName string, productID uuid.UUID, quantity decimal.Decimal, containerID *uuid.UUID) error {
return s.ocrRepo.SaveMatch(rawName, productID, quantity, containerID)
}
// GetKnownMatches возвращает список всех обученных связей
@@ -122,8 +142,14 @@ func (s *Service) GetKnownMatches() ([]ocr.ProductMatch, error) {
return s.ocrRepo.GetAllMatches()
}
// GetUnmatchedItems возвращает список частых нераспознанных строк
func (s *Service) GetUnmatchedItems() ([]ocr.UnmatchedItem, error) {
// Берем топ 50 нераспознанных
return s.ocrRepo.GetTopUnmatched(50)
}
// FindKnownMatch ищет, знаем ли мы уже этот товар
func (s *Service) FindKnownMatch(rawName string) (*uuid.UUID, error) {
func (s *Service) FindKnownMatch(rawName string) (*ocr.ProductMatch, error) {
return s.ocrRepo.FindMatch(rawName)
}

View File

@@ -49,8 +49,15 @@ func NewService(
// SyncCatalog загружает номенклатуру и сохраняет в БД
func (s *Service) SyncCatalog() error {
logger.Log.Info("Начало синхронизации номенклатуры")
logger.Log.Info("Начало синхронизации каталога...")
// 1. Сначала Единицы измерения (чтобы FK не ругался)
if err := s.syncMeasureUnits(); err != nil {
return err
}
// 2. Товары
logger.Log.Info("Запрос товаров из RMS...")
products, err := s.rmsClient.FetchCatalog()
if err != nil {
return fmt.Errorf("ошибка получения каталога из RMS: %w", err)
@@ -64,6 +71,19 @@ func (s *Service) SyncCatalog() error {
return nil
}
func (s *Service) syncMeasureUnits() error {
logger.Log.Info("Синхронизация единиц измерения...")
units, err := s.rmsClient.FetchMeasureUnits()
if err != nil {
return fmt.Errorf("ошибка получения ед.изм: %w", err)
}
if err := s.catalogRepo.SaveMeasureUnits(units); err != nil {
return fmt.Errorf("ошибка сохранения ед.изм: %w", err)
}
logger.Log.Info("Единицы измерения обновлены", zap.Int("count", len(units)))
return nil
}
// SyncRecipes загружает техкарты за указанный период (или за последние 30 дней по умолчанию)
func (s *Service) SyncRecipes() error {
logger.Log.Info("Начало синхронизации техкарт")

View File

@@ -5,6 +5,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/shopspring/decimal"
"go.uber.org/zap"
ocrService "rmser/internal/services/ocr"
@@ -31,8 +32,10 @@ func (h *OCRHandler) GetCatalog(c *gin.Context) {
}
type MatchRequest struct {
RawName string `json:"raw_name" binding:"required"`
ProductID string `json:"product_id" binding:"required"`
RawName string `json:"raw_name" binding:"required"`
ProductID string `json:"product_id" binding:"required"`
Quantity float64 `json:"quantity"`
ContainerID *string `json:"container_id"`
}
// SaveMatch сохраняет привязку (обучение)
@@ -49,7 +52,19 @@ func (h *OCRHandler) SaveMatch(c *gin.Context) {
return
}
if err := h.service.SaveMapping(req.RawName, pID); err != nil {
qty := decimal.NewFromFloat(1.0)
if req.Quantity > 0 {
qty = decimal.NewFromFloat(req.Quantity)
}
var contID *uuid.UUID
if req.ContainerID != nil && *req.ContainerID != "" {
if uid, err := uuid.Parse(*req.ContainerID); err == nil {
contID = &uid
}
}
if err := h.service.SaveMapping(req.RawName, pID, qty, contID); err != nil {
logger.Log.Error("Ошибка сохранения матчинга", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -69,3 +84,14 @@ func (h *OCRHandler) GetMatches(c *gin.Context) {
c.JSON(http.StatusOK, matches)
}
// GetUnmatched возвращает список нераспознанных позиций для подсказок
func (h *OCRHandler) GetUnmatched(c *gin.Context) {
items, err := h.service.GetUnmatchedItems()
if err != nil {
logger.Log.Error("Ошибка получения списка unmatched", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, items)
}