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

@@ -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 {