mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
start rmser
This commit is contained in:
34
internal/domain/catalog/entity.go
Normal file
34
internal/domain/catalog/entity.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
|
||||
Parent *Product `gorm:"foreignKey:ParentID"`
|
||||
Children []*Product `gorm:"foreignKey:ParentID"`
|
||||
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Repository интерфейс для каталога
|
||||
type Repository interface {
|
||||
SaveProducts(products []Product) error
|
||||
GetAll() ([]Product, error)
|
||||
GetActiveGoods() ([]Product, error)
|
||||
}
|
||||
20
internal/domain/interfaces.go
Normal file
20
internal/domain/interfaces.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"rmser/internal/domain/catalog"
|
||||
"rmser/internal/domain/invoices"
|
||||
"rmser/internal/domain/recipes"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
// Catalog
|
||||
SaveProducts(products []catalog.Product) error
|
||||
|
||||
// Recipes
|
||||
SaveRecipes(recipes []recipes.Recipe) error
|
||||
|
||||
// Invoices
|
||||
GetLastInvoiceDate() (*time.Time, error)
|
||||
SaveInvoices(invoices []invoices.Invoice) error
|
||||
}
|
||||
43
internal/domain/invoices/entity.go
Normal file
43
internal/domain/invoices/entity.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package invoices
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"rmser/internal/domain/catalog"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// Invoice - Приходная накладная
|
||||
type Invoice struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;"`
|
||||
DocumentNumber string `gorm:"type:varchar(100);index"`
|
||||
DateIncoming time.Time `gorm:"index"`
|
||||
SupplierID uuid.UUID `gorm:"type:uuid;index"`
|
||||
DefaultStoreID uuid.UUID `gorm:"type:uuid;index"`
|
||||
Status string `gorm:"type:varchar(50)"`
|
||||
|
||||
Items []InvoiceItem `gorm:"foreignKey:InvoiceID;constraint:OnDelete:CASCADE"`
|
||||
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// InvoiceItem - Позиция накладной
|
||||
type InvoiceItem struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"`
|
||||
InvoiceID uuid.UUID `gorm:"type:uuid;not null;index"`
|
||||
ProductID uuid.UUID `gorm:"type:uuid;not null"`
|
||||
Amount decimal.Decimal `gorm:"type:numeric(19,4);not null"`
|
||||
Price decimal.Decimal `gorm:"type:numeric(19,4);not null"`
|
||||
Sum decimal.Decimal `gorm:"type:numeric(19,4);not null"`
|
||||
VatSum decimal.Decimal `gorm:"type:numeric(19,4)"`
|
||||
|
||||
Product catalog.Product `gorm:"foreignKey:ProductID"`
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
GetLastInvoiceDate() (*time.Time, error)
|
||||
SaveInvoices(invoices []Invoice) error
|
||||
}
|
||||
31
internal/domain/ocr/entity.go
Normal file
31
internal/domain/ocr/entity.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package ocr
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"rmser/internal/domain/catalog"
|
||||
)
|
||||
|
||||
// ProductMatch связывает текст из чека с конкретным товаром в iiko
|
||||
type ProductMatch struct {
|
||||
// RawName - распознанный текст (ключ).
|
||||
// Лучше хранить в нижнем регистре и без лишних пробелов.
|
||||
RawName string `gorm:"type:varchar(255);primary_key"`
|
||||
|
||||
ProductID uuid.UUID `gorm:"type:uuid;not null;index"`
|
||||
|
||||
// Product - связь для GORM
|
||||
Product catalog.Product `gorm:"foreignKey:ProductID"`
|
||||
|
||||
UpdatedAt time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
// SaveMatch сохраняет или обновляет привязку
|
||||
SaveMatch(rawName string, productID uuid.UUID) error
|
||||
|
||||
// FindMatch ищет товар по точному совпадению названия
|
||||
FindMatch(rawName string) (*uuid.UUID, error)
|
||||
}
|
||||
48
internal/domain/operations/entity.go
Normal file
48
internal/domain/operations/entity.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package operations
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"rmser/internal/domain/catalog"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
type OperationType string
|
||||
|
||||
const (
|
||||
OpTypePurchase OperationType = "PURCHASE" // Закупка (Приход)
|
||||
OpTypeUsage OperationType = "USAGE" // Расход (Реализация + Списание)
|
||||
OpTypeUnknown OperationType = "UNKNOWN" // Прочее (Инвентаризация, Перемещения - игнорируем пока)
|
||||
)
|
||||
|
||||
// StoreOperation - запись из складского отчета
|
||||
type StoreOperation struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"`
|
||||
ProductID uuid.UUID `gorm:"type:uuid;not null;index"`
|
||||
|
||||
// Наш внутренний, "очищенный" тип операции
|
||||
OpType OperationType `gorm:"type:varchar(50);index"`
|
||||
|
||||
// Raw данные из iiko для отладки и детализации
|
||||
DocumentType string `gorm:"type:varchar(100);index"` // INCOMING_INVOICE, etc.
|
||||
TransactionType string `gorm:"type:varchar(100)"` // INVOICE, WRITEOFF, etc.
|
||||
DocumentNumber string `gorm:"type:varchar(100)"`
|
||||
|
||||
Amount decimal.Decimal `gorm:"type:numeric(19,4)"`
|
||||
Sum decimal.Decimal `gorm:"type:numeric(19,4)"`
|
||||
Cost decimal.Decimal `gorm:"type:numeric(19,4)"`
|
||||
|
||||
// Период синхронизации (для перезаписи данных)
|
||||
PeriodFrom time.Time `gorm:"index"`
|
||||
PeriodTo time.Time `gorm:"index"`
|
||||
|
||||
Product catalog.Product `gorm:"foreignKey:ProductID"`
|
||||
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
SaveOperations(ops []StoreOperation, opType OperationType, dateFrom, dateTo time.Time) error
|
||||
}
|
||||
36
internal/domain/recipes/entity.go
Normal file
36
internal/domain/recipes/entity.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package recipes
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"rmser/internal/domain/catalog"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// Recipe - Технологическая карта
|
||||
type Recipe struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;"`
|
||||
ProductID uuid.UUID `gorm:"type:uuid;not null;index"`
|
||||
DateFrom time.Time `gorm:"index"`
|
||||
DateTo *time.Time
|
||||
|
||||
Product catalog.Product `gorm:"foreignKey:ProductID"`
|
||||
Items []RecipeItem `gorm:"foreignKey:RecipeID;constraint:OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
// RecipeItem - Ингредиент
|
||||
type RecipeItem struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"`
|
||||
RecipeID uuid.UUID `gorm:"type:uuid;not null;index"`
|
||||
ProductID uuid.UUID `gorm:"type:uuid;not null;index"`
|
||||
AmountIn decimal.Decimal `gorm:"type:numeric(19,4);not null"`
|
||||
AmountOut decimal.Decimal `gorm:"type:numeric(19,4);not null"`
|
||||
|
||||
Product catalog.Product `gorm:"foreignKey:ProductID"`
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
SaveRecipes(recipes []Recipe) error
|
||||
}
|
||||
43
internal/domain/recommendations/entity.go
Normal file
43
internal/domain/recommendations/entity.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package recommendations
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Типы проблем
|
||||
const (
|
||||
TypeUnused = "UNUSED_IN_RECIPES" // Товар не используется в техкартах
|
||||
TypeNoIncoming = "NO_INCOMING" // Ингредиент (GOODS) в техкарте, но нет приходов
|
||||
TypeStale = "STALE_GOODS" // Есть приходы, но нет продаж
|
||||
TypeDishInRecipe = "DISH_IN_RECIPE" // Блюдо (DISH) в составе другого блюда
|
||||
TypePurchasedButUnused = "PURCHASED_BUT_UNUSED" // Активно закупается, но нет в техкартах
|
||||
TypeUsageNoIncoming = "USAGE_NO_INCOMING" // Есть расходы, но нет приходов
|
||||
)
|
||||
|
||||
// Recommendation - Результат анализа
|
||||
type Recommendation struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"`
|
||||
Type string `gorm:"type:varchar(50);index"`
|
||||
ProductID uuid.UUID `gorm:"type:uuid;index"`
|
||||
ProductName string `gorm:"type:varchar(255)"`
|
||||
Reason string `gorm:"type:text"`
|
||||
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Repository отвечает за аналитические выборки и хранение результатов
|
||||
type Repository interface {
|
||||
// Методы анализа (возвращают список структур, но не пишут в БД)
|
||||
FindUnusedGoods() ([]Recommendation, error)
|
||||
FindNoIncomingIngredients(days int) ([]Recommendation, error)
|
||||
FindStaleGoods(days int) ([]Recommendation, error)
|
||||
FindDishesInRecipes() ([]Recommendation, error)
|
||||
FindPurchasedButUnused(days int) ([]Recommendation, error)
|
||||
FindUsageWithoutPurchase(days int) ([]Recommendation, error)
|
||||
|
||||
// Методы "Кэша" в БД
|
||||
SaveAll(items []Recommendation) error // Удаляет старые и пишет новые
|
||||
GetAll() ([]Recommendation, error)
|
||||
}
|
||||
110
internal/infrastructure/db/postgres.go
Normal file
110
internal/infrastructure/db/postgres.go
Normal 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)
|
||||
}
|
||||
}
|
||||
84
internal/infrastructure/ocr_client/client.go
Normal file
84
internal/infrastructure/ocr_client/client.go
Normal 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
|
||||
}
|
||||
13
internal/infrastructure/ocr_client/dto.go
Normal file
13
internal/infrastructure/ocr_client/dto.go
Normal 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"` // Сумма
|
||||
}
|
||||
52
internal/infrastructure/redis/client.go
Normal file
52
internal/infrastructure/redis/client.go
Normal 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)
|
||||
}
|
||||
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
|
||||
}
|
||||
556
internal/infrastructure/rms/client.go
Normal file
556
internal/infrastructure/rms/client.go
Normal 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
|
||||
}
|
||||
146
internal/infrastructure/rms/dto.go
Normal file
146
internal/infrastructure/rms/dto.go
Normal 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"`
|
||||
}
|
||||
86
internal/services/invoices/service.go
Normal file
86
internal/services/invoices/service.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package invoices
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/shopspring/decimal"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"rmser/internal/domain/invoices"
|
||||
"rmser/internal/infrastructure/rms"
|
||||
"rmser/pkg/logger"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
rmsClient rms.ClientI
|
||||
// Здесь можно добавить репозитории каталога и контрагентов для валидации,
|
||||
// но для краткости пока опустим глубокую валидацию.
|
||||
}
|
||||
|
||||
func NewService(rmsClient rms.ClientI) *Service {
|
||||
return &Service{
|
||||
rmsClient: rmsClient,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateRequestDTO - структура входящего JSON запроса от фронта/OCR
|
||||
type CreateRequestDTO struct {
|
||||
DocumentNumber string `json:"document_number"`
|
||||
DateIncoming string `json:"date_incoming"` // YYYY-MM-DD
|
||||
SupplierID uuid.UUID `json:"supplier_id"`
|
||||
StoreID uuid.UUID `json:"store_id"`
|
||||
Items []struct {
|
||||
ProductID uuid.UUID `json:"product_id"`
|
||||
Amount decimal.Decimal `json:"amount"`
|
||||
Price decimal.Decimal `json:"price"`
|
||||
} `json:"items"`
|
||||
}
|
||||
|
||||
// SendInvoiceToRMS валидирует DTO, собирает доменную модель и отправляет в RMS
|
||||
func (s *Service) SendInvoiceToRMS(req CreateRequestDTO) (string, error) {
|
||||
// 1. Базовая валидация
|
||||
if len(req.Items) == 0 {
|
||||
return "", fmt.Errorf("список товаров пуст")
|
||||
}
|
||||
|
||||
dateInc, err := time.Parse("2006-01-02", req.DateIncoming)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("неверный формат даты (ожидается YYYY-MM-DD): %v", err)
|
||||
}
|
||||
|
||||
// 2. Сборка доменной модели
|
||||
inv := invoices.Invoice{
|
||||
ID: uuid.Nil, // Новый документ
|
||||
DocumentNumber: req.DocumentNumber,
|
||||
DateIncoming: dateInc,
|
||||
SupplierID: req.SupplierID,
|
||||
DefaultStoreID: req.StoreID,
|
||||
Status: "NEW",
|
||||
Items: make([]invoices.InvoiceItem, 0, len(req.Items)),
|
||||
}
|
||||
|
||||
for _, itemDTO := range req.Items {
|
||||
sum := itemDTO.Amount.Mul(itemDTO.Price) // Пересчитываем сумму
|
||||
|
||||
inv.Items = append(inv.Items, invoices.InvoiceItem{
|
||||
ProductID: itemDTO.ProductID,
|
||||
Amount: itemDTO.Amount,
|
||||
Price: itemDTO.Price,
|
||||
Sum: sum,
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Отправка через клиент
|
||||
logger.Log.Info("Отправка накладной в RMS",
|
||||
zap.String("supplier", req.SupplierID.String()),
|
||||
zap.Int("items_count", len(inv.Items)))
|
||||
|
||||
docNum, err := s.rmsClient.CreateIncomingInvoice(inv)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return docNum, nil
|
||||
}
|
||||
147
internal/services/ocr/service.go
Normal file
147
internal/services/ocr/service.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package ocr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/shopspring/decimal"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"rmser/internal/domain/catalog"
|
||||
"rmser/internal/domain/ocr"
|
||||
"rmser/internal/infrastructure/ocr_client"
|
||||
"rmser/pkg/logger"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
ocrRepo ocr.Repository
|
||||
catalogRepo catalog.Repository
|
||||
pyClient *ocr_client.Client // Клиент к Python сервису
|
||||
}
|
||||
|
||||
func NewService(
|
||||
ocrRepo ocr.Repository,
|
||||
catalogRepo catalog.Repository,
|
||||
pyClient *ocr_client.Client,
|
||||
) *Service {
|
||||
return &Service{
|
||||
ocrRepo: ocrRepo,
|
||||
catalogRepo: catalogRepo,
|
||||
pyClient: pyClient,
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessReceiptImage - основной метод: Картинка -> Распознавание -> Матчинг
|
||||
func (s *Service) ProcessReceiptImage(ctx context.Context, imgData []byte) ([]ProcessedItem, error) {
|
||||
// 1. Отправляем в Python
|
||||
rawResult, err := s.pyClient.ProcessImage(ctx, imgData, "receipt.jpg")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("python ocr error: %w", err)
|
||||
}
|
||||
|
||||
var processed []ProcessedItem
|
||||
|
||||
// 2. Обрабатываем каждую строку
|
||||
for _, rawItem := range rawResult.Items {
|
||||
item := ProcessedItem{
|
||||
RawName: rawItem.RawName,
|
||||
Amount: decimal.NewFromFloat(rawItem.Amount),
|
||||
Price: decimal.NewFromFloat(rawItem.Price),
|
||||
Sum: decimal.NewFromFloat(rawItem.Sum),
|
||||
}
|
||||
|
||||
// 3. Ищем соответствие
|
||||
// Сначала проверяем таблицу ручного обучения (product_matches)
|
||||
matchID, 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
|
||||
item.IsMatched = true
|
||||
item.MatchSource = "learned"
|
||||
} else {
|
||||
// Если не нашли, пробуем найти точное совпадение по имени в каталоге (на всякий случай)
|
||||
// (В реальном проекте тут может быть нечеткий поиск, но пока точный)
|
||||
// TODO: Добавить метод FindByName в репозиторий каталога, если нужно
|
||||
}
|
||||
|
||||
processed = append(processed, item)
|
||||
}
|
||||
|
||||
return processed, nil
|
||||
}
|
||||
|
||||
// ProcessedItem - результат обработки одной строки чека
|
||||
type ProcessedItem struct {
|
||||
RawName string
|
||||
Amount decimal.Decimal
|
||||
Price decimal.Decimal
|
||||
Sum decimal.Decimal
|
||||
|
||||
IsMatched bool
|
||||
ProductID *uuid.UUID
|
||||
MatchSource string // "learned", "auto", "manual"
|
||||
}
|
||||
|
||||
// ProductForIndex DTO для внешнего сервиса
|
||||
type ProductForIndex struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// GetCatalogForIndexing возвращает список товаров для построения индекса
|
||||
func (s *Service) GetCatalogForIndexing() ([]ProductForIndex, error) {
|
||||
products, err := s.catalogRepo.GetActiveGoods()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]ProductForIndex, 0, len(products))
|
||||
for _, p := range products {
|
||||
result = append(result, ProductForIndex{
|
||||
ID: p.ID.String(),
|
||||
Name: p.Name,
|
||||
Code: p.Code,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SaveMapping сохраняет связь "Текст из чека" -> "Наш товар"
|
||||
func (s *Service) SaveMapping(rawName string, productID uuid.UUID) error {
|
||||
return s.ocrRepo.SaveMatch(rawName, productID)
|
||||
}
|
||||
|
||||
// FindKnownMatch ищет, знаем ли мы уже этот товар
|
||||
func (s *Service) FindKnownMatch(rawName string) (*uuid.UUID, error) {
|
||||
return s.ocrRepo.FindMatch(rawName)
|
||||
}
|
||||
|
||||
// SearchProducts ищет товары в БД по части названия (для ручного выбора в боте)
|
||||
func (s *Service) SearchProducts(query string) ([]catalog.Product, error) {
|
||||
// Этот метод нужно поддержать в репозитории, пока сделаем заглушку или фильтрацию в памяти
|
||||
// Для MVP добавим метод SearchByName в интерфейс репозитория
|
||||
all, err := s.catalogRepo.GetActiveGoods()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Простейший поиск в памяти (для начала хватит)
|
||||
query = strings.ToLower(query)
|
||||
var result []catalog.Product
|
||||
for _, p := range all {
|
||||
if strings.Contains(strings.ToLower(p.Name), query) {
|
||||
result = append(result, p)
|
||||
if len(result) >= 10 { // Ограничим выдачу
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
82
internal/services/recommend/service.go
Normal file
82
internal/services/recommend/service.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package recommend
|
||||
|
||||
import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"rmser/internal/domain/recommendations"
|
||||
"rmser/pkg/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
AnalyzeDaysNoIncoming = 90 // Ищем ингредиенты без закупок за 30 дней
|
||||
AnalyzeDaysStale = 90 // Ищем неликвид за 60 дней
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo recommendations.Repository
|
||||
}
|
||||
|
||||
func NewService(repo recommendations.Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
// RefreshRecommendations выполняет анализ и сохраняет результаты в БД
|
||||
func (s *Service) RefreshRecommendations() error {
|
||||
logger.Log.Info("Запуск пересчета рекомендаций...")
|
||||
|
||||
var all []recommendations.Recommendation
|
||||
|
||||
// 1. Unused
|
||||
if unused, err := s.repo.FindUnusedGoods(); err == nil {
|
||||
all = append(all, unused...)
|
||||
} else {
|
||||
logger.Log.Error("Ошибка unused", zap.Error(err))
|
||||
}
|
||||
|
||||
// 2. Purchased but Unused
|
||||
if purchUnused, err := s.repo.FindPurchasedButUnused(AnalyzeDaysNoIncoming); err == nil {
|
||||
all = append(all, purchUnused...)
|
||||
} else {
|
||||
logger.Log.Error("Ошибка purchased_unused", zap.Error(err))
|
||||
}
|
||||
|
||||
// 3. No Incoming (Ингредиенты без закупок)
|
||||
if noInc, err := s.repo.FindNoIncomingIngredients(AnalyzeDaysNoIncoming); err == nil {
|
||||
all = append(all, noInc...)
|
||||
} else {
|
||||
logger.Log.Error("Ошибка no_incoming", zap.Error(err))
|
||||
}
|
||||
|
||||
// 4. Usage without Purchase (Расход без прихода) <-- НОВОЕ
|
||||
if usageNoPurch, err := s.repo.FindUsageWithoutPurchase(AnalyzeDaysNoIncoming); err == nil {
|
||||
all = append(all, usageNoPurch...)
|
||||
} else {
|
||||
logger.Log.Error("Ошибка usage_no_purchase", zap.Error(err))
|
||||
}
|
||||
|
||||
// 5. Stale (Неликвид)
|
||||
if stale, err := s.repo.FindStaleGoods(AnalyzeDaysStale); err == nil {
|
||||
all = append(all, stale...)
|
||||
} else {
|
||||
logger.Log.Error("Ошибка stale", zap.Error(err))
|
||||
}
|
||||
|
||||
// 6. Dish in Recipe
|
||||
if dishInRec, err := s.repo.FindDishesInRecipes(); err == nil {
|
||||
all = append(all, dishInRec...)
|
||||
} else {
|
||||
logger.Log.Error("Ошибка dish_in_recipe", zap.Error(err))
|
||||
}
|
||||
|
||||
// Сохраняем
|
||||
if err := s.repo.SaveAll(all); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Log.Info("Рекомендации обновлены", zap.Int("total_count", len(all)))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) GetRecommendations() ([]recommendations.Recommendation, error) {
|
||||
return s.repo.GetAll()
|
||||
}
|
||||
244
internal/services/sync/service.go
Normal file
244
internal/services/sync/service.go
Normal file
@@ -0,0 +1,244 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/shopspring/decimal"
|
||||
|
||||
"rmser/internal/domain/catalog"
|
||||
"rmser/internal/domain/invoices"
|
||||
"rmser/internal/domain/operations"
|
||||
"rmser/internal/domain/recipes"
|
||||
"rmser/internal/infrastructure/rms"
|
||||
"rmser/pkg/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
// Пресеты от пользователя
|
||||
PresetPurchases = "1a3297e1-cb05-55dc-98a7-c13f13bc85a7" // Закупки
|
||||
PresetUsage = "24d9402e-2d01-eca1-ebeb-7981f7d1cb86" // Расход
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
rmsClient rms.ClientI
|
||||
catalogRepo catalog.Repository
|
||||
recipeRepo recipes.Repository
|
||||
invoiceRepo invoices.Repository
|
||||
opRepo operations.Repository
|
||||
}
|
||||
|
||||
func NewService(
|
||||
rmsClient rms.ClientI,
|
||||
catalogRepo catalog.Repository,
|
||||
recipeRepo recipes.Repository,
|
||||
invoiceRepo invoices.Repository,
|
||||
opRepo operations.Repository,
|
||||
) *Service {
|
||||
return &Service{
|
||||
rmsClient: rmsClient,
|
||||
catalogRepo: catalogRepo,
|
||||
recipeRepo: recipeRepo,
|
||||
invoiceRepo: invoiceRepo,
|
||||
opRepo: opRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// SyncCatalog загружает номенклатуру и сохраняет в БД
|
||||
func (s *Service) SyncCatalog() error {
|
||||
logger.Log.Info("Начало синхронизации номенклатуры")
|
||||
|
||||
products, err := s.rmsClient.FetchCatalog()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ошибка получения каталога из RMS: %w", err)
|
||||
}
|
||||
|
||||
if err := s.catalogRepo.SaveProducts(products); err != nil {
|
||||
return fmt.Errorf("ошибка сохранения продуктов в БД: %w", err)
|
||||
}
|
||||
|
||||
logger.Log.Info("Синхронизация номенклатуры завершена", zap.Int("count", len(products)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// SyncRecipes загружает техкарты за указанный период (или за последние 30 дней по умолчанию)
|
||||
func (s *Service) SyncRecipes() error {
|
||||
logger.Log.Info("Начало синхронизации техкарт")
|
||||
|
||||
// RMS требует dateFrom. Берем широкий диапазон, например, с начала года или фиксированную дату,
|
||||
// либо можно сделать конфигурируемым. Для примера берем -3 месяца от текущей даты.
|
||||
// В реальном проде лучше брать дату последнего изменения, если API поддерживает revision,
|
||||
// но V2 API iiko часто требует полной перезагрузки актуальных карт.
|
||||
dateFrom := time.Now().AddDate(0, -3, 0)
|
||||
dateTo := time.Now() // +1 месяц вперед на случай будущих меню
|
||||
|
||||
recipes, err := s.rmsClient.FetchRecipes(dateFrom, dateTo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ошибка получения техкарт из RMS: %w", err)
|
||||
}
|
||||
|
||||
if err := s.recipeRepo.SaveRecipes(recipes); err != nil {
|
||||
return fmt.Errorf("ошибка сохранения техкарт в БД: %w", err)
|
||||
}
|
||||
|
||||
logger.Log.Info("Синхронизация техкарт завершена", zap.Int("count", len(recipes)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// SyncInvoices загружает накладные. Если в базе пусто, грузит за последние N дней.
|
||||
func (s *Service) SyncInvoices() error {
|
||||
logger.Log.Info("Начало синхронизации накладных")
|
||||
|
||||
lastDate, err := s.invoiceRepo.GetLastInvoiceDate()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ошибка получения даты последней накладной: %w", err)
|
||||
}
|
||||
|
||||
var from time.Time
|
||||
to := time.Now()
|
||||
|
||||
if lastDate != nil {
|
||||
// Берем следующий день после последней загрузки или тот же день, чтобы обновить изменения
|
||||
from = *lastDate
|
||||
} else {
|
||||
// Дефолтная загрузка за 30 дней назад
|
||||
from = time.Now().AddDate(0, 0, -30)
|
||||
}
|
||||
|
||||
logger.Log.Info("Запрос накладных", zap.Time("from", from), zap.Time("to", to))
|
||||
|
||||
invoices, err := s.rmsClient.FetchInvoices(from, to)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ошибка получения накладных из RMS: %w", err)
|
||||
}
|
||||
|
||||
if len(invoices) == 0 {
|
||||
logger.Log.Info("Новых накладных не найдено")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := s.invoiceRepo.SaveInvoices(invoices); err != nil {
|
||||
return fmt.Errorf("ошибка сохранения накладных в БД: %w", err)
|
||||
}
|
||||
|
||||
logger.Log.Info("Синхронизация накладных завершена", zap.Int("count", len(invoices)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// classifyOperation определяет тип операции на основе DocumentType
|
||||
func classifyOperation(docType string) operations.OperationType {
|
||||
switch docType {
|
||||
// === ПРИХОД (PURCHASE) ===
|
||||
case "INCOMING_INVOICE": // Приходная накладная
|
||||
return operations.OpTypePurchase
|
||||
case "INCOMING_SERVICE": // Акт приема услуг (редко товары, но бывает)
|
||||
return operations.OpTypePurchase
|
||||
|
||||
// === РАСХОД (USAGE) ===
|
||||
case "SALES_DOCUMENT": // Акт реализации (продажа)
|
||||
return operations.OpTypeUsage
|
||||
case "WRITEOFF_DOCUMENT": // Акт списания (порча, проработки)
|
||||
return operations.OpTypeUsage
|
||||
case "OUTGOING_INVOICE": // Расходная накладная
|
||||
return operations.OpTypeUsage
|
||||
case "SESSION_ACCEPTANCE": // Принятие смены (иногда агрегирует продажи)
|
||||
return operations.OpTypeUsage
|
||||
case "DISASSEMBLE_DOCUMENT": // Акт разбора (расход целого)
|
||||
return operations.OpTypeUsage
|
||||
|
||||
// === Спорные/Игнорируемые ===
|
||||
// RETURNED_INVOICE (Возвратная накладная) - технически это уменьшение прихода,
|
||||
// но для рекомендаций "что мы покупаем" лучше обрабатывать отдельно или как минус-purchase.
|
||||
// Пока отнесем к UNKNOWN, чтобы не портить статистику чистого прихода,
|
||||
// либо можно считать как Purchase с отрицательным Amount (если XML дает минус).
|
||||
case "RETURNED_INVOICE":
|
||||
return operations.OpTypeUnknown
|
||||
|
||||
case "INTERNAL_TRANSFER":
|
||||
return operations.OpTypeUnknown // Перемещение нас не интересует в рамках рекомендаций "купил/продал"
|
||||
case "INCOMING_INVENTORY":
|
||||
return operations.OpTypeUnknown // Инвентаризация
|
||||
|
||||
default:
|
||||
return operations.OpTypeUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) SyncStoreOperations() error {
|
||||
dateTo := time.Now()
|
||||
dateFrom := dateTo.AddDate(0, 0, -30)
|
||||
|
||||
// 1. Синхронизируем Закупки (PresetPurchases)
|
||||
// Мы передаем OpTypePurchase, чтобы репозиторий знал, какую "полку" очистить перед записью.
|
||||
if err := s.syncReport(PresetPurchases, operations.OpTypePurchase, dateFrom, dateTo); err != nil {
|
||||
return fmt.Errorf("ошибка синхронизации закупок: %w", err)
|
||||
}
|
||||
|
||||
// 2. Синхронизируем Расход (PresetUsage)
|
||||
if err := s.syncReport(PresetUsage, operations.OpTypeUsage, dateFrom, dateTo); err != nil {
|
||||
return fmt.Errorf("ошибка синхронизации расхода: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) syncReport(presetID string, targetOpType operations.OperationType, from, to time.Time) error {
|
||||
logger.Log.Info("Запрос отчета RMS", zap.String("preset", presetID))
|
||||
|
||||
items, err := s.rmsClient.FetchStoreOperations(presetID, from, to)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var ops []operations.StoreOperation
|
||||
for _, item := range items {
|
||||
// 1. Валидация товара
|
||||
pID, err := uuid.Parse(item.ProductID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// 2. Определение реального типа операции
|
||||
realOpType := classifyOperation(item.DocumentType)
|
||||
|
||||
// 3. Фильтрация "мусора"
|
||||
// Если мы грузим отчет "Закупки", но туда попало "Перемещение" (из-за кривого пресета),
|
||||
// мы это пропустим. Либо если документ неизвестного типа.
|
||||
if realOpType == operations.OpTypeUnknown {
|
||||
continue
|
||||
}
|
||||
|
||||
// Важно: Мы сохраняем только то, что соответствует целевому типу этапа синхронизации.
|
||||
// Если в пресете "Закупки" попалась "Реализация", мы не должны писать её в "Закупки",
|
||||
// и не должны писать в "Расход" (так как мы сейчас чистим "Закупки").
|
||||
if realOpType != targetOpType {
|
||||
continue
|
||||
}
|
||||
|
||||
ops = append(ops, operations.StoreOperation{
|
||||
ProductID: pID,
|
||||
OpType: realOpType,
|
||||
DocumentType: item.DocumentType,
|
||||
TransactionType: item.TransactionType,
|
||||
DocumentNumber: item.DocumentNum,
|
||||
Amount: decimal.NewFromFloat(item.Amount),
|
||||
Sum: decimal.NewFromFloat(item.Sum),
|
||||
Cost: decimal.NewFromFloat(item.Cost),
|
||||
PeriodFrom: from,
|
||||
PeriodTo: to,
|
||||
})
|
||||
}
|
||||
|
||||
if err := s.opRepo.SaveOperations(ops, targetOpType, from, to); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Log.Info("Отчет сохранен",
|
||||
zap.String("op_type", string(targetOpType)),
|
||||
zap.Int("received", len(items)),
|
||||
zap.Int("saved", len(ops)))
|
||||
return nil
|
||||
}
|
||||
50
internal/transport/http/handlers/invoices.go
Normal file
50
internal/transport/http/handlers/invoices.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
invService "rmser/internal/services/invoices"
|
||||
"rmser/pkg/logger"
|
||||
)
|
||||
|
||||
type InvoiceHandler struct {
|
||||
service *invService.Service
|
||||
}
|
||||
|
||||
func NewInvoiceHandler(service *invService.Service) *InvoiceHandler {
|
||||
return &InvoiceHandler{service: service}
|
||||
}
|
||||
|
||||
// SendInvoice godoc
|
||||
// @Summary Создать приходную накладную в iikoRMS
|
||||
// @Description Принимает JSON с данными накладной и отправляет их в iiko
|
||||
// @Tags invoices
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param input body invService.CreateRequestDTO true "Invoice Data"
|
||||
// @Success 200 {object} map[string]string "created_number"
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
func (h *InvoiceHandler) SendInvoice(c *gin.Context) {
|
||||
var req invService.CreateRequestDTO
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Неверный формат JSON: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
docNum, err := h.service.SendInvoiceToRMS(req)
|
||||
if err != nil {
|
||||
logger.Log.Error("Ошибка отправки накладной", zap.Error(err))
|
||||
// Возвращаем 502 Bad Gateway, т.к. ошибка скорее всего на стороне RMS
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
"created_number": docNum,
|
||||
})
|
||||
}
|
||||
59
internal/transport/http/handlers/ocr.go
Normal file
59
internal/transport/http/handlers/ocr.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
ocrService "rmser/internal/services/ocr"
|
||||
"rmser/pkg/logger"
|
||||
)
|
||||
|
||||
type OCRHandler struct {
|
||||
service *ocrService.Service
|
||||
}
|
||||
|
||||
func NewOCRHandler(service *ocrService.Service) *OCRHandler {
|
||||
return &OCRHandler{service: service}
|
||||
}
|
||||
|
||||
// GetCatalog возвращает список товаров для OCR сервиса
|
||||
func (h *OCRHandler) GetCatalog(c *gin.Context) {
|
||||
items, err := h.service.GetCatalogForIndexing()
|
||||
if err != nil {
|
||||
logger.Log.Error("Ошибка получения каталога для OCR", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, items)
|
||||
}
|
||||
|
||||
type MatchRequest struct {
|
||||
RawName string `json:"raw_name" binding:"required"`
|
||||
ProductID string `json:"product_id" binding:"required"`
|
||||
}
|
||||
|
||||
// SaveMatch сохраняет привязку (обучение)
|
||||
func (h *OCRHandler) SaveMatch(c *gin.Context) {
|
||||
var req MatchRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
pID, err := uuid.Parse(req.ProductID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid product_id format"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.SaveMapping(req.RawName, pID); err != nil {
|
||||
logger.Log.Error("Ошибка сохранения матчинга", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "saved"})
|
||||
}
|
||||
141
internal/transport/telegram/bot.go
Normal file
141
internal/transport/telegram/bot.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
tele "gopkg.in/telebot.v3"
|
||||
"gopkg.in/telebot.v3/middleware"
|
||||
|
||||
"rmser/config"
|
||||
"rmser/internal/services/ocr"
|
||||
"rmser/pkg/logger"
|
||||
)
|
||||
|
||||
type Bot struct {
|
||||
b *tele.Bot
|
||||
ocrService *ocr.Service
|
||||
adminIDs map[int64]struct{}
|
||||
}
|
||||
|
||||
func NewBot(cfg config.TelegramConfig, ocrService *ocr.Service) (*Bot, error) {
|
||||
pref := tele.Settings{
|
||||
Token: cfg.Token,
|
||||
Poller: &tele.LongPoller{Timeout: 10 * time.Second},
|
||||
OnError: func(err error, c tele.Context) {
|
||||
logger.Log.Error("Telegram error", zap.Error(err))
|
||||
},
|
||||
}
|
||||
|
||||
b, err := tele.NewBot(pref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
admins := make(map[int64]struct{})
|
||||
for _, id := range cfg.AdminIDs {
|
||||
admins[id] = struct{}{}
|
||||
}
|
||||
|
||||
bot := &Bot{
|
||||
b: b,
|
||||
ocrService: ocrService,
|
||||
adminIDs: admins,
|
||||
}
|
||||
|
||||
bot.initHandlers()
|
||||
return bot, nil
|
||||
}
|
||||
|
||||
func (bot *Bot) Start() {
|
||||
logger.Log.Info("Запуск Telegram бота...")
|
||||
bot.b.Start()
|
||||
}
|
||||
|
||||
func (bot *Bot) Stop() {
|
||||
bot.b.Stop()
|
||||
}
|
||||
|
||||
// Middleware для проверки прав (только админы)
|
||||
func (bot *Bot) authMiddleware(next tele.HandlerFunc) tele.HandlerFunc {
|
||||
return func(c tele.Context) error {
|
||||
if len(bot.adminIDs) > 0 {
|
||||
if _, ok := bot.adminIDs[c.Sender().ID]; !ok {
|
||||
return c.Send("⛔ У вас нет доступа к этому боту.")
|
||||
}
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
func (bot *Bot) initHandlers() {
|
||||
bot.b.Use(middleware.Logger())
|
||||
bot.b.Use(bot.authMiddleware)
|
||||
|
||||
bot.b.Handle("/start", func(c tele.Context) error {
|
||||
return c.Send("👋 Привет! Я RMSER Bot.\nОтправь мне фото накладной или чека, и я попробую его распознать.")
|
||||
})
|
||||
|
||||
bot.b.Handle(tele.OnPhoto, bot.handlePhoto)
|
||||
}
|
||||
|
||||
func (bot *Bot) handlePhoto(c tele.Context) error {
|
||||
// 1. Скачиваем фото
|
||||
photo := c.Message().Photo
|
||||
// Берем файл самого высокого качества (последний в массиве, но telebot дает удобный доступ)
|
||||
file, err := bot.b.FileByID(photo.FileID)
|
||||
if err != nil {
|
||||
return c.Send("Ошибка доступа к файлу.")
|
||||
}
|
||||
|
||||
// Читаем тело файла
|
||||
fileURL := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", bot.b.Token, file.FilePath)
|
||||
resp, err := http.Get(fileURL)
|
||||
if err != nil {
|
||||
return c.Send("Ошибка скачивания файла.")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
imgData, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return c.Send("Ошибка чтения файла.")
|
||||
}
|
||||
|
||||
c.Send("⏳ Обрабатываю чек через OCR...")
|
||||
|
||||
// 2. Отправляем в сервис
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
items, err := bot.ocrService.ProcessReceiptImage(ctx, imgData)
|
||||
if err != nil {
|
||||
logger.Log.Error("OCR processing failed", zap.Error(err))
|
||||
return c.Send("❌ Ошибка распознавания: " + err.Error())
|
||||
}
|
||||
|
||||
// 3. Формируем отчет
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("🧾 <b>Результат (%d поз.):</b>\n\n", len(items)))
|
||||
|
||||
matchedCount := 0
|
||||
for _, item := range items {
|
||||
if item.IsMatched {
|
||||
matchedCount++
|
||||
sb.WriteString(fmt.Sprintf("✅ %s\n └ <code>%s</code> x %s = %s\n",
|
||||
item.RawName, item.Amount, item.Price, item.Sum))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("❓ <b>%s</b>\n └ Нет привязки!\n", item.RawName))
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("\nРаспознано: %d/%d", matchedCount, len(items)))
|
||||
|
||||
// Тут можно добавить кнопки, если что-то не распознано
|
||||
// Но для начала просто текст
|
||||
return c.Send(sb.String(), tele.ModeHTML)
|
||||
}
|
||||
Reference in New Issue
Block a user