Files
shtrihscanner/main.go
SERTY 6c3950953e 0.1.3-prod
reduce timeouts
added tests and mock interface
2025-08-22 03:38:57 +03:00

456 lines
18 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 main
import (
"encoding/json"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strconv"
"time"
"shtrih-kkt/pkg/shtrih"
"gopkg.in/natefinch/lumberjack.v2"
)
const (
configFileName = "connect.json"
serviceConfigName = "service.json"
logsDir = "logs"
comSearchTimeout = 200 * time.Millisecond
tcpSearchTimeout = 200 * time.Millisecond
)
// Глобальная переменная для пути вывода. Это позволяет подменять ее в тестах.
var outputDir = "date"
// --- СТРУКТУРЫ ДЛЯ ПАРСИНГА КОНФИГУРАЦИОННЫХ ФАЙЛОВ ---
type ConfigFile struct {
Timeout int `json:"timeout_to_ip_port"`
Shtrih []ConnectionSettings `json:"shtrih"`
Atol []interface{} `json:"atol"`
}
type ConnectionSettings struct {
TypeConnect int32 `json:"type_connect"`
ComPort string `json:"com_port"`
ComBaudrate string `json:"com_baudrate"`
IP string `json:"ip"`
IPPort string `json:"ip_port"`
}
type ServiceFile struct {
Service ServiceConfig `json:"service"`
}
type ServiceConfig struct {
LogLevel string `json:"log_level"`
LogDays int `json:"log_days"`
}
type PolledDevice struct {
Config shtrih.Config
Info *shtrih.FiscalInfo
}
// --- ОСНОВНАЯ ЛОГИКА ПРИЛОЖЕНИЯ ---
func main() {
log.Println("Запуск приложения для сбора данных с ККТ Штрих-М...")
configData, err := os.ReadFile(configFileName)
if err != nil {
if os.IsNotExist(err) {
log.Printf("Файл конфигурации '%s' не найден. Запускаю режим автопоиска...", configFileName)
runDiscoveryMode()
} else {
log.Fatalf("Ошибка чтения файла конфигурации '%s': %v", configFileName, err)
}
} else {
log.Printf("Найден файл конфигурации '%s'. Запускаю стационарный режим...", configFileName)
runConfigMode(configData)
}
log.Println("Работа приложения завершена.")
}
func setupLogger() {
data, err := os.ReadFile(serviceConfigName)
if err != nil {
log.Printf("Предупреждение: файл настроек '%s' не найден. Логирование продолжится в консоль.", serviceConfigName)
return
}
var serviceFile ServiceFile
if err := json.Unmarshal(data, &serviceFile); err != nil {
log.Printf("Предупреждение: не удалось прочитать настройки из '%s' (%v). Логирование продолжится в консоль.", serviceConfigName, err)
return
}
logDays := serviceFile.Service.LogDays
if logDays <= 0 {
logDays = 7
}
if err := os.MkdirAll(logsDir, 0755); err != nil {
log.Printf("Ошибка создания директории для логов '%s': %v. Логирование продолжится в консоль.", logsDir, err)
return
}
logFilePath := filepath.Join(logsDir, "shtrih-scanner.log")
lumberjackLogger := &lumberjack.Logger{
Filename: logFilePath,
MaxSize: 5,
MaxBackups: 10,
MaxAge: logDays,
Compress: true,
}
log.SetOutput(io.MultiWriter(os.Stdout, lumberjackLogger))
log.Printf("Логирование настроено. Уровень: %s, ротация: %d дней. Файл: %s", serviceFile.Service.LogLevel, logDays, logFilePath)
}
func runConfigMode(data []byte) {
setupLogger()
var configFile ConfigFile
if err := json.Unmarshal(data, &configFile); err != nil {
log.Printf("Ошибка парсинга JSON из '%s': %v. Переключаюсь на режим автопоиска.", configFileName, err)
runDiscoveryMode()
return
}
if len(configFile.Shtrih) == 0 {
log.Printf("В файле '%s' не найдено настроек для 'shtrih'. Переключаюсь на режим автопоиска.", configFileName)
runDiscoveryMode()
return
}
log.Printf("Найдено %d конфигураций для Штрих-М. Начинаю опрос...", len(configFile.Shtrih))
configs := convertSettingsToConfigs(configFile.Shtrih)
if len(configs) == 0 {
log.Println("Не удалось создать ни одной валидной конфигурации из файла. Проверьте данные в connect.json.")
return
}
// Передаем конструктор реального драйвера shtrih.New
processDevices(configs, shtrih.New)
}
func runDiscoveryMode() {
configs, err := shtrih.SearchDevices(comSearchTimeout, tcpSearchTimeout)
if err != nil {
log.Printf("Во время поиска устройств произошла ошибка: %v", err)
}
if len(configs) == 0 {
log.Println("В ходе сканирования не найдено ни одного устройства Штрих-М.")
return
}
log.Printf("Найдено %d устройств. Начинаю сбор информации...", len(configs))
// Передаем конструктор реального драйвера shtrih.New
polledDevices := processDevices(configs, shtrih.New)
if len(polledDevices) > 0 {
saveConfiguration(polledDevices)
}
}
// processDevices принимает функцию-фабрику `newDriverFunc` для создания драйвера.
// Это позволяет подменять реальный драйвер на мок-драйвер в тестах.
func processDevices(configs []shtrih.Config, newDriverFunc func(shtrih.Config) shtrih.Driver) []PolledDevice {
var polledDevices []PolledDevice
for _, config := range configs {
log.Printf("--- Опрашиваю устройство: %+v ---", config)
// Используем переданную функцию-фабрику для создания драйвера
driver := newDriverFunc(config)
if err := driver.Connect(); err != nil {
log.Printf("Не удалось подключиться к устройству: %v", err)
continue
}
info, err := driver.GetFiscalInfo()
driver.Disconnect()
if err != nil {
log.Printf("Ошибка при получении фискальной информации: %v", err)
continue
}
if info == nil || info.SerialNumber == "" {
log.Println("Получена пустая информация или отсутствует серийный номер, данные проигнорированы.")
continue
}
polledDevices = append(polledDevices, PolledDevice{Config: config, Info: info})
}
if len(polledDevices) == 0 {
log.Println("--- Не удалось собрать данные ни с одного устройства. Завершение. ---")
return nil
}
log.Printf("--- Всего собрано данных с %d устройств. Начинаю обработку файлов. ---", len(polledDevices))
sourceWSDataMap := findSourceWorkstationData()
var successCount int
for _, pd := range polledDevices {
kktInfo := pd.Info
fileName := fmt.Sprintf("%s.json", kktInfo.SerialNumber)
filePath := filepath.Join(outputDir, fileName)
if _, err := os.Stat(filePath); err == nil {
log.Printf("Файл для ККТ %s уже существует. Обновляю временные метки...", kktInfo.SerialNumber)
if err := updateTimestampInFile(filePath); err != nil {
log.Printf("Не удалось обновить файл %s: %v", filePath, err)
} else {
log.Printf("Файл %s успешно обновлен.", filePath)
successCount++
}
} else {
var wsDataToUse map[string]interface{}
if sourceWSDataMap != nil {
log.Printf("Создаю новый файл для ККТ %s, используя данные о рабочей станции из файла-донора.", kktInfo.SerialNumber)
wsDataToUse = sourceWSDataMap
} else {
log.Printf("Создаю первичный файл для ККТ %s с базовыми данными о рабочей станции.", kktInfo.SerialNumber)
hostname, _ := os.Hostname()
wsDataToUse = map[string]interface{}{"hostname": hostname}
}
if err := saveNewMergedInfo(kktInfo, wsDataToUse, filePath); err != nil {
log.Printf("Не удалось создать файл для ККТ %s: %v", kktInfo.SerialNumber, err)
} else {
successCount++
}
}
}
log.Printf("--- Обработка файлов завершена. Успешно создано/обновлено: %d файлов. ---", successCount)
return polledDevices
}
// --- ФУНКЦИИ ДЛЯ РАБОТЫ С ФАЙЛАМИ ---
// findSourceWorkstationData ищет в папке /date файл с данными о рабочей станции.
// Логика поиска:
// 1. Ищет "идеальный" донор: файл с "hostname", но без "modelName". Если находит - сразу возвращает его.
// 2. Если идеальный не найден, ищет "первый подходящий": любой файл с "hostname", даже если там есть "modelName".
func findSourceWorkstationData() map[string]interface{} {
files, err := os.ReadDir(outputDir)
if err != nil {
return nil
}
var firstCandidate map[string]interface{} // Переменная для хранения "первого подходящего" кандидата
for _, file := range files {
if file.IsDir() || filepath.Ext(file.Name()) != ".json" {
continue
}
filePath := filepath.Join(outputDir, file.Name())
data, err := os.ReadFile(filePath)
if err != nil {
log.Printf("Предупреждение: не удалось прочитать файл-донор %s: %v", filePath, err)
continue
}
var content map[string]interface{}
if err := json.Unmarshal(data, &content); err != nil {
log.Printf("Предупреждение: не удалось распарсить JSON из файла-донора %s: %v", filePath, err)
continue
}
// Проверяем наличие ключевых полей
_, hasModelName := content["modelName"]
_, hasHostname := content["hostname"]
// Если у файла нет hostname, он нам точно не интересен
if !hasHostname {
continue
}
// Сценарий 1: Найден "идеальный" донор (без modelName)
if !hasModelName {
log.Printf("Найден идеальный файл-донор с данными о рабочей станции: %s", filePath)
return content // Сразу возвращаем его
}
// Сценарий 2: Файл не идеальный, но подходит как кандидат (есть и hostname, и modelName)
// Сохраняем только самого первого кандидата из списка файлов.
if firstCandidate == nil {
firstCandidate = content
log.Printf("Найден файл-кандидат на роль донора (будет использован, если не найдется идеальный): %s", filePath)
}
}
// После проверки всех файлов, если мы так и не вернули идеального донора,
// используем первого подходящего кандидата, которого нашли.
if firstCandidate != nil {
log.Println("Идеальный донор не найден, используется первый подходящий файл-кандидат.")
return firstCandidate
}
// Если мы дошли до сюда, значит не было найдено ни одного файла с полем "hostname".
log.Println("В папке /date не найдено файлов-доноров. Будут использованы базовые данные.")
return nil
}
func updateTimestampInFile(filePath string) error {
data, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("ошибка чтения файла: %w", err)
}
var content map[string]interface{}
if err := json.Unmarshal(data, &content); err != nil {
return fmt.Errorf("ошибка парсинга JSON: %w", err)
}
currentTime := time.Now().Format("2006-01-02 15:04:05")
content["current_time"] = currentTime
content["v_time"] = currentTime
updatedData, err := json.MarshalIndent(content, "", " ")
if err != nil {
return fmt.Errorf("ошибка маршалинга JSON: %w", err)
}
return os.WriteFile(filePath, updatedData, 0644)
}
// saveNewMergedInfo объединяет данные ККТ и данные рабочей станции (в виде map) и сохраняет в новый JSON-файл.
// Данные от ККТ имеют приоритет и перезаписывают одноименные поля из данных донора.
func saveNewMergedInfo(kktInfo *shtrih.FiscalInfo, wsData map[string]interface{}, filePath string) error {
// Шаг 1: Преобразуем данные от нашего ККТ (Штрих) в map.
var kktMap map[string]interface{}
kktJSON, _ := json.Marshal(kktInfo)
json.Unmarshal(kktJSON, &kktMap)
// Шаг 2: Создаем итоговую карту. Начинаем с данных донора, чтобы они были "внизу".
// Мы делаем копию wsData, чтобы не изменять оригинальную карту, которая может быть использована в других итерациях.
finalMap := make(map[string]interface{})
for key, value := range wsData {
finalMap[key] = value
}
// Шаг 3: "Накладываем" данные от нашего ККТ поверх.
// Все совпадающие ключи будут перезаписаны значениями от Штриха.
for key, value := range kktMap {
// Пропускаем пустые значения от ККТ, чтобы случайно не затереть
// хорошее значение из донора пустым.
if s, ok := value.(string); ok && s == "" {
continue
}
finalMap[key] = value
}
// Шаг 4: Устанавливаем актуальные временные метки. Они всегда должны быть свежими.
currentTime := time.Now().Format("2006-01-02 15:04:05")
finalMap["current_time"] = currentTime
finalMap["v_time"] = currentTime
// Шаг 5: Создаем директорию и сохраняем файл.
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
return fmt.Errorf("не удалось создать директорию '%s': %w", outputDir, err)
}
finalJSON, err := json.MarshalIndent(finalMap, "", " ")
if err != nil {
return fmt.Errorf("ошибка маршалинга итогового JSON: %w", err)
}
if err := os.WriteFile(filePath, finalJSON, 0644); err != nil {
return fmt.Errorf("ошибка записи в файл '%s': %w", filePath, err)
}
log.Printf("Данные для ККТ %s успешно сохранены в новый файл: %s", kktInfo.SerialNumber, filePath)
return nil
}
func saveConfiguration(polledDevices []PolledDevice) {
log.Printf("Сохранение %d найденных конфигураций в файл '%s'...", len(polledDevices), configFileName)
var configFile ConfigFile
data, err := os.ReadFile(configFileName)
if err == nil {
if err := json.Unmarshal(data, &configFile); err != nil {
log.Printf("Предупреждение: файл '%s' поврежден (%v). Он будет перезаписан.", configFileName, err)
configFile = ConfigFile{}
}
}
var newShtrihSettings []ConnectionSettings
for _, pd := range polledDevices {
newShtrihSettings = append(newShtrihSettings, convertConfigToSettings(pd.Config))
}
configFile.Shtrih = newShtrihSettings
updatedData, err := json.MarshalIndent(configFile, "", " ")
if err != nil {
log.Printf("Ошибка: не удалось преобразовать конфигурацию в JSON: %v", err)
return
}
if err := os.WriteFile(configFileName, updatedData, 0644); err != nil {
log.Printf("Ошибка: не удалось записать конфигурацию в файл '%s': %v", configFileName, err)
return
}
log.Printf("Конфигурация успешно сохранена в '%s'.", configFileName)
}
// --- ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ---
func convertSettingsToConfigs(settings []ConnectionSettings) []shtrih.Config {
var configs []shtrih.Config
baudRateMap := map[string]int32{
"115200": 6, "57600": 5, "38400": 4, "19200": 3, "9600": 2, "4800": 1,
}
for _, s := range settings {
config := shtrih.Config{ConnectionType: s.TypeConnect, Password: 30}
switch s.TypeConnect {
case 0:
comNum, err := strconv.Atoi(s.ComPort[3:])
if err != nil {
log.Printf("Некорректное имя COM-порта '%s' в конфигурации, пропуск.", s.ComPort)
continue
}
baudRate, ok := baudRateMap[s.ComBaudrate]
if !ok {
log.Printf("Некорректная скорость '%s' для порта '%s', пропуск.", s.ComBaudrate, s.ComPort)
continue
}
config.ComName = s.ComPort
config.ComNumber = int32(comNum)
config.BaudRate = baudRate
case 6:
port, err := strconv.Atoi(s.IPPort)
if err != nil {
log.Printf("Некорректный TCP-порт '%s' для IP '%s', пропуск.", s.IPPort, s.IP)
continue
}
config.IPAddress = s.IP
config.TCPPort = int32(port)
default:
log.Printf("Неизвестный тип подключения '%d', пропуск.", s.TypeConnect)
continue
}
configs = append(configs, config)
}
return configs
}
func convertConfigToSettings(config shtrih.Config) ConnectionSettings {
baudRateReverseMap := map[int32]string{
6: "115200", 5: "57600", 4: "38400", 3: "19200", 2: "9600", 1: "4800",
}
settings := ConnectionSettings{TypeConnect: config.ConnectionType}
switch config.ConnectionType {
case 0:
settings.ComPort = config.ComName
settings.ComBaudrate = baudRateReverseMap[config.BaudRate]
case 6:
settings.IP = config.IPAddress
settings.IPPort = strconv.Itoa(int(config.TCPPort))
}
return settings
}