From af852b9ed624b242e872dece11e6062253fcfb73 Mon Sep 17 00:00:00 2001 From: SERTY Date: Wed, 20 Aug 2025 08:21:24 +0300 Subject: [PATCH] 0.1.0-prod --- .gitignore | 2 + go.mod | 13 + go.sum | 17 ++ main.go | 384 +++++++++++++++++++++++++ pkg/shtrih/driver.go | 574 ++++++++++++++++++++++++++++++++++++++ pkg/shtrih/mock_driver.go | 33 +++ 6 files changed, 1023 insertions(+) create mode 100644 .gitignore create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 pkg/shtrih/driver.go create mode 100644 pkg/shtrih/mock_driver.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0e362af --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.exe +*.json \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9a689c9 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module shtrih-kkt + +go 1.23.4 + +require ( + github.com/go-ole/go-ole v1.3.0 + go.bug.st/serial v1.6.4 +) + +require ( + github.com/creack/goselect v0.1.2 // indirect + golang.org/x/sys v0.19.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d531aec --- /dev/null +++ b/go.sum @@ -0,0 +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.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/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= +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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..c5dfd24 --- /dev/null +++ b/main.go @@ -0,0 +1,384 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "strconv" + "time" + + "shtrih-kkt/pkg/shtrih" +) + +const ( + configFileName = "connect.json" + outputDir = "date" + comSearchTimeout = 500 * time.Millisecond + tcpSearchTimeout = 2 * time.Second +) + +// ConfigFile соответствует структуре файла connect.json +type ConfigFile struct { + Timeout int `json:"timeout_to_ip_port"` + Shtrih []ConnectionSettings `json:"shtrih"` + Atol []interface{} `json:"atol"` +} + +// ConnectionSettings описывает один блок настроек подключения для Штрих-М +type ConnectionSettings struct { + TypeConnect int32 `json:"type_connect"` + ComPort string `json:"com_port"` + ComBaudrate string `json:"com_baudrate"` + IP string `json:"ip"` + IPPort string `json:"ip_port"` +} + +// PolledDevice связывает конфигурацию, использованную для подключения, +// с фискальной информацией, полученной от устройства. +type PolledDevice struct { + Config shtrih.Config + Info *shtrih.FiscalInfo +} + +func main() { + log.Println("Запуск приложения для сбора данных с ККТ Штрих-М...") + + configData, err := os.ReadFile(configFileName) + if err != nil { + if os.IsNotExist(err) { + log.Printf("Файл конфигурации '%s' не найден. Запускаю режим автопоиска устройств...", configFileName) + runDiscoveryMode() + } else { + log.Fatalf("Ошибка чтения файла конфигурации '%s': %v", configFileName, err) + } + } else { + log.Printf("Найден файл конфигурации '%s'. Запускаю стационарный режим...", configFileName) + runConfigMode(configData) + } + + log.Println("Работа приложения завершена.") +} + +func runConfigMode(data []byte) { + var configFile ConfigFile + if err := json.Unmarshal(data, &configFile); err != nil { + log.Printf("Ошибка парсинга JSON из '%s': %v. Переключаюсь на режим автопоиска.", configFileName, err) + runDiscoveryMode() + return + } + + if len(configFile.Shtrih) == 0 { + log.Printf("В файле '%s' не найдено настроек для 'shtrih'. Переключаюсь на режим автопоиска.", configFileName) + runDiscoveryMode() + return + } + + log.Printf("Найдено %d конфигураций для Штрих-М. Начинаю опрос...", len(configFile.Shtrih)) + configs := convertSettingsToConfigs(configFile.Shtrih) + if len(configs) == 0 { + log.Println("Не удалось создать ни одной валидной конфигурации из файла. Проверьте данные в connect.json.") + return + } + + processDevices(configs) +} + +func runDiscoveryMode() { + configs, err := shtrih.SearchDevices(comSearchTimeout, tcpSearchTimeout) + if err != nil { + log.Printf("Во время поиска устройств произошла ошибка: %v", err) + } + + if len(configs) == 0 { + log.Println("В ходе сканирования не найдено ни одного устройства Штрих-М.") + return + } + + log.Printf("Найдено %d устройств. Начинаю сбор информации...", len(configs)) + polledDevices := processDevices(configs) + + // Если были успешно опрошены какие-либо устройства, сохраняем их конфигурацию + if len(polledDevices) > 0 { + saveConfiguration(polledDevices) + } +} +// processDevices - основная функция, реализующая "умную" логику обновления и создания файлов. +// Теперь она возвращает срез успешно опрошенных устройств. +func processDevices(configs []shtrih.Config) []PolledDevice { + // Шаг 1: Сначала собираем информацию со всех найденных устройств. + var polledDevices []PolledDevice // Было: var freshKKTData []*shtrih.FiscalInfo + for _, config := range configs { + log.Printf("--- Опрашиваю устройство: %+v ---", config) + driver := shtrih.New(config) + + if err := driver.Connect(); err != nil { + log.Printf("Не удалось подключиться к устройству: %v", err) + continue + } + + info, err := driver.GetFiscalInfo() + driver.Disconnect() // Отключаемся сразу после получения данных + + if err != nil { + log.Printf("Ошибка при получении фискальной информации: %v", err) + continue + } + if info == nil || info.SerialNumber == "" { + log.Println("Получена пустая информация или отсутствует серийный номер, данные проигнорированы.") + continue + } + // Сохраняем и конфигурацию, и результат + polledDevices = append(polledDevices, PolledDevice{Config: config, Info: info}) + } + + if len(polledDevices) == 0 { + log.Println("--- Не удалось собрать данные ни с одного устройства. Завершение. ---") + return nil // Возвращаем nil, если ничего не найдено + } + + log.Printf("--- Всего собрано данных с %d устройств. Начинаю обработку файлов. ---", len(polledDevices)) + + // Шаг 2: Ищем "донора" данных о рабочей станции в папке /date. + sourceWSDataMap := findSourceWorkstationData() + + // Шаг 3: Обрабатываем каждого "свежего" ККТ в соответствии с новой логикой. + var successCount int + for _, pd := range polledDevices { // Итерируемся по новой структуре + kktInfo := pd.Info // Получаем доступ к данным ККТ + fileName := fmt.Sprintf("%s.json", kktInfo.SerialNumber) + filePath := filepath.Join(outputDir, fileName) + + // ... (остальная часть цикла остается без изменений) ... + if _, err := os.Stat(filePath); err == nil { + log.Printf("Файл для ККТ %s уже существует. Обновляю временную метку...", kktInfo.SerialNumber) + if err := updateTimestampInFile(filePath); err != nil { + log.Printf("Не удалось обновить файл %s: %v", filePath, err) + } else { + log.Printf("Файл %s успешно обновлен.", filePath) + successCount++ + } + } else { + var wsDataToUse map[string]interface{} + if sourceWSDataMap != nil { + log.Printf("Создаю новый файл для ККТ %s, используя данные о рабочей станции из файла-донора.", kktInfo.SerialNumber) + wsDataToUse = sourceWSDataMap + } else { + log.Printf("Создаю первичный файл для ККТ %s с базовыми данными о рабочей станции.", kktInfo.SerialNumber) + hostname, _ := os.Hostname() + wsDataToUse = map[string]interface{}{"hostname": hostname} + } + 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("--- Обработка файлов завершена. Успешно создано/обновлено: %d файлов. ---", successCount) + + return polledDevices // Возвращаем результат +} +// findSourceWorkstationData ищет в папке /date любой .json файл и извлекает из него +// все данные как `map[string]interface{}`. +func findSourceWorkstationData() map[string]interface{} { + files, err := os.ReadDir(outputDir) + if err != nil { + return nil + } + + 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 + } + + var content map[string]interface{} + if err := json.Unmarshal(data, &content); err != nil { + log.Printf("Предупреждение: не удалось распарсить JSON из файла-донора %s: %v", filePath, err) + continue + } + + // Проверяем, что это не файл от нашего ККТ (у него не должно быть поля modelName) + // и что у него есть hostname. Это делает выбор донора более надежным. + _, hasModelName := content["modelName"] + _, hasHostname := content["hostname"] + if !hasModelName && hasHostname { + log.Printf("Найден файл-донор с данными о рабочей станции: %s", filePath) + return content // Возвращаем все содержимое файла как карту. + } + } + } + + log.Println("В папке /date не найдено файлов-доноров. Будут использованы базовые данные.") + return nil +} + +// updateTimestampInFile читает JSON-файл, обновляет в нем поле current_time и перезаписывает его. +func updateTimestampInFile(filePath string) error { + data, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("ошибка чтения файла: %w", err) + } + + var content map[string]interface{} + if err := json.Unmarshal(data, &content); err != nil { + return fmt.Errorf("ошибка парсинга JSON: %w", err) + } + + 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) + } + + return os.WriteFile(filePath, updatedData, 0644) +} + +// saveNewMergedInfo объединяет данные ККТ и данные рабочей станции (в виде map) и сохраняет в новый JSON-файл. +func saveNewMergedInfo(kktInfo *shtrih.FiscalInfo, wsData map[string]interface{}, filePath string) error { + var kktMap map[string]interface{} + kktJSON, _ := json.Marshal(kktInfo) + json.Unmarshal(kktJSON, &kktMap) + + // Сливаем карты. Ключи из wsData перезапишут любые совпадения в kktMap. + for key, value := range wsData { + kktMap[key] = value + } + + // Удаляем поля, специфичные для ККТ, из данных донора, если они случайно туда попали. + // Это предотвратит запись, например, "serialNumber" от АТОЛ в файл Штриха. + delete(kktMap, "serialNumber") + + // Возвращаем серийный номер нашего ККТ, который мы сохранили в структуре kktInfo. + kktMap["serialNumber"] = kktInfo.SerialNumber + + if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { + return fmt.Errorf("не удалось создать директорию '%s': %w", outputDir, err) + } + + finalJSON, err := json.MarshalIndent(kktMap, "", " ") + if err != nil { + return fmt.Errorf("ошибка маршалинга итогового JSON: %w", err) + } + + if err := os.WriteFile(filePath, finalJSON, 0644); err != nil { + return fmt.Errorf("ошибка записи в файл '%s': %w", filePath, err) + } + + log.Printf("Данные для ККТ %s успешно сохранены в новый файл: %s", kktInfo.SerialNumber, filePath) + return nil +} + +// 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, + } + + for _, s := range settings { + config := shtrih.Config{ + ConnectionType: s.TypeConnect, + Password: 30, + } + switch s.TypeConnect { + case 0: // COM-порт + comNum, err := strconv.Atoi(s.ComPort[3:]) + if err != nil { + log.Printf("Некорректное имя COM-порта '%s' в конфигурации, пропуск.", s.ComPort) + continue + } + baudRate, ok := baudRateMap[s.ComBaudrate] + if !ok { + log.Printf("Некорректная скорость '%s' для порта '%s', пропуск.", s.ComBaudrate, s.ComPort) + continue + } + config.ComName = s.ComPort + config.ComNumber = int32(comNum) + config.BaudRate = baudRate + case 6: // TCP/IP + port, err := strconv.Atoi(s.IPPort) + if err != nil { + log.Printf("Некорректный TCP-порт '%s' для IP '%s', пропуск.", s.IPPort, s.IP) + continue + } + config.IPAddress = s.IP + config.TCPPort = int32(port) + default: + log.Printf("Неизвестный тип подключения '%d', пропуск.", s.TypeConnect) + continue + } + configs = append(configs, config) + } + return configs +} + +// 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", + } + + settings := ConnectionSettings{ + TypeConnect: config.ConnectionType, + } + + switch config.ConnectionType { + case 0: // COM-порт + settings.ComPort = config.ComName + settings.ComBaudrate = baudRateReverseMap[config.BaudRate] + case 6: // TCP/IP + settings.IP = config.IPAddress + settings.IPPort = strconv.Itoa(int(config.TCPPort)) + } + return settings +} \ No newline at end of file diff --git a/pkg/shtrih/driver.go b/pkg/shtrih/driver.go new file mode 100644 index 0000000..f926195 --- /dev/null +++ b/pkg/shtrih/driver.go @@ -0,0 +1,574 @@ +package shtrih + +import ( + "fmt" + "log" + "net" + "runtime" + "strconv" + "strings" + "sync" + "time" + + "github.com/go-ole/go-ole" + "github.com/go-ole/go-ole/oleutil" + "go.bug.st/serial" +) + +// ... (структуры, интерфейс, New, Connect, Disconnect и все остальные методы остаются без изменений) ... +type Config struct { + ConnectionType int32 `json:"connectionType"` + IPAddress string `json:"ipAddress,omitempty"` + TCPPort int32 `json:"tcpPort,omitempty"` + ComName string `json:"comName,omitempty"` + ComNumber int32 `json:"-"` + BaudRate int32 `json:"baudRate,omitempty"` + Password int32 `json:"-"` +} +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"` + AttributeExcise bool `json:"attribute_excise"` + AttributeMarked bool `json:"attribute_marked"` + LicensesRawHex string `json:"licenses,omitempty"` +} +type Driver interface { + Connect() error + Disconnect() error + GetFiscalInfo() (*FiscalInfo, error) +} +type comDriver struct { + config Config + dispatch *ole.IDispatch + connected bool +} + +func New(config Config) Driver { return &comDriver{config: config} } +func (d *comDriver) Connect() error { + if d.connected { + return nil + } + runtime.LockOSThread() + if err := ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED); err != nil { + if err := ole.CoInitialize(0); err != nil { + runtime.UnlockOSThread() + return fmt.Errorf("COM init failed: %w", err) + } + } + unknown, err := oleutil.CreateObject("AddIn.DrvFR") + if err != nil { + ole.CoUninitialize() + runtime.UnlockOSThread() + return fmt.Errorf("create COM object failed: %w", err) + } + d.dispatch, err = unknown.QueryInterface(ole.IID_IDispatch) + if err != nil { + unknown.Release() + ole.CoUninitialize() + runtime.UnlockOSThread() + return fmt.Errorf("query interface failed: %w", err) + } + unknown.Release() + oleutil.PutProperty(d.dispatch, "ConnectionType", d.config.ConnectionType) + oleutil.PutProperty(d.dispatch, "Password", d.config.Password) + if d.config.ConnectionType == 0 { + oleutil.PutProperty(d.dispatch, "ComNumber", d.config.ComNumber) + oleutil.PutProperty(d.dispatch, "BaudRate", d.config.BaudRate) + } else if d.config.ConnectionType == 6 { + oleutil.PutProperty(d.dispatch, "IPAddress", d.config.IPAddress) + oleutil.PutProperty(d.dispatch, "TCPPort", d.config.TCPPort) + oleutil.PutProperty(d.dispatch, "UseIPAddress", true) + } + if _, err := oleutil.CallMethod(d.dispatch, "Connect"); err != nil { + d.Disconnect() + return fmt.Errorf("connect call failed: %w", err) + } + if err := d.checkError(); err != nil { + d.Disconnect() + return fmt.Errorf("driver error on connect: %w", err) + } + d.connected = true + log.Println("Подключение к ККТ успешно установлено.") + return nil +} +func (d *comDriver) Disconnect() error { + if !d.connected { + return nil + } + oleutil.CallMethod(d.dispatch, "Disconnect") + d.dispatch.Release() + ole.CoUninitialize() + runtime.UnlockOSThread() + d.connected = false + log.Println("Соединение с ККТ разорвано.") + return nil +} +func (d *comDriver) GetFiscalInfo() (*FiscalInfo, error) { + if !d.connected { + return nil, fmt.Errorf("драйвер не подключен") + } + info := &FiscalInfo{} + var err error + if err = d.getBaseDeviceInfo(info); err != nil { + return nil, fmt.Errorf("ошибка получения базовой информации об устройстве: %w", err) + } + if err = d.getFiscalizationInfo(info); err != nil { + return nil, fmt.Errorf("ошибка получения информации о фискализации: %w", err) + } + if err = d.getFnInfo(info); err != nil { + return nil, fmt.Errorf("ошибка получения информации о ФН: %w", err) + } + if err = d.getInfoFromTables(info); err != nil { + return nil, fmt.Errorf("ошибка получения информации из таблиц: %w", err) + } + return info, nil +} +func (d *comDriver) getBaseDeviceInfo(info *FiscalInfo) error { + var err error + var major, minor, release, build int32 + major, err = d.getPropertyInt32("DriverMajorVersion") + if err != nil { + return err + } + minor, err = d.getPropertyInt32("DriverMinorVersion") + if err != nil { + return err + } + release, err = d.getPropertyInt32("DriverRelease") + if err != nil { + return err + } + build, err = d.getPropertyInt32("DriverBuild") + if err != nil { + return err + } + info.InstalledDriver = fmt.Sprintf("%d.%d.%d.%d", major, minor, release, build) + if _, err = oleutil.CallMethod(d.dispatch, "GetDeviceMetrics"); err != nil { + return err + } + if err = d.checkError(); err != nil { + return err + } + info.ModelName, err = d.getPropertyString("UDescription") + if err != nil { + return err + } + if _, err = oleutil.CallMethod(d.dispatch, "GetECRStatus"); err != nil { + return err + } + if err = d.checkError(); err != nil { + return err + } + ecrSoftDateVar, err := d.getPropertyVariant("ECRSoftDate") + if err != nil { + return err + } + defer ecrSoftDateVar.Clear() + if ecrSoftDate, ok := ecrSoftDateVar.Value().(time.Time); ok && !ecrSoftDate.IsZero() { + info.SoftwareDate = ecrSoftDate.Format("2006-01-02") + } + if _, err := oleutil.CallMethod(d.dispatch, "ReadFeatureLicenses"); err == nil { + if errCheck := d.checkError(); errCheck == nil { + info.LicensesRawHex, _ = d.getPropertyString("License") + } else { + log.Printf("Предупреждение: не удалось проверить результат ReadFeatureLicenses: %v", errCheck) + } + } else { + log.Printf("Предупреждение: не удалось вызвать метод ReadFeatureLicenses: %v", err) + } + return nil +} +func (d *comDriver) getFiscalizationInfo(info *FiscalInfo) error { + log.Println("Запрос данных последней фискализации (FNGetFiscalizationResult)...") + oleutil.PutProperty(d.dispatch, "RegistrationNumber", 1) + if _, err := oleutil.CallMethod(d.dispatch, "FNGetFiscalizationResult"); err != nil { + return err + } + if err := d.checkError(); err != nil { + return err + } + var err error + info.RNM, err = d.getPropertyString("KKTRegistrationNumber") + if err != nil { + return err + } + inn, err := d.getPropertyString("INN") + if err != nil { + return err + } + info.Inn = strings.TrimSpace(inn) + regDateVar, err := d.getPropertyVariant("Date") + if err != nil { + return err + } + defer regDateVar.Clear() + regTimeStr, err := d.getPropertyString("Time") + if err != nil { + return err + } + if regDateOle, ok := regDateVar.Value().(time.Time); ok { + regTime, _ := time.Parse("15:04:05", regTimeStr) + info.RegistrationDate = time.Date(regDateOle.Year(), regDateOle.Month(), regDateOle.Day(), regTime.Hour(), regTime.Minute(), regTime.Second(), 0, time.Local).Format("2006-01-02 15:04:05") + } + workMode, err := d.getPropertyInt32("WorkMode") + if err != nil { + return err + } + workModeEx, err := d.getPropertyInt32("WorkModeEx") + if err != nil { + return err + } + info.AttributeMarked = (workMode & 0x10) != 0 + info.AttributeExcise = (workModeEx & 0x01) != 0 + return nil +} +func (d *comDriver) getFnInfo(info *FiscalInfo) error { + log.Println("Запрос данных ФН...") + var err error + if _, err = oleutil.CallMethod(d.dispatch, "FNGetSerial"); err != nil { + return err + } + if err = d.checkError(); err != nil { + return err + } + info.FnSerial, err = d.getPropertyString("SerialNumber") + if err != nil { + return err + } + if _, err = oleutil.CallMethod(d.dispatch, "FNGetExpirationTime"); err != nil { + return err + } + if err = d.checkError(); err != nil { + return err + } + fnEndDateVar, err := d.getPropertyVariant("Date") + if err != nil { + return err + } + defer fnEndDateVar.Clear() + if fnEndDate, ok := fnEndDateVar.Value().(time.Time); ok { + info.FnEndDate = fnEndDate.Format("2006-01-02 15:04:05") + } + if _, err = oleutil.CallMethod(d.dispatch, "FNGetImplementation"); err != nil { + return err + } + if err = d.checkError(); err != nil { + return err + } + fnExec, err := d.getPropertyString("FNImplementation") + if err != nil { + return err + } + info.FnExecution = strings.TrimSpace(fnExec) + return nil +} +func (d *comDriver) getInfoFromTables(info *FiscalInfo) error { + log.Println("Чтение данных из таблиц ККТ...") + sn, err := d.readTableField(18, 1, 1) + if err != nil { + log.Printf("Предупреждение: не удалось прочитать серийный номер из таблицы 18, поля 1: %v", err) + } else { + info.SerialNumber = strings.TrimSpace(sn) + } + orgName, err := d.readTableField(18, 1, 7) + if err != nil { + log.Printf("Предупреждение: не удалось прочитать название организации из таблицы 18, поля 7: %v", err) + } else { + info.OrganizationName = strings.TrimSpace(orgName) + } + ofdName, err := d.readTableField(18, 1, 10) + if err != nil { + log.Printf("Предупреждение: не удалось прочитать название ОФД из таблицы 18, поля 10: %v", err) + } else { + info.OfdName = strings.TrimSpace(ofdName) + } + ffdValueStr, err := d.readTableField(17, 1, 17) + if err != nil { + log.Printf("Предупреждение: не удалось прочитать версию ФФД из таблицы 18, поля 17: %v", err) + info.FfdVersion = "не определена" + } else { + ffdValue, _ := strconv.Atoi(strings.TrimSpace(ffdValueStr)) + switch ffdValue { + case 2: + info.FfdVersion = "105" + case 4: + info.FfdVersion = "120" + default: + info.FfdVersion = fmt.Sprintf("неизвестный код (%d)", ffdValue) + } + } + return nil +} +func (d *comDriver) readTableField(tableNum, rowNum, fieldNum int) (string, error) { + oleutil.PutProperty(d.dispatch, "TableNumber", tableNum) + oleutil.PutProperty(d.dispatch, "RowNumber", rowNum) + oleutil.PutProperty(d.dispatch, "FieldNumber", fieldNum) + if _, err := oleutil.CallMethod(d.dispatch, "ReadTable"); err != nil { + return "", err + } + if err := d.checkError(); err != nil { + return "", err + } + return d.getPropertyString("ValueOfFieldString") +} +func (d *comDriver) checkError() error { + resultCode, err := d.getPropertyInt32("ResultCode") + if err != nil { + return fmt.Errorf("не удалось прочитать ResultCode: %w", err) + } + if resultCode != 0 { + description, _ := d.getPropertyString("ResultCodeDescription") + return fmt.Errorf("ошибка драйвера: [%d] %s", resultCode, description) + } + return nil +} +func (d *comDriver) getPropertyVariant(propName string) (*ole.VARIANT, error) { + return oleutil.GetProperty(d.dispatch, propName) +} +func (d *comDriver) getPropertyString(propName string) (string, error) { + variant, err := d.getPropertyVariant(propName) + if err != nil { + return "", fmt.Errorf("не удалось получить свойство '%s': %w", propName, err) + } + defer variant.Clear() + return variant.ToString(), nil +} +func (d *comDriver) getPropertyInt32(propName string) (int32, error) { + variant, err := d.getPropertyVariant(propName) + if err != nil { + return 0, fmt.Errorf("не удалось получить свойство '%s': %w", propName, err) + } + defer variant.Clear() + v := variant.Value() + if v == nil { + return 0, nil + } + switch val := v.(type) { + case int: + return int32(val), nil + case int8: + return int32(val), nil + case int16: + return int32(val), nil + case int32: + return val, nil + case int64: + return int32(val), nil + case uint: + return int32(val), nil + case uint8: + return int32(val), nil + case uint16: + return int32(val), nil + case uint32: + return int32(val), nil + case uint64: + return int32(val), nil + case string: + i, err := strconv.ParseInt(strings.TrimSpace(val), 10, 32) + if err == nil { + return int32(i), nil + } + return 0, fmt.Errorf("не удалось сконвертировать строку '%s' в int32 для свойства %s", val, propName) + default: + return 0, fmt.Errorf("неожиданный тип для %s: %T", propName, v) + } +} + +// --- НОВЫЙ И ИЗМЕНЕННЫЙ КОД --- + +// SearchDevices выполняет последовательный поиск ККТ на COM-портах, +// а затем параллельный поиск в стандартных RNDIS IP-подсетях. +func SearchDevices(comTimeout, tcpTimeout time.Duration) ([]Config, error) { + log.Println("--- Начинаю поиск устройств на COM-портах ---") + var foundDevices []Config + + // --- Этап 1: Последовательный поиск на COM-портах --- + ports, err := serial.GetPortsList() + if err != nil { + log.Printf("Не удалось получить список COM-портов: %v", err) + } else if len(ports) == 0 { + log.Println("В системе не найдено COM-портов.") + } else { + log.Printf("Найдены COM-порты: %v. Начинаю проверку...", ports) + for _, portName := range ports { + log.Printf("Проверяю порт %s...", portName) + config, err := findOnComPort(portName, comTimeout) + if err == nil { + log.Printf("!!! Устройство найдено на порту %s, скорость: %d", config.ComName, config.BaudRate) + foundDevices = append(foundDevices, *config) + } + } + } + + // --- Этап 2: Параллельный поиск в RNDIS-сетях --- + log.Println("--- Начинаю поиск устройств в RNDIS-сетях ---") + + var wg sync.WaitGroup + foundChan := make(chan Config) + + // Запускаем сканирование в отдельной горутине + wg.Add(1) + go func() { + defer wg.Done() + scanRNDISNetworks(tcpTimeout, foundChan) + }() + + // Горутина для закрытия канала после завершения сканирования + go func() { + wg.Wait() + close(foundChan) + }() + + // Собираем результаты из канала + for config := range foundChan { + foundDevices = append(foundDevices, config) + } + + log.Printf("--- Поиск завершен. Всего найдено устройств: %d ---", len(foundDevices)) + return foundDevices, nil +} + +// findOnComPort инкапсулирует весь жизненный цикл COM для поиска на одном порту. +// Он включает дополнительную верификацию через быстрый запрос состояния. +func findOnComPort(portName string, timeout time.Duration) (*Config, error) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + comNum, err := strconv.Atoi(strings.TrimPrefix(strings.ToUpper(portName), "COM")) + if err != nil { + return nil, fmt.Errorf("некорректное имя порта: %s", portName) + } + + // Стандартные скорости обмена для ККТ + baudRates := []int32{115200, 57600, 38400, 19200, 9600} + // В документации BaudRate - это индекс от 0 до 6. + // 6 = 115200, 5 = 57600, 4 = 38400, 3 = 19200, 2 = 9600 + baudRateIndexes := map[int32]int32{ + 115200: 6, + 57600: 5, + 38400: 4, + 19200: 3, + 9600: 2, + } + + for _, baud := range baudRates { + // Для каждой скорости выполняем полную проверку + if err := ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED); err != nil { + if err := ole.CoInitialize(0); err != nil { + continue + } + } + + unknown, err := oleutil.CreateObject("AddIn.DrvFR") + if err != nil { + ole.CoUninitialize() + continue + } + + dispatch, err := unknown.QueryInterface(ole.IID_IDispatch) + if err != nil { + unknown.Release() + ole.CoUninitialize() + continue + } + + // Настраиваем драйвер для быстрого подключения + oleutil.PutProperty(dispatch, "ConnectionType", 0) + oleutil.PutProperty(dispatch, "Password", 30) + oleutil.PutProperty(dispatch, "ComNumber", comNum) + oleutil.PutProperty(dispatch, "BaudRate", baudRateIndexes[baud]) + oleutil.PutProperty(dispatch, "Timeout", timeout.Milliseconds()) // Устанавливаем короткий таймаут! + + // Пытаемся подключиться + _, connectErr := oleutil.CallMethod(dispatch, "Connect") + tempDriver := &comDriver{dispatch: dispatch} + checkErr := tempDriver.checkError() + + if connectErr == nil && checkErr == nil { + // Успех! + log.Printf("Успешная верификация на порту %s, скорость %d", portName, baud) + dispatch.Release() + unknown.Release() + ole.CoUninitialize() + return &Config{ + ConnectionType: 0, + ComName: portName, + ComNumber: int32(comNum), + BaudRate: baudRateIndexes[baud], + Password: 30, + }, nil + } + + // Неудача, освобождаем ресурсы и пробуем следующую скорость + dispatch.Release() + unknown.Release() + ole.CoUninitialize() + } + + return nil, fmt.Errorf("устройство не найдено на порту %s", portName) +} + +// scanRNDISNetworks ищет устройства в стандартных RNDIS-подсетях. +func scanRNDISNetworks(timeout time.Duration, foundChan chan<- Config) { + var wg sync.WaitGroup + ports := []int32{7778} // Стандартный порт для Штрих-М + subnets := []string{"192.168.137.", "192.168.138."} + + // Ограничиваем количество одновременных горутин, чтобы не перегружать систему + const maxGoroutines = 50 + guard := make(chan struct{}, maxGoroutines) + + for _, subnet := range subnets { + for i := 1; i <= 254; i++ { + ip := subnet + strconv.Itoa(i) + + wg.Add(1) + guard <- struct{}{} // Занимаем слот + go func(ip string, port int32) { + defer wg.Done() + checkIP(ip, port, timeout, foundChan) + <-guard // Освобождаем слот + }(ip, ports[0]) + } + } + wg.Wait() +} + +// checkIP проверяет доступность порта и верифицирует, что на нем находится ККТ. +func checkIP(ip string, port int32, timeout time.Duration, foundChan chan<- Config) { + address := fmt.Sprintf("%s:%d", ip, port) + conn, err := net.DialTimeout("tcp", address, timeout) + if err != nil { + return // Порт закрыт или хост недоступен + } + conn.Close() + + // Порт открыт, теперь проверяем, действительно ли это наша ККТ + log.Printf("Найден открытый порт на %s. Проверяю совместимость...", address) + config := Config{ + ConnectionType: 6, + IPAddress: ip, + TCPPort: port, + Password: 30, + } + driver := New(config) + if err := driver.Connect(); err == nil { + log.Printf("!!! Найдено и подтверждено устройство по TCP/IP: %s", address) + foundChan <- config + driver.Disconnect() + } else { + log.Printf("Порт %s открыт, но устройство не ответило как ККТ: %v", address, err) + } +} diff --git a/pkg/shtrih/mock_driver.go b/pkg/shtrih/mock_driver.go new file mode 100644 index 0000000..e49ddff --- /dev/null +++ b/pkg/shtrih/mock_driver.go @@ -0,0 +1,33 @@ +package shtrih + +// mockDriver — это имитация драйвера для тестирования. +type mockDriver struct { + FiscalInfoToReturn *FiscalInfo + ErrorToReturn error +} + +// NewMock создает новый мок-драйвер. +func NewMock(info *FiscalInfo, err error) Driver { + return &mockDriver{ + FiscalInfoToReturn: info, + ErrorToReturn: err, + } +} + +func (m *mockDriver) Connect() error { + if m.ErrorToReturn != nil { + return m.ErrorToReturn + } + return nil +} + +func (m *mockDriver) Disconnect() error { + return nil +} + +func (m *mockDriver) GetFiscalInfo() (*FiscalInfo, error) { + if m.ErrorToReturn != nil { + return nil, m.ErrorToReturn + } + return m.FiscalInfoToReturn, nil +}