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) FetchStores() ([]catalog.Store, 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) } 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, 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 }