Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
12 commits
Select commit Hold shift + click to select a range
5054f8c
Офлайн-тесты Client: настройка handler, тесты стенда банка в отдельно…
EgorGruzdev Sep 22, 2026
b9e2517
GetPackList: отметка времени в формате dd.MM.yyyy HH:mm:ss
EgorGruzdev Sep 22, 2026
255d6c2
Код ошибки банка строкой, исходная ошибка доступна из ClientException
EgorGruzdev Sep 22, 2026
ba9f106
Ошибка банка разбирается при любом HTTP-статусе, неожиданный ответ да…
EgorGruzdev Sep 22, 2026
0101b44
Повторный вход при истечении сессии (ошибки 1006/1007)
EgorGruzdev Sep 22, 2026
ea196b7
Необязательные по XSD элементы стали nullable: официальный Statement.…
EgorGruzdev Sep 22, 2026
d71db82
Повторяющиеся элементы стали массивами: Packet::$documents, CheckType…
EgorGruzdev Sep 22, 2026
832b234
Разбор данных, которые терялись: InnerDocKind, атрибуты Data, подписи…
EgorGruzdev Sep 22, 2026
f712eaf
Явные nullable-параметры: неявные устарели в PHP 8.4
EgorGruzdev Sep 22, 2026
19652d7
CI: GitHub Actions, PHP 8.0–8.5 × Guzzle 7/8
EgorGruzdev Sep 22, 2026
ee9e375
README: ошибки, повторный вход, настройка handler, запуск тестов, бей…
EgorGruzdev Sep 22, 2026
1389cfe
CI: push только в master, PR проверяются через pull_request
EgorGruzdev Sep 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Tests

on:
push:
branches: [master]
pull_request:

jobs:
tests:
name: PHP ${{ matrix.php }}, Guzzle ${{ matrix.guzzle }}
runs-on: ubuntu-latest

strategy:
fail-fast: false
matrix:
php: ['8.0', '8.1', '8.2', '8.3', '8.4', '8.5']
guzzle: ['7', '8']

steps:
- uses: actions/checkout@v7

- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: dom, curl
coverage: none

- name: Install dependencies
run: |
composer require "guzzlehttp/guzzle:^${{ matrix.guzzle }}.0" --no-update --no-interaction
composer update --prefer-dist --no-interaction --no-progress

# Тесты против стенда банка (группа bank-stand) исключены в phpunit.xml
- name: Run tests
run: vendor/bin/phpunit
30 changes: 24 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ PHP-клиент для обмена с банком по протоколу **1
и даёт типизированные объекты для транспортного контейнера (`Packet`) и документов
внутри него: запрос выписки, выписка, извещение о состоянии обработки контейнера и др.

