Compare commits
6 Commits
6fab3c0a0d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ac80c029f | |||
| e9fa4f30e1 | |||
| a02209c698 | |||
| 6c3950953e | |||
| 6c95d944a1 | |||
| 173d4c670c |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,2 +1,4 @@
|
||||
*.exe
|
||||
*.json
|
||||
*.json
|
||||
*.log
|
||||
*.zip
|
||||
193
README.md
Normal file
193
README.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# Go-библиотека и утилита для ККТ "Штрих-М" (shtrih-kkt)
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
Go-библиотека и консольная утилита для взаимодействия с фискальными регистраторами (ККТ) "Штрих-М" через официальный 32-битный COM-драйвер. Проект разработан с фокусом на безопасный сбор данных в режиме "только чтение" и предоставляет гибкие режимы работы для различных сценариев использования.
|
||||
|
||||
**Важнейшее ограничение:** Приложения, использующие эту библиотеку, **должны быть скомпилированы для 32-битной архитектуры (`GOARCH=386`)** из-за зависимости от 32-битного COM-драйвера.
|
||||
|
||||
## Ключевые возможности
|
||||
|
||||
* **Надежная обертка над COM-драйвером:** Предоставляет безопасный и удобный Go-интерфейс для драйвера "Штрих-М".
|
||||
* **Комплексный сбор данных:** Агрегирует полную информацию о ККТ, включая регистрационные данные, статус ФН, версии ПО, лицензии и атрибуты торговли.
|
||||
* **Умный автопоиск устройств:**
|
||||
* **COM-порты:** Автоматически сканирует все системные COM-порты на двух самых распространенных скоростях (`115200` и `4800`), предотвращая зависания на "портах-призраках".
|
||||
* **TCP/IP (RNDIS):** Cканирует стандартные для 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
3
go.mod
@@ -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
10
go.sum
@@ -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=
|
||||
|
||||
536
main.go
536
main.go
@@ -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
|
||||
configFileName = "connect.json"
|
||||
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 []ConnectionSettings `json:"shtrih"`
|
||||
Atol []interface{} `json:"atol"`
|
||||
// Если ключ "shtrih" в JSON отсутствует, это поле будет nil.
|
||||
// Если ключ есть, но массив пуст (shtrih: []), поле будет пустым срезом.
|
||||
Shtrih []ConnectionSettings `json:"shtrih"`
|
||||
}
|
||||
|
||||
// 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,141 +236,202 @@ 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++
|
||||
}
|
||||
// Определяем, какие данные о рабочей станции использовать.
|
||||
var wsDataToUse map[string]interface{}
|
||||
if sourceWSDataMap != nil {
|
||||
log.Printf("Готовлю данные для ККТ %s, используя информацию из файла-донора.", kktInfo.SerialNumber)
|
||||
wsDataToUse = sourceWSDataMap
|
||||
} 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}
|
||||
}
|
||||
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)
|
||||
} else {
|
||||
successCount++
|
||||
}
|
||||
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 {
|
||||
// Логика в 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" {
|
||||
filePath := filepath.Join(outputDir, file.Name())
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
log.Printf("Предупреждение: не удалось прочитать файл-донор %s: %v", filePath, err)
|
||||
continue
|
||||
}
|
||||
if file.IsDir() || filepath.Ext(file.Name()) != ".json" {
|
||||
continue
|
||||
}
|
||||
|
||||
var content map[string]interface{}
|
||||
if err := json.Unmarshal(data, &content); err != nil {
|
||||
log.Printf("Предупреждение: не удалось распарсить JSON из файла-донора %s: %v", filePath, err)
|
||||
continue
|
||||
}
|
||||
filePath := filepath.Join(outputDir, file.Name())
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
log.Printf("Предупреждение: не удалось прочитать файл-донор %s: %v", filePath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Проверяем, что это не файл от нашего ККТ (у него не должно быть поля modelName)
|
||||
// и что у него есть hostname. Это делает выбор донора более надежным.
|
||||
_, hasModelName := content["modelName"]
|
||||
_, hasHostname := content["hostname"]
|
||||
if !hasModelName && hasHostname {
|
||||
log.Printf("Найден файл-донор с данными о рабочей станции: %s", filePath)
|
||||
return content // Возвращаем все содержимое файла как карту.
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Получаем имя файла без расширения .json
|
||||
baseName := strings.TrimSuffix(file.Name(), filepath.Ext(file.Name()))
|
||||
|
||||
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++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
content["current_time"] = time.Now().Format("2006-01-02 15:04:05")
|
||||
|
||||
updatedData, err := json.MarshalIndent(content, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("ошибка маршалинга JSON: %w", err)
|
||||
if deletedCount > 0 {
|
||||
log.Printf("Очистка завершена. Удалено %d файлов.", deletedCount)
|
||||
} else {
|
||||
log.Println("Некорректных файлов для удаления не найдено.")
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// Сливаем карты. Ключи из wsData перезапишут любые совпадения в kktMap.
|
||||
// Шаг 2: Создаем итоговую карту. Начинаем с данных донора, чтобы они были "внизу".
|
||||
// Мы делаем копию wsData, чтобы не изменять оригинальную карту, которая может быть использована в других итерациях.
|
||||
finalMap := make(map[string]interface{})
|
||||
for key, value := range wsData {
|
||||
kktMap[key] = value
|
||||
finalMap[key] = value
|
||||
}
|
||||
|
||||
// Удаляем поля, специфичные для ККТ, из данных донора, если они случайно туда попали.
|
||||
// Это предотвратит запись, например, "serialNumber" от АТОЛ в файл Штриха.
|
||||
delete(kktMap, "serialNumber")
|
||||
|
||||
// Возвращаем серийный номер нашего ККТ, который мы сохранили в структуре kktInfo.
|
||||
kktMap["serialNumber"] = kktInfo.SerialNumber
|
||||
// Шаг 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(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,65 +571,18 @@ 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))
|
||||
}
|
||||
return settings
|
||||
}
|
||||
}
|
||||
|
||||
187
main_test.go
Normal file
187
main_test.go
Normal 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() вернула данные, хотя в папке не было валидных доноров.")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -37,22 +37,23 @@ type Config struct {
|
||||
|
||||
// FiscalInfo содержит агрегированную информацию о фискальном регистраторе.
|
||||
type FiscalInfo struct {
|
||||
ModelName string `json:"modelName"` // Наименование модели ККТ
|
||||
SerialNumber string `json:"serialNumber"` // Заводской номер ККТ
|
||||
RNM string `json:"RNM"` // Регистрационный номер машины (РНМ)
|
||||
OrganizationName string `json:"organizationName"` // Наименование организации пользователя
|
||||
Inn string `json:"INN"` // ИНН пользователя
|
||||
FnSerial string `json:"fn_serial"` // Серийный номер фискального накопителя
|
||||
RegistrationDate string `json:"datetime_reg"` // Дата и время регистрации ККТ
|
||||
FnEndDate string `json:"dateTime_end"` // Дата окончания срока действия ФН
|
||||
OfdName string `json:"ofdName"` // Наименование ОФД
|
||||
SoftwareDate string `json:"bootVersion"` // Версия (дата) прошивки ККТ
|
||||
FfdVersion string `json:"ffdVersion"` // Версия ФФД
|
||||
FnExecution string `json:"fnExecution"` // Исполнение ФН
|
||||
InstalledDriver string `json:"installed_driver"` // Версия установленного COM-драйвера
|
||||
AttributeExcise bool `json:"attribute_excise"` // Признак торговли подакцизными товарами
|
||||
AttributeMarked bool `json:"attribute_marked"` // Признак торговли маркированными товарами
|
||||
LicensesRawHex string `json:"licenses,omitempty"` // Строка с лицензиями в HEX-формате
|
||||
ModelName string `json:"modelName"` // Наименование модели ККТ
|
||||
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"` // Дата и время регистрации ККТ
|
||||
FnEndDate string `json:"dateTime_end"` // Дата окончания срока действия ФН
|
||||
OfdName string `json:"ofdName"` // Наименование ОФД
|
||||
SoftwareDate string `json:"bootVersion"` // Версия (дата) прошивки ККТ
|
||||
FfdVersion string `json:"ffdVersion"` // Версия ФФД
|
||||
FnExecution string `json:"fnExecution"` // Исполнение ФН
|
||||
InstalledDriver string `json:"installed_driver"` // Версия установленного COM-драйвера
|
||||
AttributeExcise bool `json:"attribute_excise"` // Признак торговли подакцизными товарами
|
||||
AttributeMarked bool `json:"attribute_marked"` // Признак торговли маркированными товарами
|
||||
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,10 +205,19 @@ 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
|
||||
}
|
||||
@@ -238,7 +249,7 @@ func (d *comDriver) getFiscalizationInfo(info *FiscalInfo) error {
|
||||
|
||||
workMode, _ := d.getPropertyInt32("WorkMode")
|
||||
workModeEx, _ := d.getPropertyInt32("WorkModeEx")
|
||||
info.AttributeMarked = (workMode & 0x10) != 0 // Бит 4 - признак торговли маркированными товарами
|
||||
info.AttributeMarked = (workMode & 0x10) != 0 // Бит 4 - признак торговли маркированными товарами
|
||||
info.AttributeExcise = (workModeEx & 0x01) != 0 // Бит 0 - признак торговли подакцизными товарами
|
||||
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)
|
||||
@@ -564,4 +579,4 @@ func checkIP(ip string, port int32, timeout time.Duration, foundChan chan<- Conf
|
||||
foundChan <- config
|
||||
driver.Disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
178
pkg/shtrih/driver_test.go
Normal file
178
pkg/shtrih/driver_test.go
Normal 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
95
pkg/shtrih/license.go
Normal 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 ""
|
||||
}
|
||||
@@ -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
41
updater.go
Normal 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("Функционал автообновления пока не реализован.")
|
||||
}
|
||||
Reference in New Issue
Block a user