Files
rmser/internal/infrastructure/rms/client.go
SERTY 88620f3fb6 0202-финиш перед десктопом
пересчет поправил
редактирование с перепроведением
галка автопроведения работает
рекомендации починил
2026-02-02 13:53:38 +03:00

1003 lines
30 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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/internal/domain/suppliers"
"rmser/pkg/logger"
)
const (
tokenTTL = 45 * time.Minute // Время жизни токена до принудительного обновления
)
// ClientI интерфейс
type ClientI interface {
Auth() error
Logout() error
FetchCatalog() ([]catalog.Product, error)
FetchStores() ([]catalog.Store, error)
FetchSuppliers() ([]suppliers.Supplier, error)
FetchMeasureUnits() ([]catalog.MeasureUnit, error)
FetchRecipes(dateFrom, dateTo time.Time) ([]recipes.Recipe, error)
FetchInvoices(from, to time.Time) ([]invoices.Invoice, error)
FetchStoreOperations(presetID string, from, to time.Time) ([]StoreReportItemXML, error)
CreateIncomingInvoice(inv invoices.Invoice) (string, error)
UnprocessIncomingInvoice(inv invoices.Invoice) error
GetProductByID(id uuid.UUID) (*ProductFullDTO, error)
UpdateProduct(product ProductFullDTO) (*ProductFullDTO, 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
}
}
// Обработка MainUnit
var mainUnitID *uuid.UUID
if p.MainUnit != nil {
if uid, err := uuid.Parse(*p.MainUnit); err == nil {
mainUnitID = &uid
}
}
// Маппинг фасовок
var containers []catalog.ProductContainer
for _, contDto := range p.Containers {
cID, err := uuid.Parse(contDto.ID)
if err == nil {
containers = append(containers, catalog.ProductContainer{
ID: cID,
ProductID: id,
Name: contDto.Name,
Count: decimal.NewFromFloat(contDto.Count),
})
}
}
products = append(products, catalog.Product{
ID: id,
ParentID: parentID,
Name: p.Name,
Num: p.Num,
Code: p.Code,
Type: p.Type,
UnitWeight: decimal.NewFromFloat(p.UnitWeight),
UnitCapacity: decimal.NewFromFloat(p.UnitCapacity),
MainUnitID: mainUnitID,
Containers: containers,
IsDeleted: p.Deleted,
})
}
return products, nil
}
// FetchStores загружает список складов (Account -> INVENTORY_ASSETS)
func (c *Client) FetchStores() ([]catalog.Store, error) {
resp, err := c.doRequest("GET", "/resto/api/v2/entities/list", map[string]string{
"rootType": "Account",
"includeDeleted": "false",
})
if err != nil {
return nil, fmt.Errorf("get stores error: %w", err)
}
defer resp.Body.Close()
var dtos []AccountDTO
if err := json.NewDecoder(resp.Body).Decode(&dtos); err != nil {
return nil, fmt.Errorf("json decode stores error: %w", err)
}
var stores []catalog.Store
for _, d := range dtos {
// Фильтруем только склады
if d.Type != "INVENTORY_ASSETS" {
continue
}
id, err := uuid.Parse(d.ID)
if err != nil {
continue
}
var parentCorpID uuid.UUID
if d.ParentCorporateID != nil {
if parsed, err := uuid.Parse(*d.ParentCorporateID); err == nil {
parentCorpID = parsed
}
}
stores = append(stores, catalog.Store{
ID: id,
Name: d.Name,
ParentCorporateID: parentCorpID,
IsDeleted: d.Deleted,
})
}
return stores, nil
}
// FetchMeasureUnits загружает справочник единиц измерения
func (c *Client) FetchMeasureUnits() ([]catalog.MeasureUnit, error) {
// rootType=MeasureUnit согласно документации iiko
resp, err := c.doRequest("GET", "/resto/api/v2/entities/list", map[string]string{
"rootType": "MeasureUnit",
"includeDeleted": "false",
})
if err != nil {
return nil, fmt.Errorf("get measure units error: %w", err)
}
defer resp.Body.Close()
var dtos []GenericEntityDTO
if err := json.NewDecoder(resp.Body).Decode(&dtos); err != nil {
return nil, fmt.Errorf("json decode error: %w", err)
}
var result []catalog.MeasureUnit
for _, d := range dtos {
id, err := uuid.Parse(d.ID)
if err != nil {
continue
}
result = append(result, catalog.MeasureUnit{
ID: id,
Name: d.Name,
Code: d.Code,
})
}
return result, nil
}
func (c *Client) FetchRecipes(dateFrom, dateTo time.Time) ([]recipes.Recipe, error) {
params := map[string]string{
"dateFrom": dateFrom.Format("2006-01-02"),
}
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,
IncomingDocumentNumber: doc.IncomingDocumentNumber,
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
}
// buildInvoiceXML формирует XML payload для накладной на основе доменной сущности
func (c *Client) buildInvoiceXML(inv invoices.Invoice) ([]byte, error) {
// Защита от паники с recover
var panicErr error
defer func() {
if r := recover(); r != nil {
logger.Log.Error("Паника в buildInvoiceXML",
zap.Any("panic", r),
zap.Stack("stack"),
)
panicErr = fmt.Errorf("panic recovered: %v", r)
}
}()
if panicErr != nil {
return nil, panicErr
}
// Маппинг Domain -> XML DTO
// Статус по умолчанию NEW, если не передан
status := inv.Status
if status == "" {
status = "NEW"
}
// Комментарий по умолчанию, если пустой
comment := inv.Comment
if comment == "" {
comment = "Loaded via RMSER OCR"
}
reqDTO := IncomingInvoiceImportXML{
DocumentNumber: inv.DocumentNumber,
IncomingDocumentNumber: inv.IncomingDocumentNumber,
DateIncoming: inv.DateIncoming.Format("02.01.2006"),
DefaultStore: inv.DefaultStoreID.String(),
Supplier: inv.SupplierID.String(),
Status: status,
Comment: comment,
}
logger.Log.Info("RMS Invoice Import Debug",
zap.String("document_number", inv.DocumentNumber),
zap.String("incoming_document_number", inv.IncomingDocumentNumber),
zap.String("supplier_id", inv.SupplierID.String()),
zap.String("store_id", inv.DefaultStoreID.String()),
)
if inv.ID != uuid.Nil {
reqDTO.ID = inv.ID.String()
}
// Логирование перед циклом по Items
logger.Log.Debug("Начинаем формирование XML для позиций накладной",
zap.Int("items_count", len(inv.Items)),
)
for i, item := range inv.Items {
// Проверка что продукт загружен (по полю ID)
if item.Product.ID == uuid.Nil {
logger.Log.Warn("Пропуск позиции: Product не загружен",
zap.String("product_id", item.ProductID.String()),
zap.Int("index", i),
)
continue
}
amount, _ := item.Amount.Float64()
price, _ := item.Price.Float64()
sum, _ := item.Sum.Float64()
xmlItem := IncomingInvoiceImportItemXML{
ProductID: item.ProductID.String(),
Amount: amount,
Price: price,
Sum: sum,
Num: i + 1,
Store: inv.DefaultStoreID.String(),
}
if item.ContainerID != nil && *item.ContainerID != uuid.Nil {
xmlItem.ContainerId = item.ContainerID.String()
}
// Проверка MainUnitID перед обращением
if item.Product.MainUnitID != nil {
xmlItem.AmountUnit = item.Product.MainUnitID.String()
}
// Логирование каждого добавленного item
logger.Log.Debug("Добавление позиции в XML",
zap.String("product_id", item.ProductID.String()),
zap.Float64("amount", amount),
zap.String("product_name", item.Product.Name),
)
reqDTO.ItemsWrapper.Items = append(reqDTO.ItemsWrapper.Items, xmlItem)
}
// Маршалинг в XML
xmlBytes, err := xml.Marshal(reqDTO)
if err != nil {
return nil, fmt.Errorf("xml marshal error: %w", err)
}
// Добавляем XML header вручную
xmlPayload := []byte(xml.Header + string(xmlBytes))
// Логирование XML перед отправкой
logger.Log.Debug("XML payload подготовлен",
zap.String("xml_payload", string(xmlPayload)),
zap.Int("payload_size", len(xmlPayload)),
)
return xmlPayload, nil
}
// CreateIncomingInvoice отправляет накладную в iiko
func (c *Client) CreateIncomingInvoice(inv invoices.Invoice) (string, error) {
// 1. Формирование XML payload
xmlPayload, err := c.buildInvoiceXML(inv)
if err != nil {
return "", fmt.Errorf("ошибка формирования XML: %w", err)
}
// 2. Получение токена
if err := c.ensureToken(); err != nil {
return "", err
}
c.mu.RLock()
token := c.token
c.mu.RUnlock()
// 3. Формирование 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)),
)
// 4. Отправка
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.Debug("Получен ответ от iiko",
zap.Int("status_code", resp.StatusCode),
zap.String("raw_response", 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
}
// UnprocessIncomingInvoice выполняет распроведение накладной в iiko
func (c *Client) UnprocessIncomingInvoice(inv invoices.Invoice) error {
// 1. Формирование XML payload
xmlPayload, err := c.buildInvoiceXML(inv)
if err != nil {
return fmt.Errorf("ошибка формирования XML: %w", err)
}
// 2. Получение токена
if err := c.ensureToken(); err != nil {
return err
}
c.mu.RLock()
token := c.token
c.mu.RUnlock()
// 3. Формирование URL
endpoint, _ := url.Parse(c.baseURL + "/resto/api/documents/unprocess/incomingInvoice")
q := endpoint.Query()
q.Set("key", token)
endpoint.RawQuery = q.Encode()
fullURL := endpoint.String()
// Логирование запроса
logger.Log.Info("RMS Unprocess Request",
zap.String("method", "POST"),
zap.String("url", fullURL),
zap.String("document_number", inv.DocumentNumber),
zap.String("invoice_id", inv.ID.String()),
)
// 4. Отправка POST запроса
req, err := http.NewRequest("POST", fullURL, bytes.NewReader(xmlPayload))
if err != nil {
return fmt.Errorf("ошибка создания запроса: %w", err)
}
req.Header.Set("Content-Type", "application/xml")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("ошибка сети: %w", err)
}
defer resp.Body.Close()
// Читаем ответ
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("ошибка чтения ответа: %w", err)
}
// Логируем ответ
logger.Log.Debug("Получен ответ от iiko на распроведение",
zap.Int("status_code", resp.StatusCode),
zap.String("raw_response", 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 ответа: %w", err)
}
if !result.Valid {
logger.Log.Warn("RMS Invoice Unprocess Failed",
zap.String("error", result.ErrorMessage),
zap.String("additional", result.AdditionalInfo),
)
return fmt.Errorf("распроведение не удалось: %s (info: %s)", result.ErrorMessage, result.AdditionalInfo)
}
logger.Log.Info("RMS Invoice Unprocess Success",
zap.String("document_number", result.DocumentNumber),
)
return nil
}
// GetProductByID получает полную структуру товара по ID (через /list?ids=...)
func (c *Client) GetProductByID(id uuid.UUID) (*ProductFullDTO, error) {
// Параметр ids должен быть списком. iiko ожидает ids=UUID
params := map[string]string{
"ids": id.String(),
"includeDeleted": "false",
}
resp, err := c.doRequest("GET", "/resto/api/v2/entities/products/list", params)
if err != nil {
return nil, fmt.Errorf("request error: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("rms error code %d", resp.StatusCode)
}
// Ответ - это массив товаров
var products []ProductFullDTO
if err := json.NewDecoder(resp.Body).Decode(&products); err != nil {
return nil, fmt.Errorf("json decode error: %w", err)
}
if len(products) == 0 {
return nil, fmt.Errorf("product not found in rms")
}
return &products[0], nil
}
// UpdateProduct отправляет полную структуру товара на обновление (/update)
func (c *Client) UpdateProduct(product ProductFullDTO) (*ProductFullDTO, error) {
// Маршалим тело
bodyBytes, err := json.Marshal(product)
if err != nil {
return nil, fmt.Errorf("json marshal error: %w", err)
}
// Используем doRequestPost (надо реализовать или вручную, т.к. doRequest у нас GET-ориентирован в текущем коде был прост)
// Расширим логику doRequest или напишем тут, т.к. это POST с JSON body
if err := c.ensureToken(); err != nil {
return nil, err
}
c.mu.RLock()
token := c.token
c.mu.RUnlock()
endpoint := c.baseURL + "/resto/api/v2/entities/products/update?key=" + token
req, err := http.NewRequest("POST", endpoint, bytes.NewReader(bodyBytes))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("update failed (code %d): %s", resp.StatusCode, string(respBody))
}
var result UpdateEntityResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("response unmarshal error: %w", err)
}
if result.Result != "SUCCESS" {
// Собираем ошибки
errMsg := "rms update error: "
for _, e := range result.Errors {
errMsg += fmt.Sprintf("[%s] %s; ", e.Code, e.Value)
}
return nil, fmt.Errorf(errMsg)
}
if result.Response == nil {
return nil, fmt.Errorf("empty response from rms after update")
}
return result.Response, nil
}
// GetServerInfo пытается получить информацию о сервере (имя, версия) без авторизации.
// Использует endpoint /resto/getServerMonitoringInfo.jsp
func GetServerInfo(baseURL string) (*ServerMonitoringInfoDTO, error) {
// Формируем URL. Убираем слэш в конце, если есть.
url := strings.TrimRight(baseURL, "/") + "/resto/getServerMonitoringInfo.jsp"
logger.Log.Info("RMS: Requesting server info", zap.String("url", url))
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(url)
if err != nil {
logger.Log.Error("RMS: Monitoring connection failed", zap.Error(err))
return nil, fmt.Errorf("connection error: %w", err)
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read body error: %w", err)
}
logger.Log.Info("RMS: Monitoring Response",
zap.Int("status", resp.StatusCode),
zap.String("body", string(bodyBytes)))
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("status code %d", resp.StatusCode)
}
var info ServerMonitoringInfoDTO
// Пробуем JSON (так как в логе пришел JSON)
if err := json.Unmarshal(bodyBytes, &info); err != nil {
// Если вдруг JSON не прошел, можно попробовать XML как фоллбек (для старых версий)
logger.Log.Warn("RMS: JSON decode failed, trying XML...", zap.Error(err))
if xmlErr := xml.Unmarshal(bodyBytes, &info); xmlErr != nil {
return nil, fmt.Errorf("decode error (json & xml failed): %w", err)
}
}
return &info, nil
}
// FetchSuppliers загружает список поставщиков через XML API
func (c *Client) FetchSuppliers() ([]suppliers.Supplier, error) {
// Endpoint /resto/api/suppliers
resp, err := c.doRequest("GET", "/resto/api/suppliers", nil)
if err != nil {
return nil, fmt.Errorf("get suppliers error: %w", err)
}
defer resp.Body.Close()
var xmlData SuppliersListXML
if err := xml.NewDecoder(resp.Body).Decode(&xmlData); err != nil {
return nil, fmt.Errorf("xml decode suppliers error: %w", err)
}
var result []suppliers.Supplier
for _, emp := range xmlData.Employees {
id, err := uuid.Parse(emp.ID)
if err != nil {
continue
}
isDeleted := emp.Deleted == "true"
result = append(result, suppliers.Supplier{
ID: id,
Name: emp.Name,
Code: emp.Code,
INN: emp.TaxpayerIdNumber,
IsDeleted: isDeleted,
// RMSServerID проставляется в сервисе перед сохранением
})
}
return result, nil
}