mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
added front - react+ts
ocr improved
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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("Начало синхронизации техкарт")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user