[![Tests](https://github.com/ttbooking/DirectBank/actions/workflows/tests.yml/badge.svg)](https://github.com/ttbooking/DirectBank/actions/workflows/tests.yml)
[![Packagist](https://img.shields.io/packagist/v/ttbooking/direct-bank.svg)](https://packagist.org/packages/ttbooking/direct-bank)
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE)

Expand Down Expand Up @@ -39,12 +40,13 @@ $client = new Client([
'apiVersion' => DefaultValue::FORMAT_VERSION, // по умолчанию '2.2.2'
'sessionId' => null, // можно передать уже полученный SID
'verify' => true, // проверка SSL-сертификата
'handler' => null, // свой Guzzle handler, например MockHandler в тестах
]);
```

Настройки проверяются в конструкторе: `url`, `customerId`, `login`, `password` и `apiVersion`
должны быть непустыми строками, `sessionId` — строкой или `null`, `verify` — булевым значением
или путём к CA-бандлу. Иначе выбрасывается
или путём к CA-бандлу, `handler` — callable. Иначе выбрасывается
`TTBooking\DirectBank\Exceptions\InvalidSettingsException` (наследник `\InvalidArgumentException`)
с именем неверной настройки.

Expand All @@ -59,6 +61,8 @@ $client = new Client($settings, $logger); // Psr\Log\LoggerInterface

Явно вызывать `createSession()` не обязательно: при первом запросе, требующем
авторизации, клиент сам выполнит `Logon` и подставит полученный `sid` в заголовки.
Если сессия истекла или стала недействительной (ошибки банка `1006` и `1007`),
клиент войдёт заново и повторит запрос один раз.

```php
$sid = $client->createSession();
Expand All @@ -73,9 +77,18 @@ $sid = $client->createSession();
| `getPackList(?DateTimeInterface $date = null): ?array` | `GET GetPackList` | список идентификаторов контейнеров, готовых к получению |
| `getPack(string $id): Packet` | `GET GetPack` | транспортный контейнер |

Если банк вернул ошибку (`ResultBank/Error`), выбрасывается
`TTBooking\DirectBank\Exceptions\ClientException` с кодом и описанием из ответа.
Транспортные ошибки пробрасываются как исключения Guzzle.
Отметка времени для `getPackList()` задаётся по часам сервера банка и передаётся
в формате `dd.MM.yyyy HH:mm:ss`.

### Ошибки

- `TTBooking\DirectBank\Exceptions\ClientException` — банк вернул ошибку (`ResultBank/Error`),
при любом HTTP-статусе. Код банка как есть — `getBankCode()` (строка, например `'1201'`),
вся ошибка — `getError()`, описание — `getMessage()`, `getCode()` — код числом.
- `TTBooking\DirectBank\Exceptions\UnexpectedResponseException` (наследник `ClientException`) —
ответ не удалось разобрать или в нём нет ожидаемых данных. HTTP-ответ — `getResponse()`,
`getCode()` — HTTP-статус.
- Сетевые ошибки (нет соединения, таймаут) пробрасываются как исключения Guzzle.

## Примеры

Expand Down Expand Up @@ -187,8 +200,13 @@ composer install
vendor/bin/phpunit
```

`tests/Objects` — офлайн-тесты маппинга XML. `tests/ClientTest.php` обращается
к тестовому стенду банка и требует сетевого доступа к нему.
По умолчанию запускаются офлайн-тесты: маппинг XML на примерах из описания стандарта 1С
и работа `Client` с подменённым HTTP-обработчиком. Тесты против тестового стенда банка
(`tests/ClientTest.php`) требуют сетевого доступа к нему и запускаются отдельно:

```bash
vendor/bin/phpunit --group bank-stand
```

## История изменений

Expand Down
6 changes: 6 additions & 0 deletions phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@
</testsuite>
</testsuites>

<groups>
<exclude>
<group>bank-stand</group>
</exclude>
</groups>

<php>
<env name="APP_ENV" value="testing"/>
</php>
Expand Down
87 changes: 76 additions & 11 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@
use GuzzleHttp\MessageFormatter;
use GuzzleHttp\Middleware;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Log\LoggerInterface;
use TTBooking\DirectBank\Dictionary\DefaultValue;
use TTBooking\DirectBank\Dictionary\ErrorCode;
use GuzzleHttp\Client as HttpClient;
use TTBooking\DirectBank\Exceptions\ClientException;
use TTBooking\DirectBank\Exceptions\InvalidSettingsException;
use TTBooking\DirectBank\Exceptions\UnexpectedResponseException;
use TTBooking\DirectBank\Objects\Packet;
use TTBooking\DirectBank\Objects\ResultBank;

Expand All @@ -39,19 +42,28 @@ public function createSession(): string
{
$result = $this->invoke('POST', 'Logon');

return $result->getSuccess()->getLogonResponse()->getSID();
$response = $result->getSuccess()->getLogonResponse()
?? throw new UnexpectedResponseException('Bank response to Logon has no LogonResponse.');

return $response->getSID();
}

public function sendPack(Packet $packet): string
{
$result = $this->invoke('POST', 'SendPack', (string) $packet);

return $result->getSuccess()->getSendPacketResponse()->getID();
$response = $result->getSuccess()->getSendPacketResponse()
?? throw new UnexpectedResponseException('Bank response to SendPack has no SendPacketResponse.');

return $response->getID();
}

public function getPackList(\DateTimeInterface $dateTime = null): ?array
/**
* @param \DateTimeInterface|null $dateTime отметка времени по часам сервера банка
*/
public function getPackList(?\DateTimeInterface $dateTime = null): ?array
{
$result = $this->invoke('GET', 'GetPackList', query: ['date' => $dateTime ? $dateTime->format('Y-m-d H:i:s') : null]);
$result = $this->invoke('GET', 'GetPackList', query: ['date' => $dateTime?->format(DefaultValue::TIMESTAMP_FORMAT)]);

return $result->getSuccess()->getGetPacketListResponse()?->getPacketID();
}
Expand All @@ -60,7 +72,8 @@ public function getPack(string $uid): Packet
{
$result = $this->invoke('GET', 'GetPack', query: ['id' => $uid]);

return $result->getSuccess()->getGetPacketResponse();
return $result->getSuccess()->getGetPacketResponse()
?? throw new UnexpectedResponseException('Bank response to GetPack has no GetPacketResponse.');
}

/**
Expand All @@ -81,31 +94,82 @@ protected function validateSettings(array $settings): void
if (isset($settings['verify']) && ! is_bool($settings['verify']) && ! is_string($settings['verify'])) {
throw new InvalidSettingsException('Setting "verify" must be a boolean or a path to a CA bundle.');
}

if (isset($settings['handler']) && ! is_callable($settings['handler'])) {
throw new InvalidSettingsException('Setting "handler" must be a Guzzle handler (callable).');
}
}

/**
* @return ResultBank с заполненным Success
*
* @throws \GuzzleHttp\Exception\GuzzleException
* @throws \TTBooking\DirectBank\Exceptions\ClientException
*/
protected function invoke(string $method, string $path, string $body = null, array $query = []): ResultBank
protected function invoke(string $method, string $path, ?string $body = null, array $query = [], bool $reauthenticate = true): ResultBank
{
$client = $this->getHttpClient($this->settings, $path !== 'Logon');

$response = $client->request($method, $path, ['body' => $body, 'query' => $query]);

$result = new ResultBank();
$result->mapFromXml((string) $response->getBody());
$result = $this->parseResult($response);

if ($error = $result?->getError()) {
// Сессия истекла или недействительна: входим заново и повторяем запрос один раз
if ($reauthenticate && $path !== 'Logon' && in_array($error->getCode(), ErrorCode::REAUTHENTICATE, true)) {
$this->settings['sessionId'] = null;

return $this->invoke($method, $path, $body, $query, false);
}

throw ClientException::fromError($error);
}

if ($response->getStatusCode() >= 400) {
throw UnexpectedResponseException::fromResponse(
sprintf('Bank responded with HTTP %d %s and no error description.', $response->getStatusCode(), $response->getReasonPhrase()),
$response
);
}

if (! $result?->getSuccess()) {
throw UnexpectedResponseException::fromResponse(sprintf('Bank response to %s has neither Success nor Error.', $path), $response);
}

return $result;
}

/**
* Ошибка банка приходит в теле ResultBank при любом HTTP-статусе,
* поэтому тело разбирается до проверки статуса.
*
* @throws \TTBooking\DirectBank\Exceptions\UnexpectedResponseException
*/
protected function parseResult(ResponseInterface $response): ?ResultBank
{
$body = (string) $response->getBody();

if (trim($body) === '') {
return null;
}

try {
$result = new ResultBank();
$result->mapFromXml($body);
} catch (\Throwable $e) {
if ($response->getStatusCode() >= 400) {
return null;
}

if($error = $result->getError()) {
throw new ClientException($error->getDescription(), $error->getCode());
throw UnexpectedResponseException::fromResponse('Unable to parse bank response: ' . $e->getMessage(), $response, $e);
}

return $result;
}

protected function getHttpClient(array $settings, $withAuth = false): HttpClient
{
$handler = new CurlHandler();
$handler = $this->settings['handler'] ?? new CurlHandler();
$stack = HandlerStack::create($handler);

if ($this->logger) {
Expand Down Expand Up @@ -143,6 +207,7 @@ protected function getHttpClient(array $settings, $withAuth = false): HttpClient
$settings['password'],
],
'verify' => $this->settings['verify'] ?? true,
'http_errors' => false,
'handler' => $stack,
]);
}
Expand Down
2 changes: 1 addition & 1 deletion src/ClientInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public function createSession(): string;

public function sendPack(Packet $packet): string;

public function getPackList(\DateTimeInterface $dateTime = null): ?array;
public function getPackList(?\DateTimeInterface $dateTime = null): ?array;

public function getPack(string $uid) : Packet;
}
3 changes: 3 additions & 0 deletions src/Dictionary/DefaultValue.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,7 @@
class DefaultValue
{
const FORMAT_VERSION = '2.2.2';

//Формат отметки времени в GetPackList: dd.MM.yyyy HH:mm:ss
const TIMESTAMP_FORMAT = 'd.m.Y H:i:s';
}
19 changes: 19 additions & 0 deletions src/Dictionary/ErrorCode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);

namespace TTBooking\DirectBank\Dictionary;


class ErrorCode
{
//Сессия закрыта по тайм-ауту
const SESSION_TIMEOUT = '1006';
//Некорректный идентификатор сессии
const INVALID_SESSION_ID = '1007';

//Ошибки, после которых требуется повторная аутентификация
const REAUTHENTICATE = [
self::SESSION_TIMEOUT,
self::INVALID_SESSION_ID,
];
}
28 changes: 27 additions & 1 deletion src/Exceptions/ClientException.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,33 @@
namespace TTBooking\DirectBank\Exceptions;


use TTBooking\DirectBank\Objects\ErrorType;

class ClientException extends \Exception
{
protected ?ErrorType $error = null;

public static function fromError(ErrorType $error): static
{
$exception = new static($error->getDescription(), (int) $error->getCode());
$exception->error = $error;

return $exception;
}

/**
* Ошибка из ответа банка (ResultBank/Error), если она была
*/
public function getError(): ?ErrorType
{
return $this->error;
}

}
/**
* Код ошибки банка как есть, строкой из 4 символов
*/
public function getBankCode(): ?string
{
return $this->error?->getCode();
}
}
28 changes: 28 additions & 0 deletions src/Exceptions/UnexpectedResponseException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);

namespace TTBooking\DirectBank\Exceptions;


use Psr\Http\Message\ResponseInterface;

/**
* Ответ банка не удалось разобрать как ResultBank или в нём нет ожидаемых данных
*/
class UnexpectedResponseException extends ClientException
{
protected ?ResponseInterface $response = null;

public static function fromResponse(string $message, ResponseInterface $response, ?\Throwable $previous = null): static
{
$exception = new static($message, $response->getStatusCode(), $previous);
$exception->response = $response;

return $exception;
}

public function getResponse(): ?ResponseInterface
{
return $this->response;
}
}
Loading
Loading