mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
start rmser
This commit is contained in:
84
internal/infrastructure/repository/catalog/postgres.go
Normal file
84
internal/infrastructure/repository/catalog/postgres.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"rmser/internal/domain/catalog"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type pgRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB) catalog.Repository {
|
||||
return &pgRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *pgRepository) SaveProducts(products []catalog.Product) error {
|
||||
// Сортировка (родители -> дети)
|
||||
sorted := sortProductsByHierarchy(products)
|
||||
return r.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
UpdateAll: true,
|
||||
}).CreateInBatches(sorted, 100).Error
|
||||
}
|
||||
|
||||
func (r *pgRepository) GetAll() ([]catalog.Product, error) {
|
||||
var products []catalog.Product
|
||||
err := r.db.Find(&products).Error
|
||||
return products, err
|
||||
}
|
||||
|
||||
// Вспомогательная функция сортировки
|
||||
func sortProductsByHierarchy(products []catalog.Product) []catalog.Product {
|
||||
if len(products) == 0 {
|
||||
return products
|
||||
}
|
||||
childrenMap := make(map[uuid.UUID][]catalog.Product)
|
||||
var roots []catalog.Product
|
||||
allIDs := make(map[uuid.UUID]struct{}, len(products))
|
||||
|
||||
for _, p := range products {
|
||||
allIDs[p.ID] = struct{}{}
|
||||
}
|
||||
|
||||
for _, p := range products {
|
||||
if p.ParentID == nil {
|
||||
roots = append(roots, p)
|
||||
} else {
|
||||
if _, exists := allIDs[*p.ParentID]; exists {
|
||||
childrenMap[*p.ParentID] = append(childrenMap[*p.ParentID], p)
|
||||
} else {
|
||||
roots = append(roots, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]catalog.Product, 0, len(products))
|
||||
queue := roots
|
||||
for len(queue) > 0 {
|
||||
current := queue[0]
|
||||
queue = queue[1:]
|
||||
result = append(result, current)
|
||||
if children, ok := childrenMap[current.ID]; ok {
|
||||
queue = append(queue, children...)
|
||||
delete(childrenMap, current.ID)
|
||||
}
|
||||
}
|
||||
for _, remaining := range childrenMap {
|
||||
result = append(result, remaining...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetActiveGoods возвращает только активные товары (не удаленные, тип GOODS)
|
||||
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"}).
|
||||
Order("name ASC").
|
||||
Find(&products).Error
|
||||
return products, err
|
||||
}
|
||||
52
internal/infrastructure/repository/invoices/postgres.go
Normal file
52
internal/infrastructure/repository/invoices/postgres.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package invoices
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"rmser/internal/domain/invoices"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type pgRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB) invoices.Repository {
|
||||
return &pgRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *pgRepository) GetLastInvoiceDate() (*time.Time, error) {
|
||||
var inv invoices.Invoice
|
||||
err := r.db.Order("date_incoming DESC").First(&inv).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &inv.DateIncoming, nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) SaveInvoices(list []invoices.Invoice) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, inv := range list {
|
||||
if err := tx.Omit("Items").Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
UpdateAll: true,
|
||||
}).Create(&inv).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("invoice_id = ?", inv.ID).Delete(&invoices.InvoiceItem{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(inv.Items) > 0 {
|
||||
if err := tx.Create(&inv.Items).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
49
internal/infrastructure/repository/ocr/postgres.go
Normal file
49
internal/infrastructure/repository/ocr/postgres.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package ocr
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"rmser/internal/domain/ocr"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type pgRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB) ocr.Repository {
|
||||
return &pgRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *pgRepository) SaveMatch(rawName string, productID uuid.UUID) error {
|
||||
normalized := strings.ToLower(strings.TrimSpace(rawName))
|
||||
match := ocr.ProductMatch{
|
||||
RawName: normalized,
|
||||
ProductID: productID,
|
||||
}
|
||||
|
||||
// Upsert: если такая строка уже была, обновляем ссылку на товар
|
||||
return r.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "raw_name"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"product_id", "updated_at"}),
|
||||
}).Create(&match).Error
|
||||
}
|
||||
|
||||
func (r *pgRepository) FindMatch(rawName string) (*uuid.UUID, error) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(rawName))
|
||||
var match ocr.ProductMatch
|
||||
|
||||
err := r.db.Where("raw_name = ?", normalized).First(&match).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &match.ProductID, nil
|
||||
}
|
||||
39
internal/infrastructure/repository/operations/postgres.go
Normal file
39
internal/infrastructure/repository/operations/postgres.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package operations
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"rmser/internal/domain/operations"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type pgRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB) operations.Repository {
|
||||
return &pgRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *pgRepository) SaveOperations(ops []operations.StoreOperation, opType operations.OperationType, dateFrom, dateTo time.Time) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 1. Удаляем старые записи этого типа, которые пересекаются с периодом.
|
||||
// Так как отчет агрегированный, мы привязываемся к периоду "с" и "по".
|
||||
// Упрощение: удаляем всё, где PeriodFrom совпадает с текущей выгрузкой,
|
||||
// предполагая, что мы всегда грузим одними и теми же квантами (например, месяц или неделя).
|
||||
// Для надежности удалим всё, что попадает в диапазон.
|
||||
if err := tx.Where("op_type = ? AND period_from >= ? AND period_to <= ?", opType, dateFrom, dateTo).
|
||||
Delete(&operations.StoreOperation{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. Вставляем новые
|
||||
if len(ops) > 0 {
|
||||
if err := tx.CreateInBatches(ops, 500).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
38
internal/infrastructure/repository/recipes/postgres.go
Normal file
38
internal/infrastructure/repository/recipes/postgres.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package recipes
|
||||
|
||||
import (
|
||||
"rmser/internal/domain/recipes"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type pgRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB) recipes.Repository {
|
||||
return &pgRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *pgRepository) SaveRecipes(list []recipes.Recipe) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, recipe := range list {
|
||||
if err := tx.Omit("Items").Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
UpdateAll: true,
|
||||
}).Create(&recipe).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("recipe_id = ?", recipe.ID).Delete(&recipes.RecipeItem{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(recipe.Items) > 0 {
|
||||
if err := tx.Create(&recipe.Items).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
243
internal/infrastructure/repository/recommendations/postgres.go
Normal file
243
internal/infrastructure/repository/recommendations/postgres.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package recommendations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"rmser/internal/domain/operations"
|
||||
"rmser/internal/domain/recommendations"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type pgRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB) recommendations.Repository {
|
||||
return &pgRepository{db: db}
|
||||
}
|
||||
|
||||
// --- Методы Хранения ---
|
||||
|
||||
func (r *pgRepository) SaveAll(items []recommendations.Recommendation) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Delete(&recommendations.Recommendation{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(items) > 0 {
|
||||
if err := tx.CreateInBatches(items, 100).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *pgRepository) GetAll() ([]recommendations.Recommendation, error) {
|
||||
var items []recommendations.Recommendation
|
||||
err := r.db.Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
// --- Методы Аналитики ---
|
||||
|
||||
// 1. Товары (GOODS/PREPARED), не используемые в техкартах
|
||||
func (r *pgRepository) FindUnusedGoods() ([]recommendations.Recommendation, error) {
|
||||
var results []recommendations.Recommendation
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
p.id as product_id,
|
||||
p.name as product_name,
|
||||
'Товар не используется ни в одной техкарте' as reason,
|
||||
? as type
|
||||
FROM products p
|
||||
WHERE p.type IN ('GOODS', 'PREPARED')
|
||||
AND p.is_deleted = false -- Проверка на удаление
|
||||
AND p.id NOT IN (
|
||||
SELECT DISTINCT product_id FROM recipe_items
|
||||
)
|
||||
AND p.id NOT IN (
|
||||
SELECT DISTINCT product_id FROM recipes
|
||||
)
|
||||
ORDER BY p.name ASC
|
||||
`
|
||||
|
||||
if err := r.db.Raw(query, recommendations.TypeUnused).Scan(&results).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// 2. Закупается, но нет в техкартах
|
||||
func (r *pgRepository) FindPurchasedButUnused(days int) ([]recommendations.Recommendation, error) {
|
||||
var results []recommendations.Recommendation
|
||||
dateFrom := time.Now().AddDate(0, 0, -days)
|
||||
|
||||
query := `
|
||||
SELECT DISTINCT
|
||||
p.id as product_id,
|
||||
p.name as product_name,
|
||||
'Товар активно закупается, но не включен ни в одну техкарту' as reason,
|
||||
? as type
|
||||
FROM store_operations so
|
||||
JOIN products p ON so.product_id = p.id
|
||||
WHERE
|
||||
so.op_type = ?
|
||||
AND so.period_from >= ?
|
||||
AND p.is_deleted = false -- Проверка на удаление
|
||||
AND p.id NOT IN (
|
||||
SELECT DISTINCT product_id FROM recipe_items
|
||||
)
|
||||
ORDER BY p.name ASC
|
||||
`
|
||||
|
||||
if err := r.db.Raw(query, recommendations.TypePurchasedButUnused, operations.OpTypePurchase, dateFrom).Scan(&results).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// 3. Ингредиенты в актуальных техкартах без закупок
|
||||
func (r *pgRepository) FindNoIncomingIngredients(days int) ([]recommendations.Recommendation, error) {
|
||||
var results []recommendations.Recommendation
|
||||
dateFrom := time.Now().AddDate(0, 0, -days)
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
p.id as product_id,
|
||||
p.name as product_name,
|
||||
'Нет закупок (' || ? || ' дн). Входит в: ' || STRING_AGG(DISTINCT parent.name, ', ') as reason,
|
||||
? as type
|
||||
FROM recipe_items ri
|
||||
JOIN recipes r ON ri.recipe_id = r.id
|
||||
JOIN products p ON ri.product_id = p.id
|
||||
JOIN products parent ON r.product_id = parent.id
|
||||
WHERE
|
||||
(r.date_to IS NULL OR r.date_to >= CURRENT_DATE)
|
||||
AND p.type = 'GOODS'
|
||||
AND p.is_deleted = false -- Сам ингредиент не удален
|
||||
AND parent.is_deleted = false -- Блюдо, в которое он входит, не удалено
|
||||
AND p.id NOT IN (
|
||||
SELECT product_id
|
||||
FROM store_operations
|
||||
WHERE op_type = ?
|
||||
AND period_from >= ?
|
||||
)
|
||||
GROUP BY p.id, p.name
|
||||
ORDER BY p.name ASC
|
||||
`
|
||||
|
||||
if err := r.db.Raw(query, strconv.Itoa(days), recommendations.TypeNoIncoming, operations.OpTypePurchase, dateFrom).Scan(&results).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// 4. Товары, которые закупаем, но не расходуем ("Висяки")
|
||||
func (r *pgRepository) FindStaleGoods(days int) ([]recommendations.Recommendation, error) {
|
||||
var results []recommendations.Recommendation
|
||||
dateFrom := time.Now().AddDate(0, 0, -days)
|
||||
|
||||
query := `
|
||||
SELECT DISTINCT
|
||||
p.id as product_id,
|
||||
p.name as product_name,
|
||||
? as reason,
|
||||
? as type
|
||||
FROM store_operations so
|
||||
JOIN products p ON so.product_id = p.id
|
||||
WHERE
|
||||
so.op_type = ?
|
||||
AND so.period_from >= ?
|
||||
AND p.is_deleted = false -- Проверка на удаление
|
||||
AND p.id NOT IN (
|
||||
SELECT product_id
|
||||
FROM store_operations
|
||||
WHERE op_type = ?
|
||||
AND period_from >= ?
|
||||
)
|
||||
ORDER BY p.name ASC
|
||||
`
|
||||
|
||||
reason := fmt.Sprintf("Были закупки, но нет расхода за %d дн.", days)
|
||||
|
||||
if err := r.db.Raw(query, reason, recommendations.TypeStale, operations.OpTypePurchase, dateFrom, operations.OpTypeUsage, dateFrom).
|
||||
Scan(&results).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// 5. Блюдо используется в техкарте другого блюда
|
||||
func (r *pgRepository) FindDishesInRecipes() ([]recommendations.Recommendation, error) {
|
||||
var results []recommendations.Recommendation
|
||||
|
||||
query := `
|
||||
SELECT DISTINCT
|
||||
child.id as product_id,
|
||||
child.name as product_name,
|
||||
'Является Блюдом (DISH), но указан ингредиентом в: ' || parent.name as reason,
|
||||
? as type
|
||||
FROM recipe_items ri
|
||||
JOIN products child ON ri.product_id = child.id
|
||||
JOIN recipes r ON ri.recipe_id = r.id
|
||||
JOIN products parent ON r.product_id = parent.id
|
||||
WHERE
|
||||
child.type = 'DISH'
|
||||
AND child.is_deleted = false -- Вложенное блюдо не удалено
|
||||
AND parent.is_deleted = false -- Родительское блюдо не удалено
|
||||
AND (r.date_to IS NULL OR r.date_to >= CURRENT_DATE)
|
||||
ORDER BY child.name ASC
|
||||
`
|
||||
|
||||
if err := r.db.Raw(query, recommendations.TypeDishInRecipe).Scan(&results).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// 6. Есть расход (Usage), но нет прихода (Purchase)
|
||||
func (r *pgRepository) FindUsageWithoutPurchase(days int) ([]recommendations.Recommendation, error) {
|
||||
var results []recommendations.Recommendation
|
||||
dateFrom := time.Now().AddDate(0, 0, -days)
|
||||
|
||||
query := `
|
||||
SELECT DISTINCT
|
||||
p.id as product_id,
|
||||
p.name as product_name,
|
||||
? as reason,
|
||||
? as type
|
||||
FROM store_operations so
|
||||
JOIN products p ON so.product_id = p.id
|
||||
WHERE
|
||||
so.op_type = ? -- Есть расход (продажа/списание)
|
||||
AND so.period_from >= ?
|
||||
AND p.type = 'GOODS' -- Только для товаров
|
||||
AND p.is_deleted = false -- Товар жив
|
||||
AND p.id NOT IN ( -- Но не было закупок
|
||||
SELECT product_id
|
||||
FROM store_operations
|
||||
WHERE op_type = ?
|
||||
AND period_from >= ?
|
||||
)
|
||||
ORDER BY p.name ASC
|
||||
`
|
||||
|
||||
reason := fmt.Sprintf("Товар расходуется (продажи/списания), но не закупался последние %d дн.", days)
|
||||
|
||||
// Аргументы: reason, type, OpUsage, date, OpPurchase, date
|
||||
if err := r.db.Raw(query,
|
||||
reason,
|
||||
recommendations.TypeUsageNoIncoming,
|
||||
operations.OpTypeUsage,
|
||||
dateFrom,
|
||||
operations.OpTypePurchase,
|
||||
dateFrom,
|
||||
).Scan(&results).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
Reference in New Issue
Block a user