start rmser

This commit is contained in:
2025-11-29 08:40:24 +03:00
commit 5aa2238eea
2117 changed files with 375169 additions and 0 deletions

View File

@@ -0,0 +1,110 @@
package db
import (
"database/sql"
"fmt"
"log"
"os"
"regexp"
"rmser/internal/domain/catalog"
"rmser/internal/domain/invoices"
"rmser/internal/domain/ocr"
"rmser/internal/domain/operations"
"rmser/internal/domain/recipes"
"rmser/internal/domain/recommendations"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func NewPostgresDB(dsn string) *gorm.DB {
// 1. Проверка и создание БД перед основным подключением
ensureDBExists(dsn)
// 2. Настройка логгера GORM
newLogger := logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags),
logger.Config{
SlowThreshold: time.Second,
LogLevel: logger.Warn,
IgnoreRecordNotFoundError: true,
Colorful: true,
},
)
// 3. Основное подключение
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: newLogger,
})
if err != nil {
panic(fmt.Sprintf("не удалось подключиться к БД: %v", err))
}
// 4. Автомиграция
err = db.AutoMigrate(
&catalog.Product{},
&recipes.Recipe{},
&recipes.RecipeItem{},
&invoices.Invoice{},
&invoices.InvoiceItem{},
&operations.StoreOperation{},
&recommendations.Recommendation{},
&ocr.ProductMatch{},
)
if err != nil {
panic(fmt.Sprintf("ошибка миграции БД: %v", err))
}
return db
}
// ensureDBExists подключается к системной БД 'postgres' и создает целевую, если её нет
func ensureDBExists(fullDSN string) {
// Регулярка для извлечения имени базы из DSN (ищем dbname=... )
re := regexp.MustCompile(`dbname=([^\s]+)`)
matches := re.FindStringSubmatch(fullDSN)
if len(matches) < 2 {
// Если не нашли dbname, возможно формат URL (postgres://...),
// пропускаем авто-создание, полагаемся на ошибку драйвера
return
}
targetDB := matches[1]
// Заменяем целевую БД на системную 'postgres' для подключения
maintenanceDSN := re.ReplaceAllString(fullDSN, "dbname=postgres")
// Используем стандартный sql драйвер через pgx (который под капотом у gorm/postgres)
// Важно: нам не нужен GORM здесь, нужен чистый SQL для CREATE DATABASE
db, err := sql.Open("pgx", maintenanceDSN)
if err != nil {
// Если не вышло подключиться к postgres, просто выходим,
// основная ошибка вылетит при попытке gorm.Open
log.Printf("[WARN] Не удалось подключиться к системной БД для проверки: %v", err)
return
}
defer db.Close()
// Проверяем существование базы
var exists bool
checkSQL := fmt.Sprintf("SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = '%s')", targetDB)
err = db.QueryRow(checkSQL).Scan(&exists)
if err != nil {
log.Printf("[WARN] Ошибка проверки существования БД: %v", err)
return
}
if !exists {
log.Printf("[INFO] База данных '%s' не найдена. Создаю...", targetDB)
// CREATE DATABASE не может быть выполнен в транзакции, поэтому Exec
_, err = db.Exec(fmt.Sprintf("CREATE DATABASE \"%s\"", targetDB))
if err != nil {
panic(fmt.Sprintf("не удалось создать базу данных %s: %v", targetDB, err))
}
log.Printf("[INFO] База данных '%s' успешно создана", targetDB)
}
}

View File

