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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user