mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-05 03:12:34 -06:00
854 lines
25 KiB
Go
854 lines
25 KiB
Go
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)
|
||
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
|
||
}
|
||
|
||
// CreateIncomingInvoice отправляет накладную в iiko
|
||
func (c *Client) CreateIncomingInvoice(inv invoices.Invoice) (string, error) {
|
||
// 1. Маппинг 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,
|
||
}
|
||
|
||
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()
|
||
|
||
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()
|
||
}
|
||
|
||
reqDTO.ItemsWrapper.Items = append(reqDTO.ItemsWrapper.Items, xmlItem)
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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
|
||
}
|