@@ -0,0 +1,84 @@
package ocr_client
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/textproto"
"time"
)
type Client struct {
pythonServiceURL string
httpClient *http.Client
}
func NewClient(pythonServiceURL string) *Client {
return &Client{
pythonServiceURL: pythonServiceURL,
httpClient: &http.Client{
// OCR может быть долгим, ставим таймаут побольше (например, 30 сек)
Timeout: 30 * time.Second,
},
}
}
// ProcessImage отправляет изображение в Python и возвращает сырые данные
func (c *Client) ProcessImage(ctx context.Context, imageData []byte, filename string) (*RecognitionResult, error) {
// 1. Создаем буфер для multipart формы
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
// Создаем заголовок части вручную, чтобы прописать Content-Type: image/jpeg
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="image"; filename="%s"`, filename))
h.Set("Content-Type", "image/jpeg") // Явно указываем, что это картинка
part, err := writer.CreatePart(h)
if err != nil {
return nil, fmt.Errorf("create part error: %w", err)
}
// Записываем байты картинки
if _, err := io.Copy(part, bytes.NewReader(imageData)); err != nil {
return nil, fmt.Errorf("copy file error: %w", err)
}
// Закрываем writer, чтобы записать boundary
if err := writer.Close(); err != nil {
return nil, fmt.Errorf("writer close error: %w", err)
}
// 2. Создаем запрос
req, err := http.NewRequestWithContext(ctx, "POST", c.pythonServiceURL+"/recognize", body)
if err != nil {
return nil, fmt.Errorf("create request error: %w", err)
}
// Важно: Content-Type с boundary
req.Header.Set("Content-Type", writer.FormDataContentType())
// 3. Отправляем
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("ocr service request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("ocr service error (code %d): %s", resp.StatusCode, string(bodyBytes))
}
// 4. Парсим ответ
var result RecognitionResult
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("json decode error: %w", err)
}
return &result, nil
}

View File

@@ -0,0 +1,13 @@
package ocr_client
// RecognitionResult - ответ от Python сервиса
type RecognitionResult struct {
Items []RecognizedItem `json:"items"`
}
type RecognizedItem struct {
RawName string `json:"raw_name"` // Текст названия из чека
Amount float64 `json:"amount"` // Кол-во
Price float64 `json:"price"` // Цена
Sum float64 `json:"sum"` // Сумма
}

View File

@@ -0,0 +1,52 @@
package redis
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
type Client struct {
rdb *redis.Client
}
func NewClient(addr, password string, dbIndex int) (*Client, error) {
rdb := redis.NewClient(&redis.Options{
Addr: addr,
Password: password,
DB: dbIndex,
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := rdb.Ping(ctx).Err(); err != nil {
return nil, fmt.Errorf("ошибка подключения к Redis: %w", err)
}
return &Client{rdb: rdb}, nil
}
// Set сохраняет значение (структуру) в JSON
func (c *Client) Set(ctx context.Context, key string, value any, ttl time.Duration) error {
bytes, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("json marshal error: %w", err)
}
return c.rdb.Set(ctx, key, bytes, ttl).Err()
}
// Get загружает значение в переданный указатель dest
func (c *Client) Get(ctx context.Context, key string, dest any) error {
val, err := c.rdb.Get(ctx, key).Result()
if err != nil {
if err == redis.Nil {
return nil // Ключ не найден, не считаем ошибкой
}
return err
}
return json.Unmarshal([]byte(val), dest)
}

View 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
}

View 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
})
}

View 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
}

View 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
})
}

View 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
})
}

View 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
}

View File

@@ -0,0 +1,556 @@
package rms
import (
"bytes"
"crypto/sha1"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/shopspring/decimal"
"go.uber.org/zap"
"rmser/internal/domain/catalog"
"rmser/internal/domain/invoices"
"rmser/internal/domain/recipes"
"rmser/pkg/logger"
)
const (
tokenTTL = 45 * time.Minute // Время жизни токена до принудительного обновления
)
// ClientI интерфейс
type ClientI interface {
Auth() error
Logout() error
FetchCatalog() ([]catalog.Product, 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)
CreateIncomingInvoice(inv invoices.Invoice) (string, error)
}
type Client struct {
baseURL string
login string
passwordHash string
httpClient *http.Client
// Защита токена для конкурентного доступа
mu sync.RWMutex
token string
tokenCreatedAt time.Time
}
func NewClient(baseURL, login, password string) *Client {
h := sha1.New()
h.Write([]byte(password))
passHash := fmt.Sprintf("%x", h.Sum(nil))
return &Client{
baseURL: baseURL,
login: login,
passwordHash: passHash,
httpClient: &http.Client{Timeout: 60 * time.Second},
}
}
// Auth выполняет вход
func (c *Client) Auth() error {
c.mu.Lock()
defer c.mu.Unlock()
return c.authUnsafe()
}
// authUnsafe - внутренняя логика авторизации без блокировок (вызывается внутри Lock)
func (c *Client) authUnsafe() error {
endpoint := c.baseURL + "/resto/api/auth"
data := url.Values{}
data.Set("login", c.login)
data.Set("pass", c.passwordHash)
req, err := http.NewRequest("POST", endpoint, strings.NewReader(data.Encode()))
if err != nil {
return fmt.Errorf("ошибка создания запроса auth: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("ошибка сети auth: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("ошибка авторизации (code %d): %s", resp.StatusCode, string(body))
}
c.token = string(body)
c.tokenCreatedAt = time.Now() // Запоминаем время получения
logger.Log.Info("RMS: Успешная авторизация", zap.String("token_preview", c.token[:5]+"..."))
return nil
}
// Logout освобождает лицензию
func (c *Client) Logout() error {
c.mu.Lock()
defer c.mu.Unlock()
return c.logoutUnsafe()
}
// logoutUnsafe - внутренняя логика логаута
func (c *Client) logoutUnsafe() error {
if c.token == "" {
return nil
}
endpoint := c.baseURL + "/resto/api/logout"
data := url.Values{}
data.Set("key", c.token)
req, err := http.NewRequest("POST", endpoint, strings.NewReader(data.Encode()))
if err == nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.httpClient.Do(req)
if err == nil {
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
logger.Log.Info("RMS: Токен освобожден")
} else {
logger.Log.Warn("RMS: Ошибка освобождения токена", zap.Int("code", resp.StatusCode))
}
}
}
// Сбрасываем токен в любом случае, даже если запрос не прошел (он все равно протухнет)
c.token = ""
c.tokenCreatedAt = time.Time{}
return nil
}
// ensureToken проверяет срок жизни токена и обновляет его при необходимости
func (c *Client) ensureToken() error {
c.mu.RLock()
token := c.token
createdAt := c.tokenCreatedAt
c.mu.RUnlock()
// Если токена нет или он протух
if token == "" || time.Since(createdAt) > tokenTTL {
c.mu.Lock()
defer c.mu.Unlock()
// Double check locking (вдруг другая горутина уже обновила)
if c.token != "" && time.Since(c.tokenCreatedAt) <= tokenTTL {
return nil
}
if c.token != "" {
logger.Log.Info("RMS: Время жизни токена истекло (>45 мин), пересоздание...")
_ = c.logoutUnsafe() // Пытаемся освободить старый
}
return c.authUnsafe()
}
return nil
}
// doRequest выполняет запрос с автоматическим управлением токеном
func (c *Client) doRequest(method, path string, queryParams map[string]string) (*http.Response, error) {
// 1. Проверка времени жизни (45 минут)
if err := c.ensureToken(); err != nil {
return nil, err
}
// Читаем токен под RLock
c.mu.RLock()
currentToken := c.token
c.mu.RUnlock()
buildURL := func() string {
u, _ := url.Parse(c.baseURL + path)
q := u.Query()
q.Set("key", currentToken)
for k, v := range queryParams {
q.Set(k, v)
}
u.RawQuery = q.Encode()
return u.String()
}
req, _ := http.NewRequest(method, buildURL(), nil)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
// 2. Реактивная обработка 401 (если сервер перезагрузился или убил сессию раньше времени)
if resp.StatusCode == http.StatusUnauthorized {
resp.Body.Close()
logger.Log.Warn("RMS: Получен 401 Unauthorized, принудительная ре-авторизация...")
c.mu.Lock()
// Сбрасываем токен и логинимся заново
c.token = ""
authErr := c.authUnsafe()
c.mu.Unlock()
if authErr != nil {
return nil, authErr
}
// Повторяем запрос с новым токеном
c.mu.RLock()
currentToken = c.token
c.mu.RUnlock()
req, _ = http.NewRequest(method, buildURL(), nil)
return c.httpClient.Do(req)
}
if resp.StatusCode != http.StatusOK {
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("api error: code=%d, body=%s", resp.StatusCode, string(body))
}
return resp, nil
}
// --- Методы получения данных (без изменений логики парсинга) ---
func (c *Client) FetchCatalog() ([]catalog.Product, error) {
var products []catalog.Product
// Группы
respGroups, err := c.doRequest("GET", "/resto/api/v2/entities/products/group/list", map[string]string{
"includeDeleted": "true",
})
if err != nil {
return nil, fmt.Errorf("get groups error: %w", err)
}
defer respGroups.Body.Close()
var groupDTOs []GroupDTO
if err := json.NewDecoder(respGroups.Body).Decode(&groupDTOs); err != nil {
return nil, fmt.Errorf("json decode groups error: %w", err)
}
// Товары
respProds, err := c.doRequest("GET", "/resto/api/v2/entities/products/list", map[string]string{
"includeDeleted": "true",
})
if err != nil {
return nil, fmt.Errorf("get products error: %w", err)
}
defer respProds.Body.Close()
var prodDTOs []ProductDTO
if err := json.NewDecoder(respProds.Body).Decode(&prodDTOs); err != nil {
return nil, fmt.Errorf("json decode products error: %w", err)
}
// Маппинг групп
for _, g := range groupDTOs {
id, _ := uuid.Parse(g.ID)
var parentID *uuid.UUID
if g.ParentID != nil {
if pid, err := uuid.Parse(*g.ParentID); err == nil {
parentID = &pid
}
}
products = append(products, catalog.Product{
ID: id,
ParentID: parentID,
Name: g.Name,
Num: g.Num,
Code: g.Code,
Type: "GROUP",
IsDeleted: g.Deleted,
})
}
// Маппинг товаров
for _, p := range prodDTOs {
id, _ := uuid.Parse(p.ID)
var parentID *uuid.UUID
if p.ParentID != nil {
if pid, err := uuid.Parse(*p.ParentID); err == nil {
parentID = &pid
}
}
products = append(products, catalog.Product{
ID: id,
ParentID: parentID,
Name: p.Name,
Num: p.Num,
Code: p.Code,
Type: p.Type,
UnitWeight: decimal.NewFromFloat(p.UnitWeight),
UnitCapacity: decimal.NewFromFloat(p.UnitCapacity),
IsDeleted: p.Deleted,
})
}
return products, nil
}
func (c *Client) FetchRecipes(dateFrom, dateTo time.Time) ([]recipes.Recipe, error) {
params := map[string]string{
"dateFrom": dateFrom.Format("2006-01-02"),
}
if !dateTo.IsZero() {
params["dateTo"] = dateTo.Format("2006-01-02")
}
resp, err := c.doRequest("GET", "/resto/api/v2/assemblyCharts/getAll", params)
if err != nil {
return nil, fmt.Errorf("get recipes error: %w", err)
}
defer resp.Body.Close()
var apiResp AssemblyChartsResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return nil, fmt.Errorf("json decode recipes error: %w", err)
}
var allrecipes []recipes.Recipe
for _, chart := range apiResp.AssemblyCharts {
rID, _ := uuid.Parse(chart.ID)
pID, _ := uuid.Parse(chart.AssembledProductID)
df, _ := time.Parse("2006-01-02", chart.DateFrom)
var dt *time.Time
if chart.DateTo != nil {
if t, err := time.Parse("2006-01-02", *chart.DateTo); err == nil {
dt = &t
}
}
var items []recipes.RecipeItem
for _, item := range chart.Items {
iPID, _ := uuid.Parse(item.ProductID)
// FIX: Генерируем уникальный ID для каждой строки в нашей БД,
// чтобы избежать конфликтов PK при переиспользовании строк в iiko.
items = append(items, recipes.RecipeItem{
ID: uuid.New(),
RecipeID: rID,
ProductID: iPID,
AmountIn: decimal.NewFromFloat(item.AmountIn),
AmountOut: decimal.NewFromFloat(item.AmountOut),
})
}
allrecipes = append(allrecipes, recipes.Recipe{
ID: rID,
ProductID: pID,
DateFrom: df,
DateTo: dt,
Items: items,
})
}
return allrecipes, nil
}
func (c *Client) FetchInvoices(from, to time.Time) ([]invoices.Invoice, error) {
params := map[string]string{
"from": from.Format("2006-01-02"),
"to": to.Format("2006-01-02"),
"currentYear": "false",
}
resp, err := c.doRequest("GET", "/resto/api/documents/export/incomingInvoice", params)
if err != nil {
return nil, fmt.Errorf("get invoices error: %w", err)
}
defer resp.Body.Close()
var xmlData IncomingInvoiceListXML
if err := xml.NewDecoder(resp.Body).Decode(&xmlData); err != nil {
return nil, fmt.Errorf("xml decode invoices error: %w", err)
}
var allinvoices []invoices.Invoice
for _, doc := range xmlData.Documents {
docID, _ := uuid.Parse(doc.ID)
supID, _ := uuid.Parse(doc.Supplier)
storeID, _ := uuid.Parse(doc.DefaultStore)
dateInc, _ := time.Parse("2006-01-02T15:04:05", doc.DateIncoming)
var items []invoices.InvoiceItem
for _, it := range doc.Items {
pID, _ := uuid.Parse(it.Product)
items = append(items, invoices.InvoiceItem{
InvoiceID: docID,
ProductID: pID,
Amount: decimal.NewFromFloat(it.Amount),
Price: decimal.NewFromFloat(it.Price),
Sum: decimal.NewFromFloat(it.Sum),
VatSum: decimal.NewFromFloat(it.VatSum),
})
}
allinvoices = append(allinvoices, invoices.Invoice{
ID: docID,
DocumentNumber: doc.DocumentNumber,
DateIncoming: dateInc,
SupplierID: supID,
DefaultStoreID: storeID,
Status: doc.Status,
Items: items,
})
}
return allinvoices, nil
}
// FetchStoreOperations загружает складской отчет по ID пресета
func (c *Client) FetchStoreOperations(presetID string, from, to time.Time) ([]StoreReportItemXML, error) {
params := map[string]string{
"presetId": presetID,
"dateFrom": from.Format("02.01.2006"), // В документации формат DD.MM.YYYY
"dateTo": to.Format("02.01.2006"),
}
resp, err := c.doRequest("GET", "/resto/api/reports/storeOperations", params)
if err != nil {
return nil, fmt.Errorf("fetch store operations error: %w", err)
}
defer resp.Body.Close()
var report StoreReportResponse
if err := xml.NewDecoder(resp.Body).Decode(&report); err != nil {
// Иногда RMS возвращает пустой ответ или ошибку текстом при отсутствии данных
return nil, fmt.Errorf("xml decode store operations error: %w", err)
}
return report.Items, nil
}
// CreateIncomingInvoice отправляет накладную в iiko
func (c *Client) CreateIncomingInvoice(inv invoices.Invoice) (string, error) {
// 1. Маппинг Domain -> XML DTO
reqDTO := IncomingInvoiceImportXML{
DocumentNumber: inv.DocumentNumber,
DateIncoming: inv.DateIncoming.Format("02.01.2006"),
DefaultStore: inv.DefaultStoreID.String(),
Supplier: inv.SupplierID.String(),
Status: "NEW",
Comment: "Loaded via RMSER OCR",
}
if inv.ID != uuid.Nil {
reqDTO.ID = inv.ID.String()
}
for i, item := range inv.Items {
amount, _ := item.Amount.Float64()
price, _ := item.Price.Float64()
sum, _ := item.Sum.Float64()
reqDTO.ItemsWrapper.Items = append(reqDTO.ItemsWrapper.Items, IncomingInvoiceImportItemXML{
ProductID: item.ProductID.String(),
Amount: amount,
Price: price,
Sum: sum,
Num: i + 1,
Store: inv.DefaultStoreID.String(),
})
}
// 2. Маршалинг в XML
xmlBytes, err := xml.Marshal(reqDTO)
if err != nil {
return "", fmt.Errorf("xml marshal error: %w", err)
}
// Добавляем XML header вручную
xmlPayload := []byte(xml.Header + string(xmlBytes))
// 3. Получение токена
if err := c.ensureToken(); err != nil {
return "", err
}
c.mu.RLock()
token := c.token
c.mu.RUnlock()
// 4. Формирование URL
endpoint, _ := url.Parse(c.baseURL + "/resto/api/documents/import/incomingInvoice")
q := endpoint.Query()
q.Set("key", token)
endpoint.RawQuery = q.Encode()
fullURL := endpoint.String()
// --- ЛОГИРОВАНИЕ ЗАПРОСА (URL + BODY) ---
// Логируем как Info, чтобы точно увидеть в консоли при отладке
logger.Log.Info("RMS POST Request Debug",
zap.String("method", "POST"),
zap.String("url", fullURL),
zap.String("body_payload", string(xmlPayload)),
)
// ----------------------------------------
// 5. Отправка
req, err := http.NewRequest("POST", fullURL, bytes.NewReader(xmlPayload))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/xml")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("network error: %w", err)
}
defer resp.Body.Close()
// Читаем ответ
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
// Логируем ответ для симметрии
logger.Log.Info("RMS POST Response Debug",
zap.Int("status_code", resp.StatusCode),
zap.String("response_body", string(respBody)),
)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("http error %d: %s", resp.StatusCode, string(respBody))
}
var result DocumentValidationResult
if err := xml.Unmarshal(respBody, &result); err != nil {
return "", fmt.Errorf("xml response unmarshal error: %w", err)
}
if !result.Valid {
logger.Log.Warn("RMS Invoice Import Failed",
zap.String("error", result.ErrorMessage),
zap.String("additional", result.AdditionalInfo),
)
return "", fmt.Errorf("iiko validation failed: %s (info: %s)", result.ErrorMessage, result.AdditionalInfo)
}
return result.DocumentNumber, nil
}

View File

@@ -0,0 +1,146 @@
package rms
import (
"encoding/xml"
)
// --- 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"`
}
type GroupDTO struct {
ID string `json:"id"`
ParentID *string `json:"parent"`
Name string `json:"name"`
Num string `json:"num"`
Code string `json:"code"`
Description string `json:"description"`
Deleted bool `json:"deleted"`
}
type AssemblyChartsResponse struct {
AssemblyCharts []AssemblyChartDTO `json:"assemblyCharts"`
// preparedCharts и другие поля пока опускаем, если не нужны для базового импорта
}
type AssemblyChartDTO struct {
ID string `json:"id"`
AssembledProductID string `json:"assembledProductId"`
DateFrom string `json:"dateFrom"` // Format: "2018-01-29" (yyyy-MM-dd)
DateTo *string `json:"dateTo"` // Nullable
Items []AssemblyItemDTO `json:"items"`
}
type AssemblyItemDTO struct {
ID string `json:"id"` // Добавили поле ID строки техкарты
ProductID string `json:"productId"`
AmountIn float64 `json:"amountIn"`
AmountOut float64 `json:"amountOut"`
}
// --- XML DTOs (Legacy API) ---
type IncomingInvoiceListXML struct {
XMLName xml.Name `xml:"incomingInvoiceDtoes"`
Documents []IncomingInvoiceXML `xml:"document"`
}
type IncomingInvoiceXML struct {
ID string `xml:"id"`
DocumentNumber string `xml:"documentNumber"`
DateIncoming string `xml:"dateIncoming"` // Format: yyyy-MM-ddTHH:mm:ss
Status string `xml:"status"` // PROCESSED, NEW, DELETED
Supplier string `xml:"supplier"` // GUID
DefaultStore string `xml:"defaultStore"` // GUID
Items []InvoiceItemXML `xml:"items>item"`
}
type InvoiceItemXML struct {
Product string `xml:"product"` // GUID
Amount float64 `xml:"amount"` // Количество в основных единицах
Price float64 `xml:"price"` // Цена за единицу
Sum float64 `xml:"sum"` // Сумма без скидки (обычно)
VatSum float64 `xml:"vatSum"` // Сумма НДС
}
// --- XML DTOs (Store Reports) ---
type StoreReportResponse struct {
XMLName xml.Name `xml:"storeReportItemDtoes"`
Items []StoreReportItemXML `xml:"storeReportItemDto"`
}
type StoreReportItemXML struct {
// Основные идентификаторы
ProductID string `xml:"product"` // GUID товара
ProductGroup string `xml:"productGroup"` // GUID группы
Store string `xml:"primaryStore"` // GUID склада
DocumentID string `xml:"documentId"` // GUID документа
DocumentNum string `xml:"documentNum"` // Номер документа (строка)
// Типы (ENUMs)
DocumentType string `xml:"documentType"` // Например: INCOMING_INVOICE
TransactionType string `xml:"type"` // Например: INVOICE, WRITEOFF
// Финансы и количество
Amount float64 `xml:"amount"` // Количество
Sum float64 `xml:"sum"` // Сумма с НДС
SumWithoutNds float64 `xml:"sumWithoutNds"` // Сумма без НДС
Cost float64 `xml:"cost"` // Себестоимость
// Флаги и даты (используем строки для дат, так как парсинг делаем в сервисе)
Incoming bool `xml:"incoming"`
Date string `xml:"date"`
OperationalDate string `xml:"operationalDate"`
}
// --- XML DTOs (Import API) ---
// IncomingInvoiceImportXML описывает структуру для POST запроса импорта
type IncomingInvoiceImportXML struct {
XMLName xml.Name `xml:"document"`
ID string `xml:"id,omitempty"` // GUID, если редактируем
DocumentNumber string `xml:"documentNumber,omitempty"`
DateIncoming string `xml:"dateIncoming,omitempty"` // Format: dd.MM.yyyy
Invoice string `xml:"invoice,omitempty"` // Номер счет-фактуры
DefaultStore string `xml:"defaultStore"` // GUID склада (обязательно)
Supplier string `xml:"supplier"` // GUID поставщика (обязательно)
Comment string `xml:"comment,omitempty"`
Status string `xml:"status,omitempty"` // NEW, PROCESSED
ItemsWrapper struct {
Items []IncomingInvoiceImportItemXML `xml:"item"`
} `xml:"items"`
}
type IncomingInvoiceImportItemXML struct {
ProductID string `xml:"product"` // GUID товара
Amount float64 `xml:"amount"` // Кол-во в базовых единицах
Price float64 `xml:"price"` // Цена за единицу
Sum float64 `xml:"sum,omitempty"`
Store string `xml:"store"` // GUID склада
// Поля ниже можно опустить, если iiko должна сама подтянуть их из карточки товара
// или если мы работаем в базовых единицах.
AmountUnit string `xml:"amountUnit,omitempty"` // GUID единицы измерения
Num int `xml:"num,omitempty"` // Номер строки
}
// DocumentValidationResult описывает ответ сервера при импорте
type DocumentValidationResult struct {
XMLName xml.Name `xml:"documentValidationResult"`
Valid bool `xml:"valid"`
Warning bool `xml:"warning"`
DocumentNumber string `xml:"documentNumber"`
OtherSuggestedNumber string `xml:"otherSuggestedNumber"`
ErrorMessage string `xml:"errorMessage"`
AdditionalInfo string `xml:"additionalInfo"`
}