mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
start rmser
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user