0202-финиш перед десктопом

пересчет поправил
редактирование с перепроведением
галка автопроведения работает
рекомендации починил
This commit is contained in:
2026-02-02 13:53:38 +03:00
parent 10882f55c8
commit 88620f3fb6
37 changed files with 1905 additions and 11162 deletions

View File

@@ -7,8 +7,10 @@ import (
"time"
"rmser/internal/domain/account"
"rmser/pkg/logger"
"github.com/google/uuid"
"go.uber.org/zap"
"gorm.io/gorm"
)
@@ -78,7 +80,8 @@ func (r *pgRepository) ConnectServer(userID uuid.UUID, rawURL, login, encryptedP
var created bool
err := r.db.Transaction(func(tx *gorm.DB) error {
err := tx.Where("base_url = ?", cleanURL).First(&server).Error
// Сначала ищем среди удаленных серверов
err := tx.Unscoped().Where("base_url = ?", cleanURL).First(&server).Error
if err != nil && err != gorm.ErrRecordNotFound {
return err
}
@@ -100,8 +103,17 @@ func (r *pgRepository) ConnectServer(userID uuid.UUID, rawURL, login, encryptedP
return err
}
created = true
} else if server.DeletedAt.Valid {
// --- СЦЕНАРИЙ 2: ВОССТАНОВЛЕНИЕ УДАЛЕННОГО СЕРВЕРА ---
// Восстанавливаем сервер, сохраняя старые значения Balance, InvoiceCount и ID
server.Name = name
server.DeletedAt = gorm.DeletedAt{} // Сбрасываем deleted_at
if err := tx.Save(&server).Error; err != nil {
return err
}
created = true // При восстановлении пользователь становится владельцем
} else {
// --- СЦЕНАРИЙ 2: СУЩЕСТВУЮЩИЙ СЕРВЕР ---
// --- СЦЕНАРИЙ 3: СУЩЕСТВУЮЩИЙ АКТИВНЫЙ СЕРВЕР ---
var userCount int64
tx.Model(&account.ServerUser{}).Where("server_id = ?", server.ID).Count(&userCount)
if userCount >= int64(server.MaxUsers) {
@@ -156,9 +168,92 @@ func (r *pgRepository) SaveServerSettings(server *account.RMSServer) error {
"root_group_guid": server.RootGroupGUID,
"auto_process": server.AutoProcess,
"max_users": server.MaxUsers,
"sync_interval": server.SyncInterval,
}).Error
}
// UpdateLastActivity обновляет время последней активности пользователя
func (r *pgRepository) UpdateLastActivity(serverID uuid.UUID) error {
result := r.db.Model(&account.RMSServer{}).
Where("id = ?", serverID).
Update("last_activity_at", gorm.Expr("NOW()"))
if result.Error != nil {
logger.Log.Error("Failed to update last_activity_at",
zap.String("server_id", serverID.String()),
zap.Error(result.Error))
return result.Error
}
if result.RowsAffected == 0 {
logger.Log.Warn("UpdateLastActivity: server not found",
zap.String("server_id", serverID.String()))
return fmt.Errorf("сервер не найден")
}
return nil
}
// UpdateLastSync обновляет время последней успешной синхронизации
func (r *pgRepository) UpdateLastSync(serverID uuid.UUID) error {
result := r.db.Model(&account.RMSServer{}).
Where("id = ?", serverID).
Update("last_sync_at", gorm.Expr("NOW()"))
if result.Error != nil {
logger.Log.Error("Failed to update last_sync_at",
zap.String("server_id", serverID.String()),
zap.Error(result.Error))
return result.Error
}
if result.RowsAffected == 0 {
logger.Log.Warn("UpdateLastSync: server not found",
zap.String("server_id", serverID.String()))
return fmt.Errorf("сервер не найден")
}
return nil
}
// GetServersForSync возвращает серверы, готовые для синхронизации
func (r *pgRepository) GetServersForSync(idleThreshold time.Duration) ([]account.RMSServer, error) {
var servers []account.RMSServer
// Конвертируем duration в минуты для SQL
idleMinutes := int(idleThreshold.Minutes())
query := `
SELECT * FROM rms_servers
WHERE
deleted_at IS NULL
AND (
-- Случай 1: Настало время периодической синхронизации
(EXTRACT(EPOCH FROM (NOW() - COALESCE(last_sync_at, '1970-01-01'::timestamp))) / 60) >= sync_interval
OR
-- Случай 2: Прошло N мин с последней активности, и активность была ПОЗЖЕ синхронизации
(
last_activity_at > last_sync_at
AND (EXTRACT(EPOCH FROM (NOW() - last_activity_at)) / 60) >= ?
)
)
`
err := r.db.Raw(query, idleMinutes).Scan(&servers).Error
if err != nil {
logger.Log.Error("Failed to get servers for sync",
zap.Int("idle_threshold_minutes", idleMinutes),
zap.Error(err))
return nil, err
}
logger.Log.Info("Servers ready for sync",
zap.Int("count", len(servers)),
zap.Int("idle_threshold_minutes", idleMinutes))
return servers, nil
}
func (r *pgRepository) SetActiveServer(userID, serverID uuid.UUID) error {
return r.db.Transaction(func(tx *gorm.DB) error {
// Проверка доступа
@@ -252,7 +347,7 @@ func (r *pgRepository) GetAllAvailableServers(userID uuid.UUID) ([]account.RMSSe
}
func (r *pgRepository) DeleteServer(serverID uuid.UUID) error {
// Полное удаление сервера и всех связей
// Мягкое удаление сервера и всех связей
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("server_id = ?", serverID).Delete(&account.ServerUser{}).Error; err != nil {
return err

View File

@@ -160,20 +160,23 @@ func (r *pgRepository) GetActive(serverID uuid.UUID) ([]drafts.DraftInvoice, err
return list, err
}
func (r *pgRepository) GetRMSInvoiceIDToPhotoURLMap(serverID uuid.UUID) (map[uuid.UUID]string, error) {
func (r *pgRepository) GetLinkedDraftsMap(serverID uuid.UUID) (map[uuid.UUID]drafts.LinkedDraftInfo, error) {
var draftsList []drafts.DraftInvoice
err := r.db.
Select("rms_invoice_id", "sender_photo_url").
Select("id", "rms_invoice_id", "sender_photo_url").
Where("rms_server_id = ? AND rms_invoice_id IS NOT NULL", serverID).
Find(&draftsList).Error
if err != nil {
return nil, err
}
result := make(map[uuid.UUID]string)
result := make(map[uuid.UUID]drafts.LinkedDraftInfo)
for _, d := range draftsList {
if d.RMSInvoiceID != nil {
result[*d.RMSInvoiceID] = d.SenderPhotoURL
result[*d.RMSInvoiceID] = drafts.LinkedDraftInfo{
DraftID: d.ID,
PhotoURL: d.SenderPhotoURL,
}
}
}
return result, nil

View File

@@ -87,3 +87,17 @@ func (r *pgRepository) CountRecent(serverID uuid.UUID, days int) (int64, error)
Count(&count).Error
return count, err
}
func (r *pgRepository) GetStats(serverID uuid.UUID) (total int64, lastMonth int64, last24h int64, err error) {
query := `
SELECT
COUNT(*) FILTER (WHERE status != 'DELETED') as total,
COUNT(*) FILTER (WHERE status != 'DELETED' AND created_at >= NOW() - INTERVAL '1 month') as last_month,
COUNT(*) FILTER (WHERE status != 'DELETED' AND created_at >= NOW() - INTERVAL '24 hours') as last_24h
FROM invoices
WHERE rms_server_id = $1
`
err = r.db.Raw(query, serverID).Row().Scan(&total, &lastMonth, &last24h)
return total, lastMonth, last24h, err
}

View File

@@ -3,12 +3,12 @@ package recommendations
import (
"fmt"
"strconv"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"rmser/internal/domain/operations"
"rmser/internal/domain/recommendations"
"gorm.io/gorm"
)
type pgRepository struct {
@@ -21,11 +21,18 @@ func NewRepository(db *gorm.DB) recommendations.Repository {
// --- Методы Хранения ---
func (r *pgRepository) SaveAll(items []recommendations.Recommendation) error {
func (r *pgRepository) SaveAll(serverID uuid.UUID, 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 {
// Удаляем только записи ЭТОГО сервера
if err := tx.Where("rms_server_id = ?", serverID).Delete(&recommendations.Recommendation{}).Error; err != nil {
return err
}
// Проставляем server_id для всех записей
for i := range items {
items[i].RMSServerID = serverID
}
if len(items) > 0 {
if err := tx.CreateInBatches(items, 100).Error; err != nil {
return err
@@ -35,16 +42,16 @@ func (r *pgRepository) SaveAll(items []recommendations.Recommendation) error {
})
}
func (r *pgRepository) GetAll() ([]recommendations.Recommendation, error) {
func (r *pgRepository) GetAll(serverID uuid.UUID) ([]recommendations.Recommendation, error) {
var items []recommendations.Recommendation
err := r.db.Find(&items).Error
err := r.db.Where("rms_server_id = ?", serverID).Find(&items).Error
return items, err
}
// --- Методы Аналитики ---
// 1. Товары (GOODS/PREPARED), не используемые в техкартах
func (r *pgRepository) FindUnusedGoods() ([]recommendations.Recommendation, error) {
func (r *pgRepository) FindUnusedGoods(serverID uuid.UUID) ([]recommendations.Recommendation, error) {
var results []recommendations.Recommendation
query := `
@@ -54,27 +61,30 @@ func (r *pgRepository) FindUnusedGoods() ([]recommendations.Recommendation, erro
'Товар не используется ни в одной техкарте' as reason,
? as type
FROM products p
WHERE p.type IN ('GOODS', 'PREPARED')
AND p.is_deleted = false -- Проверка на удаление
WHERE p.rms_server_id = ?
AND p.type IN ('GOODS', 'PREPARED')
AND p.is_deleted = false
AND p.id NOT IN (
SELECT DISTINCT product_id FROM recipe_items
SELECT DISTINCT ri.product_id FROM recipe_items ri
JOIN recipes r ON ri.recipe_id = r.id
WHERE r.rms_server_id = ?
)
AND p.id NOT IN (
SELECT DISTINCT product_id FROM recipes
SELECT DISTINCT r.product_id FROM recipes r
WHERE r.rms_server_id = ?
)
ORDER BY p.name ASC
`
if err := r.db.Raw(query, recommendations.TypeUnused).Scan(&results).Error; err != nil {
if err := r.db.Raw(query, recommendations.TypeUnused, serverID, serverID, serverID).Scan(&results).Error; err != nil {
return nil, err
}
return results, nil
}
// 2. Закупается, но нет в техкартах
func (r *pgRepository) FindPurchasedButUnused(days int) ([]recommendations.Recommendation, error) {
func (r *pgRepository) FindPurchasedButUnused(serverID uuid.UUID, days int) ([]recommendations.Recommendation, error) {
var results []recommendations.Recommendation
dateFrom := time.Now().AddDate(0, 0, -days)
query := `
SELECT DISTINCT
@@ -84,26 +94,33 @@ func (r *pgRepository) FindPurchasedButUnused(days int) ([]recommendations.Recom
? 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
WHERE
so.rms_server_id = ?
AND so.op_type = ?
AND so.period_to >= CURRENT_DATE - INTERVAL '1 day'
AND p.is_deleted = false
AND p.id NOT IN (
SELECT DISTINCT ri.product_id FROM recipe_items ri
JOIN recipes r ON ri.recipe_id = r.id
WHERE r.rms_server_id = ?
)
ORDER BY p.name ASC
`
if err := r.db.Raw(query, recommendations.TypePurchasedButUnused, operations.OpTypePurchase, dateFrom).Scan(&results).Error; err != nil {
if err := r.db.Raw(query,
recommendations.TypePurchasedButUnused,
serverID,
operations.OpTypePurchase,
serverID,
).Scan(&results).Error; err != nil {
return nil, err
}
return results, nil
}
// 3. Ингредиенты в актуальных техкартах без закупок
func (r *pgRepository) FindNoIncomingIngredients(days int) ([]recommendations.Recommendation, error) {
func (r *pgRepository) FindNoIncomingIngredients(serverID uuid.UUID, days int) ([]recommendations.Recommendation, error) {
var results []recommendations.Recommendation
dateFrom := time.Now().AddDate(0, 0, -days)
query := `
SELECT
@@ -115,31 +132,38 @@ func (r *pgRepository) FindNoIncomingIngredients(days int) ([]recommendations.Re
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)
WHERE
r.rms_server_id = ?
AND (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.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 >= ?
SELECT so.product_id
FROM store_operations so
WHERE so.rms_server_id = ?
AND so.op_type = ?
AND so.period_to >= CURRENT_DATE - INTERVAL '1 day'
)
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 {
if err := r.db.Raw(query,
strconv.Itoa(days),
recommendations.TypeNoIncoming,
serverID,
serverID,
operations.OpTypePurchase,
).Scan(&results).Error; err != nil {
return nil, err
}
return results, nil
}
// 4. Товары, которые закупаем, но не расходуем ("Висяки")
func (r *pgRepository) FindStaleGoods(days int) ([]recommendations.Recommendation, error) {
func (r *pgRepository) FindStaleGoods(serverID uuid.UUID, days int) ([]recommendations.Recommendation, error) {
var results []recommendations.Recommendation
dateFrom := time.Now().AddDate(0, 0, -days)
query := `
SELECT DISTINCT
@@ -149,30 +173,38 @@ func (r *pgRepository) FindStaleGoods(days int) ([]recommendations.Recommendatio
? 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 >= ?
WHERE
so.rms_server_id = ?
AND so.op_type = ?
AND so.period_to >= CURRENT_DATE - INTERVAL '1 day'
AND p.is_deleted = false
AND p.id NOT IN (
SELECT so2.product_id
FROM store_operations so2
WHERE so2.rms_server_id = ?
AND so2.op_type = ?
AND so2.period_to >= CURRENT_DATE - INTERVAL '1 day'
)
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 {
if err := r.db.Raw(query,
reason,
recommendations.TypeStale,
serverID,
operations.OpTypePurchase,
serverID,
operations.OpTypeUsage,
).Scan(&results).Error; err != nil {
return nil, err
}
return results, nil
}
// 5. Блюдо используется в техкарте другого блюда
func (r *pgRepository) FindDishesInRecipes() ([]recommendations.Recommendation, error) {
func (r *pgRepository) FindDishesInRecipes(serverID uuid.UUID) ([]recommendations.Recommendation, error) {
var results []recommendations.Recommendation
query := `
@@ -186,23 +218,23 @@ func (r *pgRepository) FindDishesInRecipes() ([]recommendations.Recommendation,
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 -- Родительское блюдо не удалено
r.rms_server_id = ?
AND 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 {
if err := r.db.Raw(query, recommendations.TypeDishInRecipe, serverID).Scan(&results).Error; err != nil {
return nil, err
}
return results, nil
}
// 6. Есть расход (Usage), но нет прихода (Purchase)
func (r *pgRepository) FindUsageWithoutPurchase(days int) ([]recommendations.Recommendation, error) {
func (r *pgRepository) FindUsageWithoutPurchase(serverID uuid.UUID, days int) ([]recommendations.Recommendation, error) {
var results []recommendations.Recommendation
dateFrom := time.Now().AddDate(0, 0, -days)
query := `
SELECT DISTINCT
@@ -212,30 +244,31 @@ func (r *pgRepository) FindUsageWithoutPurchase(days int) ([]recommendations.Rec
? 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 >= ?
WHERE
so.rms_server_id = ?
AND so.op_type = ?
AND so.period_to >= CURRENT_DATE - INTERVAL '1 day'
AND p.type = 'GOODS'
AND p.is_deleted = false
AND p.id NOT IN (
SELECT so2.product_id
FROM store_operations so2
WHERE so2.rms_server_id = ?
AND so2.op_type = ?
AND so2.period_to >= CURRENT_DATE - INTERVAL '1 day'
)
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,
serverID,
operations.OpTypeUsage,
dateFrom,
serverID,
operations.OpTypePurchase,
dateFrom,
).Scan(&results).Error; err != nil {
return nil, err
}

View File

@@ -40,6 +40,7 @@ type ClientI interface {
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)
}
@@ -555,9 +556,24 @@ func (c *Client) FetchStoreOperations(presetID string, from, to time.Time) ([]St
return report.Items, nil
}
// CreateIncomingInvoice отправляет накладную в iiko
func (c *Client) CreateIncomingInvoice(inv invoices.Invoice) (string, error) {
// 1. Маппинг Domain -> XML DTO
// 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
@@ -592,7 +608,21 @@ func (c *Client) CreateIncomingInvoice(inv invoices.Invoice) (string, error) {
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()
@@ -610,18 +640,47 @@ func (c *Client) CreateIncomingInvoice(inv invoices.Invoice) (string, error) {
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)
}
// 2. Маршалинг в XML
// Маршалинг в XML
xmlBytes, err := xml.Marshal(reqDTO)
if err != nil {
return "", fmt.Errorf("xml marshal error: %w", err)
return nil, fmt.Errorf("xml marshal error: %w", err)
}
// Добавляем XML header вручную
xmlPayload := []byte(xml.Header + string(xmlBytes))
// 3. Получение токена
// Логирование 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
}
@@ -630,7 +689,7 @@ func (c *Client) CreateIncomingInvoice(inv invoices.Invoice) (string, error) {
token := c.token
c.mu.RUnlock()
// 4. Формирование URL
// 3. Формирование URL
endpoint, _ := url.Parse(c.baseURL + "/resto/api/documents/import/incomingInvoice")
q := endpoint.Query()
q.Set("key", token)
@@ -646,7 +705,7 @@ func (c *Client) CreateIncomingInvoice(inv invoices.Invoice) (string, error) {
zap.String("body_payload", string(xmlPayload)),
)
// 5. Отправка
// 4. Отправка
req, err := http.NewRequest("POST", fullURL, bytes.NewReader(xmlPayload))
if err != nil {
return "", err
@@ -666,9 +725,9 @@ func (c *Client) CreateIncomingInvoice(inv invoices.Invoice) (string, error) {
}
// Логируем ответ для симметрии
logger.Log.Info("RMS POST Response Debug",
logger.Log.Debug("Получен ответ от iiko",
zap.Int("status_code", resp.StatusCode),
zap.String("response_body", string(respBody)),
zap.String("raw_response", string(respBody)),
)
if resp.StatusCode != http.StatusOK {
@@ -691,6 +750,89 @@ func (c *Client) CreateIncomingInvoice(inv invoices.Invoice) (string, error) {
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