mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
start rmser
This commit is contained in:
86
internal/services/invoices/service.go
Normal file
86
internal/services/invoices/service.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package invoices
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/shopspring/decimal"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"rmser/internal/domain/invoices"
|
||||
"rmser/internal/infrastructure/rms"
|
||||
"rmser/pkg/logger"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
rmsClient rms.ClientI
|
||||
// Здесь можно добавить репозитории каталога и контрагентов для валидации,
|
||||
// но для краткости пока опустим глубокую валидацию.
|
||||
}
|
||||
|
||||
func NewService(rmsClient rms.ClientI) *Service {
|
||||
return &Service{
|
||||
rmsClient: rmsClient,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateRequestDTO - структура входящего JSON запроса от фронта/OCR
|
||||
type CreateRequestDTO struct {
|
||||
DocumentNumber string `json:"document_number"`
|
||||
DateIncoming string `json:"date_incoming"` // YYYY-MM-DD
|
||||
SupplierID uuid.UUID `json:"supplier_id"`
|
||||
StoreID uuid.UUID `json:"store_id"`
|
||||
Items []struct {
|
||||
ProductID uuid.UUID `json:"product_id"`
|
||||
Amount decimal.Decimal `json:"amount"`
|
||||
Price decimal.Decimal `json:"price"`
|
||||
} `json:"items"`
|
||||
}
|
||||
|
||||
// SendInvoiceToRMS валидирует DTO, собирает доменную модель и отправляет в RMS
|
||||
func (s *Service) SendInvoiceToRMS(req CreateRequestDTO) (string, error) {
|
||||
// 1. Базовая валидация
|
||||
if len(req.Items) == 0 {
|
||||
return "", fmt.Errorf("список товаров пуст")
|
||||
}
|
||||
|
||||
dateInc, err := time.Parse("2006-01-02", req.DateIncoming)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("неверный формат даты (ожидается YYYY-MM-DD): %v", err)
|
||||
}
|
||||
|
||||
// 2. Сборка доменной модели
|
||||
inv := invoices.Invoice{
|
||||
ID: uuid.Nil, // Новый документ
|
||||
DocumentNumber: req.DocumentNumber,
|
||||
DateIncoming: dateInc,
|
||||
SupplierID: req.SupplierID,
|
||||
DefaultStoreID: req.StoreID,
|
||||
Status: "NEW",
|
||||
Items: make([]invoices.InvoiceItem, 0, len(req.Items)),
|
||||
}
|
||||
|
||||
for _, itemDTO := range req.Items {
|
||||
sum := itemDTO.Amount.Mul(itemDTO.Price) // Пересчитываем сумму
|
||||
|
||||
inv.Items = append(inv.Items, invoices.InvoiceItem{
|
||||
ProductID: itemDTO.ProductID,
|
||||
Amount: itemDTO.Amount,
|
||||
Price: itemDTO.Price,
|
||||
Sum: sum,
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Отправка через клиент
|
||||
logger.Log.Info("Отправка накладной в RMS",
|
||||
zap.String("supplier", req.SupplierID.String()),
|
||||
zap.Int("items_count", len(inv.Items)))
|
||||
|
||||
docNum, err := s.rmsClient.CreateIncomingInvoice(inv)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return docNum, nil
|
||||
}
|
||||
147
internal/services/ocr/service.go
Normal file
147
internal/services/ocr/service.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package ocr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/shopspring/decimal"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"rmser/internal/domain/catalog"
|
||||
"rmser/internal/domain/ocr"
|
||||
"rmser/internal/infrastructure/ocr_client"
|
||||
"rmser/pkg/logger"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
ocrRepo ocr.Repository
|
||||
catalogRepo catalog.Repository
|
||||
pyClient *ocr_client.Client // Клиент к Python сервису
|
||||
}
|
||||
|
||||
func NewService(
|
||||
ocrRepo ocr.Repository,
|
||||
catalogRepo catalog.Repository,
|
||||
pyClient *ocr_client.Client,
|
||||
) *Service {
|
||||
return &Service{
|
||||
ocrRepo: ocrRepo,
|
||||
catalogRepo: catalogRepo,
|
||||
pyClient: pyClient,
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessReceiptImage - основной метод: Картинка -> Распознавание -> Матчинг
|
||||
func (s *Service) ProcessReceiptImage(ctx context.Context, imgData []byte) ([]ProcessedItem, error) {
|
||||
// 1. Отправляем в Python
|
||||
rawResult, err := s.pyClient.ProcessImage(ctx, imgData, "receipt.jpg")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("python ocr error: %w", err)
|
||||
}
|
||||
|
||||
var processed []ProcessedItem
|
||||
|
||||
// 2. Обрабатываем каждую строку
|
||||
for _, rawItem := range rawResult.Items {
|
||||
item := ProcessedItem{
|
||||
RawName: rawItem.RawName,
|
||||
Amount: decimal.NewFromFloat(rawItem.Amount),
|
||||
Price: decimal.NewFromFloat(rawItem.Price),
|
||||
Sum: decimal.NewFromFloat(rawItem.Sum),
|
||||
}
|
||||
|
||||
// 3. Ищем соответствие
|
||||
// Сначала проверяем таблицу ручного обучения (product_matches)
|
||||
matchID, err := s.ocrRepo.FindMatch(rawItem.RawName)
|
||||
if err != nil {
|
||||
logger.Log.Error("db error finding match", zap.Error(err))
|
||||
}
|
||||
|
||||
if matchID != nil {
|
||||
// Нашли в обучении
|
||||
item.ProductID = matchID
|
||||
item.IsMatched = true
|
||||
item.MatchSource = "learned"
|
||||
} else {
|
||||
// Если не нашли, пробуем найти точное совпадение по имени в каталоге (на всякий случай)
|
||||
// (В реальном проекте тут может быть нечеткий поиск, но пока точный)
|
||||
// TODO: Добавить метод FindByName в репозиторий каталога, если нужно
|
||||
}
|
||||
|
||||
processed = append(processed, item)
|
||||
}
|
||||
|
||||
return processed, nil
|
||||
}
|
||||
|
||||
// ProcessedItem - результат обработки одной строки чека
|
||||
type ProcessedItem struct {
|
||||
RawName string
|
||||
Amount decimal.Decimal
|
||||
Price decimal.Decimal
|
||||
Sum decimal.Decimal
|
||||
|
||||
IsMatched bool
|
||||
ProductID *uuid.UUID
|
||||
MatchSource string // "learned", "auto", "manual"
|
||||
}
|
||||
|
||||
// ProductForIndex DTO для внешнего сервиса
|
||||
type ProductForIndex struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// GetCatalogForIndexing возвращает список товаров для построения индекса
|
||||
func (s *Service) GetCatalogForIndexing() ([]ProductForIndex, error) {
|
||||
products, err := s.catalogRepo.GetActiveGoods()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]ProductForIndex, 0, len(products))
|
||||
for _, p := range products {
|
||||
result = append(result, ProductForIndex{
|
||||
ID: p.ID.String(),
|
||||
Name: p.Name,
|
||||
Code: p.Code,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SaveMapping сохраняет связь "Текст из чека" -> "Наш товар"
|
||||
func (s *Service) SaveMapping(rawName string, productID uuid.UUID) error {
|
||||
return s.ocrRepo.SaveMatch(rawName, productID)
|
||||
}
|
||||
|
||||
// FindKnownMatch ищет, знаем ли мы уже этот товар
|
||||
func (s *Service) FindKnownMatch(rawName string) (*uuid.UUID, error) {
|
||||
return s.ocrRepo.FindMatch(rawName)
|
||||
}
|
||||
|
||||
// SearchProducts ищет товары в БД по части названия (для ручного выбора в боте)
|
||||
func (s *Service) SearchProducts(query string) ([]catalog.Product, error) {
|
||||
// Этот метод нужно поддержать в репозитории, пока сделаем заглушку или фильтрацию в памяти
|
||||
// Для MVP добавим метод SearchByName в интерфейс репозитория
|
||||
all, err := s.catalogRepo.GetActiveGoods()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Простейший поиск в памяти (для начала хватит)
|
||||
query = strings.ToLower(query)
|
||||
var result []catalog.Product
|
||||
for _, p := range all {
|
||||
if strings.Contains(strings.ToLower(p.Name), query) {
|
||||
result = append(result, p)
|
||||
if len(result) >= 10 { // Ограничим выдачу
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
82
internal/services/recommend/service.go
Normal file
82
internal/services/recommend/service.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package recommend
|
||||
|
||||
import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"rmser/internal/domain/recommendations"
|
||||
"rmser/pkg/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
AnalyzeDaysNoIncoming = 90 // Ищем ингредиенты без закупок за 30 дней
|
||||
AnalyzeDaysStale = 90 // Ищем неликвид за 60 дней
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo recommendations.Repository
|
||||
}
|
||||
|
||||
func NewService(repo recommendations.Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
// RefreshRecommendations выполняет анализ и сохраняет результаты в БД
|
||||
func (s *Service) RefreshRecommendations() error {
|
||||
logger.Log.Info("Запуск пересчета рекомендаций...")
|
||||
|
||||
var all []recommendations.Recommendation
|
||||
|
||||
// 1. Unused
|
||||
if unused, err := s.repo.FindUnusedGoods(); err == nil {
|
||||
all = append(all, unused...)
|
||||
} else {
|
||||
logger.Log.Error("Ошибка unused", zap.Error(err))
|
||||
}
|
||||
|
||||
// 2. Purchased but Unused
|
||||
if purchUnused, err := s.repo.FindPurchasedButUnused(AnalyzeDaysNoIncoming); err == nil {
|
||||
all = append(all, purchUnused...)
|
||||
} else {
|
||||
logger.Log.Error("Ошибка purchased_unused", zap.Error(err))
|
||||
}
|
||||
|
||||
// 3. No Incoming (Ингредиенты без закупок)
|
||||
if noInc, err := s.repo.FindNoIncomingIngredients(AnalyzeDaysNoIncoming); err == nil {
|
||||
all = append(all, noInc...)
|
||||
} else {
|
||||
logger.Log.Error("Ошибка no_incoming", zap.Error(err))
|
||||
}
|
||||
|
||||
// 4. Usage without Purchase (Расход без прихода) <-- НОВОЕ
|
||||
if usageNoPurch, err := s.repo.FindUsageWithoutPurchase(AnalyzeDaysNoIncoming); err == nil {
|
||||
all = append(all, usageNoPurch...)
|
||||
} else {
|
||||
logger.Log.Error("Ошибка usage_no_purchase", zap.Error(err))
|
||||
}
|
||||
|
||||
// 5. Stale (Неликвид)
|
||||
if stale, err := s.repo.FindStaleGoods(AnalyzeDaysStale); err == nil {
|
||||
all = append(all, stale...)
|
||||
} else {
|
||||
logger.Log.Error("Ошибка stale", zap.Error(err))
|
||||
}
|
||||
|
||||
// 6. Dish in Recipe
|
||||
if dishInRec, err := s.repo.FindDishesInRecipes(); err == nil {
|
||||
all = append(all, dishInRec...)
|
||||
} else {
|
||||
logger.Log.Error("Ошибка dish_in_recipe", zap.Error(err))
|
||||
}
|
||||
|
||||
// Сохраняем
|
||||
if err := s.repo.SaveAll(all); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Log.Info("Рекомендации обновлены", zap.Int("total_count", len(all)))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) GetRecommendations() ([]recommendations.Recommendation, error) {
|
||||
return s.repo.GetAll()
|
||||
}
|
||||
244
internal/services/sync/service.go
Normal file
244
internal/services/sync/service.go
Normal file
@@ -0,0 +1,244 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/shopspring/decimal"
|
||||
|
||||
"rmser/internal/domain/catalog"
|
||||
"rmser/internal/domain/invoices"
|
||||
"rmser/internal/domain/operations"
|
||||
"rmser/internal/domain/recipes"
|
||||
"rmser/internal/infrastructure/rms"
|
||||
"rmser/pkg/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
// Пресеты от пользователя
|
||||
PresetPurchases = "1a3297e1-cb05-55dc-98a7-c13f13bc85a7" // Закупки
|
||||
PresetUsage = "24d9402e-2d01-eca1-ebeb-7981f7d1cb86" // Расход
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
rmsClient rms.ClientI
|
||||
catalogRepo catalog.Repository
|
||||
recipeRepo recipes.Repository
|
||||
invoiceRepo invoices.Repository
|
||||
opRepo operations.Repository
|
||||
}
|
||||
|
||||
func NewService(
|
||||
rmsClient rms.ClientI,
|
||||
catalogRepo catalog.Repository,
|
||||
recipeRepo recipes.Repository,
|
||||
invoiceRepo invoices.Repository,
|
||||
opRepo operations.Repository,
|
||||
) *Service {
|
||||
return &Service{
|
||||
rmsClient: rmsClient,
|
||||
catalogRepo: catalogRepo,
|
||||
recipeRepo: recipeRepo,
|
||||
invoiceRepo: invoiceRepo,
|
||||
opRepo: opRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// SyncCatalog загружает номенклатуру и сохраняет в БД
|
||||
func (s *Service) SyncCatalog() error {
|
||||
logger.Log.Info("Начало синхронизации номенклатуры")
|
||||
|
||||
products, err := s.rmsClient.FetchCatalog()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ошибка получения каталога из RMS: %w", err)
|
||||
}
|
||||
|
||||
if err := s.catalogRepo.SaveProducts(products); err != nil {
|
||||
return fmt.Errorf("ошибка сохранения продуктов в БД: %w", err)
|
||||
}
|
||||
|
||||
logger.Log.Info("Синхронизация номенклатуры завершена", zap.Int("count", len(products)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// SyncRecipes загружает техкарты за указанный период (или за последние 30 дней по умолчанию)
|
||||
func (s *Service) SyncRecipes() error {
|
||||
logger.Log.Info("Начало синхронизации техкарт")
|
||||
|
||||
// RMS требует dateFrom. Берем широкий диапазон, например, с начала года или фиксированную дату,
|
||||
// либо можно сделать конфигурируемым. Для примера берем -3 месяца от текущей даты.
|
||||
// В реальном проде лучше брать дату последнего изменения, если API поддерживает revision,
|
||||
// но V2 API iiko часто требует полной перезагрузки актуальных карт.
|
||||
dateFrom := time.Now().AddDate(0, -3, 0)
|
||||
dateTo := time.Now() // +1 месяц вперед на случай будущих меню
|
||||
|
||||
recipes, err := s.rmsClient.FetchRecipes(dateFrom, dateTo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ошибка получения техкарт из RMS: %w", err)
|
||||
}
|
||||
|
||||
if err := s.recipeRepo.SaveRecipes(recipes); err != nil {
|
||||
return fmt.Errorf("ошибка сохранения техкарт в БД: %w", err)
|
||||
}
|
||||
|
||||
logger.Log.Info("Синхронизация техкарт завершена", zap.Int("count", len(recipes)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// SyncInvoices загружает накладные. Если в базе пусто, грузит за последние N дней.
|
||||
func (s *Service) SyncInvoices() error {
|
||||
logger.Log.Info("Начало синхронизации накладных")
|
||||
|
||||
lastDate, err := s.invoiceRepo.GetLastInvoiceDate()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ошибка получения даты последней накладной: %w", err)
|
||||
}
|
||||
|
||||
var from time.Time
|
||||
to := time.Now()
|
||||
|
||||
if lastDate != nil {
|
||||
// Берем следующий день после последней загрузки или тот же день, чтобы обновить изменения
|
||||
from = *lastDate
|
||||
} else {
|
||||
// Дефолтная загрузка за 30 дней назад
|
||||
from = time.Now().AddDate(0, 0, -30)
|
||||
}
|
||||
|
||||
logger.Log.Info("Запрос накладных", zap.Time("from", from), zap.Time("to", to))
|
||||
|
||||
invoices, err := s.rmsClient.FetchInvoices(from, to)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ошибка получения накладных из RMS: %w", err)
|
||||
}
|
||||
|
||||
if len(invoices) == 0 {
|
||||
logger.Log.Info("Новых накладных не найдено")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := s.invoiceRepo.SaveInvoices(invoices); err != nil {
|
||||
return fmt.Errorf("ошибка сохранения накладных в БД: %w", err)
|
||||
}
|
||||
|
||||
logger.Log.Info("Синхронизация накладных завершена", zap.Int("count", len(invoices)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// classifyOperation определяет тип операции на основе DocumentType
|
||||
func classifyOperation(docType string) operations.OperationType {
|
||||
switch docType {
|
||||
// === ПРИХОД (PURCHASE) ===
|
||||
case "INCOMING_INVOICE": // Приходная накладная
|
||||
return operations.OpTypePurchase
|
||||
case "INCOMING_SERVICE": // Акт приема услуг (редко товары, но бывает)
|
||||
return operations.OpTypePurchase
|
||||
|
||||
// === РАСХОД (USAGE) ===
|
||||
case "SALES_DOCUMENT": // Акт реализации (продажа)
|
||||
return operations.OpTypeUsage
|
||||
case "WRITEOFF_DOCUMENT": // Акт списания (порча, проработки)
|
||||
return operations.OpTypeUsage
|
||||
case "OUTGOING_INVOICE": // Расходная накладная
|
||||
return operations.OpTypeUsage
|
||||
case "SESSION_ACCEPTANCE": // Принятие смены (иногда агрегирует продажи)
|
||||
return operations.OpTypeUsage
|
||||
case "DISASSEMBLE_DOCUMENT": // Акт разбора (расход целого)
|
||||
return operations.OpTypeUsage
|
||||
|
||||
// === Спорные/Игнорируемые ===
|
||||
// RETURNED_INVOICE (Возвратная накладная) - технически это уменьшение прихода,
|
||||
// но для рекомендаций "что мы покупаем" лучше обрабатывать отдельно или как минус-purchase.
|
||||
// Пока отнесем к UNKNOWN, чтобы не портить статистику чистого прихода,
|
||||
// либо можно считать как Purchase с отрицательным Amount (если XML дает минус).
|
||||
case "RETURNED_INVOICE":
|
||||
return operations.OpTypeUnknown
|
||||
|
||||
case "INTERNAL_TRANSFER":
|
||||
return operations.OpTypeUnknown // Перемещение нас не интересует в рамках рекомендаций "купил/продал"
|
||||
case "INCOMING_INVENTORY":
|
||||
return operations.OpTypeUnknown // Инвентаризация
|
||||
|
||||
default:
|
||||
return operations.OpTypeUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) SyncStoreOperations() error {
|
||||
dateTo := time.Now()
|
||||
dateFrom := dateTo.AddDate(0, 0, -30)
|
||||
|
||||
// 1. Синхронизируем Закупки (PresetPurchases)
|
||||
// Мы передаем OpTypePurchase, чтобы репозиторий знал, какую "полку" очистить перед записью.
|
||||
if err := s.syncReport(PresetPurchases, operations.OpTypePurchase, dateFrom, dateTo); err != nil {
|
||||
return fmt.Errorf("ошибка синхронизации закупок: %w", err)
|
||||
}
|
||||
|
||||
// 2. Синхронизируем Расход (PresetUsage)
|
||||
if err := s.syncReport(PresetUsage, operations.OpTypeUsage, dateFrom, dateTo); err != nil {
|
||||
return fmt.Errorf("ошибка синхронизации расхода: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) syncReport(presetID string, targetOpType operations.OperationType, from, to time.Time) error {
|
||||
logger.Log.Info("Запрос отчета RMS", zap.String("preset", presetID))
|
||||
|
||||
items, err := s.rmsClient.FetchStoreOperations(presetID, from, to)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var ops []operations.StoreOperation
|
||||
for _, item := range items {
|
||||
// 1. Валидация товара
|
||||
pID, err := uuid.Parse(item.ProductID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// 2. Определение реального типа операции
|
||||
realOpType := classifyOperation(item.DocumentType)
|
||||
|
||||
// 3. Фильтрация "мусора"
|
||||
// Если мы грузим отчет "Закупки", но туда попало "Перемещение" (из-за кривого пресета),
|
||||
// мы это пропустим. Либо если документ неизвестного типа.
|
||||
if realOpType == operations.OpTypeUnknown {
|
||||
continue
|
||||
}
|
||||
|
||||
// Важно: Мы сохраняем только то, что соответствует целевому типу этапа синхронизации.
|
||||
// Если в пресете "Закупки" попалась "Реализация", мы не должны писать её в "Закупки",
|
||||
// и не должны писать в "Расход" (так как мы сейчас чистим "Закупки").
|
||||
if realOpType != targetOpType {
|
||||
continue
|
||||
}
|
||||
|
||||
ops = append(ops, operations.StoreOperation{
|
||||
ProductID: pID,
|
||||
OpType: realOpType,
|
||||
DocumentType: item.DocumentType,
|
||||
TransactionType: item.TransactionType,
|
||||
DocumentNumber: item.DocumentNum,
|
||||
Amount: decimal.NewFromFloat(item.Amount),
|
||||
Sum: decimal.NewFromFloat(item.Sum),
|
||||
Cost: decimal.NewFromFloat(item.Cost),
|
||||
PeriodFrom: from,
|
||||
PeriodTo: to,
|
||||
})
|
||||
}
|
||||
|
||||
if err := s.opRepo.SaveOperations(ops, targetOpType, from, to); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Log.Info("Отчет сохранен",
|
||||
zap.String("op_type", string(targetOpType)),
|
||||
zap.Int("received", len(items)),
|
||||
zap.Int("saved", len(ops)))
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user