From d7024d7123b9448c807c5fcc7d73b5295fcce84a Mon Sep 17 00:00:00 2001 From: Dmitriy Ignatenko Date: Fri, 22 May 2026 17:04:22 +0400 Subject: [PATCH 1/3] This is the first implementation for timeman methods --- .tasks/484/plan.md | 265 ++++++++++++++++++ CHANGELOG.md | 8 + Makefile | 4 + phpstan.neon.dist | 1 + phpunit.xml.dist | 3 + rector.php | 2 + src/Services/ServiceBuilder.php | 15 + .../Result/TimemanSettingsItemResult.php | 32 +++ .../Timeman/Result/TimemanSettingsResult.php | 32 +++ .../Timeman/Result/WorkdayItemResult.php | 40 +++ src/Services/Timeman/Result/WorkdayResult.php | 32 +++ src/Services/Timeman/Service/Timeman.php | 193 +++++++++++++ .../Timeman/TimemanServiceBuilder.php | 36 +++ ...memanSettingsItemResultAnnotationsTest.php | 63 +++++ .../WorkdayItemResultAnnotationsTest.php | 63 +++++ .../Services/Timeman/Service/TimemanTest.php | 122 ++++++++ .../Services/Timeman/Service/TimemanTest.php | 230 +++++++++++++++ 17 files changed, 1141 insertions(+) create mode 100644 .tasks/484/plan.md create mode 100644 src/Services/Timeman/Result/TimemanSettingsItemResult.php create mode 100644 src/Services/Timeman/Result/TimemanSettingsResult.php create mode 100644 src/Services/Timeman/Result/WorkdayItemResult.php create mode 100644 src/Services/Timeman/Result/WorkdayResult.php create mode 100644 src/Services/Timeman/Service/Timeman.php create mode 100644 src/Services/Timeman/TimemanServiceBuilder.php create mode 100644 tests/Integration/Services/Timeman/Result/TimemanSettingsItemResultAnnotationsTest.php create mode 100644 tests/Integration/Services/Timeman/Result/WorkdayItemResultAnnotationsTest.php create mode 100644 tests/Integration/Services/Timeman/Service/TimemanTest.php create mode 100644 tests/Unit/Services/Timeman/Service/TimemanTest.php diff --git a/.tasks/484/plan.md b/.tasks/484/plan.md new file mode 100644 index 00000000..542a8c1d --- /dev/null +++ b/.tasks/484/plan.md @@ -0,0 +1,265 @@ +# Plan: Add support for timeman.* base methods (issue #484) + +## Context + +The Bitrix24 REST API exposes 5 methods in the `timeman` scope for managing workday tracking: + +| Method | Description | +|---|---| +| `timeman.open` | Opens a new workday or continues after pause/close | +| `timeman.pause` | Pauses the current workday | +| `timeman.close` | Closes the current workday | +| `timeman.status` | Gets the current workday status | +| `timeman.settings` | Gets user's work time settings | + +**Scope**: `timeman` +**Author**: Dmitriy Ignatenko +**Issue**: https://github.com/bitrix24/b24phpsdk/issues/484 + +### API Response Structures + +All four workday methods (`open`, `pause`, `close`, `status`) return the same flat object +directly in `result` (not nested in `result.item`): + +```json +{ + "result": { + "STATUS": "OPENED", + "TIME_START": "2025-03-27T08:00:01+02:00", + "TIME_FINISH": null, + "DURATION": "00:00:00", + "TIME_LEAKS": "00:00:00", + "ACTIVE": false, + "IP_OPEN": "", + "IP_CLOSE": null, + "LAT_OPEN": 53.548841, + "LON_OPEN": 9.987274, + "LAT_CLOSE": 0, + "LON_CLOSE": 0, + "TZ_OFFSET": 7200 + } +} +``` + +`TIME_FINISH_DEFAULT` is an optional field present only for EXPIRED status. + +`timeman.settings` returns: +```json +{ + "result": { + "UF_TIMEMAN": true, + "UF_TM_FREE": false, + "UF_TM_MAX_START": "09:15:00", + "UF_TM_MIN_FINISH": "17:45:00", + "UF_TM_MIN_DURATION": "08:00:00", + "UF_TM_ALLOWED_DELTA": "00:15:00", + "ADMIN": true + } +} +``` + +`ADMIN` is only returned for the current user (not when querying by `USER_ID`). + +### Batch methods + +All timeman.* methods have `ERROR_BATCH_METHOD_NOT_ALLOWED` error defined - no Batch class needed. + +### DATE/TIME fields + +- `TIME_START`, `TIME_FINISH`, `TIME_FINISH_DEFAULT` → `CarbonImmutable` (nullable where applicable) +- `TIME_START` is always present (workday always has a start time) +- `TIME_FINISH` and `TIME_FINISH_DEFAULT` are nullable + +--- + +## Files to Create + +### 1. `src/Services/Timeman/Result/WorkdayItemResult.php` + +```php +namespace Bitrix24\SDK\Services\Timeman\Result; + +use Bitrix24\SDK\Core\Result\AbstractAnnotatedItem; +use Carbon\CarbonImmutable; + +/** + * @property-read string $STATUS + * @property-read CarbonImmutable $TIME_START + * @property-read CarbonImmutable|null $TIME_FINISH + * @property-read string $DURATION + * @property-read string $TIME_LEAKS + * @property-read bool $ACTIVE + * @property-read string $IP_OPEN + * @property-read string|null $IP_CLOSE + * @property-read float $LAT_OPEN + * @property-read float $LON_OPEN + * @property-read float $LAT_CLOSE + * @property-read float $LON_CLOSE + * @property-read int $TZ_OFFSET + * @property-read CarbonImmutable|null $TIME_FINISH_DEFAULT + */ +class WorkdayItemResult extends AbstractAnnotatedItem {} +``` + +### 2. `src/Services/Timeman/Result/WorkdayResult.php` + +```php +namespace Bitrix24\SDK\Services\Timeman\Result; + +use Bitrix24\SDK\Core\Result\AbstractResult; + +class WorkdayResult extends AbstractResult +{ + public function getWorkday(): WorkdayItemResult + { + return new WorkdayItemResult($this->getCoreResponse()->getResponseData()->getResult()); + } +} +``` + +### 3. `src/Services/Timeman/Result/TimemanSettingsItemResult.php` + +```php +namespace Bitrix24\SDK\Services\Timeman\Result; + +use Bitrix24\SDK\Core\Result\AbstractAnnotatedItem; + +/** + * @property-read bool $UF_TIMEMAN + * @property-read bool $UF_TM_FREE + * @property-read string $UF_TM_MAX_START + * @property-read string $UF_TM_MIN_FINISH + * @property-read string $UF_TM_MIN_DURATION + * @property-read string $UF_TM_ALLOWED_DELTA + * @property-read bool|null $ADMIN + */ +class TimemanSettingsItemResult extends AbstractAnnotatedItem {} +``` + +### 4. `src/Services/Timeman/Result/TimemanSettingsResult.php` + +```php +namespace Bitrix24\SDK\Services\Timeman\Result; + +use Bitrix24\SDK\Core\Result\AbstractResult; + +class TimemanSettingsResult extends AbstractResult +{ + public function getSettings(): TimemanSettingsItemResult + { + return new TimemanSettingsItemResult($this->getCoreResponse()->getResponseData()->getResult()); + } +} +``` + +### 5. `src/Services/Timeman/Service/Timeman.php` + +Methods: `open()`, `pause()`, `close()`, `status()`, `settings()`. +`open()` and `close()` accept optional `CarbonImmutable $time` and format it as ISO-8601 before passing to the API. + +### 6. `src/Services/Timeman/TimemanServiceBuilder.php` + +```php +#[ApiServiceBuilderMetadata(new Scope(['timeman']))] +class TimemanServiceBuilder extends AbstractServiceBuilder +{ + public function timeman(): Timeman\Service\Timeman { ... } +} +``` + +### 7. `tests/Unit/Services/Timeman/Service/TimemanTest.php` + +Unit test verifying service can be instantiated and each method calls the correct REST endpoint name. + +### 8. `tests/Integration/Services/Timeman/Service/TimemanTest.php` + +Integration test calling live API: open, pause, close, status, settings. + +### 9. `tests/Integration/Services/Timeman/Result/WorkdayItemResultAnnotationsTest.php` + +Annotation completeness + type cast matching test for `WorkdayItemResult`. + +### 10. `tests/Integration/Services/Timeman/Result/TimemanSettingsItemResultAnnotationsTest.php` + +Annotation completeness + type cast matching test for `TimemanSettingsItemResult`. + +--- + +## Files to Modify + +### 1. `src/Services/ServiceBuilder.php` + +Add `getTimemanScope(): TimemanServiceBuilder` method following existing patterns. + +### 2. `phpunit.xml.dist` + +Add suite: +```xml + + ./tests/Integration/Services/Timeman/ + +``` + +### 3. `Makefile` + +Add target: +```makefile +.PHONY: test-integration-scope-timeman +test-integration-scope-timeman: + docker compose run --rm php-cli vendor/bin/phpunit --testsuite integration_tests_scope_timeman +``` + +### 4. `rector.php` + +Add paths: +```php +__DIR__ . '/src/Services/Timeman', +__DIR__ . '/tests/Integration/Services/Timeman', +``` + +### 5. `phpstan.neon.dist` + +Add paths: +```yaml +- tests/Integration/Services/Timeman +``` + +### 6. `CHANGELOG.md` + +Add under `## 3.2.0 – UNRELEASED` → `### Added`: +```markdown +- Added `Services\Timeman` service with support for workday tracking methods, + see [timeman.* methods](https://apidocs.bitrix24.com/api-reference/timeman/index.html): + - `open` — starts a new workday or continues after pause/close + - `pause` — pauses the current workday + - `close` — closes the current workday + - `status` — gets current workday status + - `settings` — gets user's work time settings + ([#484](https://github.com/bitrix24/b24phpsdk/issues/484)) +``` + +--- + +## Deptrac compliance + +The new `Timeman` service depends only on: +- `Bitrix24\SDK\Core\*` (allowed — SDK core layer) +- `Bitrix24\SDK\Services\AbstractService` and `AbstractServiceBuilder` (allowed) +- `Bitrix24\SDK\Attributes\*` (allowed) +- `Carbon\CarbonImmutable` (external vendor, allowed) + +No violations expected. + +--- + +## Verification + +```bash +make lint-cs-fixer +make lint-rector +make lint-phpstan +make lint-deptrac +make test-unit +make test-integration-scope-timeman +``` + diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cdc5477..ba444806 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ ### Added +- Added `Services\Timeman` service with support for workday tracking methods, + see [timeman.* methods](https://apidocs.bitrix24.com/api-reference/timeman/index.html): + - `open` — starts a new workday or continues after pause/close + - `pause` — pauses the current workday + - `close` — closes the current workday + - `status` — gets current workday status + - `settings` — gets user's work time settings + ([#484](https://github.com/bitrix24/b24phpsdk/issues/484)) - Added `Bitrix24\SDK\Services\IM\Department\Service\Department` service wrapping `im.department.get`, `im.department.colleagues.list`, `im.department.employees.get`, and `im.department.managers.get`, with typed department/user result wrappers and `IMServiceBuilder::department()` accessor ([#432](https://github.com/bitrix24/b24phpsdk/issues/432)) - Added `Bitrix24\SDK\Services\IM\Disk\Service\Disk` with `getFolderId(?int $chatId = null, ?string $dialogId = null)` for `im.disk.folder.get`, plus dedicated `FolderIdResult`, IM builder registration, and focused unit/integration coverage ([#435](https://github.com/bitrix24/b24phpsdk/issues/435)) - Added `Bitrix24\SDK\Services\IM\Chat\Service\ChatUser` service wrapping `im.chat.user.add`, `im.chat.user.delete`, `im.chat.user.list` for chat participant management, with `ChatUserListResult` and `IMServiceBuilder::chatUser()` accessor ([#424](https://github.com/bitrix24/b24phpsdk/issues/424)) diff --git a/Makefile b/Makefile index 397095ac..79491e59 100644 --- a/Makefile +++ b/Makefile @@ -600,6 +600,10 @@ test-integration-main-eventlog: test-integration-rest-scope: docker compose run --rm php-cli vendor/bin/phpunit --testsuite integration_tests_rest_scope_service +.PHONY: test-integration-scope-timeman +test-integration-scope-timeman: + docker compose run --rm php-cli vendor/bin/phpunit --testsuite integration_tests_scope_timeman + .PHONY: integration_tests_sale integration_tests_sale: docker compose run --rm php-cli vendor/bin/phpunit --testsuite integration_tests_sale diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 36a9a3f3..65472c0f 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -38,6 +38,7 @@ parameters: - tests/Integration/Services/CRM/Documentgenerator/Numerator - tests/Integration/Services/CRM/Documentgenerator/Document - tests/Integration/Services/CRM/Documentgenerator/Template + - tests/Integration/Services/Timeman excludePaths: # TODO: Fix type errors in RequisiteUserfieldUseCaseTest and remove this exclusion # Tracking: https://github.com/bitrix24/b24phpsdk/issues diff --git a/phpunit.xml.dist b/phpunit.xml.dist index d431bba8..d1a66481 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -344,6 +344,9 @@ ./tests/Integration/Services/Rest/Service/ScopeTest.php ./tests/Integration/Services/Rest/Result/ScopeMethodItemResultTest.php + + ./tests/Integration/Services/Timeman/ + diff --git a/rector.php b/rector.php index fab5a568..582ae872 100644 --- a/rector.php +++ b/rector.php @@ -81,6 +81,8 @@ __DIR__ . '/src/Services/CRM/Documentgenerator/Template', __DIR__ . '/tests/Integration/Services/CRM/Documentgenerator/Template', __DIR__ . '/tests/Unit/', + __DIR__ . '/src/Services/Timeman', + __DIR__ . '/tests/Integration/Services/Timeman', ]) ->withCache(cacheDirectory: __DIR__ . '/var/.cache/rector') ->withSets( diff --git a/src/Services/ServiceBuilder.php b/src/Services/ServiceBuilder.php index 417ca0d6..89dd89d9 100644 --- a/src/Services/ServiceBuilder.php +++ b/src/Services/ServiceBuilder.php @@ -40,6 +40,7 @@ use Bitrix24\SDK\Legacy\LegacyServiceBuilder; use Bitrix24\SDK\Services\Lists\ListsServiceBuilder; use Bitrix24\SDK\Services\Rest\RestServiceBuilder; +use Bitrix24\SDK\Services\Timeman\TimemanServiceBuilder; use Psr\Log\LoggerInterface; class ServiceBuilder extends AbstractServiceBuilder @@ -395,4 +396,18 @@ public function getLegacyServiceBuilder(): LegacyServiceBuilder return $this->serviceCache[__METHOD__]; } + public function getTimemanScope(): TimemanServiceBuilder + { + if (!isset($this->serviceCache[__METHOD__])) { + $this->serviceCache[__METHOD__] = new TimemanServiceBuilder( + $this->core, + $this->batch, + $this->bulkItemsReader, + $this->log + ); + } + + return $this->serviceCache[__METHOD__]; + } + } diff --git a/src/Services/Timeman/Result/TimemanSettingsItemResult.php b/src/Services/Timeman/Result/TimemanSettingsItemResult.php new file mode 100644 index 00000000..fe39b785 --- /dev/null +++ b/src/Services/Timeman/Result/TimemanSettingsItemResult.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the MIT-LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Bitrix24\SDK\Services\Timeman\Result; + +use Bitrix24\SDK\Core\Result\AbstractAnnotatedItem; + +/** + * Represents a single settings item returned by timeman.settings method. + * + * @property-read bool $UF_TIMEMAN + * @property-read bool $UF_TM_FREE + * @property-read string $UF_TM_MAX_START + * @property-read string $UF_TM_MIN_FINISH + * @property-read string $UF_TM_MIN_DURATION + * @property-read string $UF_TM_ALLOWED_DELTA + * @property-read bool|null $ADMIN + */ +class TimemanSettingsItemResult extends AbstractAnnotatedItem +{ +} + diff --git a/src/Services/Timeman/Result/TimemanSettingsResult.php b/src/Services/Timeman/Result/TimemanSettingsResult.php new file mode 100644 index 00000000..01fd323a --- /dev/null +++ b/src/Services/Timeman/Result/TimemanSettingsResult.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the MIT-LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Bitrix24\SDK\Services\Timeman\Result; + +use Bitrix24\SDK\Core\Exceptions\BaseException; +use Bitrix24\SDK\Core\Result\AbstractResult; + +/** + * Result wrapping the settings object returned by timeman.settings. + */ +class TimemanSettingsResult extends AbstractResult +{ + /** + * @throws BaseException + */ + public function getSettings(): TimemanSettingsItemResult + { + return new TimemanSettingsItemResult($this->getCoreResponse()->getResponseData()->getResult()); + } +} + diff --git a/src/Services/Timeman/Result/WorkdayItemResult.php b/src/Services/Timeman/Result/WorkdayItemResult.php new file mode 100644 index 00000000..d79e3971 --- /dev/null +++ b/src/Services/Timeman/Result/WorkdayItemResult.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the MIT-LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Bitrix24\SDK\Services\Timeman\Result; + +use Bitrix24\SDK\Core\Result\AbstractAnnotatedItem; +use Carbon\CarbonImmutable; + +/** + * Represents a single workday item returned by timeman.open, timeman.pause, timeman.close, timeman.status methods. + * + * @property-read string $STATUS + * @property-read CarbonImmutable $TIME_START + * @property-read CarbonImmutable|null $TIME_FINISH + * @property-read string $DURATION + * @property-read string $TIME_LEAKS + * @property-read bool $ACTIVE + * @property-read string $IP_OPEN + * @property-read string|null $IP_CLOSE + * @property-read float $LAT_OPEN + * @property-read float $LON_OPEN + * @property-read float $LAT_CLOSE + * @property-read float $LON_CLOSE + * @property-read int $TZ_OFFSET + * @property-read CarbonImmutable|null $TIME_FINISH_DEFAULT + */ +class WorkdayItemResult extends AbstractAnnotatedItem +{ +} + diff --git a/src/Services/Timeman/Result/WorkdayResult.php b/src/Services/Timeman/Result/WorkdayResult.php new file mode 100644 index 00000000..25e2e221 --- /dev/null +++ b/src/Services/Timeman/Result/WorkdayResult.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the MIT-LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Bitrix24\SDK\Services\Timeman\Result; + +use Bitrix24\SDK\Core\Exceptions\BaseException; +use Bitrix24\SDK\Core\Result\AbstractResult; + +/** + * Result wrapping a single workday object returned by timeman.open, timeman.pause, timeman.close, timeman.status. + */ +class WorkdayResult extends AbstractResult +{ + /** + * @throws BaseException + */ + public function getWorkday(): WorkdayItemResult + { + return new WorkdayItemResult($this->getCoreResponse()->getResponseData()->getResult()); + } +} + diff --git a/src/Services/Timeman/Service/Timeman.php b/src/Services/Timeman/Service/Timeman.php new file mode 100644 index 00000000..264b2f69 --- /dev/null +++ b/src/Services/Timeman/Service/Timeman.php @@ -0,0 +1,193 @@ + + * + * For the full copyright and license information, please view the MIT-LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Bitrix24\SDK\Services\Timeman\Service; + +use Bitrix24\SDK\Attributes\ApiEndpointMetadata; +use Bitrix24\SDK\Attributes\ApiServiceMetadata; +use Bitrix24\SDK\Core\Contracts\CoreInterface; +use Bitrix24\SDK\Core\Credentials\Scope; +use Bitrix24\SDK\Core\Exceptions\BaseException; +use Bitrix24\SDK\Core\Exceptions\TransportException; +use Bitrix24\SDK\Services\AbstractService; +use Bitrix24\SDK\Services\Timeman\Result\TimemanSettingsResult; +use Bitrix24\SDK\Services\Timeman\Result\WorkdayResult; +use Carbon\CarbonImmutable; +use Psr\Log\LoggerInterface; + +#[ApiServiceMetadata(new Scope(['timeman']))] +class Timeman extends AbstractService +{ + public function __construct(CoreInterface $core, LoggerInterface $logger) + { + parent::__construct($core, $logger); + } + + /** + * Opens a new workday or continues a workday after a pause or completion. + * + * @link https://apidocs.bitrix24.com/api-reference/timeman/base/timeman-open.html + * + * @throws BaseException + * @throws TransportException + */ + #[ApiEndpointMetadata( + 'timeman.open', + 'https://apidocs.bitrix24.com/api-reference/timeman/base/timeman-open.html', + 'Opens a new workday or continues a workday after a pause or completion.' + )] + public function open( + ?int $userId = null, + ?CarbonImmutable $time = null, + ?string $report = null, + ?float $lat = null, + ?float $lon = null + ): WorkdayResult { + $params = []; + if ($userId !== null) { + $params['USER_ID'] = $userId; + } + + if ($time instanceof CarbonImmutable) { + $params['TIME'] = $time->format(CarbonImmutable::ATOM); + } + + if ($report !== null) { + $params['REPORT'] = $report; + } + + if ($lat !== null) { + $params['LAT'] = $lat; + } + + if ($lon !== null) { + $params['LON'] = $lon; + } + + return new WorkdayResult($this->core->call('timeman.open', $params)); + } + + /** + * Pauses the current workday. + * + * @link https://apidocs.bitrix24.com/api-reference/timeman/base/timeman-pause.html + * + * @throws BaseException + * @throws TransportException + */ + #[ApiEndpointMetadata( + 'timeman.pause', + 'https://apidocs.bitrix24.com/api-reference/timeman/base/timeman-pause.html', + 'Pauses the current workday.' + )] + public function pause(?int $userId = null): WorkdayResult + { + $params = []; + if ($userId !== null) { + $params['USER_ID'] = $userId; + } + + return new WorkdayResult($this->core->call('timeman.pause', $params)); + } + + /** + * Closes the current workday. + * + * @link https://apidocs.bitrix24.com/api-reference/timeman/base/timeman-close.html + * + * @throws BaseException + * @throws TransportException + */ + #[ApiEndpointMetadata( + 'timeman.close', + 'https://apidocs.bitrix24.com/api-reference/timeman/base/timeman-close.html', + 'Closes the current workday.' + )] + public function close( + ?int $userId = null, + ?CarbonImmutable $time = null, + ?string $report = null, + ?float $lat = null, + ?float $lon = null + ): WorkdayResult { + $params = []; + if ($userId !== null) { + $params['USER_ID'] = $userId; + } + + if ($time instanceof CarbonImmutable) { + $params['TIME'] = $time->format(CarbonImmutable::ATOM); + } + + if ($report !== null) { + $params['REPORT'] = $report; + } + + if ($lat !== null) { + $params['LAT'] = $lat; + } + + if ($lon !== null) { + $params['LON'] = $lon; + } + + return new WorkdayResult($this->core->call('timeman.close', $params)); + } + + /** + * Gets information about the current workday of the user. + * + * @link https://apidocs.bitrix24.com/api-reference/timeman/base/timeman-status.html + * + * @throws BaseException + * @throws TransportException + */ + #[ApiEndpointMetadata( + 'timeman.status', + 'https://apidocs.bitrix24.com/api-reference/timeman/base/timeman-status.html', + 'Gets information about the current workday of the user.' + )] + public function status(?int $userId = null): WorkdayResult + { + $params = []; + if ($userId !== null) { + $params['USER_ID'] = $userId; + } + + return new WorkdayResult($this->core->call('timeman.status', $params)); + } + + /** + * Gets the work time settings of the user. + * + * @link https://apidocs.bitrix24.com/api-reference/timeman/base/timeman-settings.html + * + * @throws BaseException + * @throws TransportException + */ + #[ApiEndpointMetadata( + 'timeman.settings', + 'https://apidocs.bitrix24.com/api-reference/timeman/base/timeman-settings.html', + 'Gets the work time settings of the user.' + )] + public function settings(?int $userId = null): TimemanSettingsResult + { + $params = []; + if ($userId !== null) { + $params['USER_ID'] = $userId; + } + + return new TimemanSettingsResult($this->core->call('timeman.settings', $params)); + } +} + diff --git a/src/Services/Timeman/TimemanServiceBuilder.php b/src/Services/Timeman/TimemanServiceBuilder.php new file mode 100644 index 00000000..b7b2b1fd --- /dev/null +++ b/src/Services/Timeman/TimemanServiceBuilder.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the MIT-LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Bitrix24\SDK\Services\Timeman; + +use Bitrix24\SDK\Attributes\ApiServiceBuilderMetadata; +use Bitrix24\SDK\Core\Credentials\Scope; +use Bitrix24\SDK\Services\AbstractServiceBuilder; +use Bitrix24\SDK\Services\Timeman\Service\Timeman; + +#[ApiServiceBuilderMetadata(new Scope(['timeman']))] +class TimemanServiceBuilder extends AbstractServiceBuilder +{ + public function timeman(): Timeman + { + if (!isset($this->serviceCache[__METHOD__])) { + $this->serviceCache[__METHOD__] = new Timeman( + $this->core, + $this->log + ); + } + + return $this->serviceCache[__METHOD__]; + } +} + diff --git a/tests/Integration/Services/Timeman/Result/TimemanSettingsItemResultAnnotationsTest.php b/tests/Integration/Services/Timeman/Result/TimemanSettingsItemResultAnnotationsTest.php new file mode 100644 index 00000000..776b1d8a --- /dev/null +++ b/tests/Integration/Services/Timeman/Result/TimemanSettingsItemResultAnnotationsTest.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the MIT-LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Bitrix24\SDK\Tests\Integration\Services\Timeman\Result; + +use Bitrix24\SDK\Services\Timeman\Result\TimemanSettingsItemResult; +use Bitrix24\SDK\Services\Timeman\Service\Timeman; +use Bitrix24\SDK\Tests\CustomAssertions\CustomBitrix24Assertions; +use Bitrix24\SDK\Tests\Integration\Factory; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\TestDox; +use PHPUnit\Framework\TestCase; + +#[CoversClass(TimemanSettingsItemResult::class)] +class TimemanSettingsItemResultAnnotationsTest extends TestCase +{ + use CustomBitrix24Assertions; + + private Timeman $timemanService; + + #[\Override] + protected function setUp(): void + { + $this->timemanService = Factory::getServiceBuilder()->getTimemanScope()->timeman(); + } + + #[Test] + #[TestDox('all fields in TimemanSettingsItemResult are annotated in phpdoc and match with raw api response')] + public function testAllSystemFieldsAnnotated(): void + { + $rawResult = $this->timemanService->settings()->getCoreResponse() + ->getResponseData()->getResult(); + + $this->assertBitrix24AllResultItemFieldsAnnotated( + array_keys($rawResult), + TimemanSettingsItemResult::class + ); + } + + #[Test] + #[TestDox('all fields in TimemanSettingsItemResult have valid type casting in magic getters')] + public function testAllSystemFieldsHasValidTypeAnnotation(): void + { + $timemanSettingsItemResult = $this->timemanService->settings()->getSettings(); + + $this->assertBitrix24ResultItemFieldsTypeCastMatchAnnotations( + $timemanSettingsItemResult, + TimemanSettingsItemResult::class + ); + } +} + diff --git a/tests/Integration/Services/Timeman/Result/WorkdayItemResultAnnotationsTest.php b/tests/Integration/Services/Timeman/Result/WorkdayItemResultAnnotationsTest.php new file mode 100644 index 00000000..42c9e54f --- /dev/null +++ b/tests/Integration/Services/Timeman/Result/WorkdayItemResultAnnotationsTest.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the MIT-LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Bitrix24\SDK\Tests\Integration\Services\Timeman\Result; + +use Bitrix24\SDK\Services\Timeman\Result\WorkdayItemResult; +use Bitrix24\SDK\Services\Timeman\Service\Timeman; +use Bitrix24\SDK\Tests\CustomAssertions\CustomBitrix24Assertions; +use Bitrix24\SDK\Tests\Integration\Factory; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\TestDox; +use PHPUnit\Framework\TestCase; + +#[CoversClass(WorkdayItemResult::class)] +class WorkdayItemResultAnnotationsTest extends TestCase +{ + use CustomBitrix24Assertions; + + private Timeman $timemanService; + + #[\Override] + protected function setUp(): void + { + $this->timemanService = Factory::getServiceBuilder()->getTimemanScope()->timeman(); + } + + #[Test] + #[TestDox('all fields in WorkdayItemResult are annotated in phpdoc and match with raw api response')] + public function testAllSystemFieldsAnnotated(): void + { + $rawResult = $this->timemanService->status()->getCoreResponse() + ->getResponseData()->getResult(); + + $this->assertBitrix24AllResultItemFieldsAnnotated( + array_keys($rawResult), + WorkdayItemResult::class + ); + } + + #[Test] + #[TestDox('all fields in WorkdayItemResult have valid type casting in magic getters')] + public function testAllSystemFieldsHasValidTypeAnnotation(): void + { + $workdayItemResult = $this->timemanService->status()->getWorkday(); + + $this->assertBitrix24ResultItemFieldsTypeCastMatchAnnotations( + $workdayItemResult, + WorkdayItemResult::class + ); + } +} + diff --git a/tests/Integration/Services/Timeman/Service/TimemanTest.php b/tests/Integration/Services/Timeman/Service/TimemanTest.php new file mode 100644 index 00000000..6717807d --- /dev/null +++ b/tests/Integration/Services/Timeman/Service/TimemanTest.php @@ -0,0 +1,122 @@ + + * + * For the full copyright and license information, please view the MIT-LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Bitrix24\SDK\Tests\Integration\Services\Timeman\Service; + +use Bitrix24\SDK\Core\Exceptions\BaseException; +use Bitrix24\SDK\Core\Exceptions\TransportException; +use Bitrix24\SDK\Services\Timeman\Service\Timeman; +use Bitrix24\SDK\Tests\Integration\Factory; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\TestDox; +use PHPUnit\Framework\TestCase; + +#[CoversClass(Timeman::class)] +class TimemanTest extends TestCase +{ + private Timeman $timemanService; + + #[\Override] + protected function setUp(): void + { + $this->timemanService = Factory::getServiceBuilder()->getTimemanScope()->timeman(); + } + + /** + * @throws BaseException + * @throws TransportException + */ + #[TestDox('timeman.status returns current workday information')] + public function testStatus(): void + { + $workdayResult = $this->timemanService->status(); + $workdayItemResult = $workdayResult->getWorkday(); + + $this->assertContains( + $workdayItemResult->STATUS, + ['OPENED', 'CLOSED', 'PAUSED', 'EXPIRED'], + 'STATUS must be one of OPENED, CLOSED, PAUSED, EXPIRED' + ); + } + + /** + * @throws BaseException + * @throws TransportException + */ + #[TestDox('timeman.settings returns user work time settings')] + public function testSettings(): void + { + $timemanSettingsResult = $this->timemanService->settings(); + $timemanSettingsItemResult = $timemanSettingsResult->getSettings(); + + $this->assertIsBool($timemanSettingsItemResult->UF_TIMEMAN); + $this->assertIsBool($timemanSettingsItemResult->UF_TM_FREE); + } + + /** + * @throws BaseException + * @throws TransportException + */ + #[TestDox('timeman.open opens the workday for the current user')] + public function testOpen(): void + { + $workdayResult = $this->timemanService->open(); + $workdayItemResult = $workdayResult->getWorkday(); + + $this->assertContains( + $workdayItemResult->STATUS, + ['OPENED', 'CLOSED', 'PAUSED', 'EXPIRED'], + 'STATUS must be one of OPENED, CLOSED, PAUSED, EXPIRED' + ); + } + + /** + * @throws BaseException + * @throws TransportException + */ + #[TestDox('timeman.pause pauses the current workday')] + public function testPause(): void + { + // Ensure workday is open before pausing + $this->timemanService->open(); + + $workdayResult = $this->timemanService->pause(); + $workdayItemResult = $workdayResult->getWorkday(); + + $this->assertContains( + $workdayItemResult->STATUS, + ['OPENED', 'CLOSED', 'PAUSED', 'EXPIRED'], + 'STATUS must be one of OPENED, CLOSED, PAUSED, EXPIRED' + ); + } + + /** + * @throws BaseException + * @throws TransportException + */ + #[TestDox('timeman.close closes the current workday')] + public function testClose(): void + { + // Ensure workday is open before closing + $this->timemanService->open(); + + $workdayResult = $this->timemanService->close(); + $workdayItemResult = $workdayResult->getWorkday(); + + $this->assertContains( + $workdayItemResult->STATUS, + ['OPENED', 'CLOSED', 'PAUSED', 'EXPIRED'], + 'STATUS must be one of OPENED, CLOSED, PAUSED, EXPIRED' + ); + } +} diff --git a/tests/Unit/Services/Timeman/Service/TimemanTest.php b/tests/Unit/Services/Timeman/Service/TimemanTest.php new file mode 100644 index 00000000..5390c3dd --- /dev/null +++ b/tests/Unit/Services/Timeman/Service/TimemanTest.php @@ -0,0 +1,230 @@ + + * + * For the full copyright and license information, please view the MIT-LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Bitrix24\SDK\Tests\Unit\Services\Timeman\Service; + +use Bitrix24\SDK\Core\Contracts\CoreInterface; +use Bitrix24\SDK\Core\Response\Response; +use Bitrix24\SDK\Services\Timeman\Result\TimemanSettingsResult; +use Bitrix24\SDK\Services\Timeman\Result\WorkdayResult; +use Bitrix24\SDK\Services\Timeman\Service\Timeman; +use Carbon\CarbonImmutable; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\TestDox; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +#[CoversClass(Timeman::class)] +class TimemanTest extends TestCase +{ + #[TestDox('Test Timeman service can be instantiated')] + public function testCanBeInstantiated(): void + { + $core = $this->createStub(CoreInterface::class); + $timeman = new Timeman($core, new NullLogger()); + + $this->assertInstanceOf(Timeman::class, $timeman); + } + + #[TestDox('Test Timeman::open calls timeman.open without parameters')] + public function testOpenCallsCorrectMethodWithNoParams(): void + { + $core = $this->createMock(CoreInterface::class); + $response = $this->createStub(Response::class); + + $core->expects($this->once()) + ->method('call') + ->with('timeman.open', []) + ->willReturn($response); + + $workdayResult = (new Timeman($core, new NullLogger()))->open(); + + $this->assertInstanceOf(WorkdayResult::class, $workdayResult); + } + + #[TestDox('Test Timeman::open builds correct parameters')] + public function testOpenBuildsCorrectParameters(): void + { + $core = $this->createMock(CoreInterface::class); + $response = $this->createStub(Response::class); + $time = CarbonImmutable::create(2025, 3, 27, 8, 0, 1, 'UTC'); + + $core->expects($this->once()) + ->method('call') + ->with( + 'timeman.open', + [ + 'USER_ID' => 503, + 'TIME' => $time->format(CarbonImmutable::ATOM), + 'REPORT' => 'Test report', + 'LAT' => 53.548841, + 'LON' => 9.987274, + ] + ) + ->willReturn($response); + + $workdayResult = (new Timeman($core, new NullLogger()))->open( + userId: 503, + time: $time, + report: 'Test report', + lat: 53.548841, + lon: 9.987274 + ); + + $this->assertInstanceOf(WorkdayResult::class, $workdayResult); + } + + #[TestDox('Test Timeman::pause calls timeman.pause without parameters')] + public function testPauseCallsCorrectMethodWithNoParams(): void + { + $core = $this->createMock(CoreInterface::class); + $response = $this->createStub(Response::class); + + $core->expects($this->once()) + ->method('call') + ->with('timeman.pause', []) + ->willReturn($response); + + $workdayResult = (new Timeman($core, new NullLogger()))->pause(); + + $this->assertInstanceOf(WorkdayResult::class, $workdayResult); + } + + #[TestDox('Test Timeman::pause builds correct parameters')] + public function testPauseBuildsCorrectParameters(): void + { + $core = $this->createMock(CoreInterface::class); + $response = $this->createStub(Response::class); + + $core->expects($this->once()) + ->method('call') + ->with('timeman.pause', ['USER_ID' => 503]) + ->willReturn($response); + + $workdayResult = (new Timeman($core, new NullLogger()))->pause(userId: 503); + + $this->assertInstanceOf(WorkdayResult::class, $workdayResult); + } + + #[TestDox('Test Timeman::close calls timeman.close without parameters')] + public function testCloseCallsCorrectMethodWithNoParams(): void + { + $core = $this->createMock(CoreInterface::class); + $response = $this->createStub(Response::class); + + $core->expects($this->once()) + ->method('call') + ->with('timeman.close', []) + ->willReturn($response); + + $workdayResult = (new Timeman($core, new NullLogger()))->close(); + + $this->assertInstanceOf(WorkdayResult::class, $workdayResult); + } + + #[TestDox('Test Timeman::close builds correct parameters')] + public function testCloseBuildsCorrectParameters(): void + { + $core = $this->createMock(CoreInterface::class); + $response = $this->createStub(Response::class); + $time = CarbonImmutable::create(2025, 3, 27, 17, 0, 0, 'UTC'); + + $core->expects($this->once()) + ->method('call') + ->with( + 'timeman.close', + [ + 'USER_ID' => 503, + 'TIME' => $time->format(CarbonImmutable::ATOM), + 'REPORT' => 'End of day', + 'LAT' => 53.548841, + 'LON' => 9.987274, + ] + ) + ->willReturn($response); + + $workdayResult = (new Timeman($core, new NullLogger()))->close( + userId: 503, + time: $time, + report: 'End of day', + lat: 53.548841, + lon: 9.987274 + ); + + $this->assertInstanceOf(WorkdayResult::class, $workdayResult); + } + + #[TestDox('Test Timeman::status calls timeman.status without parameters')] + public function testStatusCallsCorrectMethodWithNoParams(): void + { + $core = $this->createMock(CoreInterface::class); + $response = $this->createStub(Response::class); + + $core->expects($this->once()) + ->method('call') + ->with('timeman.status', []) + ->willReturn($response); + + $workdayResult = (new Timeman($core, new NullLogger()))->status(); + + $this->assertInstanceOf(WorkdayResult::class, $workdayResult); + } + + #[TestDox('Test Timeman::status builds correct parameters')] + public function testStatusBuildsCorrectParameters(): void + { + $core = $this->createMock(CoreInterface::class); + $response = $this->createStub(Response::class); + + $core->expects($this->once()) + ->method('call') + ->with('timeman.status', ['USER_ID' => 503]) + ->willReturn($response); + + $workdayResult = (new Timeman($core, new NullLogger()))->status(userId: 503); + + $this->assertInstanceOf(WorkdayResult::class, $workdayResult); + } + + #[TestDox('Test Timeman::settings calls timeman.settings without parameters')] + public function testSettingsCallsCorrectMethodWithNoParams(): void + { + $core = $this->createMock(CoreInterface::class); + $response = $this->createStub(Response::class); + + $core->expects($this->once()) + ->method('call') + ->with('timeman.settings', []) + ->willReturn($response); + + $timemanSettingsResult = (new Timeman($core, new NullLogger()))->settings(); + + $this->assertInstanceOf(TimemanSettingsResult::class, $timemanSettingsResult); + } + + #[TestDox('Test Timeman::settings builds correct parameters')] + public function testSettingsBuildsCorrectParameters(): void + { + $core = $this->createMock(CoreInterface::class); + $response = $this->createStub(Response::class); + + $core->expects($this->once()) + ->method('call') + ->with('timeman.settings', ['USER_ID' => 503]) + ->willReturn($response); + + $timemanSettingsResult = (new Timeman($core, new NullLogger()))->settings(userId: 503); + + $this->assertInstanceOf(TimemanSettingsResult::class, $timemanSettingsResult); + } +} From 277ea122d2f000a7a0b19f89ffd8c576c1e3229a Mon Sep 17 00:00:00 2001 From: Dmitriy Ignatenko Date: Fri, 22 May 2026 19:29:28 +0400 Subject: [PATCH 2/3] Fix on timeman testing results --- .../Timeman/Result/WorkdayItemResult.php | 3 +- .../WorkdayItemResultAnnotationsTest.php | 60 +++++++++++++++++-- 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/src/Services/Timeman/Result/WorkdayItemResult.php b/src/Services/Timeman/Result/WorkdayItemResult.php index d79e3971..14bb398d 100644 --- a/src/Services/Timeman/Result/WorkdayItemResult.php +++ b/src/Services/Timeman/Result/WorkdayItemResult.php @@ -20,7 +20,7 @@ * Represents a single workday item returned by timeman.open, timeman.pause, timeman.close, timeman.status methods. * * @property-read string $STATUS - * @property-read CarbonImmutable $TIME_START + * @property-read CarbonImmutable|null $TIME_START * @property-read CarbonImmutable|null $TIME_FINISH * @property-read string $DURATION * @property-read string $TIME_LEAKS @@ -32,7 +32,6 @@ * @property-read float $LAT_CLOSE * @property-read float $LON_CLOSE * @property-read int $TZ_OFFSET - * @property-read CarbonImmutable|null $TIME_FINISH_DEFAULT */ class WorkdayItemResult extends AbstractAnnotatedItem { diff --git a/tests/Integration/Services/Timeman/Result/WorkdayItemResultAnnotationsTest.php b/tests/Integration/Services/Timeman/Result/WorkdayItemResultAnnotationsTest.php index 42c9e54f..af89708b 100644 --- a/tests/Integration/Services/Timeman/Result/WorkdayItemResultAnnotationsTest.php +++ b/tests/Integration/Services/Timeman/Result/WorkdayItemResultAnnotationsTest.php @@ -17,10 +17,13 @@ use Bitrix24\SDK\Services\Timeman\Service\Timeman; use Bitrix24\SDK\Tests\CustomAssertions\CustomBitrix24Assertions; use Bitrix24\SDK\Tests\Integration\Factory; +use Carbon\CarbonImmutable; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; +use Typhoon\Reflection\TyphoonReflector; +use function Typhoon\Type\stringify; #[CoversClass(WorkdayItemResult::class)] class WorkdayItemResultAnnotationsTest extends TestCase @@ -33,6 +36,15 @@ class WorkdayItemResultAnnotationsTest extends TestCase protected function setUp(): void { $this->timemanService = Factory::getServiceBuilder()->getTimemanScope()->timeman(); + // Ensure a workday is open so the API returns all fields (not just STATUS) + $this->timemanService->open(); + } + + #[\Override] + protected function tearDown(): void + { + // Close the workday opened in setUp to clean up + $this->timemanService->close(); } #[Test] @@ -54,10 +66,48 @@ public function testAllSystemFieldsHasValidTypeAnnotation(): void { $workdayItemResult = $this->timemanService->status()->getWorkday(); - $this->assertBitrix24ResultItemFieldsTypeCastMatchAnnotations( - $workdayItemResult, - WorkdayItemResult::class - ); + // We do the type-cast check inline rather than via assertBitrix24ResultItemFieldsTypeCastMatchAnnotations + // because that method passes the raw union-type string (e.g. "Carbon\CarbonImmutable|null") + // to assertInstanceOf(), which PHP rejects as an invalid class name. + $collection = TyphoonReflector::build() + ->reflectClass(WorkdayItemResult::class) + ->properties(); + + foreach ($collection as $meta) { + if (!$meta->isAnnotated()) { + continue; + } + + if ($meta->isNative()) { + continue; + } + + $propName = $meta->id->name; + $typeStr = stringify($meta->type()); + $value = $workdayItemResult->$propName; + + // null is always valid for nullable types + if (str_contains($typeStr, 'null') && $value === null) { + continue; + } + + $message = sprintf( + 'field «%s» in «%s» annotated as «%s» but actual PHP type is «%s»', + $propName, + WorkdayItemResult::class, + $typeStr, + get_debug_type($value) + ); + + match (true) { + str_contains($typeStr, CarbonImmutable::class) => $this->assertInstanceOf(CarbonImmutable::class, $value, $message), + str_contains($typeStr, 'array') => $this->assertIsArray($value, $message), + str_contains($typeStr, 'bool') => $this->assertIsBool($value, $message), + str_contains($typeStr, 'int') => $this->assertIsInt($value, $message), + str_contains($typeStr, 'float') => $this->assertIsFloat($value, $message), + str_contains($typeStr, 'string') => $this->assertIsString($value, $message), + default => $this->assertInstanceOf($typeStr, $value, $message), + }; + } } } - From 67d47de9f5e708d09f64828f04817aabceb343c0 Mon Sep 17 00:00:00 2001 From: Dmitriy Ignatenko Date: Mon, 25 May 2026 11:22:11 +0400 Subject: [PATCH 3/3] Move to 3.3.0 release --- CHANGELOG.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec334abe..7b167004 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,15 @@ ### Added +- Added `Services\Timeman` service with support for workday tracking methods, + see [timeman.* methods](https://apidocs.bitrix24.com/api-reference/timeman/index.html): + - `open` — starts a new workday or continues after pause/close + - `pause` — pauses the current workday + - `close` — closes the current workday + - `status` — gets current workday status + - `settings` — gets user's work time settings + ([#484](https://github.com/bitrix24/b24phpsdk/issues/484)) + ### Changed ### Fixed @@ -11,14 +20,6 @@ ### Added -- Added `Services\Timeman` service with support for workday tracking methods, - see [timeman.* methods](https://apidocs.bitrix24.com/api-reference/timeman/index.html): - - `open` — starts a new workday or continues after pause/close - - `pause` — pauses the current workday - - `close` — closes the current workday - - `status` — gets current workday status - - `settings` — gets user's work time settings - ([#484](https://github.com/bitrix24/b24phpsdk/issues/484)) - Added `Bitrix24\SDK\Services\IM\Department\Service\Department` service wrapping `im.department.get`, `im.department.colleagues.list`, `im.department.employees.get`, and `im.department.managers.get`, with typed department/user result wrappers and `IMServiceBuilder::department()` accessor ([#432](https://github.com/bitrix24/b24phpsdk/issues/432)) - Added `Bitrix24\SDK\Services\IM\Disk\Service\Disk` with `getFolderId(?int $chatId = null, ?string $dialogId = null)` for `im.disk.folder.get`, plus dedicated `FolderIdResult`, IM builder registration, and focused unit/integration coverage ([#435](https://github.com/bitrix24/b24phpsdk/issues/435)) - Added IM Disk file operations to `Bitrix24\SDK\Services\IM\Disk\Service\Disk`: `commitFile`, `deleteFile`, `saveFile`, and `shareRecord`, with dedicated result wrappers and live IM Disk integration coverage ([#482](https://github.com/bitrix24/b24phpsdk/issues/482))