Compare commits

...

6 Commits

Author SHA1 Message Date
2ac80c029f 0.1.5-prod
windows 7 compatible
address added
2025-09-22 03:16:33 +03:00
e9fa4f30e1 0.1.5-dev
config[map] added for kept existing config
2025-09-09 06:30:41 +03:00
a02209c698 0.1.4-prod
added skip shtrih-searsh - [] in connect.json
fixed license reader
added clearing date-folder after read and before write
2025-08-30 06:08:45 +03:00
6c3950953e 0.1.3-prod
reduce timeouts
added tests and mock interface
2025-08-22 03:38:57 +03:00
6c95d944a1 0.1.2-prod 2025-08-21 10:42:56 +03:00
173d4c670c 0.1.2-prod 2025-08-21 10:42:35 +03:00
11 changed files with 1179 additions and 201 deletions

2
.gitignore vendored
View File

@@ -1,2 +1,4 @@
*.exe
*.json
*.log
*.zip

193
README.md Normal file
View File

@@ -0,0 +1,193 @@
# Go-библиотека и утилита для ККТ "Штрих-М" (shtrih-kkt)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
Go-библиотека и консольная утилита для взаимодействия с фискальными регистраторами (ККТ) "Штрих-М" через официальный 32-битный COM-драйвер. Проект разработан с фокусом на безопасный сбор данных в режиме "только чтение" и предоставляет гибкие режимы работы для различных сценариев использования.
**Важнейшее ограничение:** Приложения, использующие эту библиотеку, **должны быть скомпилированы для 32-битной архитектуры (`GOARCH=386`)** из-за зависимости от 32-битного COM-драйвера.
## Ключевые возможности
* **Надежная обертка над COM-драйвером:** Предоставляет безопасный и удобный Go-интерфейс для драйвера "Штрих-М".
* **Комплексный сбор данных:** Агрегирует полную информацию о ККТ, включая регистрационные данные, статус ФН, версии ПО, лицензии и атрибуты торговли.
* **Умный автопоиск устройств:**
* **COM-порты:** Автоматически сканирует все системные COM-порты на двух самых распространенных скоростях (`115200` и `4800`), предотвращая зависания на "портах-призраках".
* **TCP/IP (RNDIS):**анирует стандартные для RNDIS-устройств IP-подсети (`192.168.137.0/24`, `192.168.138.0/24`).
* **Два режима работы утилиты:**
1. **Режим автопоиска:** При первом запуске или отсутствии конфигурации выполняет полный поиск устройств, собирает с них данные и **сохраняет найденные конфигурации** в `connect.json` для последующих быстрых запусков.
2. **Стационарный режим:** При наличии файла `connect.json` использует заданные в нем параметры для быстрого опроса конкретных ККТ, пропуская этап сканирования.
* **Управление данными:**
* Сохраняет информацию о каждом ККТ в отдельный JSON-файл (`/date/{ЗН_ККТ}.json`).
* "Обогащает" данные ККТ информацией о рабочей станции (hostname, TeamViewer ID и т.д.), заимствуя ее из существующих JSON-файлов в папке `/date`.
* Автоматически обновляет временные метки (`current_time`, `v_time`) в существующих файлах при повторных опросах.
* **Файловое логирование с ротацией:** В стационарном режиме ведет подробный лог в папке `/logs`, настройки которого (срок хранения) задаются в файле `service.json`.
* **Тестируемость:** Включает в себя `mockDriver` для написания unit-тестов без необходимости подключения реального оборудования.
## Архитектура
Библиотека построена на простом интерфейсе `Driver`, что позволяет легко подменять реализации:
* `comDriver`: Основная реализация для работы с реальным COM-драйвером.
* `mockDriver`: Имитационная реализация для unit-тестирования.
Это обеспечивает слабую связанность и позволяет тестировать логику приложений, использующих библиотеку, в изолированной среде.
## Начало работы
### Требования
1. **Go:** Версия 1.23 или выше.
2. **Windows:** Поддержка Windows 7 и выше.
3. **32-битный (x86) тулчейн Go:** Даже на 64-битной системе для компиляции требуется 32-битный набор инструментов.
4. **Драйвер "Штрих-М":** На целевой машине должен быть установлен и зарегистрирован официальный драйвер от "Штрих-М" (например, `DrvFR_4.15_882.exe`).
### Использование библиотеки в вашем проекте
1. **Добавьте библиотеку в ваш проект:**
```bash
go get github.com/your-username/shtrih-kkt/pkg/shtrih
```
2. **Пример использования:**
```go
package main
import (
"fmt"
"log"
"shtrih-kkt/pkg/shtrih"
)
func main() {
// Конфигурация для подключения по COM-порту
config := shtrih.Config{
ConnectionType: 0,
ComName: "COM3",
ComNumber: 3,
BaudRate: 6, // Индекс для 115200
Password: 30,
}
// Создаем новый драйвер
driver := shtrih.New(config)
// Подключаемся
if err := driver.Connect(); err != nil {
log.Fatalf("Ошибка подключения: %v", err)
}
// Гарантируем отключение в конце
defer driver.Disconnect()
// Получаем информацию
info, err := driver.GetFiscalInfo()
if err != nil {
log.Fatalf("Ошибка получения информации: %v", err)
}
fmt.Printf("Успешно получены данные для ККТ модели: %s\n", info.ModelName)
fmt.Printf("Серийный номер: %s\n", info.SerialNumber)
}
```
### Использование готовой утилиты `shtrih-scanner.exe`
Утилита предназначена для запуска "рядом" с другими служебными файлами.
1. **Режим автопоиска (первый запуск):**
* Просто запустите `shtrih-scanner.exe`.
* Программа выполнит полный поиск устройств.
* В папке `/date` будут созданы JSON-файлы с данными для каждой найденной ККТ.
* Будет создан или перезаписан файл `connect.json` с параметрами найденных устройств.
2. **Стационарный режим (последующие запуски):**
* Убедитесь, что рядом с `shtrih-scanner.exe` лежит `connect.json`.
* Для настройки логирования создайте файл `service.json`.
* Запустите `shtrih-scanner.exe`.
* Программа быстро опросит устройства из `connect.json` и обновит временные метки в файлах в папке `/date`.
#### Конфигурационные файлы
* `connect.json` (генерируется автоматически):
```json
{
"shtrih": [
{
"type_connect": 0,
"com_port": "COM1",
"com_baudrate": "115200"
},
{
"type_connect": 6,
"ip": "192.168.137.111",
"ip_port": "7778"
}
]
}
```
* `service.json` (создается вручную для настройки логов):
```json
{
"service": {
"log_level": "info",
"log_days": 7
}
}
```
## Сборка проекта
**Важно:** Сборка должна производиться для архитектуры `386`.
Откройте терминал в корневой папке проекта.
**Для PowerShell:**
```powershell
$env:GOARCH="386"; $env:GOOS="windows"; go build -o shtrih-scanner.exe
```
**Для CMD:**
```cmd
set GOARCH=386
set GOOS=windows
go build -o shtrih-scanner.exe
```
**Сборка без консольного окна (для фоновой работы):**
```powershell
$env:GOARCH="386"; $env:GOOS="windows"; go build -ldflags="-H=windowsgui" -o shtrih-scanner.exe
```
## Тестирование
Для запуска unit-тестов, использующих `mockDriver`, выполните команду в корне проекта:
```bash
go test ./...
```
## Структура проекта
```
shtrih-kkt/
├── go.mod # Файл модуля Go
├── main.go # Исходный код утилиты shtrih-scanner.exe
├── README.md # Этот файл
└── pkg/
└── shtrih/
├── driver.go # Основная логика библиотеки и реализация comDriver
├── mock_driver.go # Реализация mockDriver для тестов
└── driver_test.go # Unit-тесты для библиотеки
---
# Файлы, создаваемые во время работы:
shtrih-scanner.exe
connect.json
service.json
date/
│ └── 0012345678901234.json
logs/
└── shtrih-scanner.log
```
## Лицензия
Этот проект распространяется под лицензией MIT. См. файл `LICENSE` для получения дополнительной информации.

3
go.mod
View File

@@ -1,10 +1,11 @@
module shtrih-kkt
go 1.23.4
go 1.17
require (
github.com/go-ole/go-ole v1.3.0
go.bug.st/serial v1.6.4
gopkg.in/natefinch/lumberjack.v2 v2.2.1
)
require (

10
go.sum
View File

@@ -1,11 +1,17 @@
github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0=
github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A=
@@ -13,5 +19,9 @@ go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

478
main.go
View File

@@ -3,30 +3,44 @@ package main
import (
"encoding/json"
"fmt"
"io"
"log"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"shtrih-kkt/pkg/shtrih"
"gopkg.in/natefinch/lumberjack.v2"
)
const (
configFileName = "connect.json"
outputDir = "date"
comSearchTimeout = 220 * time.Millisecond
tcpSearchTimeout = 300 * time.Millisecond
serviceConfigName = "service.json"
logsDir = "logs"
comSearchTimeout = 200 * time.Millisecond
tcpSearchTimeout = 200 * time.Millisecond
)
// ConfigFile соответствует структуре файла connect.json
// Глобальная переменная для пути вывода. Это позволяет подменять ее в тестах.
var (
outputDir = "date"
version = "0.1.5-dev"
)
// --- СТРУКТУРЫ ДЛЯ ПАРСИНГА КОНФИГУРАЦИОННЫХ ФАЙЛОВ ---
// ConfigFile используется для чтения секции "shtrih" из connect.json.
// Остальные секции файла игнорируются при чтении, но сохраняются при записи.
type ConfigFile struct {
Timeout int `json:"timeout_to_ip_port"`
// Если ключ "shtrih" в JSON отсутствует, это поле будет nil.
// Если ключ есть, но массив пуст (shtrih: []), поле будет пустым срезом.
Shtrih []ConnectionSettings `json:"shtrih"`
Atol []interface{} `json:"atol"`
}
// ConnectionSettings описывает один блок настроек подключения для Штрих-М
type ConnectionSettings struct {
TypeConnect int32 `json:"type_connect"`
ComPort string `json:"com_port"`
@@ -35,20 +49,42 @@ type ConnectionSettings struct {
IPPort string `json:"ip_port"`
}
// PolledDevice связывает конфигурацию, использованную для подключения,
// с фискальной информацией, полученной от устройства.
type ServiceFile struct {
Service ServiceConfig `json:"service"`
}
type ServiceConfig struct {
LogLevel string `json:"log_level"`
LogDays int `json:"log_days"`
UpdateURL string `json:"update_url"`
}
type PolledDevice struct {
Config shtrih.Config
Info *shtrih.FiscalInfo
}
func main() {
log.Println("Запуск приложения для сбора данных с ККТ Штрих-М...")
// --- ОСНОВНАЯ ЛОГИКА ПРИЛОЖЕНИЯ ---
func main() {
log.Printf("Запуск приложения для сбора данных с ККТ Штрих-М, версия: %s", version)
// Загружаем сервисную конфигурацию в самом начале.
serviceConfig := loadServiceConfig()
// Настраиваем файловое логирование.
setupLogger(serviceConfig)
// В фоне запускаем проверку обновлений, если URL указан.
if serviceConfig != nil {
go checkForUpdates(version, serviceConfig.UpdateURL)
}
// Основная логика приложения.
configData, err := os.ReadFile(configFileName)
if err != nil {
if os.IsNotExist(err) {
log.Printf("Файл конфигурации '%s' не найден. Запускаю режим автопоиска устройств...", configFileName)
log.Printf("Файл конфигурации '%s' не найден. Запускаю режим автопоиска...", configFileName)
runDiscoveryMode()
} else {
log.Fatalf("Ошибка чтения файла конфигурации '%s': %v", configFileName, err)
@@ -61,7 +97,63 @@ func main() {
log.Println("Работа приложения завершена.")
}
// loadServiceConfig читает и парсит service.json.
// Возвращает конфигурацию или nil, если файл не найден или поврежден.
func loadServiceConfig() *ServiceConfig {
data, err := os.ReadFile(serviceConfigName)
if err != nil {
if !os.IsNotExist(err) {
log.Printf("Предупреждение: ошибка чтения файла '%s': %v.", serviceConfigName, err)
}
return nil
}
var serviceFile ServiceFile
if err := json.Unmarshal(data, &serviceFile); err != nil {
log.Printf("Предупреждение: не удалось прочитать настройки из '%s' (%v).", serviceConfigName, err)
return nil
}
return &serviceFile.Service
}
func setupLogger(config *ServiceConfig) {
// setupLogger настраивает систему логирования на основе конфигурации.
// Если конфигурация не передана или повреждена, логирование продолжается в консоль.
// Создает директорию для логов и настраивает ротацию с использованием lumberjack.
if config == nil {
log.Printf("Предупреждение: файл настроек '%s' не найден или некорректен. Логирование продолжится в консоль.", serviceConfigName)
return
}
logDays := config.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", config.LogLevel, logDays, logFilePath)
}
func runConfigMode(data []byte) {
// runConfigMode запускает приложение в стационарном режиме с использованием
// конфигурации из файла connect.json. Парсит настройки устройств и запускает
// процесс опроса ККТ. При ошибках парсинга переключается на режим автопоиска.
var configFile ConfigFile
if err := json.Unmarshal(data, &configFile); err != nil {
log.Printf("Ошибка парсинга JSON из '%s': %v. Переключаюсь на режим автопоиска.", configFileName, err)
@@ -69,12 +161,20 @@ func runConfigMode(data []byte) {
return
}
if len(configFile.Shtrih) == 0 {
log.Printf("В файле '%s' не найдено настроек для 'shtrih'. Переключаюсь на режим автопоиска.", configFileName)
// Проверяем наличие секции shtrih в файле, но не пустоту массива.
// Пустой массив shtrih: [] является валидным состоянием.
if configFile.Shtrih == nil {
log.Printf("В файле '%s' отсутствует секция 'shtrih'. Переключаюсь на режим автопоиска.", configFileName)
runDiscoveryMode()
return
}
if len(configFile.Shtrih) == 0 {
log.Println("Список устройств 'shtrih' в конфигурации пуст. Сканирование не требуется.")
// Здесь можно завершить работу, так как опрашивать нечего.
return
}
log.Printf("Найдено %d конфигураций для Штрих-М. Начинаю опрос...", len(configFile.Shtrih))
configs := convertSettingsToConfigs(configFile.Shtrih)
if len(configs) == 0 {
@@ -82,10 +182,14 @@ func runConfigMode(data []byte) {
return
}
processDevices(configs)
// Передаем конструктор реального драйвера shtrih.New
processDevices(configs, shtrih.New)
}
func runDiscoveryMode() {
// runDiscoveryMode запускает приложение в режиме автопоиска устройств.
// Выполняет сканирование COM-портов и TCP-сетей для обнаружения ККТ Штрих-М.
// При обнаружении устройств сохраняет их конфигурацию для последующих запусков.
configs, err := shtrih.SearchDevices(comSearchTimeout, tcpSearchTimeout)
if err != nil {
log.Printf("Во время поиска устройств произошла ошибка: %v", err)
@@ -93,25 +197,28 @@ func runDiscoveryMode() {
if len(configs) == 0 {
log.Println("В ходе сканирования не найдено ни одного устройства Штрих-М.")
return
// Сохраняем информацию об отсутствии устройств, чтобы не сканировать в следующий раз.
saveEmptyShtrihConfig()
return // Завершаем работу, так как устройств нет.
}
log.Printf("Найдено %d устройств. Начинаю сбор информации...", len(configs))
polledDevices := processDevices(configs)
// Передаем конструктор реального драйвера shtrih.New
polledDevices := processDevices(configs, shtrih.New)
// Если были успешно опрошены какие-либо устройства, сохраняем их конфигурацию
if len(polledDevices) > 0 {
saveConfiguration(polledDevices)
}
}
// processDevices - основная функция, реализующая "умную" логику обновления и создания файлов.
// Теперь она возвращает срез успешно опрошенных устройств.
func processDevices(configs []shtrih.Config) []PolledDevice {
// Шаг 1: Сначала собираем информацию со всех найденных устройств.
var polledDevices []PolledDevice // Было: var freshKKTData []*shtrih.FiscalInfo
// 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 := shtrih.New(config)
// Используем переданную функцию-фабрику для создания драйвера
driver := newDriverFunc(config)
if err := driver.Connect(); err != nil {
log.Printf("Не удалось подключиться к устройству: %v", err)
@@ -119,7 +226,7 @@ func processDevices(configs []shtrih.Config) []PolledDevice {
}
info, err := driver.GetFiscalInfo()
driver.Disconnect() // Отключаемся сразу после получения данных
driver.Disconnect()
if err != nil {
log.Printf("Ошибка при получении фискальной информации: %v", err)
@@ -129,68 +236,69 @@ func processDevices(configs []shtrih.Config) []PolledDevice {
log.Println("Получена пустая информация или отсутствует серийный номер, данные проигнорированы.")
continue
}
// Сохраняем и конфигурацию, и результат
polledDevices = append(polledDevices, PolledDevice{Config: config, Info: info})
}
if len(polledDevices) == 0 {
log.Println("--- Не удалось собрать данные ни с одного устройства. Завершение. ---")
return nil // Возвращаем nil, если ничего не найдено
return nil
}
log.Printf("--- Всего собрано данных с %d устройств. Начинаю обработку файлов. ---", len(polledDevices))
// Шаг 2: Ищем "донора" данных о рабочей станции в папке /date.
sourceWSDataMap := findSourceWorkstationData()
// Шаг 3: Обрабатываем каждого "свежего" ККТ в соответствии с новой логикой.
cleanupDateDirectory()
var successCount int
for _, pd := range polledDevices { // Итерируемся по новой структуре
kktInfo := pd.Info // Получаем доступ к данным ККТ
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)
log.Printf("Готовлю данные для ККТ %s, используя информацию из файла-донора.", kktInfo.SerialNumber)
wsDataToUse = sourceWSDataMap
} else {
log.Printf("Создаю первичный файл для ККТ %s с базовыми данными о рабочей станции.", kktInfo.SerialNumber)
log.Printf("Готовлю данные для ККТ %s с базовой информацией о рабочей станции (донор не найден).", kktInfo.SerialNumber)
hostname, _ := os.Hostname()
wsDataToUse = map[string]interface{}{"hostname": hostname}
}
wsDataToUse["current_time"] = time.Now().Format("2006-01-02 15:04:05")
// Безусловно сохраняем/перезаписываем файл.
if err := saveNewMergedInfo(kktInfo, wsDataToUse, filePath); err != nil {
log.Printf("Не удалось создать файл для ККТ %s: %v", kktInfo.SerialNumber, err)
log.Printf("Не удалось создать/перезаписать файл для ККТ %s: %v", kktInfo.SerialNumber, err)
} else {
// Логика в saveNewMergedInfo уже выводит сообщение об успехе.
successCount++
}
}
}
log.Printf("--- Обработка файлов завершена. Успешно создано/обновлено: %d файлов. ---", successCount)
return polledDevices // Возвращаем результат
return polledDevices
}
// findSourceWorkstationData ищет в папке /date любой .json файл и извлекает из него
// все данные как `map[string]interface{}`.
// --- ФУНКЦИИ ДЛЯ РАБОТЫ С ФАЙЛАМИ ---
// 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" {
if file.IsDir() || filepath.Ext(file.Name()) != ".json" {
continue
}
filePath := filepath.Join(outputDir, file.Name())
data, err := os.ReadFile(filePath)
if err != nil {
@@ -204,66 +312,126 @@ func findSourceWorkstationData() map[string]interface{} {
continue
}
// Проверяем, что это не файл от нашего ККТ (у него не должно быть поля modelName)
// и что у него есть hostname. Это делает выбор донора более надежным.
// Проверяем наличие ключевых полей
_, hasModelName := content["modelName"]
_, hasHostname := content["hostname"]
if !hasModelName && hasHostname {
log.Printf("Найден файл-донор с данными о рабочей станции: %s", filePath)
return content // Возвращаем все содержимое файла как карту.
// Если у файла нет 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
}
// updateTimestampInFile читает JSON-файл, обновляет в нем поле current_time и перезаписывает его.
func updateTimestampInFile(filePath string) error {
data, err := os.ReadFile(filePath)
// cleanupDateDirectory сканирует рабочую директорию и удаляет файлы,
// имя которых (без расширения) содержит нечисловые символы.
// Это необходимо для очистки временных/донорских файлов перед записью актуальных данных.
func cleanupDateDirectory() {
log.Println("Запуск очистки рабочей директории от временных файлов...")
files, err := os.ReadDir(outputDir)
if err != nil {
return fmt.Errorf("ошибка чтения файла: %w", err)
// Если директория еще не создана, это не ошибка. Просто выходим.
if os.IsNotExist(err) {
log.Printf("Директория '%s' не найдена, очистка не требуется.", outputDir)
return
}
log.Printf("Ошибка чтения директории '%s' при очистке: %v", outputDir, err)
return
}
var content map[string]interface{}
if err := json.Unmarshal(data, &content); err != nil {
return fmt.Errorf("ошибка парсинга JSON: %w", err)
// Регулярное выражение для проверки, что строка состоит только из цифр.
isNumeric := regexp.MustCompile(`^[0-9]+$`).MatchString
deletedCount := 0
for _, file := range files {
if file.IsDir() || filepath.Ext(file.Name()) != ".json" {
continue
}
content["current_time"] = time.Now().Format("2006-01-02 15:04:05")
// Получаем имя файла без расширения .json
baseName := strings.TrimSuffix(file.Name(), filepath.Ext(file.Name()))
updatedData, err := json.MarshalIndent(content, "", " ")
if err != nil {
return fmt.Errorf("ошибка маршалинга JSON: %w", err)
if !isNumeric(baseName) {
filePath := filepath.Join(outputDir, file.Name())
log.Printf("Обнаружен некорректный файл '%s'. Удаляю...", file.Name())
if err := os.Remove(filePath); err != nil {
log.Printf("Не удалось удалить файл '%s': %v", filePath, err)
} else {
log.Printf("Файл '%s' успешно удален.", file.Name())
deletedCount++
}
}
}
return os.WriteFile(filePath, updatedData, 0644)
if deletedCount > 0 {
log.Printf("Очистка завершена. Удалено %d файлов.", deletedCount)
} else {
log.Println("Некорректных файлов для удаления не найдено.")
}
}
// 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)
// Сливаем карты. Ключи из wsData перезапишут любые совпадения в kktMap.
// Шаг 2: Создаем итоговую карту. Начинаем с данных донора, чтобы они были "внизу".
// Мы делаем копию wsData, чтобы не изменять оригинальную карту, которая может быть использована в других итерациях.
finalMap := make(map[string]interface{})
for key, value := range wsData {
kktMap[key] = value
finalMap[key] = value
}
// Удаляем поля, специфичные для ККТ, из данных донора, если они случайно туда попали.
// Это предотвратит запись, например, "serialNumber" от АТОЛ в файл Штриха.
delete(kktMap, "serialNumber")
// Шаг 3: "Накладываем" данные от нашего ККТ поверх.
// Все совпадающие ключи будут перезаписаны значениями от Штриха.
for key, value := range kktMap {
// Пропускаем пустые значения от ККТ, чтобы случайно не затереть
// хорошее значение из донора пустым.
if s, ok := value.(string); ok && s == "" {
continue
}
finalMap[key] = value
}
// Возвращаем серийный номер нашего ККТ, который мы сохранили в структуре kktInfo.
kktMap["serialNumber"] = kktInfo.SerialNumber
// Шаг 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(kktMap, "", " ")
finalJSON, err := json.MarshalIndent(finalMap, "", " ")
if err != nil {
return fmt.Errorf("ошибка маршалинга итогового JSON: %w", err)
}
@@ -276,20 +444,103 @@ func saveNewMergedInfo(kktInfo *shtrih.FiscalInfo, wsData map[string]interface{}
return nil
}
// convertSettingsToConfigs преобразует настройки из файла в формат, понятный библиотеке.
func convertSettingsToConfigs(settings []ConnectionSettings) []shtrih.Config {
var configs []shtrih.Config
baudRateMap := map[string]int32{
"115200": 6, "57600": 5, "38400": 4, "19200": 3, "9600": 2,
// saveEmptyShtrihConfig создает или обновляет connect.json, указывая,
// что устройства "Штрих-М" не были найдены. Функция работает неразрушающим
// образом, сохраняя все остальные данные в файле.
func saveEmptyShtrihConfig() {
log.Printf("Сохраняю конфигурацию с пустым списком устройств Штрих-М в '%s'...", configFileName)
// Используем map[string]interface{} для редактирования JSON.
configMap := make(map[string]interface{})
// Пытаемся прочитать существующий файл, чтобы не затереть другие секции.
data, err := os.ReadFile(configFileName)
if err == nil {
// Если файл есть, парсим его в нашу карту.
if err := json.Unmarshal(data, &configMap); err != nil {
log.Printf("Предупреждение: файл '%s' поврежден (%v). Он будет перезаписан.", configFileName, err)
// В случае ошибки парсинга, начинаем с пустой карты, чтобы исправить файл.
configMap = make(map[string]interface{})
}
} else if !os.IsNotExist(err) {
// Логируем ошибку, если это не "файл не найден".
log.Printf("Предупреждение: не удалось прочитать '%s' (%v). Файл будет создан заново.", configFileName, err)
}
for _, s := range settings {
config := shtrih.Config{
ConnectionType: s.TypeConnect,
Password: 30,
// Устанавливаем или обновляем только ключ 'shtrih'.
configMap["shtrih"] = []ConnectionSettings{}
// Маршалинг и запись обратно в файл.
updatedData, err := json.MarshalIndent(configMap, "", " ")
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 saveConfiguration(polledDevices []PolledDevice) {
// saveConfiguration сохраняет конфигурацию найденных устройств в файл connect.json.
// Функция работает неразрушающим образом, сохраняя все остальные секции файла.
// Преобразует внутренние структуры shtrih.Config в формат ConnectionSettings для JSON.
log.Printf("Сохранение %d найденных конфигураций в файл '%s'...", len(polledDevices), configFileName)
// Используем map[string]interface{} для неразрушающего редактирования JSON.
configMap := make(map[string]interface{})
// Пытаемся прочитать существующий файл, чтобы не затереть другие секции.
data, err := os.ReadFile(configFileName)
if err == nil {
// Если файл есть, парсим его в нашу карту.
if err := json.Unmarshal(data, &configMap); err != nil {
log.Printf("Предупреждение: файл '%s' поврежден (%v). Он будет перезаписан.", configFileName, err)
// В случае ошибки парсинга, начинаем с пустой карты.
configMap = make(map[string]interface{})
}
} else if !os.IsNotExist(err) {
log.Printf("Предупреждение: не удалось прочитать '%s' (%v). Файл будет создан заново.", configFileName, err)
}
// Готовим новый срез с настройками для устройств Штрих-М.
var newShtrihSettings []ConnectionSettings
for _, pd := range polledDevices {
newShtrihSettings = append(newShtrihSettings, convertConfigToSettings(pd.Config))
}
// Обновляем в карте только ключ 'shtrih'. Все остальные ключи остаются нетронутыми.
configMap["shtrih"] = newShtrihSettings
// Маршалинг и запись обратно в файл.
updatedData, err := json.MarshalIndent(configMap, "", " ")
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 {
// convertSettingsToConfigs преобразует настройки подключения из JSON-формата
// в внутренние структуры 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: // COM-порт
case 0:
comNum, err := strconv.Atoi(s.ComPort[3:])
if err != nil {
log.Printf("Некорректное имя COM-порта '%s' в конфигурации, пропуск.", s.ComPort)
@@ -303,7 +554,7 @@ func convertSettingsToConfigs(settings []ConnectionSettings) []shtrih.Config {
config.ComName = s.ComPort
config.ComNumber = int32(comNum)
config.BaudRate = baudRate
case 6: // TCP/IP
case 6:
port, err := strconv.Atoi(s.IPPort)
if err != nil {
log.Printf("Некорректный TCP-порт '%s' для IP '%s', пропуск.", s.IPPort, s.IP)
@@ -320,63 +571,16 @@ func convertSettingsToConfigs(settings []ConnectionSettings) []shtrih.Config {
return configs
}
// saveConfiguration обновляет файл connect.json, записывая в него
// конфигурации успешно найденных и опрошенных устройств.
func saveConfiguration(polledDevices []PolledDevice) {
log.Printf("Сохранение %d найденных конфигураций в файл '%s'...", len(polledDevices), configFileName)
// Шаг 1: Читаем существующий файл или создаем новую структуру.
var configFile ConfigFile
data, err := os.ReadFile(configFileName)
if err == nil {
// Файл есть, парсим его, чтобы не потерять другие секции (например, "atol")
if err := json.Unmarshal(data, &configFile); err != nil {
log.Printf("Предупреждение: файл '%s' поврежден (%v). Он будет перезаписан.", configFileName, err)
configFile = ConfigFile{} // Создаем пустую структуру в случае ошибки
}
}
// Шаг 2: Формируем новый срез настроек для "shtrih".
var newShtrihSettings []ConnectionSettings
for _, pd := range polledDevices {
newShtrihSettings = append(newShtrihSettings, convertConfigToSettings(pd.Config))
}
// Шаг 3: Полностью заменяем секцию "shtrih" новыми данными.
configFile.Shtrih = newShtrihSettings
// Шаг 4: Записываем обновленную структуру обратно в файл.
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)
}
// convertConfigToSettings преобразует внутренний формат shtrih.Config
// в формат ConnectionSettings для записи в connect.json.
func convertConfigToSettings(config shtrih.Config) ConnectionSettings {
// Карта для обратного преобразования индекса скорости в строку
baudRateReverseMap := map[int32]string{
6: "115200", 5: "57600", 4: "38400", 3: "19200", 2: "9600",
6: "115200", 5: "57600", 4: "38400", 3: "19200", 2: "9600", 1: "4800",
}
settings := ConnectionSettings{
TypeConnect: config.ConnectionType,
}
settings := ConnectionSettings{TypeConnect: config.ConnectionType}
switch config.ConnectionType {
case 0: // COM-порт
case 0:
settings.ComPort = config.ComName
settings.ComBaudrate = baudRateReverseMap[config.BaudRate]
case 6: // TCP/IP
case 6:
settings.IP = config.IPAddress
settings.IPPort = strconv.Itoa(int(config.TCPPort))
}

187
main_test.go Normal file
View File

@@ -0,0 +1,187 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"shtrih-kkt/pkg/shtrih"
"testing"
)
// loadCanonicalKKTData загружает эталонные данные ККТ из файла для тестов.
// Путь к файлу указывается относительно корня проекта.
func loadCanonicalKKTData(t *testing.T, path string) *shtrih.FiscalInfo {
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("Критическая ошибка: не удалось прочитать канонический файл данных '%s': %v", path, err)
}
var info shtrih.FiscalInfo
if err := json.Unmarshal(data, &info); err != nil {
t.Fatalf("Критическая ошибка: не удалось распарсить JSON из канонического файла '%s': %v", path, err)
}
return &info
}
// TestProcessDevices_NewFileFromDonor проверяет сценарий, когда для ККТ еще нет
// файла, но есть файл-донор с данными о рабочей станции.
func TestProcessDevices_NewFileFromDonor(t *testing.T) {
// --- Arrange (Подготовка) ---
// 1. Создаем временную директорию для теста, чтобы не засорять реальную папку `date`.
tempDir := t.TempDir()
originalOutputDir := outputDir
outputDir = tempDir
defer func() { outputDir = originalOutputDir }()
// 2. Загружаем канонические данные для мок-драйвера из файла.
// Этот файл должен быть создан заранее и помещен в pkg/shtrih/testdata/
mockKKTData := loadCanonicalKKTData(t, "pkg/shtrih/testdata/canonical_kkt_data.json")
// 3. Создаем файл-донор с уникальными данными о рабочей станции во временной папке.
donorData := map[string]interface{}{
"hostname": "DONOR-PC",
"teamviewer_id": "999888777",
"vc": "3.0-donor-test",
}
donorBytes, _ := json.Marshal(donorData)
donorFilePath := filepath.Join(tempDir, "donor.json")
if err := os.WriteFile(donorFilePath, donorBytes, 0644); err != nil {
t.Fatalf("Не удалось создать тестовый файл-донор: %v", err)
}
// 4. Создаем "фабрику", которая вернет мок-драйвер с нашими каноническими данными.
mockDriverFactory := func(config shtrih.Config) shtrih.Driver {
return shtrih.NewMockDriver(mockKKTData, nil, nil)
}
// 5. Готовим входные данные для processDevices.
testConfigs := []shtrih.Config{{ConnectionType: 6, IPAddress: "127.0.0.1"}}
// --- Act (Действие) ---
processDevices(testConfigs, mockDriverFactory)
// --- Assert (Проверка) ---
// 1. Проверяем, что был создан правильный JSON-файл.
expectedFileName := mockKKTData.SerialNumber + ".json"
resultFilePath := filepath.Join(tempDir, expectedFileName)
if _, err := os.Stat(resultFilePath); os.IsNotExist(err) {
t.Fatalf("Ожидалось, что будет создан файл '%s', но он не найден.", resultFilePath)
}
// 2. Читаем созданный файл и проверяем его содержимое.
resultBytes, err := os.ReadFile(resultFilePath)
if err != nil {
t.Fatalf("Не удалось прочитать результирующий файл '%s': %v", resultFilePath, err)
}
var resultMap map[string]interface{}
if err := json.Unmarshal(resultBytes, &resultMap); err != nil {
t.Fatalf("Не удалось распарсить JSON из результирующего файла: %v", err)
}
// 3. Проверяем, что данные из ККТ и донора корректно слились.
// Проверка поля от ККТ.
if resultMap["modelName"] != mockKKTData.ModelName {
t.Errorf("Поле 'modelName' неверно. Ожидалось '%s', получено '%v'", mockKKTData.ModelName, resultMap["modelName"])
}
// Проверка полей от донора.
if resultMap["hostname"] != donorData["hostname"] {
t.Errorf("Поле 'hostname' из донора не было добавлено. Ожидалось '%s', получено '%v'", donorData["hostname"], resultMap["hostname"])
}
if resultMap["vc"] != donorData["vc"] {
t.Errorf("Поле 'vc' из донора не было добавлено. Ожидалось '%s', получено '%v'", donorData["vc"], resultMap["vc"])
}
// Проверка автоматически сгенерированных полей времени.
if _, ok := resultMap["current_time"]; !ok {
t.Error("Отсутствует обязательное поле 'current_time'.")
}
if _, ok := resultMap["v_time"]; !ok {
t.Error("Отсутствует обязательное поле 'v_time'.")
}
}
// TestFindSourceWorkstationData_FileHandling проверяет непосредственно логику
// поиска и чтения донор-файла в смоделированной файловой структуре.
func TestFindSourceWorkstationData_FileHandling(t *testing.T) {
// --- Сценарий 1: В папке /date есть правильный донор-файл ---
t.Run("when valid donor file exists", func(t *testing.T) {
// Arrange: Готовим файловую систему
tempDir := t.TempDir()
originalOutputDir := outputDir
outputDir = tempDir // Указываем, что наша папка "date" находится во временной директории
defer func() { outputDir = originalOutputDir }()
// Создаем донор-файл с уникальными данными для проверки
donorData := map[string]interface{}{
"hostname": "REAL-DONOR-PC",
"vc": "v_from_real_file",
}
donorBytes, _ := json.Marshal(donorData)
if err := os.WriteFile(filepath.Join(tempDir, "donor_to_find.json"), donorBytes, 0644); err != nil {
t.Fatalf("Не удалось создать тестовый файл-донор: %v", err)
}
// Создаем "файл-ловушку" от другого ККТ, который должен быть проигнорирован
kktTrapData := map[string]interface{}{
"modelName": "SOME-OTHER-KKT",
"serialNumber": "TRAP000001",
"hostname": "FAKE-HOSTNAME",
}
trapBytes, _ := json.Marshal(kktTrapData)
if err := os.WriteFile(filepath.Join(tempDir, "000001.json"), trapBytes, 0644); err != nil {
t.Fatalf("Не удалось создать файл-ловушку: %v", err)
}
// Act: Вызываем тестируемую функцию
resultMap := findSourceWorkstationData()
// Assert: Проверяем результат
if resultMap == nil {
t.Fatal("findSourceWorkstationData() вернула nil, хотя ожидались данные из донора.")
}
if resultMap["hostname"] != "REAL-DONOR-PC" {
t.Errorf("Hostname из донора прочитан неверно. Ожидалось 'REAL-DONOR-PC', получено '%v'", resultMap["hostname"])
}
if resultMap["vc"] != "v_from_real_file" {
t.Errorf("Поле 'vc' из донора прочитано неверно. Ожидалось 'v_from_real_file', получено '%v'", resultMap["vc"])
}
})
// --- Сценарий 2: В папке /date нет подходящих файлов ---
t.Run("when no valid donor file exists", func(t *testing.T) {
// Arrange: Готовим файловую систему
tempDir := t.TempDir()
originalOutputDir := outputDir
outputDir = tempDir
defer func() { outputDir = originalOutputDir }()
// Создаем только "файл-ловушку", который не должен считаться донором
kktTrapData := map[string]interface{}{
"modelName": "SOME-OTHER-KKT",
"serialNumber": "TRAP000001",
"hostname": "FAKE-HOSTNAME",
}
trapBytes, _ := json.Marshal(kktTrapData)
if err := os.WriteFile(filepath.Join(tempDir, "000001.json"), trapBytes, 0644); err != nil {
t.Fatalf("Не удалось создать файл-ловушку: %v", err)
}
// Создаем текстовый файл, который тоже должен быть проигнорирован
if err := os.WriteFile(filepath.Join(tempDir, "readme.txt"), []byte("info"), 0644); err != nil {
t.Fatalf("Не удалось создать текстовый файл: %v", err)
}
// Act: Вызываем тестируемую функцию
resultMap := findSourceWorkstationData()
// Assert: Проверяем, что функция ничего не нашла
if resultMap != nil {
t.Fatal("findSourceWorkstationData() вернула данные, хотя в папке не было валидных доноров.")
}
})
}

View File

@@ -41,6 +41,7 @@ type FiscalInfo struct {
SerialNumber string `json:"serialNumber"` // Заводской номер ККТ
RNM string `json:"RNM"` // Регистрационный номер машины (РНМ)
OrganizationName string `json:"organizationName"` // Наименование организации пользователя
Address string `json:"address"` // Адрес установки ККТ
Inn string `json:"INN"` // ИНН пользователя
FnSerial string `json:"fn_serial"` // Серийный номер фискального накопителя
RegistrationDate string `json:"datetime_reg"` // Дата и время регистрации ККТ
@@ -52,7 +53,7 @@ type FiscalInfo struct {
InstalledDriver string `json:"installed_driver"` // Версия установленного COM-драйвера
AttributeExcise bool `json:"attribute_excise"` // Признак торговли подакцизными товарами
AttributeMarked bool `json:"attribute_marked"` // Признак торговли маркированными товарами
LicensesRawHex string `json:"licenses,omitempty"` // Строка с лицензиями в HEX-формате
SubscriptionInfo string `json:"licenses,omitempty"` // Строка с лицензиями в расшифрованном виде
}
// Driver определяет основной интерфейс для работы с ККТ.
@@ -114,10 +115,11 @@ func (d *comDriver) Connect() error {
// Установка свойств подключения в зависимости от типа.
oleutil.PutProperty(d.dispatch, "ConnectionType", d.config.ConnectionType)
oleutil.PutProperty(d.dispatch, "Password", d.config.Password)
if d.config.ConnectionType == 0 { // COM-порт
switch d.config.ConnectionType {
case 0: // COM-порт
oleutil.PutProperty(d.dispatch, "ComNumber", d.config.ComNumber)
oleutil.PutProperty(d.dispatch, "BaudRate", d.config.BaudRate)
} else if d.config.ConnectionType == 6 { // TCP/IP
case 6: // TCP/IP
oleutil.PutProperty(d.dispatch, "IPAddress", d.config.IPAddress)
oleutil.PutProperty(d.dispatch, "TCPPort", d.config.TCPPort)
oleutil.PutProperty(d.dispatch, "UseIPAddress", true)
@@ -203,11 +205,20 @@ func (d *comDriver) getBaseDeviceInfo(info *FiscalInfo) error {
}
}
// Получаем и расшифровываем информацию о лицензии
if _, err := oleutil.CallMethod(d.dispatch, "ReadFeatureLicenses"); err == nil {
if errCheck := d.checkError(); errCheck == nil {
info.LicensesRawHex, _ = d.getPropertyString("License")
hexLicense, _ := d.getPropertyString("License")
info.SubscriptionInfo = decodeLicense(hexLicense)
if info.SubscriptionInfo != "" {
log.Printf("Информация о лицензии успешно расшифрована: %s", info.SubscriptionInfo)
} else if hexLicense != "" {
log.Printf("Не удалось распознать формат полученной лицензии: %s", hexLicense)
}
}
} else {
log.Printf("Предупреждение: команда ReadFeatureLicenses не выполнена, информация о лицензиях недоступна.")
}
return nil
}
@@ -289,6 +300,10 @@ func (d *comDriver) getInfoFromTables(info *FiscalInfo) error {
if err == nil {
info.OfdName = strings.TrimSpace(ofdName)
}
address, err := d.readTableField(18, 1, 9)
if err == nil {
info.Address = strings.TrimSpace(address)
}
// Версия ФФД хранится в виде кода: 2 - "1.05", 4 - "1.2"
ffdValueStr, err := d.readTableField(17, 1, 17)

178
pkg/shtrih/driver_test.go Normal file
View File

@@ -0,0 +1,178 @@
// Тесты для пакета shtrih
package shtrih
import (
"encoding/json"
"fmt"
"os"
"reflect"
"testing"
)
// getSampleFiscalInfo создает пример структуры FiscalInfo для использования в тестах.
func getSampleFiscalInfo() *FiscalInfo {
return &FiscalInfo{
ModelName: "ШТРИХ-М-01Ф",
SerialNumber: "0012345678901234",
RNM: "0009876543210987",
OrganizationName: "ООО Ромашка",
Inn: "7701234567",
FnSerial: "9960440300112233",
FfdVersion: "120",
}
}
// TestMockDriver_SuccessfulPath проверяет стандартный успешный сценарий:
// подключение -> получение данных -> отключение.
func TestMockDriver_SuccessfulPath(t *testing.T) {
// Arrange: Готовим данные и создаем мок-драйвер.
sampleData := getSampleFiscalInfo()
driver := NewMockDriver(sampleData, nil, nil)
// Act 1: Подключаемся.
err := driver.Connect()
if err != nil {
t.Fatalf("Connect() вернул неожиданную ошибку: %v", err)
}
// Act 2: Получаем фискальную информацию.
info, err := driver.GetFiscalInfo()
if err != nil {
t.Fatalf("GetFiscalInfo() вернул неожиданную ошибку: %v", err)
}
// Act 3: Отключаемся.
err = driver.Disconnect()
if err != nil {
t.Fatalf("Disconnect() вернул неожиданную ошибку: %v", err)
}
// Assert: Проверяем, что полученные данные соответствуют ожидаемым.
if !reflect.DeepEqual(info, sampleData) {
t.Errorf("Полученные данные не совпадают с мок-данными.\nПолучено: %+v\nОжидалось: %+v", info, sampleData)
}
// Assert: Проверяем, что все методы были вызваны.
// Для этого нам нужно преобразовать интерфейс обратно в конкретный тип мок-драйвера.
mock, ok := driver.(*mockDriver)
if !ok {
t.Fatal("Не удалось преобразовать драйвер в *mockDriver для проверки вызовов.")
}
if !mock.ConnectCalled {
t.Error("Ожидалось, что Connect() будет вызван, но этого не произошло.")
}
if !mock.GetFiscalInfoCalled {
t.Error("Ожидалось, что GetFiscalInfo() будет вызван, но этого не произошло.")
}
if !mock.DisconnectCalled {
t.Error("Ожидалось, что Disconnect() будет вызван, но этого не произошло.")
}
}
// TestMockDriver_ConnectError проверяет, что драйвер корректно обрабатывает
// ошибку, возвращаемую при подключении.
func TestMockDriver_ConnectError(t *testing.T) {
// Arrange: Создаем симулируемую ошибку.
simulatedError := fmt.Errorf("порт COM5 занят")
driver := NewMockDriver(nil, simulatedError, nil)
// Act: Пытаемся подключиться.
err := driver.Connect()
// Assert: Проверяем, что была возвращена именно наша ошибка.
if err == nil {
t.Fatal("Connect() не вернул ошибку, хотя ожидалось.")
}
if err != simulatedError {
t.Errorf("Connect() вернул неверную ошибку. Получено: %v, Ожидалось: %v", err, simulatedError)
}
}
// TestMockDriver_GetInfoWhileDisconnected проверяет, что попытка получить
// данные без предварительного подключения вернет ошибку.
func TestMockDriver_GetInfoWhileDisconnected(t *testing.T) {
// Arrange: Создаем стандартный мок-драйвер.
driver := NewMockDriver(getSampleFiscalInfo(), nil, nil)
// Act: Сразу пытаемся получить данные.
_, err := driver.GetFiscalInfo()
// Assert: Проверяем, что получили ошибку.
if err == nil {
t.Fatal("GetFiscalInfo() не вернул ошибку при вызове без подключения.")
}
}
// TestMockDriver_GetInfoError проверяет, что драйвер корректно обрабатывает
// ошибку, возвращаемую при получении данных.
func TestMockDriver_GetInfoError(t *testing.T) {
// Arrange: Создаем симулируемую ошибку.
simulatedError := fmt.Errorf("ошибка чтения ФН")
driver := NewMockDriver(nil, nil, simulatedError)
// Act
err := driver.Connect()
if err != nil {
t.Fatalf("Connect() неожиданно вернул ошибку: %v", err)
}
info, err := driver.GetFiscalInfo()
// Assert
if err == nil {
t.Fatal("GetFiscalInfo() не вернул ошибку, хотя ожидалось.")
}
if err != simulatedError {
t.Errorf("GetFiscalInfo() вернул неверную ошибку. Получено: %v, Ожидалось: %v", err, simulatedError)
}
if info != nil {
t.Error("GetFiscalInfo() вернул данные вместе с ошибкой, хотя должен был вернуть nil.")
}
}
// loadMockDataFromFile читает "канонический" JSON-файл и преобразует его
// в структуру FiscalInfo для использования в мок-драйвере.
func loadMockDataFromFile(filePath string) (*FiscalInfo, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("не удалось прочитать файл с мок-данными '%s': %w", filePath, err)
}
var info FiscalInfo
if err := json.Unmarshal(data, &info); err != nil {
return nil, fmt.Errorf("не удалось распарсить JSON из файла '%s': %w", filePath, err)
}
return &info, nil
}
// TestMockDriver_WithDataFromFile проверяет полный цикл работы мок-драйвера,
// используя для этого данные, загруженные из реального JSON-файла.
func TestMockDriver_WithDataFromFile(t *testing.T) {
// Arrange 1: Загружаем данные из нашего "золотого" файла.
// Путь указывается относительно корня пакета.
canonicalData, err := loadMockDataFromFile("testdata/canonical_kkt_data.json")
if err != nil {
// Если файл не найден или поврежден, тест должен провалиться.
t.Fatalf("Подготовка теста провалилась: не удалось загрузить мок-данные: %v", err)
}
// Arrange 2: Создаем мок-драйвер, передавая ему загруженные данные.
driver := NewMockDriver(canonicalData, nil, nil)
// Act
if err := driver.Connect(); err != nil {
t.Fatalf("Connect() вернул неожиданную ошибку: %v", err)
}
defer driver.Disconnect()
info, err := driver.GetFiscalInfo()
if err != nil {
t.Fatalf("GetFiscalInfo() вернул неожиданную ошибку: %v", err)
}
// Assert: Сверяем, что драйвер вернул в точности те данные, что были в файле.
if !reflect.DeepEqual(info, canonicalData) {
t.Errorf("Данные, возвращенные мок-драйвером, не совпадают с данными из файла.\nПолучено: %+v\nОжидалось: %+v", info, canonicalData)
}
}

95
pkg/shtrih/license.go Normal file
View File

@@ -0,0 +1,95 @@
// Файл: pkg/shtrih/license.go
package shtrih
import (
"fmt"
"sort"
"strings"
)
// licenseInfo хранит информацию о квартале и годе для конкретного суффикса лицензии.
type licenseInfo struct {
quarter string
year int
}
// licenseMap сопоставляет суффиксы HEX-лицензий с датой окончания подписки.
// Ключи должны быть в верхнем регистре.
var licenseMap = map[string]licenseInfo{
// 2027
"FFFFFFFF": {"4", 2027},
"FFFFFF7F": {"3", 2027},
"FFFFFF3F": {"2", 2027},
"FFFFFF1F": {"1", 2027},
// 2026
"FFFFFF0F": {"4", 2026},
"FFFFFF07": {"3", 2026},
"FFFFFF03": {"2", 2026},
"FFFFFF01": {"1", 2026},
// 2025
"FFFFFF00": {"4", 2025},
"FFFF7F00": {"3", 2025},
"FFFF3F00": {"2", 2025},
"FFFF1F00": {"1", 2025},
// 2024
"FFFF0F00": {"4", 2024},
"FFFF0700": {"3", 2024},
"FFFF0300": {"2", 2024},
"FFFF0100": {"1", 2024},
// 2023
"FFFF": {"4", 2023},
"FF7F": {"3", 2023},
"FF3F": {"2", 2023},
"FF1F": {"1", 2023},
// 2022
"FF0F": {"4", 2022},
"FF07": {"3", 2022},
"FF03": {"2", 2022},
"FF01": {"1", 2022},
// 2021
"FF00": {"4", 2021},
"7F00": {"3", 2021},
"3F00": {"2", 2021},
"1F00": {"1", 2021},
// 2020
"0F00": {"4", 2020},
"0700": {"3", 2020},
"0300": {"2", 2020},
"0100": {"1", 2020},
}
// sortedLicenseKeys хранит ключи из licenseMap, отсортированные по убыванию длины.
// Это необходимо, чтобы длинные суффиксы ("FFFFFFFF") проверялись раньше коротких ("FFFF").
var sortedLicenseKeys []string
func init() {
keys := make([]string, 0, len(licenseMap))
for k := range licenseMap {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
return len(keys[i]) > len(keys[j])
})
sortedLicenseKeys = keys
}
// decodeLicense расшифровывает HEX-строку лицензии в человекочитаемый формат.
// Она ищет наиболее длинное известное вхождение кода лицензии внутри всей строки.
// Если лицензия не распознана, возвращает пустую строку.
func decodeLicense(hex string) string {
if hex == "" {
return ""
}
upperHex := strings.ToUpper(hex)
// Итерируемся по ключам, отсортированным от самого длинного к самому короткому.
for _, licenseCode := range sortedLicenseKeys {
// Проверяем, содержится ли код лицензии ГДЕ-ЛИБО в большой HEX-строке.
if strings.Contains(upperHex, licenseCode) {
info := licenseMap[licenseCode]
return fmt.Sprintf("Подписка до %s квартала %d года", info.quarter, info.year)
}
}
return ""
}

View File

@@ -1,33 +1,85 @@
// Package shtrih (продолжение)
package shtrih
// mockDriver — это имитация драйвера для тестирования.
import (
"fmt"
"log"
)
// mockDriver представляет собой имитацию реального драйвера для целей тестирования.
// Он реализует интерфейс Driver.
type mockDriver struct {
FiscalInfoToReturn *FiscalInfo
ErrorToReturn error
// MockData - это структура с фискальными данными, которую вернет GetFiscalInfo.
MockData *FiscalInfo
// ConnectErr - ошибка, которую вернет метод Connect, если она задана.
ConnectErr error
// GetFiscalInfoErr - ошибка, которую вернет метод GetFiscalInfo, если она задана.
GetFiscalInfoErr error
// Внутренние флаги для проверки вызовов в тестах.
connected bool
ConnectCalled bool
DisconnectCalled bool
GetFiscalInfoCalled bool
}
// NewMock создает новый мок-драйвер.
func NewMock(info *FiscalInfo, err error) Driver {
// NewMockDriver является конструктором для создания нового мок-драйвера.
// Позволяет заранее определить, какие данные и ошибки будут возвращаться.
func NewMockDriver(data *FiscalInfo, connectErr, getInfoErr error) Driver {
return &mockDriver{
FiscalInfoToReturn: info,
ErrorToReturn: err,
MockData: data,
ConnectErr: connectErr,
GetFiscalInfoErr: getInfoErr,
}
}
// Connect имитирует подключение к ККТ.
func (m *mockDriver) Connect() error {
if m.ErrorToReturn != nil {
return m.ErrorToReturn
m.ConnectCalled = true
log.Println("Mock Driver: Connect() вызван.")
// Если была задана ошибка подключения, возвращаем ее.
if m.ConnectErr != nil {
return m.ConnectErr
}
// Если уже "подключены", ничего не делаем.
if m.connected {
return nil
}
m.connected = true
return nil
}
// Disconnect имитирует отключение от ККТ.
func (m *mockDriver) Disconnect() error {
m.DisconnectCalled = true
log.Println("Mock Driver: Disconnect() вызван.")
// Если не были "подключены", ничего не делаем.
if !m.connected {
return nil
}
m.connected = false
return nil
}
// GetFiscalInfo имитирует получение фискальных данных.
func (m *mockDriver) GetFiscalInfo() (*FiscalInfo, error) {
if m.ErrorToReturn != nil {
return nil, m.ErrorToReturn
m.GetFiscalInfoCalled = true
log.Println("Mock Driver: GetFiscalInfo() вызван.")
// Проверяем, было ли установлено "соединение".
if !m.connected {
return nil, fmt.Errorf("мок-драйвер: не подключен")
}
return m.FiscalInfoToReturn, nil
// Если была задана ошибка получения данных, возвращаем ее.
if m.GetFiscalInfoErr != nil {
return nil, m.GetFiscalInfoErr
}
// Если не были предоставлены мок-данные, возвращаем ошибку.
if m.MockData == nil {
return nil, fmt.Errorf("мок-драйвер: данные для имитации не предоставлены")
}
return m.MockData, nil
}

41
updater.go Normal file
View File

@@ -0,0 +1,41 @@
// Файл: updater.go
package main
import (
"log"
// В будущем здесь будет импорт библиотеки для автообновления, например:
// "github.com/minio/selfupdate"
)
// checkForUpdates — это функция-заглушка для механизма автообновления.
// Она будет выполняться в фоне, чтобы не блокировать основную работу утилиты.
func checkForUpdates(currentVersion string, updateURL string) {
if updateURL == "" {
// URL обновлений не указан в service.json, ничего не делаем.
return
}
if currentVersion == "0.0.0-dev" {
log.Println("Проверка обновлений пропущена: утилита запущена в режиме разработки.")
return
}
log.Printf("Текущая версия: %s. Проверка обновлений по адресу: %s", currentVersion, updateURL)
// ===================================================================================
// ЗДЕСЬ БУДЕТ РЕАЛИЗОВАНА ЛОГИКА АВТООБНОВЛЕНИЯ
// -----------------------------------------------------------------------------------
// Примерный псевдокод с использованием библиотеки selfupdate:
//
// resp, err := http.Get(updateURL)
// if err != nil { ... }
// defer resp.Body.Close()
//
// err := selfupdate.Apply(resp.Body, selfupdate.Options{})
// if err != nil {
// // Обработка ошибок, возможно откат
// }
// log.Println("Приложение успешно обновлено!")
// ===================================================================================
log.Println("Функционал автообновления пока не реализован.")
}