Compare commits

...
Sign in to create a new pull request.

50 commits
init ... main

Author SHA1 Message Date
Jérémy
70d38cccd1 Merge branch 'arcadia-api-integration' into 'main'
Arcadia Api Integration

Closes #1

See merge request ldc-web/projets/arcadia/arcadia-php-client!2
2026-09-18 16:52:11 +02:00
10526906eb
fix(inco): align IncoMapper and IncoApiRepository with simplified IncoListResponse 2026-09-18 12:11:51 +02:00
c24991bd7d
refactor(service): remove redundant short methods from IncoService 2026-09-18 11:17:44 +02:00
7c51b7c710
feat(inco): unify inco service and repositories around IncoShort listing 2026-09-18 10:44:46 +02:00
997fb5c405
refactor(mapper): deserialize IncoShort directly without intersection wrapper 2026-09-18 10:44:07 +02:00
6903584a28
chore: change php minimal dependency to 8.3 2026-09-17 12:54:45 +02:00
c26002e3c6
ci: fixes on pipeline 2026-09-16 17:08:25 +02:00
35097439b5
ci: adapt commands to new 85-ci image (without bash) 2026-09-16 17:01:54 +02:00
64d659de19
docs: remove obsolete TODO.md 2026-09-16 12:46:59 +02:00
dd291496a2
chore(meta): remove redundant LICENCE.md in favor of standard LICENSE 2026-09-16 12:46:49 +02:00
6d531aa559
docs: update README with comprehensive quickstart, services, pagination and error handling guides 2026-09-16 12:45:51 +02:00
892ae4b83e
ci(gitlab): add publish job to push Composer packages on tag 2026-09-16 12:45:33 +02:00
f39af35418
chore(meta): add MIT LICENSE and .gitattributes export-ignore rules 2026-09-16 12:45:23 +02:00
16383aebe0
build(composer): require published kiota package, move Tests to autoload-dev and untrack composer.lock 2026-09-16 12:45:09 +02:00
9b6fbcceda
ci: enforce minimum 85% code coverage in gitlab-ci pest test job 2026-09-16 12:12:32 +02:00
c3a7277ad1
test(dto): add unit tests for DTOs with full object graph and 100% compo parts coverage 2026-09-16 12:12:29 +02:00
c5a22c23e1
test(client): add unit tests for ArcadiaApiClient and factories 2026-09-16 12:12:25 +02:00
102b2d11a4
test(service): add unit tests for ArcadiaApiService, BrandService, EmployeeService and IncoService 2026-09-16 12:12:22 +02:00
3a8ef9409a
test(repository): add unit tests for Abstract, Brand, Employee, Inco, IncoCompo and IncoShort repositories 2026-09-16 12:12:19 +02:00
fbd5f5829f
test(mapper): add unit tests for Brand, Employee, Inco, IncoCompo and IncoShort mappers 2026-09-16 12:12:16 +02:00
4bce9ffc92
test(lib): add unit tests for ApiConfig, ApiValueObjects and Pagination 2026-09-16 12:12:12 +02:00
cdd71446b2
test(auth): add unit tests for auth middleware, signature and value objects 2026-09-16 12:12:09 +02:00
8c36902d86
refactor(mapper): make mappers non-final and adjust arch test and bypass-finals 2026-09-16 12:12:05 +02:00
080e7d73d1
refactor(dto): remove redundant JsonSerializable from PortionAmount 2026-09-16 12:00:45 +02:00
72358cf13e
test(arch): update architecture tests to enforce Mapper conventions and isolation 2026-09-16 10:31:13 +02:00
1c0ecb2bd5
refactor(mapper): rename hydrators to mappers across source, repositories and factory 2026-09-16 10:30:17 +02:00
55caa0729b
feat(service): add ApiService, update services and factories with signature calculator and Pagination VOs 2026-09-15 12:49:19 +02:00
26d42cc0d8
refactor(repository): update repositories and hydrators to use new Pagination namespace and PageSize VO 2026-09-15 12:49:15 +02:00
09c8806a5e
feat(pagination): move Pagination to namespace and introduce PageNumber, PageSize and Equatable 2026-09-15 12:49:13 +02:00
f098582fe6
feat(auth): extract request signature payload and calculator with HttpMethod enum 2026-09-15 12:49:11 +02:00
8ff36e5207
test(arch): update Pest architecture test suite for the new client architecture 2026-09-14 19:27:00 +02:00
d2ff816b0c
feat(client): implement ArcadiaApiClient entry point, services, factories, logging and observability 2026-09-14 19:26:26 +02:00
0d9e89f919
feat(repository): implement Kiota OpenAPI repositories and contracts 2026-09-14 19:26:21 +02:00
ea17c0950a
feat(hydrator): implement Kiota OpenAPI model hydrators 2026-09-14 19:26:20 +02:00
bbb3acc234
feat(dto): update DTOs, value objects, sorters, pagination and configuration 2026-09-14 19:26:16 +02:00
019e7a4025
feat(error): implement unified ApiError interface and concrete error classes 2026-09-14 19:26:13 +02:00
a2e810c2eb
feat(auth): implement HMAC authentication middleware, nonce and credentials value objects 2026-09-14 19:26:11 +02:00
c5f7199b9c
refactor(legacy): remove deprecated Genesis utilities, parsers, query builders, mappers and tests 2026-09-14 19:26:07 +02:00
eb10792328
chore(config): update dependencies, CI, sonar and development configuration 2026-09-14 19:25:58 +02:00
a3f444a670
refactor: DTO and VO updates 2026-09-14 09:40:33 +02:00
fa5e7b29ee
doc: readme update 2026-09-14 09:37:39 +02:00
3a56802b88
chore: auto theme config change 2026-09-14 09:37:18 +02:00
b72cf9fb9c
chore: package name change 2026-09-14 09:37:00 +02:00
9c47b9cd66
refactor: rename namespace from ArcadiaGenesisPhpClient to ArcadiaApiPhpClient
This change updates the namespace across multiple files to reflect the new naming convention.
2026-09-11 16:55:12 +02:00
a4524c92a6
chore: update PHP version in VSCode settings
Updated PHP version to 8.5.0 for compatibility with the latest features.
2026-09-02 15:32:05 +02:00
a1607367ee
chore: update PHPStan level to 10 for stricter analysis
Increases the PHPStan level from 8 to 10 to enforce stricter type checks.
2026-09-02 15:31:58 +02:00
8ce55313c4
chore: update dependencies for improved stability
Updated various dependencies to their latest versions to enhance project stability and security.
2026-09-02 15:31:45 +02:00
eadc5b5505
style: update VSCode settings for improved UI colors
Enhance the visual appearance of the workspace by customizing the color scheme for various UI elements.
2026-09-02 15:19:49 +02:00
c4c065a257
chore: update .gitignore to include /packages/ 2026-09-02 15:17:47 +02:00
Jeremy Munsch
7fafa64826
refactor: First draw with minimal tests Closes #1 2026-04-16 09:48:49 +02:00
180 changed files with 16771 additions and 1742 deletions

21
.editorconfig Normal file
View file

@ -0,0 +1,21 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.php]
indent_size = 4
[*.md]
trim_trailing_whitespace = false
[*.scss]
quote_type = single
[*.css]
quote_type = single

6
.gitattributes vendored Normal file
View file

@ -0,0 +1,6 @@
/tests export-ignore
/.gitlab-ci.yml export-ignore
/packages export-ignore
/phpstan.neon.dist export-ignore
.gitignore export-ignore
.gitattributes export-ignore

9
.gitignore vendored
View file

@ -1 +1,10 @@
/packages/
/vendor/
.php-cs-fixer.cache
junit.xml
.phpunit.cache
coverage
coverage.xml
test.php
.scannerwork/
/composer.lock

110
.gitlab-ci.yml Normal file
View file

@ -0,0 +1,110 @@
include:
- project: "ldc-web/shared-ci"
ref: "main"
file: "lint/phpcs.yml"
- project: "ldc-web/shared-ci"
ref: "main"
file: "lint/phpstan.yml"
- project: "ldc-web/shared-ci"
ref: "main"
file: "security/composer.yml"
- project: "ldc-web/shared-ci"
ref: "main"
file: "test/test.yml"
stages:
- lint
- security
- test
- publish
phplint:
stage: lint
image: $CI_REGISTRY/ldc-web/projets/ldc-web-images-docker/uwil-ci-8.5:latest
variables:
TEXT_REPORT: php-l-report.txt
before_script:
- composer config gitlab-token.lab.s-web.fr $CI_DEPLOY_USER $CI_DEPLOY_PASSWORD
- composer install --no-scripts --no-interaction --no-ansi --no-progress --prefer-dist
script:
- composer run-script lint:php > "$TEXT_REPORT" 2>&1; status=$?
- cat "$TEXT_REPORT"
- if [ $status -ne 0 ] || grep -qE "Parse error|Errors parsing" "$TEXT_REPORT"; then echo "PHP lint failed"; exit 1; fi
artifacts:
when: always
expose_as: "php-l report"
paths:
- php-l-report.txt
allow_failure: false
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
pint:
stage: lint
image: $CI_REGISTRY/ldc-web/projets/ldc-web-images-docker/uwil-ci-8.5:latest
variables:
PINT_REPORT: pint-report.txt
before_script:
- composer config gitlab-token.lab.s-web.fr $CI_DEPLOY_USER $CI_DEPLOY_PASSWORD
- composer install --no-scripts --no-interaction --no-ansi --no-progress --prefer-dist
script:
- vendor/bin/pint --test > "$PINT_REPORT" 2>&1; status=$?
- cat "$PINT_REPORT"
- if [ $status -ne 0 ] || grep -qE "ERROR|ERRORS|ERROR:" "$PINT_REPORT"; then echo "Pint reported errors"; exit 1; fi
artifacts:
when: always
expose_as: "pint report"
paths:
- pint-report.txt
allow_failure: false
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
pest:
stage: test
image: $CI_REGISTRY/ldc-web/projets/ldc-web-images-docker/uwil-ci-8.5:latest
variables:
JUNIT_REPORT: junit.xml
TEXT_REPORT: test-output.txt
COVERAGE_REPORT: coverage.xml
before_script:
- composer config gitlab-token.lab.s-web.fr $CI_DEPLOY_USER $CI_DEPLOY_PASSWORD
- composer install --no-scripts --no-interaction --no-ansi --no-progress --prefer-dist
script:
- rm -f "$JUNIT_REPORT" "$COVERAGE_REPORT"
- composer run-script test:coverage -- --min=85 > "$TEXT_REPORT" 2>&1; status=$?
- cat "$TEXT_REPORT"
- if [ -f "$COVERAGE_REPORT" ]; then echo "Coverage report generated $COVERAGE_REPORT"; else echo "No coverage report found"; fi
- exit $status
artifacts:
when: always
expose_as: "pest report"
paths:
- junit.xml
- test-output.txt
- coverage.xml
reports:
junit: junit.xml
allow_failure: false
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
publish:
stage: publish
image: curlimages/curl:latest
variables:
URL: "$CI_SERVER_PROTOCOL://$CI_SERVER_HOST:$CI_SERVER_PORT/api/v4/projects/$CI_PROJECT_ID/packages/composer?job_token=$CI_JOB_TOKEN"
script:
- version=$(echo "tag=$CI_COMMIT_TAG")
- insecure=$([ "$CI_SERVER_PROTOCOL" = "http" ] && echo "--insecure" || echo "")
- response=$(curl -s -w "\n%{http_code}" $insecure --data $version $URL)
- code=$(echo "$response" | tail -n 1)
- body=$(echo "$response" | head -n 1)
- if [ "$code" -eq 201 ]; then
echo "Package created - Code $code - $body";
else
echo "Could not create package - Code $code - $body";
exit 1;
fi
rules:
- if: '$CI_COMMIT_TAG =~ /^\d+.\d+.\d+/'

File diff suppressed because it is too large Load diff

8
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,8 @@
{
"recommendations": [
"bmewburn.vscode-intelephense-client",
"editorconfig.editorconfig",
"valeryanm.vscode-phpsab",
"open-southeners.laravel-pint"
]
}

20
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,20 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Listen for Xdebug",
"type": "php",
"request": "launch",
"port": 9003
},
{
"name": "Listen for Xdebug (Docker)",
"type": "php",
"request": "launch",
"port": 9004,
"pathMappings": {
"/app": "${workspaceFolder}"
}
}
]
}

111
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,111 @@
{
"sonarlint.analysisExcludes": "vendor/**,packages/**,junit.xml,coverage/**",
"php.version": "8.5.0",
"intelephense.environment.phpVersion": "8.5.0",
"intelephense.environment.includePaths": [
"./vendor/pestphp",
"./vendor/phpunit/phpunit",
"./vendor/laravel"
],
"phpSniffer.executablePath": "${workspaceFolder}/vendor/bin/phpcs",
"phpcs.executablePath": "${workspaceFolder}/vendor/bin/phpcs",
"phpsab.executablePathCS": "vendor/bin/phpcs",
"phpsab.executablePathCBF": "vendor/bin/phpcbf",
"phpsab.standard": "phpcs.xml",
"phpsab.autoRulesetSearch": false,
"phpstan.binCommand": [
"${workspaceFolder}/vendor/bin/phpstan"
],
"phpstan.configFile": "phpstan.neon.dist",
"files.associations": { "*.module": "php" },
"php.suggest.basic": false,
"laravel-pint.enable": true,
"[php]": {
"editor.formatOnSave": false
},
"editor.formatOnSave": true,
"eslint.validate": ["javascript", "typescript", "html", "vue"],
"[html]": {
"html.format.enable": false,
"editor.formatOnSave": true,
"editor.formatOnPaste": true,
"editor.defaultFormatter": "rvest.vs-code-prettier-eslint",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "always"
}
},
"[blade]": {
"editor.formatOnSave": true,
"editor.formatOnType": false
},
"[javascript]": {
"editor.autoIndent": "full",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "always"
},
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
"editor.detectIndentation": false,
"editor.formatOnSave": true
},
"[scss]": {
"editor.defaultFormatter": "stylelint.vscode-stylelint",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.stylelint": "always"
}
},
"[typescript]": {
"editor.autoIndent": "full",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "always"
},
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
"editor.detectIndentation": false,
"editor.formatOnSave": true,
"editor.formatOnSaveMode": "file"
},
"[vue]": {
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "always"
}
},
"peacock.color": "#022922",
"emeraldwalk.runonsave": {
"autoClearConsole": false,
"commands": [
{
"match": "\\.php$",
"isAsync": true,
"cmd": "${workspaceFolder}/vendor/bin/pint ${file}"
}
]
},
"workbench.colorCustomizations": {
"activityBar.activeBackground": "#045a4a",
"activityBar.background": "#045a4a",
"activityBar.foreground": "#e7e7e7",
"activityBar.inactiveForeground": "#e7e7e799",
"activityBarBadge.background": "#d029f5",
"activityBarBadge.foreground": "#e7e7e7",
"activityBarTop.activeBackground": "#045a4a",
"activityBarTop.background": "#045a4a",
"activityBarTop.foreground": "#e7e7e7",
"activityBarTop.inactiveForeground": "#e7e7e799",
"commandCenter.border": "#e7e7e799",
"commandCenter.foreground": "#e7e7e7",
"sash.hoverBorder": "#045a4a",
"statusBar.background": "#022922",
"statusBar.debuggingBackground": "#022922",
"statusBar.debuggingForeground": "#e7e7e7",
"statusBar.foreground": "#e7e7e7",
"statusBarItem.hoverBackground": "#045a4a",
"statusBarItem.remoteBackground": "#9709b6",
"statusBarItem.remoteForeground": "#e7e7e7",
"titleBar.activeBackground": "#022922",
"titleBar.activeForeground": "#e7e7e7",
"titleBar.inactiveBackground": "#02292299",
"titleBar.inactiveForeground": "#e7e7e799",
"activityBar.activeBorder": "#e7e7e7",
"activityBarTop.activeBorder": "#e7e7e7"
}
}

19
.vscode/tasks.json vendored Normal file
View file

@ -0,0 +1,19 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Pint Formatter",
"type": "shell",
"command": "./vendor/bin/pint",
"problemMatcher": [],
"presentation": {
"reveal": "silent",
"close": true
},
"group": {
"kind": "build",
"isDefault": true
}
}
]
}

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 LDC Digital
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

345
README.md
View file

@ -1,32 +1,325 @@
# Arcadia Genesis PHP Client
# Arcadia API PHP Client
## Models
[![PHP Version](https://img.shields.io/badge/php-^8.5-blue.svg)](https://www.php.net/)
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
[![Tests: Pest](https://img.shields.io/badge/tests-Pest%20PHP-purple.svg)](https://pestphp.com/)
[![PHPStan: Level 10](https://img.shields.io/badge/phpstan-level%2010-brightgreen.svg)](https://phpstan.org/)
### Employee
#### Props
```php
public string $matricule;
public string $lastname;
public string $firstname;
public string $start_date;
public string|null $end_date;
public string|null $planned_end_date;
public string $establishment;
public string $site;
public string $service;
Client PHP haut niveau, fortement typé et orienté domaine pour l'API Arcadia.
Il s'appuie sur le client OpenAPI généré par Kiota (`uwil/arcadia-kiota-open-api-client`) et fournit :
- Une couche de services métier (`EmployeeService`, `IncoService`, `BrandService`, `ApiService`).
- Des Value Objects stricts (`ArcadiaEmployeeId`, `IncoId`, `IncoCompoId`, `BrandCode`, `SiteId`, etc.).
- Des DTOs riches (`Employee`, `Inco`, `IncoCompo`, `IncoShort`, `Brand`) avec typage complet et conformité `JsonSerializable`.
- Une gestion unifiée de la pagination (`PageOf`, `Pagination`) et du parcours continu par flux (`StreamOf`).
- L'authentification automatique avec signature HMAC (`HMAC-SHA256`) et gestion des nonces.
- Le support de la journalisation (PSR-3 `LoggerInterface`) et de l'observabilité (OpenTelemetry).
- Une gestion explicite des erreurs via la hiérarchie `ApiError`.
---
## Sommaire
- [Prérequis](#prérequis)
- [Installation](#installation)
- [Initialisation](#initialisation)
- [Services et Utilisation](#services-et-utilisation)
- [Vérification de l'API (`apiService`)](#vérification-de-lapi-apiservice)
- [Gestion des Collaborateurs (`employees`)](#gestion-des-collaborateurs-employees)
- [Fiches INCO & Compositions (`incoSheets`)](#fiches-inco--compositions-incosheets)
- [Gestion des Marques (`brands`)](#gestion-des-marques-brands)
- [Pagination et Streaming](#pagination-et-streaming)
- [Gestion des Erreurs](#gestion-des-erreurs)
- [Tests et Qualité](#tests-et-qualité)
- [Licence](#licence)
---
## Prérequis
- **PHP 8.5+**
- Extensions PHP : `ext-curl`, `ext-mbstring`
- Accès au registre GitLab Composer de LDC Digital
---
## Installation
Comme cette bibliothèque et sa sous-dépendance Kiota sont hébergées sur le registre privé GitLab de LDC, configurez le dépôt Composer dans le `composer.json` de votre projet :
```json
{
"repositories": [
{
"type": "composer",
"url": "https://lab.s-web.fr/api/v4/group/23/-/packages/composer/packages.json"
}
]
}
```
#### Usage
```php
<?php
use Uwil\ArcadiaGenesisPhpClient\Datas\EmployeesDatas;
Puis installez le client via Composer :
require_once 'vendor/autoload.php';
$employee = EmployeesDatas::getById("0115590");
var_dump($employee);
?>
```bash
composer require uwil/arcadia-api-php-client
```
### INCO
TODO
Assurez-vous d'avoir configuré vos identifiants d'accès GitLab pour Composer (via `auth.json` ou variable d'environnement CI) :
```bash
composer config gitlab-token.lab.s-web.fr <deploy-token-user> <deploy-token-password>
```
---
## Initialisation
L'entrée principale de la bibliothèque est [`ArcadiaApiClient`](src/ArcadiaApiClient.php), configurée avec [`ApiConfig`](src/Lib/ApiConfig.php) :
```php
use GuzzleHttp\Psr7\Utils;
use Uwil\ArcadiaApiPhpClient\ArcadiaApiClient;
use Uwil\ArcadiaApiPhpClient\Auth\ClientIdentifier;
use Uwil\ArcadiaApiPhpClient\Auth\ClientSecret;
use Uwil\ArcadiaApiPhpClient\Lib\ApiConfig;
$config = new ApiConfig(
endpoint: Utils::uriFor('https://arcadia-api.domain.tld/api'),
apiKey: new ClientIdentifier('VOTRE_API_KEY'),
secret: new ClientSecret('VOTRE_SECRET_KEY'),
curlTimeOut: 20.0, // secondes (optionnel, défaut: 20.0)
maxRetries: 3, // tentatives en cas d'erreur réseau (optionnel, défaut: 0)
retryDelaySeconds: 1, // délai entre les retries (optionnel, défaut: 1)
ignoreSelfSignedCert: false, // pour environnements locaux de test (défaut: false)
logger: null, // ?Psr\Log\LoggerInterface
tracer: null, // ?OpenTelemetry\API\Trace\TracerInterface
);
$client = new ArcadiaApiClient($config);
```
---
## Services et Utilisation
### Vérification de l'API (`apiService`)
Permet de vérifier la disponibilité et la santé de l'API Arcadia :
```php
$isAlive = $client->apiService()->isAlive();
if ($isAlive) {
echo "L'API Arcadia est joignable et opérationnelle.\n";
}
```
---
### Gestion des Collaborateurs (`employees`)
Le service [`EmployeeService`](src/Service/EmployeeService.php) permet de consulter et rechercher les salariés.
#### Récupérer par identifiant
```php
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\ArcadiaEmployeeId;
use Uwil\ArcadiaApiPhpClient\Error\ApiError;
$employee = $client->employees()->findBy(new ArcadiaEmployeeId('0115590'));
if ($employee instanceof ApiError) {
echo "Erreur API: " . $employee->getMessage();
} elseif ($employee === null) {
echo "Collaborateur non trouvé (404).\n";
} else {
echo "Salarié: {$employee->firstname} {$employee->lastname} (Site: {$employee->site})\n";
}
```
#### Récupérer par site(s)
```php
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\SiteId;
use Uwil\ArcadiaApiPhpClient\Lib\ApiSorter\EmployeeSorter;
use Uwil\ArcadiaApiPhpClient\Lib\ApiSorter\EmployeeSortColumn;
// Pour un site unique avec tri par matricule
$page = $client->employees()->getAllOfSites(
new SiteId('01'),
new EmployeeSorter(EmployeeSortColumn::MATRICULE, 'asc')
);
// Pour plusieurs sites modifiés depuis une date
$page = $client->employees()->getAllOfSitesSince(
[new SiteId('01'), new SiteId('02')],
new DateTimeImmutable('2026-01-01')
);
```
#### Streaming de tous les collaborateurs
Le streaming parcourt automatiquement toutes les pages de façon transparente grâce à un générateur PHP :
```php
foreach ($client->employees()->streamAll() as $employee) {
echo "{$employee->matricule} - {$employee->lastname}\n";
}
```
---
### Fiches INCO & Compositions (`incoSheets`)
Le service [`IncoService`](src/Service/IncoService.php) gère les fiches techniques produit (INCO) et leurs compositions nutritionnelles détaillées.
#### Consulter une fiche INCO
```php
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\IncoId;
$inco = $client->incoSheets()->findBy(new IncoId('12345'));
if ($inco !== null && !($inco instanceof ApiError)) {
echo "Fiche: {$inco->commercialLabel}\n";
echo "Nutri-Score: {$inco->nutriscore?->grade}\n";
}
```
#### Consulter la composition nutritionnelle
Vous pouvez interroger la composition par son `IncoCompoId` ou directement par l'`IncoId` :
```php
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\IncoCompoId;
// Directement via IncoCompoId
$compo = $client->incoSheets()->findCompo(new IncoCompoId('98765'));
// Ou via IncoId
$compo = $client->incoSheets()->findCompo(new IncoId('12345'));
if ($compo !== null && !($compo instanceof ApiError)) {
echo "Énergie (kJ): {$compo->energy?->kJ?->value}\n";
echo "Acides gras saturés: {$compo->fattyAcids?->saturatedFattyAcids?->value}\n";
echo "Points Nutri-Score: {$compo->nutriScorePoints?->totalPoints}\n";
}
```
#### Recherche et listage par marque
```php
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\BrandCode;
// Liste courte (IncoShort) pour optimiser les performances d'affichage
$shortsPage = $client->incoSheets()->getAllShortOfBrands(new BrandCode('GAULOIS'));
// Recherche par motif d'identifiant
$results = $client->incoSheets()->findByIncoIdLike('POULET%', new BrandCode('GAULOIS'));
// Streaming de toutes les fiches d'une marque
foreach ($client->incoSheets()->streamAllOfBrands(new BrandCode('LEGAULOIS')) as $sheet) {
echo "Fiche #{$sheet->id->value}: {$sheet->commercialLabel}\n";
}
```
---
### Gestion des Marques (`brands`)
Le service [`BrandService`](src/Service/BrandService.php) donne accès aux marques référencées :
```php
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\BrandCode;
// Récupérer une marque
$brand = $client->brands()->findBy(new BrandCode('GAULOIS'));
// Parcourir toutes les marques
foreach ($client->brands()->streamAll() as $brand) {
echo "Marque [{$brand->code->value}] : {$brand->label}\n";
}
```
---
## Pagination et Streaming
### Objet `PageOf`
Toutes les méthodes de liste paginées retournent un objet [`PageOf<T>`](src/Lib/ApiDto/Dto/PageOf.php) contenant les éléments et les métadonnées de pagination :
```php
$page = $client->employees()->getAll();
// Éléments de la page courante
$employees = $page->items;
// Métadonnées
echo "Page actuelle : " . $page->pagination->pageNumber->value . "\n";
echo "Taille de page : " . $page->pagination->pageSize->value . "\n";
echo "Total éléments : " . $page->pagination->totalItems . "\n";
echo "Total pages : " . $page->pagination->totalPages . "\n";
```
### Objet `StreamOf`
Toutes les méthodes `stream*()` retournent un [`StreamOf<T>`](src/Lib/Pagination/StreamOf.php) qui implémente `\IteratorAggregate`. Il effectue les requêtes de page suivante automatiquement à la demande :
```php
$stream = $client->employees()->streamAll();
foreach ($stream as $employee) {
// Les pages suivantes sont chargées en arrière-plan au fur et à mesure de l'itération
}
```
---
## Gestion des Erreurs
Les méthodes du client ne lancent pas d'exceptions inattendues pour les erreurs HTTP ou de communication ; elles renvoient une instance de [`ApiError`](src/Error/ApiError.php) (ou ses sous-classes spécialisées) :
```php
use Uwil\ArcadiaApiPhpClient\Error\AuthenticationError;
use Uwil\ArcadiaApiPhpClient\Error\ValidationError;
use Uwil\ArcadiaApiPhpClient\Error\NetworkError;
use Uwil\ArcadiaApiPhpClient\Error\RateLimitError;
use Uwil\ArcadiaApiPhpClient\Error\ServerError;
use Uwil\ArcadiaApiPhpClient\Error\ApiError;
$result = $client->employees()->findBy(new ArcadiaEmployeeId('0115590'));
match (true) {
$result instanceof AuthenticationError => logAuthFailure($result),
$result instanceof ValidationError => logValidationDetails($result->getViolations()),
$result instanceof NetworkError => retryLater($result),
$result instanceof RateLimitError => handleRateLimit($result),
$result instanceof ServerError => notifyAdmin($result),
$result instanceof ApiError => logGenericError($result),
$result === null => handleNotFound(),
default => handleEmployee($result),
};
```
---
## Tests et Qualité
Le projet est rigoureusement testé avec une suite complète de tests unitaires et d'architecture.
```bash
# Lancer les tests unitaires
composer test
# Vérifier la couverture de code (seuil minimal 85% configuré)
composer test:coverage
# Analyse statique PHPStan (niveau 10)
composer lint:phpstan
# Vérifier le style du code (Laravel Pint)
composer lint:pint
# Formater le code automatiquement
composer format
```
---
## Licence
Ce projet est sous licence MIT. Consultez le fichier [LICENSE](LICENSE) pour plus d'informations.

View file

@ -1,22 +1,63 @@
{
"name": "uwil/arcadia-genesis-php-client",
"description": "PHP Client implementation for Arcadia Genesis API",
"type": "library",
"autoload": {
"psr-4": {
"Uwil\\ArcadiaGenesisPhpClient\\": "src/"
}
},
"authors": [
{
"name": "LDC Digital",
"email": "digital@ldc.fr"
}
],
"require": {
"php": "^8.0",
"ext-mbstring": "*",
"guzzlehttp/guzzle": "^7.10",
"nesbot/carbon": "^3.11"
"name": "uwil/arcadia-api-php-client",
"description": "PHP Client implementation for Arcadia API",
"type": "library",
"license": "MIT",
"autoload": {
"psr-4": {
"Uwil\\ArcadiaApiPhpClient\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"authors": [
{
"name": "LDC Digital",
"email": "digital@ldc.fr"
}
],
"scripts": {
"format": "vendor/bin/pint",
"lint:php": "find . -path './vendor' -prune -o -type f -name '*.php' -print0 | xargs -0 -n1 -P4 -I{} php -l \"{}\"",
"lint:phpcs": "vendor/bin/phpcs",
"lint:phpstan": "vendor/bin/phpstan analyse",
"lint:pint": "vendor/bin/pint --no-interaction --quiet",
"test": "vendor/bin/pest --log-junit=junit.xml",
"test:coverage": "vendor/bin/pest --log-junit=junit.xml --coverage --coverage-html=coverage --coverage-clover=coverage.xml"
},
"require": {
"ext-curl": "*",
"ext-mbstring": "*",
"php": ">=8.3",
"guzzlehttp/guzzle": "^7.10",
"uwil/arcadia-kiota-open-api-client": "^0.1.0",
"symfony/polyfill-php86": "^1.41"
},
"require-dev": {
"phpstan/phpstan": "^2.1",
"slevomat/coding-standard": "^8.28",
"friendsofphp/php-cs-fixer": "^3.94",
"laravel/pint": "^1.29",
"pestphp/pest": "^4.4",
"ace-of-aces/intellipest": "^0.3.0",
"mockery/mockery": "^1.6",
"dg/bypass-finals": "^1.9"
},
"repositories": [
{
"type": "composer",
"url": "https://lab.s-web.fr/api/v4/group/23/-/packages/composer/packages.json"
}
],
"config": {
"allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": true,
"pestphp/pest-plugin": true,
"php-http/discovery": true,
"tbachert/spi": true
}
}
}

1265
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,279 @@
# Classification Inco demandée a TOTO GPT
Voici une classification des champs en catégories sémantiques cohérentes pour des données nutritionnelles INCO :
---
## 🏷️ Classification des champs nutritionnels
### 1. **Identification / Métadonnées**
| Champ PHP | Constante | Description |
|---|---|---|
| `nom` | `NOM` | Nom du produit/fiche |
| `numero` | `NUMERO` | Numéro de la fiche |
| `source` | `SOURCE` | Source des données |
---
### 2. **Valeurs énergétiques — par 100g/100ml (référence `_R`)**
| Champ PHP | Constante | Description |
|---|---|---|
| `tailleReference` | `TAILLE_REF` | Taille de la portion de référence |
| `valEnergKjReference` | `VAL_ENERG_KJ_R` | Énergie en kJ / 100g |
| `valEnergKcalReference` | `VAL_ENERG_KCAL_R` | Énergie en kcal / 100g |
---
### 3. **Macronutriments — par 100g/100ml (`_R`) + précisions (`_PREC`)**
| Champ PHP | Constante | Description |
|---|---|---|
| `matGrassReference` / `matGrassReferencePrec` | `MAT_GRASS_R` | Matières grasses / 100g |
| `acideGrasSatureReference` / `acideGrasSatureReferencePrec` | `AGS_R` | Acides gras saturés / 100g |
| `glucidesReference` / `glucidesReferencePrec` | `GLUCIDES_R` | Glucides / 100g |
| `sucreReference` / `sucreReferencePrec` | `SUCRE_R` | Sucres / 100g |
| `fibAlimReference` / `fibAlimReferencePrec` | `FIB_ALIM_R` | Fibres alimentaires / 100g |
| `proteineReference` / `proteineReferencePrec` | `PROTEINE_R` | Protéines / 100g |
| `selReference` / `selReferencePrec` | `SEL_R` | Sel / 100g |
| `sodiumReference` | `SODIUM_R` | Sodium / 100g |
---
### 4. **Fruits, légumes, noix — par 100g/100ml (`_R`)**
| Champ PHP | Constante | Description |
|---|---|---|
| `fruitsReference` | `FRUITS_R` | Teneur en fruits / 100g |
| `fruitsCoqueSechReference` | `FRUITS_COQUE_SECH_R` | Fruits à coque et séchés / 100g |
| `pourcentFruits` | `POURCENT_FRUITS` | % fruits (global) |
---
### 5. **Valeurs nutritionnelles — par portion (`_P`) + précisions (`_PREC`)**
| Champ PHP | Constante | Description |
|---|---|---|
| `taillePortion` | `TAILLE_PORT` | Taille de la portion |
| `valEnergKjPortion` | `VAL_ENERG_KJ_P` | Énergie kJ / portion |
| `valEnergKcalPortion` | `VAL_ENERG_KCAL_P` | Énergie kcal / portion |
| `matGrassPortion` / `matGrassPortionPrec` | `MAT_GRASS_P` | Matières grasses / portion |
| `acideGrasSaturePortion` / `acideGrasSaturePortionPrec` | `AGS_P` | Acides gras saturés / portion |
| `glucidesPortion` / `glucidesPortionPrec` | `GLUCIDES_P` | Glucides / portion |
| `sucrePortion` / `sucrePortionPrec` | `SUCRE_P` | Sucres / portion |
| `fibAlimPortion` / `fibAlimPortionPrec` | `FIB_ALIM_P` | Fibres / portion |
| `proteinePortion` / `proteinePortionPrec` | `PROTEINE_P` | Protéines / portion |
| `sodiumPortion` | `SODIUM_P` | Sodium / portion |
| `selPortion` / `selPortionPrec` | `SEL_P` | Sel / portion |
| `fruitsPortion` | `FRUITS_P` | Fruits / portion |
| `fruitsCoqueSechPortion` | `FRUITS_COQUE_SECH_P` | Fruits à coque et séchés / portion |
---
### 6. **Pourcentages des apports de référence (`POURCENT_APPORT_REF_*`)**
| Champ PHP | Constante | Description |
|---|---|---|
| `pourcentApportRefE14P` | `POURCENT_APPORT_REF_E14_P` | % AR énergie E14 / portion |
| `pourcentApportRefKjoP` | `POURCENT_APPORT_REF_KJO_P` | % AR énergie kJ / portion |
| `pourcentApportRefFatP` | `POURCENT_APPORT_REF_FAT_P` | % AR matières grasses / portion |
| `pourcentApportRefFatsatP` | `POURCENT_APPORT_REF_FATSAT_P` | % AR graisses saturées / portion |
| `pourcentApportRefChovalP` | `POURCENT_APPORT_REF_CHOVAL_P` | % AR glucides / portion |
| `pourcentApportRefSugarP` | `POURCENT_APPORT_REF_SUGAR_P` | % AR sucres / portion |
| `pourcentApportRefProP` | `POURCENT_APPORT_REF_PRO_P` | % AR protéines / portion |
| `pourcentApportRefSalteqP` | `POURCENT_APPORT_REF_SALTEQ_P` | % AR sel / portion |
---
### 7. **Nutri-Score**
| Champ PHP | Constante | Description |
|---|---|---|
| `composanteN` | `COMPOSANTE_N` | Composante négative (énergie, graisses, sucres, sel) |
| `composanteN2022` | `COMPOSANTE_N_2022` | Composante négative — algorithme 2022 |
| `composanteP` | `COMPOSANTE_P` | Composante positive (fibres, protéines, fruits) |
| `composanteP2022` | `COMPOSANTE_P_2022` | Composante positive — algorithme 2022 |
| `scoreNutri` | `SCORE_NUTRI` | Score Nutri-Score brut |
| `scoreNutri2022` | `SCORE_NUTRI_2022` | Score Nutri-Score 2022 |
| `classement` | `CLASSEMENT` | Lettre Nutri-Score (A–E) |
| `nutriScore2022` | `NUTRI_SCORE_2022` | Nutri-Score lettre — algorithme 2022 |
| `nutriAArt` | `NUTRI_A_ART` | Flag article Nutri-Score A |
---
### 8. **Points de calcul du Nutri-Score**
| Champ PHP | Constante | Description |
|---|---|---|
| `pointsEnergie` | `POINTS_ENERGIE` | Points énergie |
| `pointsAcideGrasSature` | `POINTS_AGS` | Points acides gras saturés |
| `pointsSucres` | `POINTS_SUCRES` | Points sucres |
| `pointsSel` | `POINTS_SEL` | Points sel |
| `pointsFibres` | `POINTS_FIBRES` | Points fibres |
| `pointsProteines` | `POINTS_PROTEINES` | Points protéines |
| `pointsFruitsLegumesNoixFrais` | `POINTS_FNL_FRAIS` | Points fruits/noix/légumes frais |
---
### 9. **FNL (Fruits, Noix, Légumes)**
| Champ PHP | Constante | Description |
|---|---|---|
| `fruitLegumeNoixFrais` | `FNL_FRAIS` | Teneur FNL frais / 100g |
| `fruitLegumeNoixFraisPortion` | `FNL_FRAIS_PORTION` | Teneur FNL frais / portion |
| `pourcentFruitsLegumesNoixFrais` | `POURCENT_FNL_FRAIS` | % FNL frais |
---
### 10. **Ratio / Indicateurs complémentaires**
| Champ PHP | Constante | Description |
|---|---|---|
| `pSurL` | `P_SUR_L` | Ratio P/L (protéines sur lipides) |
| `viandeRouge` | `VIANDE_ROUGE` | Flag viande rouge (impact Nutri-Score 2022) |
---
### 11. **Micronutriments détaillés (`NUT_*`)**
Cette famille regroupe les vitamines, minéraux, acides aminés et acides gras détaillés — typiquement issus de bases comme **Ciqual** ou **FoodData Central**.
#### 11a. Minéraux & Oligo-éléments
| Champ PHP | Constante |
|---|---|
| `nutCa` | `NUT_CA` — Calcium |
| `nutMg` | `NUT_MG` — Magnésium |
| `nutK` | `NUT_K` — Potassium |
| `nutP` | `NUT_P` — Phosphore |
| `nutCld` | `NUT_CLD` — Chlorure |
| `nutFe` | `NUT_FE` — Fer |
| `nutZn` | `NUT_ZN` — Zinc |
| `nutCu` | `NUT_CU` — Cuivre |
| `nutMn` | `NUT_MN` — Manganèse |
| `nutSe` | `NUT_SE` — Sélénium |
| `nutId` | `NUT_ID` — Iode |
| `nutCr` | `NUT_CR` — Chrome |
| `nutMo` | `NUT_MO` — Molybdène |
| `nutS6` | `NUT_S6` — Soufre |
| `nutNitra` | `NUT_NITRA` — Nitrates |
#### 11b. Vitamines
| Champ PHP | Constante |
|---|---|
| `nutVita` | `NUT_VITA` — Vitamine A |
| `nutVitb6` | `NUT_VITB6` — Vitamine B6 |
| `nutVitb12` | `NUT_VITB12` — Vitamine B12 |
| `nutVitc` | `NUT_VITC` — Vitamine C |
| `nutVitd` | `NUT_VITD` — Vitamine D |
| `nutVite` | `NUT_VITE` — Vitamine E |
| `nutVitk` | `NUT_VITK` — Vitamine K |
| `nutChocal` | `NUT_CHOCAL` — Cholécalciférol (vit. D3) |
| `nutNia` | `NUT_NIA` — Niacine (B3) |
| `nutRibf` | `NUT_RIBF` — Riboflavine (B2) |
| `nutThia` | `NUT_THIA` — Thiamine (B1) |
| `nutPantac` | `NUT_PANTAC` — Acide pantothénique (B5) |
| `nutBiot` | `NUT_BIOT` — Biotine (B8) |
| `nutFoldfe` | `NUT_FOLDFE` — Folates équivalents (B9) |
#### 11c. Glucides détaillés
| Champ PHP | Constante |
|---|---|
| `nutStarch` | `NUT_STARCH` — Amidon |
| `nutMaltdex` | `NUT_MALTDEX` — Maltodextrines |
| `nutSucs` | `NUT_SUCS` — Saccharose |
| `nutGlus` | `NUT_GLUS` — Glucose |
| `nutLacs` | `NUT_LACS` — Lactose |
| `nutMals` | `NUT_MALS` — Maltose |
| `nutGalfb` | `NUT_GALFB` — Galactose |
| `nutGHc` | `NUT_G_HC` — Glucides hydrates de carbone |
| `nutFrufb` | `NUT_FRUFB` — Fructose lié (fibre) |
| `nutFrus` | `NUT_FRUS` — Fructose libre |
| `nutOlsac` | `NUT_OLSAC` — Oligosaccharides |
| `nutPolyl` | `NUT_POLYL` — Polyols |
| `nutFibsol` | `NUT_FIBSOL` — Fibres solubles |
| `nutInotl` | `NUT_INOTL` — Inositol |
#### 11d. Lipides / Acides gras détaillés
> Nomenclature lipidique standard (IUPAC / Ciqual) — `FXXdY` = chaîne carbonée XX, Y insaturations
| Champ PHP | Constante | Nom usuel |
|---|---|---|
| `nutAgt` | `NUT_AGT` | Acides gras trans |
| `nutChol` | `NUT_CHOL` | Cholestérol |
| `nutFd` | `NUT_FD` | Lipides totaux |
| `nutFpalm` | `NUT_FPALM` | Palmitoleïque (C16:1) |
| `nutF16d0` | `NUT_F16D0` | Acide palmitique (C16:0) |
| `nutFsteariq` | `NUT_FSTEARIQ` | Acide stéarique (C18:0) |
| `nutFoleiq` | `NUT_FOLEIQ` | Acide oléique (C18:1, ω-9) |
| `nutF18d` | `NUT_F18D` | C18 insaturé (générique) |
| `nutF18d2cn6` | `NUT_F18D2CN6` | Acide linoléique (C18:2, ω-6) |
| `nutF18d3n3` | `NUT_F18D3N3` | Acide α-linolénique (C18:3, ω-3) |
| `nutF20d4` | `NUT_F20D4` | Acide arachidonique (C20:4) |
| `nutF20d5n3` | `NUT_F20D5N3` | EPA (C20:5, ω-3) |
| `nutF22d6n3` | `NUT_F22D6N3` | DHA (C22:6, ω-3) |
| `nutF11eico` | `NUT_F11EICO` | Acide gondoïque (C20:1) |
| `nutFarachidiq` | `NUT_FARACHIDIQ` | Acide arachidique (C20:0) |
| `nutFeruciq` | `NUT_FERUCIQ` | Acide érucique (C22:1) |
| `nutFlauriq` | `NUT_FLAURIQ` | Acide laurique (C12:0) |
| `nutFmyristiq` | `NUT_FMYRISTIQ` | Acide myristique (C14:0) |
| `nutFbutyriq` | `NUT_FBUTYRIQ` | Acide butyrique (C4:0) |
| `nutFcaproiq` | `NUT_FCAPROIQ` | Acide caproïque (C6:0) |
| `nutFcapryliq` | `NUT_FCAPRYLIQ` | Acide caprylique (C8:0) |
| `nutFcapriq` | `NUT_FCAPRIQ` | Acide caprique (C10:0) |
| `nutFapun3` | `NUT_FAPUN3` | AG polyinsaturés ω-3 |
| `nutFapun6` | `NUT_FAPUN6` | AG polyinsaturés ω-6 |
| `nutFapun9` | `NUT_FAPUN9` | AG polyinsaturés ω-9 |
| `nutFapucis` | `NUT_FAPUCIS` | AG polyinsaturés cis |
| `nutFapulc` | `NUT_FAPULC` | AG polyinsaturés longue chaîne |
| `nutFamscis` | `NUT_FAMSCIS` | AG monoinsaturés cis |
| `nutFacetiq` | `NUT_FACETIQ` | Acide acétique (C2:0) |
| `nutFpropioniq` | `NUT_FPROPIONIQ` | Acide propionique (C3:0) |
| `nutFvaleriq` | `NUT_FVALERIQ` | Acide valérique (C5:0) |
| `nutFcarboceriq` | `NUT_FCARBOCERIQ` | Acide carbocérique (C26:0) |
| `nutFbeheniq` | `NUT_FBEHENIQ` | Acide béhénique (C22:0) |
| `nutFlignoceriq` | `NUT_FLIGNOCERIQ` | Acide lignocérique (C24:0) |
| `nutFnervoniq` | `NUT_FNERVONIQ` | Acide nervonique (C24:1) |
| `nutFlaceroiq` | `NUT_FLACEROIQ` | Acide lacéroïque (C32:0) |
| `nutFmelissiq` | `NUT_FMELISSIQ` | Acide mélissique (C30:0) |
| `nutFmontaniq` | `NUT_FMONTANIQ` | Acide montanique (C28:0) |
| `nutFcerotiq` | `NUT_FCEROTIQ` | Acide cérotique (C26:0 alt.) |
| `nutFpsylliq` | `NUT_FPSYLLIQ` | Acide psyllique |
| `nutFgeddiq` | `NUT_FGEDDIQ` | Acide geddique (C20:1) |
| `nutFmargariq` | `NUT_FMARGARIQ` | Acide margarique (C17:0) |
| `nutFenatiq` | `NUT_FENATIQ` | Acide énanthique (C7:0) |
| `nutFformiq` | `NUT_FFORMIQ` | Acide formique (C1:0) |
| `nutFhyeniq` | `NUT_FHYENIQ` | Acide hyénique |
| `nutFrufb` | `NUT_FRUFB` | *(déjà classé glucides)* |
| `nutFrus` | `NUT_FRUS` | *(déjà classé glucides)* |
| `nutFpelargoniq` | `NUT_FPELARGONIQ` | Acide pélargonique (C9:0) |
| `nutFundecyliq` | `NUT_FUNDECYLIQ` | Acide undécylique (C11:0) |
| `nutFpentadecyliq` | `NUT_FPENTADECYLIQ` | Acide pentadécylique (C15:0) |
| `nutFtridecyliq` | `NUT_FTRIDECYLIQ` | Acide tridécylique (C13:0) |
| `nutFtricosiliq` | `NUT_FTRICOSILIQ` | Acide tétracosanoïque (C23:0) |
| `nutFnonacosyliq` | `NUT_FNONACOSYLIQ` | Acide nonacosylique (C29:0) |
| `nutFnonadylique` | `NUT_FNONADYLIQUE` | Acide nonadylique |
| `nutFheneicosyliq` | `NUT_FHENEICOSYLIQ` | Acide héneicosylique (C21:0) |
| `nutFhentriacontyliq` | `NUT_FHENTRIACONTYLIQ` | C31:0 |
| `nutFheptatriacontyliq` | `NUT_FHEPTATRIACONTYLIQ` | C37:0 |
| `nutFhextriacontyliq` | `NUT_FHEXTRIACONTYLIQ` | C36:0 |
| `nutFoctatriacontyliq` | `NUT_FOCTATRIACONTYLIQ` | C38:0 |
| `nutF19d` | `NUT_F19D` | C19 insaturé |
| `nutFoldfe` | `NUT_FOLDFE` | *(déjà classé vitamines — folates)* |
#### 11e. Acides aminés & protéines spécifiques
| Champ PHP | Constante | Description |
|---|---|---|
| `nutAae` | `NUT_AAE` | Acides aminés essentiels (total) |
| `nutCasn` | `NUT_CASN` | Caséines |
| `nutWhey` | `NUT_WHEY` | Protéines de lactosérum |
#### 11f. Autres composés bioactifs / spéciaux
| Champ PHP | Constante | Description |
|---|---|---|
| `nutCholn` | `NUT_CHOLN` | Choline |
| `nutCarnitine` | `NUT_CARNITINE` | Carnitine |
| `nutTau` | `NUT_TAU` | Taurine |
| `nutNucleotide` | `NUT_NUCLEOTIDE` | Nucléotides |
---
## ⚠️ Points d'attention
1. **Doublon détecté** : `'acideGrasSaturePortion' => 'AGS_P'` est déclaré **deux fois** — l'une des deux entrées sera silencieusement ignorée par PHP.
2. **Champs `_PREC`** : Les suffixes `_PREC` semblent indiquer la **précision de mesure** (ex. `<`, `~`, tracé) — à documenter explicitement.
3. **Champs MISSING** : Nombreux champs présents dans le référentiel INCO mais absents du mapping — notamment toute la série `POURCENT_APPORT_REF_*_R` (apports de référence pour 100g) et certains AG comme `FATSAT_R`, `CHOVAL_R`, `SUGAR_R`.
4. **`classement` / `nutriscore`** : Vous notez vous-même que `CLASSEMENT` ne devrait pas être dans `INCO_COMPO` — à externaliser dans un objet Nutri-Score dédié.

View file

@ -0,0 +1,187 @@
{
"VAL_ENERG_KCAL_R": "300",
"VAL_ENERG_KJ_P": "",
"VAL_ENERG_KCAL_P": "",
"VAL_ENERG_KJ_R": "1251",
"NUT_VITB12": "",
"POINTS_PROTEINES": "",
"POURCENT_APPORT_REF_FATSAT_P": "",
"NUT_FVALERIQ": "",
"NUT_WHEY": "",
"NUT_FHENTRIACONTYLIQ": "",
"NUT_FAPULC": "",
"POINTS_AGS": "",
"NUT_GLUS": "",
"NUT_F20D4": "",
"NUT_F19D": "",
"NUT_F20D5N3": "",
"ID": "44897",
"NUT_FLAURIQ": "",
"MAT_GRASS_R": "17",
"NUT_FFORMIQ": "",
"MAT_GRASS_P": "",
"NUT_INOTL": "",
"NUT_MALTDEX": "",
"AGS_R_PREC": "0",
"NUT_SUCS": "",
"NUT_FAPUN6": "",
"POURCENT_FNL_FRAIS": "",
"NUT_FHEXTRIACONTYLIQ": "",
"NUT_FERUCIQ": "",
"NUT_FAPUN9": "",
"NUT_NITRA": "",
"CLASSEMENT": "D",
"POINTS_SUCRES": "",
"NUT_CHOL": "",
"NUT_CHOCAL": "",
"NUT_CHOLN": "",
"NUT_FAPUN3": "",
"NUT_FCAPRYLIQ": "",
"NUT_VITE": "",
"NUT_VITD": "",
"NUT_VITC": "",
"POURCENT_APPORT_REF_KJO_P": "",
"NUT_F18D3N3": "",
"AGS_R": "7",
"NUT_VITA": "",
"NUT_F18D": "",
"SOURCE": "1",
"SEL_P": "",
"SEL_P_PREC": "",
"NUT_FPELARGONIQ": "",
"NUT_VITK": "",
"NUT_FRUS": "",
"NUT_FBUTYRIQ": "",
"NUT_FE": "",
"SEL_R": "1.4",
"NUT_NUCLEOTIDE": "",
"NUT_FD": "",
"POURCENT_APPORT_REF_FAT_P": "",
"NUT_BIOT": "",
"NUT_FIBSOL": "",
"POURCENT_APPORT_REF_PRO_P": "",
"NUT_FCEROTIQ": "",
"MAT_GRASS_R_PREC": "0",
"NUT_PANTAC": "",
"NUT_MG": "",
"NUT_G_HC": "",
"NUT_TAU": "",
"SEL_R_PREC": "0",
"GLUCIDES_R_PREC": "0",
"TAILLE_REF": "100",
"POURCENT_APPORT_REF_E14_P": "",
"NUT_FCARBOCERIQ": "",
"NUT_FLACEROIQ": "",
"FIB_ALIM_R": "1.3",
"FIB_ALIM_P": "",
"NUT_FPENTADECYLIQ": "",
"NUT_FBEHENIQ": "",
"NUT_FNONACOSYLIQ": "",
"NUT_FPSYLLIQ": "",
"NUT_F18D2CN6": "",
"NUT_FUNDECYLIQ": "",
"NUT_FAPUCIS": "",
"NUT_FMARGARIQ": "",
"PROTEINE_R": "11",
"NUT_MO": "",
"POINTS_SEL": "",
"NUT_MN": "",
"PROTEINE_P": "",
"NUT_F11EICO": "",
"SUCRE_R": "1.5",
"SUCRE_P": "",
"SUCRE_R_PREC": "0",
"NUT_FPALM": "",
"NUT_FGEDDIQ": "",
"NUT_FMYRISTIQ": "",
"APPORT_PORT": "Apport de r\u00e9f\u00e9rence pour un adulte type (8400Kj-2000Kcal)",
"NUT_CU": "",
"SCORE_NUTRI": "14",
"NUT_CR": "",
"NUT_MALS": "",
"NUT_F16D0": "",
"NUT_FOCTATRIACONTYLIQ": "",
"NUT_FRUFB": "",
"NUT_CASN": "",
"NUT_FMONTANIQ": "",
"POINTS_ENERGIE": "",
"FRUITS_R": "0",
"COMPOSANTE_N_2022": "",
"VIANDE_ROUGE": "",
"NUT_POLYL": "",
"NUT_VITB6": "",
"NUTRI_A_ART": "",
"PROTEINE_P_PREC": "",
"NUT_STARCH": "",
"P_SUR_L": ",65",
"NUT_RIBF": "",
"SODIUM_P": "",
"NUT_FENATIQ": "",
"NUT_K": "",
"NUT_FTRIDECYLIQ": "",
"SUCRE_P_PREC": "",
"NUT_SE": "",
"NUT_FTRICOSILIQ": "",
"NUT_P": "",
"SODIUM_R": "560",
"NUT_FARACHIDIQ": "",
"NUT_FPROPIONIQ": "",
"NUT_FNERVONIQ": "",
"SCORE_NUTRI_2022": "",
"NUT_FNONADYLIQUE": "",
"FIB_ALIM_P_PREC": "",
"NUT_CA": "",
"NUT_FMELISSIQ": "",
"GLUCIDES_P_PREC": "",
"POURCENT_FRUITS": "0",
"NUT_OLSAC": "",
"COMPOSANTE_P": "6",
"GLUCIDES_P": "",
"NUT_ZN": "",
"COMPOSANTE_N": "15",
"GLUCIDES_R": "25",
"NUT_FCEROPLASTIQ": "",
"FNL_FRAIS": "",
"NOM": "-",
"NUT_AAE": "",
"NUT_FSTEARIQ": "",
"TAILLE_PORT": "",
"POURCENT_APPORT_REF_CHOVAL_P": "",
"AGS_P_PREC": "",
"PROTEINE_R_PREC": "0",
"NUT_FCAPROIQ": "",
"NUT_S6": "",
"MAT_GRASS_P_PREC": "",
"NUT_FHEPTATRIACONTYLIQ": "",
"NUT_FAMSCIS": "",
"NUT_ID": "",
"NUT_CARNITINE": "",
"POINTS_FIBRES": "",
"AGS_P": "",
"NUT_FOLDFE": "",
"COMPOSANTE_P_2022": "",
"NUT_GALFB": "",
"NUT_CLD": "",
"NUT_THIA": "",
"POURCENT_APPORT_REF_SALTEQ_P": "",
"NUT_FHENEICOSYLIQ": "",
"NUT_FOLEIQ": "",
"NUT_AGT": "",
"POINTS_FNL_FRAIS": "",
"NUT_FACETIQ": "",
"NUT_NIA": "",
"NUT_FCAPRIQ": "",
"NUT_F22D6N3": "",
"FIB_ALIM_R_PREC": "0",
"NUTRI_SCORE_2022": "",
"NUT_LACS": "",
"POURCENT_APPORT_REF_SUGAR_P": "",
"NUMERO": "-",
"ID_INCO": {
"code": "77310",
"libelle": "SC 240GFX PALET CHEVR SOIGN IQF"
},
"FRUITS_COQUE_SECH_R": "0",
"NUT_FHYENIQ": "",
"NUT_FLIGNOCERIQ": ""
}

View file

@ -0,0 +1,335 @@
{
"VILLE_GRPMT_QUAL": "",
"DATEVERSION": "2022-03-07",
"TYP_PORTION": "2",
"NB_PORTION_QUAL": "",
"CAP4_AVANTAGE": "",
"EX_GOUT": "",
"ALIMENTATION": "",
"COMM_MKT": "",
"GRAM_ENT_CARCERAL": "",
"ANTIBIO": "",
"NOM_GRPMT_QUAL": "",
"DENOM_COMM": "Farce \u00e0 base de viande de dinde et de gras de dinde ayant fait l\u2019objet d\u2019un broyage, additionn\u00e9 \u00e0 une pr\u00e9paration aromatisante.",
"GRPMT_QUAL": "",
"NUTRISCORE_ON_PACK": "D",
"CALIBRE_OEUFS": "",
"AFF_DVT_FT": "",
"AROME_ARTIF": "",
"POIDS_MOYEN_UNI": "",
"REGLEMENTATION": "",
"PMC_EURO_UVC": "0",
"DATE_STATUT_QUAL": "2022-10-26",
"AROME_NATUREL": "",
"BRIQUE_GPC": {
"code": "10005784",
"libelle": "Dinde - Pr\u00e9par\u00e9e\/Transform\u00e9e"
},
"CRIT_PHYSICO": "",
"DAT_CRE": "2017-06-29T16:12:00Z",
"AW": "",
"LIB_CONSO": "",
"AGEMINI": "",
"NB_PORTION": "\/",
"CONDI_SS_AP": "0",
"NB_ADDITIF_BLACKLIST": "0",
"UTIL_QUAL": {
"code": "SC4911560",
"libelle": "CHAUVEAU Severine"
},
"GRAM_ENT_ELEM": "",
"PMC_MINI": "",
"MENTION_COMPL": "",
"CAP4_PREUVE": "",
"COLOR_ARTIF": "",
"UNITE_CDE": "0",
"GRAM_ENT_MATERN": "",
"NB_UVC": "",
"CONDIT_EMB": {
"code": "2",
"libelle": "SOUS VIDE"
},
"CONDIT_CONSERV_1": "A conserver entre 0\u00b0C et +4\u00b0C",
"LIB_SERV_CONSO": "LE GAULOIS PROFESSIONNEL",
"DLC_RM": "",
"DLC_COMM_GRP": "10",
"POURCENT_CEREAL_MINI": "",
"ID_SERV_CONSO": {
"code": "2161",
"libelle": "LE GAULOIS PROFESSIONNEL"
},
"COMM_COMPO": "",
"GRAM_ENT_ADO": "",
"CP_EXPLOIT": "61140",
"VILLE_EXPLOIT": "Rives d andaine",
"GRAM_ENT_AGEES": "",
"PAYS_ORIGINE_PROV_ING1": "",
"COD_FAMRGPT": {
"code": "4",
"libelle": "PRODUITS ELAB CRUS"
},
"IS_HALAL": "0",
"UTIL_MKT": "",
"ADR_2_SERV_CONSO": "",
"FICHE_MERE": {
"code": "44863",
"libelle": ""
},
"CONSEIL_PREPA": "A consommer cuit \u00e0 c\u0153ur\nUne farce de volaille \u00e0 utiliser au gr\u00e9 de vos envies pour farcir des l\u00e9gumes, des volailles, des r\u00f4tis\u2026",
"INNOVATION": "0",
"TRACES_SIT_PROD": "",
"POIDS_CST": "0",
"TYPE_STOCK": "",
"COMM_REF_COMPO": "",
"ALLEGAT_AUTRE": "",
"GENCOD_SYMBOL": "2667277",
"LONGUEUR": "400",
"IS_PAM": "0",
"FICH_REF_COMPO": "0",
"ALLEGATIONS": "",
"NO_HOMOLOGATION": "",
"ALLEGATION_MKT": "",
"IONISATION": "Aucun ingr\u00e9dients ionis\u00e9 dans cette recette.",
"STATUT_QUAL": "1",
"CAP2_AVANTAGE": "",
"DAT_MAJ": "2025-06-26T14:16:00Z",
"GRAM_PLAT_ELEM": "",
"ARG_VENTE": "Une farce de volaille moelleuse \u00e0 utiliser au gr\u00e9 de vos envies pour farcir des l\u00e9gumes, des volailles, des r\u00f4tis\u2026",
"COLORANT_NATUREL": "",
"CAP2_PREUVE": "",
"SS_FAMILLE_VTE": {
"code": "77",
"libelle": "FARCES"
},
"POIDS_MAXI": "2700",
"SOC_REF_INCO": {
"code": "13",
"libelle": "SNV"
},
"TRACES_RCT": [
{
"code": "21",
"libelle": "Aucun allerg\u00e8ne"
}
],
"CP_SERV_CONSO": "49640",
"CAP4_CARAC": "",
"CIBLE": "famille",
"CALIBRE": "",
"PDS_NET_UVC": "2500",
"PDS_BRUT_UVC": "2518",
"ADRESSE_OC": "",
"SIGLE_ALLERG": "",
"CONSEIL_PREPA_1": "A consommer cuit \u00e0 c\u0153ur",
"CONSEIL_PREPA_2": "Une farce de volaille \u00e0 utiliser au gr\u00e9 de vos envies pour farcir des l\u00e9gumes, des volailles, des r\u00f4tis\u2026",
"PROD_BIO": "0",
"REF_COMPO": {
"code": "44863",
"libelle": ""
},
"NB_MUT_SHORT_LIST": "1",
"CP_GRPMT_QUAL": "",
"SAUCE": "",
"GRAM_PLAT_CARCERAL": "",
"LST_MUT": [
{
"code": "7",
"libelle": "dextrose"
},
{
"code": "11",
"libelle": "ar\u00f4me"
}
],
"DESCRIPTIF": "Farce de volaille",
"TYPE_PRODUCTION": "",
"ALLEGAT_NUTRIT": "",
"PESTICIDE": "",
"DATE_JOUR_PONTE": "",
"LIBELLE_ARTICLE_INCO": "KG - LE GAULOIS PRO - 2.5KG FARCE POUR LEGUMES PURE DINDE S\/V",
"UPDATE_VERSION": "1",
"LST_ING": "Viande de dinde (origine France): 66%, gras de dinde, \u00e9chalote, conservateur: lactate de potassium, sel, persil, dextrose, betterave en poudre, oignon, ar\u00f4mes naturels, \u00e9pices, antioxydants: acide ascorbique - ascorbate de sodium, sucre.",
"LOGO_EXPLOIT": "<a href=\"javascript:telechargerPJ('controller\/VisuFichier?sessInt=-1&TABLE=REF_FICHIERS&CLE=COD_FICHIER=270321&COLONNE=FICHIER&NOM_FICH=SNV.png&ACTION=90')\" title=\"T\u00e9l\u00e9charger le fichier SNV.png\" class=\"pseudo-link\">SNV.png<\/a>",
"VISUEL_SUGG_PRESENTATION": "",
"ADRESSE_GRPMT_QUAL": "",
"L_ALLEG_NUTRITION": "0",
"COD_GENCOD_POIDS": "2767277",
"COD_EMBAL": {
"code": "44",
"libelle": "SOUS VIDE POCHE"
},
"COMM_VOL_FERM": "",
"DENOM_LEG": "FARCE DE VOLAILLE POUR LEGUME PURE DINDE",
"NUM_AGR": "",
"COD_NDP": {
"code": "02072610",
"libelle": "DDE MRX DESOS FRAIS"
},
"ART_COMM_ACTIF": "1",
"PROD_SAISON": "0",
"COMM_TARE_SUPPL": "",
"COD_COMPOSTAGE": "1",
"LARGEUR": "300",
"DATE_STATUT_CMCL": "2019-03-13",
"DEMARCHE_QUAL": "",
"FREQ_UTIL": "",
"NB_ADDITIF_TROUVE": "3",
"NITRITE": "",
"CLASSEMENT": "D",
"CRIT_ORGANO": "",
"CAP3_AVANTAGE": "",
"TYPE_CONSERV": {
"code": "1",
"libelle": "FRAIS : [0oC,4oC]"
},
"GENCOD": "3571320448638",
"LST_ALLERGENE": [
{
"code": "21",
"libelle": "Aucun allerg\u00e8ne"
}
],
"CP_OC": "",
"ALLEGAT_SANTE": "",
"UTIL_CMCL": {
"code": "JJ2400325",
"libelle": "Jouin Joel"
},
"RESIDU_PESTICIDE": "",
"PDS_MOY_THEO": "2500",
"NB_MUT_TROUVE": "2",
"TARE_BARQ": "18",
"GARNITURE": "",
"VILLE_OC": "",
"CAP2_CARAC": "",
"LST_ADDITIF_BLACKLISTES": "",
"COMMENTAIRE_PROCESS": "",
"GRAM_PLAT_MATERN": "",
"NO_ART": {
"code": "44863",
"libelle": "FARCE PR LEG PUR DDE 2.5KG SV LGP"
},
"SPCB": "",
"EXTRA_FRAICHEUR": "",
"ISGENETICALMODIFIED": "",
"GRAM_PLAT_ADO": "",
"ALIM_ELEVAGE": "",
"PMC_EURO_KG": "1",
"ISNOTPLEINAIR": "",
"CAP3_CARAC": "",
"PMC_MAXI": "",
"DATE_STATUT_MKT": "2019-03-13",
"SITE_INT_SERV_CONSO": "www.legauloisprofessionnel.fr",
"OGM": "Conforme aux r\u00e8glements CE n\u00b0 1829\/2003 et n\u00b0 1830\/2003 : Produit Conventionnel.",
"CAP1_PREUVE": "",
"TARE_SUPPL": "",
"VERSION": "1",
"EXPLOITANT": {
"code": "13",
"libelle": "SNV"
},
"NOM_FONCT_INCO_ART": "Volailles & gibiers",
"NUTRI_A_ART": "1",
"RESIDU_CONTAMIN": "Conforme au r\u00e8glement CE 2023\/915 et ses modifications",
"SANS_OGM": "",
"ADRESSE_EXPLOIT": "447 route de la chevroliere",
"SEL_AJOUTE": "",
"COD_RAYON": {
"code": "6",
"libelle": "BOUCHERIE TRAD"
},
"LIST_NUM_AGREMENT": "FR 41.238.001 CE",
"LIST_COD_EMBAL": "EMB41238",
"COMM_NUTRI": "1",
"DUREE_VIE": "17",
"CAP1_AVANTAGE": "",
"TYPE_PAL": {
"code": "1",
"libelle": "Europe"
},
"LIST_CERTIF": "IFS FOOD",
"CRIT_MICROBIO_J0": "",
"CAP1_CARAC": "",
"CARAC_ELEVAGE": "1",
"FAMILLE_VTE": {
"code": "313",
"libelle": "FARCE"
},
"ETIQ_DONNEES_INCO": "2",
"MAIL_SERV_CONSO": "rhd@ldc.fr",
"NOM_SERV_CONSO": "GUILLET",
"INTERPROFESSION": [
{
"code": "13",
"libelle": "VOLAILLE FRANCAISE (logo homologue APVF)"
}
],
"NUTRISCORE_ON_PACK_QUALITE": "D",
"VILLE_SERV_CONSO": {
"code": "49119",
"libelle": "DAUMERAY"
},
"STATUT_CMCL": "1",
"NOM_EXPLOITANT": "SNV",
"MARQUE": {
"code": "504",
"libelle": "LE GAULOIS PROFESSIONNEL"
},
"UNITE_FACT": "2",
"VIAND_POISS": "",
"ASTUCES": "",
"DTS": "7",
"TEL_SERV_CONSO": "0241325665",
"ACTIVE": "1",
"SITE_REF_INCO": {
"code": "34",
"libelle": "SNV - VOLABRAYE"
},
"CAP3_PREUVE": "",
"HAUTEUR": "80",
"CRIT_MICROBIO": "Selon r\u00e8glement europ\u00e9en CE n\u00b0 2073\/2005 du 15\/11\/2005 compl\u00e9t\u00e9 avec des normes internes.",
"SUIVI_MODIF": "",
"LIBELLE_FACTURE_ARTICLE": "2.5 KG FARCE POUR LEGUMES PURE DINDE SOUS VIDE LE GAULOIS PROFESSIONNEL",
"NOM_OC": "",
"FAX_SERV_CONSO": "",
"CONDIT_CONSERV": "A conserver entre 0\u00b0C et +4\u00b0C\n",
"ADR_1_SERV_CONSO": "ZA Le Grand Clos",
"CRIT_MICROBIO_DLC": "",
"ORG_CERTIF": "",
"GRAM_PLAT_AGEES": "",
"LST_MUT_SHORTLIST": [
{
"code": "7",
"libelle": "dextrose"
}
],
"LIB_TYP_EMB": "SOUS VIDE POCHE",
"PMC": "",
"IS_CALCUL_GEMRCN": "",
"AGE_ABATT_MOY": "",
"POIDS_MINI": "2300",
"LST_ADDITIF": [
{
"code": "E326",
"libelle": "lactate de potassium"
},
{
"code": "E301",
"libelle": "Ascorbate de sodium"
},
{
"code": "E300",
"libelle": "Acide ascorbique"
}
],
"NUTRI_SCORE_2022": "D",
"REF_RCT": "",
"ELEVAGE": "",
"PH": "",
"STATUT_MKT": "1",
"UTILISABLE_GROUPE": {
"code": "1",
"libelle": "Oui"
},
"VIP": ""
}

View file

@ -1,20 +0,0 @@
<?php
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
use Uwil\ArcadiaGenesisPhpClient\Datas\EmployeesDatas;
use Uwil\ArcadiaGenesisPhpClient\Datas\IncoDatas;
require_once 'vendor/autoload.php';
$employee = EmployeesDatas::getById("0115590");
var_dump($employee);
// $employees = EmployeesDatas::getListModifiedSince("01/01/2026 00:00", ['LDCSER', 'SLELA', 'DDE']);
// var_dump($employees);
// $inco = IncoDatas::getById("2842");
// var_dump($inco);

54
phpcs.xml Normal file
View file

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<ruleset name="Php Coding Standard"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="vendor/squizlabs/php_codesniffer/phpcs.xsd">
<description>Standard optimisé : Analyse statique complémentaire à Pint.</description>
<arg name="colors" />
<arg name="extensions" value="php" />
<arg name="parallel" value="75" />
<file>src/</file>
<exclude-pattern>*/vendor/*</exclude-pattern>
<exclude-pattern>*/tests/*</exclude-pattern>
<rule ref="PSR12"/>
<rule ref="Generic.CodeAnalysis.EmptyStatement"/>
<rule ref="Generic.CodeAnalysis.UselessOverridingMethod"/>
<rule ref="Generic.Metrics.CyclomaticComplexity">
<properties>
<property name="complexity" value="10"/>
<property name="absoluteComplexity" value="20"/>
</properties>
</rule>
<rule ref="Generic.Metrics.NestingLevel"/>
<rule ref="Generic.PHP.DeprecatedFunctions"/>
<rule ref="Generic.PHP.ForbiddenFunctions"/>
<rule ref="Generic.PHP.NoSilencedErrors"/>
<rule ref="Squiz.PHP.Eval"/>
<rule ref="Squiz.PHP.GlobalKeyword"/>
<rule ref="Squiz.PHP.NonExecutableCode"/>
<rule ref="Squiz.Scope.StaticThisUsage"/>
<rule ref="Generic.Files.LineLength">
<properties>
<property name="lineLimit" value="150"/>
<property name="absoluteLineLimit" value="250"/>
</properties>
</rule>
<rule ref="SlevomatCodingStandard.Namespaces.UnusedUses" />
<rule ref="SlevomatCodingStandard.TypeHints.DeclareStrictTypes">
<properties>
<property name="spacesCountAroundEqualsSign" value="0" />
<property name="declareOnFirstLine" value="false" />
<property name="linesCountBeforeDeclare" value="1" />
<property name="linesCountAfterDeclare" value="1" />
</properties>
</rule>
<rule ref="PSR1.Classes.ClassDeclaration.MissingNamespace"/>
</ruleset>

9
phpstan.neon.dist Normal file
View file

@ -0,0 +1,9 @@
parameters:
paths:
- src
level: 10
# Enforce strict types analysis
treatPhpDocTypesAsCertain: false

25
phpunit.xml Normal file
View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" bootstrap="vendor/autoload.php" colors="true" cacheDirectory=".phpunit.cache">
<testsuites>
<testsuite name="Architecture">
<directory>tests/Architecture</directory>
</testsuite>
<testsuite name="Integration">
<directory>tests/Integration</directory>
</testsuite>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
<php>
</php>
</phpunit>

77
pint.json Normal file
View file

@ -0,0 +1,77 @@
{
"preset": "laravel",
"rules": {
"indentation_type": true,
"single_line_empty_body": false,
"types_spaces": {
"space": "single",
"space_multiple_catch": "single"
},
"control_structure_braces": true,
"new_with_parentheses": {
"anonymous_class": true,
"named_class": true
},
"class_definition": {
"single_item_single_line": false,
"single_line": false,
"space_before_parenthesis": true
},
"curly_braces_position": {
"allow_single_line_empty_anonymous_classes": false,
"classes_opening_brace": "next_line_unless_newline_at_signature_end",
"anonymous_classes_opening_brace": "next_line_unless_newline_at_signature_end",
"control_structures_opening_brace": "same_line",
"functions_opening_brace": "next_line_unless_newline_at_signature_end",
"anonymous_functions_opening_brace": "same_line"
},
"binary_operator_spaces": {
"default": "align_single_space_minimal",
"operators": {
"=": "align_single_space",
"|": "single_space",
"=>": "align_single_space",
".=": "align_single_space"
}
},
"concat_space": {
"spacing": "one"
},
"align_multiline_comment": {
"comment_type": "all_multiline"
},
"array_indentation": true,
"method_chaining_indentation": true,
"statement_indentation": {
"stick_comment_to_next_continuous_control_statement": true
},
"no_unused_imports": true,
"fully_qualified_strict_types": {
"import_symbols": true,
"leading_backslash_in_global_namespace": true,
"phpdoc_tags": []
},
"visibility_required": {
"elements": [
"const",
"method",
"property"
]
},
"no_useless_else": true,
"function_declaration": {
"closure_function_spacing": "one",
"closure_fn_spacing": "one",
"trailing_comma_single_line": false
},
"single_line_after_imports": true,
"combine_consecutive_issets": true,
"array_syntax": {
"syntax": "short"
},
"single_quote": true,
"blank_line_after_namespace": true,
"blank_line_after_opening_tag": true,
"declare_strict_types": true
}
}

7
sonar-project.properties Normal file
View file

@ -0,0 +1,7 @@
sonar.projectKey=arcadia-genesis-php-client
sonar.projectName=Arcadia Genesis PHP Client
sonar.sources=src
sonar.tests=tests
sonar.sourceEncoding=UTF-8
sonar.exclusions=**/vendor/**,**/packages/**,**/coverage/**,junit.xml
sonar.php.exclusions=**/vendor/**,**/packages/**

50
src/ArcadiaApiClient.php Normal file
View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient;
use Uwil\ArcadiaApiPhpClient\Factory\ApiClientFactory;
use Uwil\ArcadiaApiPhpClient\Factory\ServiceFactory;
use Uwil\ArcadiaApiPhpClient\Lib\ApiConfig;
use Uwil\ArcadiaApiPhpClient\Service\ApiService;
use Uwil\ArcadiaApiPhpClient\Service\BrandService;
use Uwil\ArcadiaApiPhpClient\Service\EmployeeService;
use Uwil\ArcadiaApiPhpClient\Service\IncoService;
class ArcadiaApiClient
{
private readonly ServiceFactory $serviceFactory;
public function __construct(
private readonly ApiConfig $apiConfig
) {
$apiClientFactory = new ApiClientFactory($this->apiConfig);
$apiClient = $apiClientFactory->createClient();
$this->serviceFactory = new ServiceFactory(
$apiClient,
$this->apiConfig
);
}
public function apiService(): ApiService
{
return $this->serviceFactory->createApiService();
}
public function employees(): EmployeeService
{
return $this->serviceFactory->createEmployeeService();
}
public function incoSheets(): IncoService
{
return $this->serviceFactory->createIncoService();
}
public function brands(): BrandService
{
return $this->serviceFactory->createBrandService();
}
}

View file

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Auth;
use GuzzleHttp\Promise\PromiseInterface;
use GuzzleHttp\Psr7\Utils;
use Psr\Http\Message\RequestInterface;
use Ramsey\Uuid\Uuid;
use Uwil\ArcadiaApiPhpClient\Enum\HttpMethod;
use Uwil\ArcadiaApiPhpClient\Lib\LibMiddleware;
/**
* @internal
*/
final class ApiMandatoryHeadersMiddleware implements LibMiddleware
{
public const string HANDLER_NAME = 'Arcadia API request Signature';
public const string HEADER_CLIENT_IDENTIFIER = 'X-Client-Identifier';
public const string HEADER_BODY_HASH = 'X-Body-Hash';
public const string HEADER_TIMESTAMP = 'X-Timestamp';
public const string HEADER_SIGNATURE = 'X-Signature';
public const string HEADER_NONCE = 'X-Nonce';
public static function signRequest(
ClientIdentifier $apiKey,
ClientSecret $withSecret,
RequestSignatureCalculator $signatureCalculator,
): callable {
return static function (callable $handler) use ($apiKey, $withSecret, $signatureCalculator): self {
return new self(
$handler,
$apiKey,
$withSecret,
$signatureCalculator
);
};
}
/**
* @param callable(RequestInterface, array<string, mixed>): PromiseInterface $nextHandler
*/
public function __construct(
private $nextHandler,
private readonly ClientIdentifier $apiKey,
private readonly ClientSecret $withSecret,
private readonly RequestSignatureCalculator $signatureCalculator,
) {
}
public function __invoke(RequestInterface $request, array $options): PromiseInterface
{
$fn = $this->nextHandler;
return $fn($this->sign($request), $options);
}
private function sign(RequestInterface $request): RequestInterface
{
$body = $request->getBody();
$body->rewind();
$bodyContents = $body->getContents();
$timestamp = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
$nonce = new Nonce(value: Uuid::uuid7());
$signatureResult = $this->signatureCalculator->sign(
new RequestSignaturePayload(
method: HttpMethod::from($request->getMethod()),
uri: $request->getUri(),
timestamp: $timestamp,
nonce: $nonce,
body: $bodyContents,
),
$this->withSecret,
);
$headers = [
self::HEADER_CLIENT_IDENTIFIER => $this->apiKey->value,
self::HEADER_TIMESTAMP => $timestamp->format(\DateTimeInterface::RFC3339),
self::HEADER_NONCE => $nonce->value->toString(),
self::HEADER_BODY_HASH => $signatureResult->bodyHash,
self::HEADER_SIGNATURE => $signatureResult->value,
];
return Utils::modifyRequest($request, [
'set_headers' => array_filter(
$headers,
static fn (string $name): bool => ! $request->hasHeader($name),
ARRAY_FILTER_USE_KEY
),
]);
}
}

View file

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Auth;
final readonly class ClientIdentifier
{
public function __construct(
#[\SensitiveParameter]
public string $value,
) {
if (empty(mb_trim($value))) {
throw new \InvalidArgumentException('ClientIdentifier cannot be empty.');
}
}
}

17
src/Auth/ClientSecret.php Normal file
View file

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Auth;
final readonly class ClientSecret
{
public function __construct(
#[\SensitiveParameter]
public string $value,
) {
if (empty(mb_trim($value))) {
throw new \InvalidArgumentException('ClientSecret cannot be empty.');
}
}
}

19
src/Auth/Nonce.php Normal file
View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Auth;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
final readonly class Nonce
{
public function __construct(
public readonly UuidInterface $value,
) {
if (! Uuid::isValid($value->toString())) {
throw new \InvalidArgumentException('Nonce value is not a valid UUID');
}
}
}

View file

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Auth;
final readonly class RequestSignature
{
public function __construct(
public string $value,
public string $bodyHash,
) {
if (empty(mb_trim($value))) {
throw new \InvalidArgumentException('Signature value cannot be empty.');
}
if (empty(mb_trim($bodyHash))) {
throw new \InvalidArgumentException('Body hash cannot be empty.');
}
}
}

View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Auth;
use Psr\Http\Message\UriInterface;
/**
* @internal
*/
class RequestSignatureCalculator
{
private const DATE_FORMAT = \DateTimeInterface::RFC3339;
private const HASH_ALGORITHM = 'sha256';
public function sign(RequestSignaturePayload $payload, ClientSecret $withSecret): RequestSignature
{
$bodyHash = hash(self::HASH_ALGORITHM, $payload->body);
$payloadToSign = [
$payload->method->value,
$this->normalizePathAndQuery($payload->uri),
$payload->timestamp->format(self::DATE_FORMAT),
$payload->nonce->value->toString(),
$bodyHash,
];
$signature = hash_hmac(
self::HASH_ALGORITHM,
implode('|', $payloadToSign),
$withSecret->value
);
return new RequestSignature(
value: $signature,
bodyHash: $bodyHash,
);
}
private function normalizePathAndQuery(UriInterface $uri): string
{
$path = $uri->getPath();
$query = $uri->getQuery();
if ($query === '') {
return $path;
}
parse_str($query, $queryParams);
ksort($queryParams);
$sortedQuery = http_build_query($queryParams);
return "{$path}?{$sortedQuery}";
}
}

View file

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Auth;
use Psr\Http\Message\UriInterface;
use Uwil\ArcadiaApiPhpClient\Enum\HttpMethod;
final readonly class RequestSignaturePayload
{
public function __construct(
public HttpMethod $method,
public UriInterface $uri,
public \DateTimeInterface $timestamp,
public Nonce $nonce,
public string $body,
) {
}
}

View file

@ -1,75 +0,0 @@
<?php
namespace Uwil\ArcadiaGenesisPhpClient\Datas;
use Uwil\ArcadiaGenesisPhpClient\Models\Employee;
use Uwil\ArcadiaGenesisPhpClient\Traits\ArcadiaTrait;
use Uwil\ArcadiaGenesisPhpClient\Transport;
class EmployeesDatas
{
use ArcadiaTrait;
private static string $table = "RU_PERSONNE_SIRH";
private static array $definition = [
'matricule' => 'MATRICULE_FIC',
'lastname' => 'NOM_FIC',
'firstname' => 'PRENOM_FIC',
'start_date' => 'DATE_DEB_FIC',
'end_date' => 'DATE_FIN_FIC',
'planned_end_date' => 'DATE_EFFET_FIC',
'establishment' => 'ETAB_FIC',
'site' => 'SITE_FIC',
'service' => 'SERVICE_FIC',
'created_at' => 'DAT_CRE',
'updated_at' => 'DAT_MAJ',
];
public static function getById(string $id): Employee|null
{
$params = [
'TABLE' => self::$table,
'S_COLS' => self::getArcadiaDefinition(),
'C_COLS' => self::getArcadiaDefinition(['matricule']),
'C_OPES' => ['='],
'C_VALS' => [$id],
];
$response = Transport::call($params);
if (empty($response[0]->values)) {
return null;
}
$model = new Employee();
$model->fill(self::mapObject($response[0]->values));
return $model;
}
public static function getListModifiedSince(string $date, array $sites = [])
{
$params = [
'TABLE' => self::$table,
'S_COLS' => self::getArcadiaDefinition(),
'C_COLS' => self::getArcadiaDefinition(
!empty($sites) ? ['site', 'updated_at'] : ['updated_at']
),
'C_OPES' => !empty($sites) ? ['IN', '>='] : ['>='],
'C_VALS' => !empty($sites) ? [implode(',', $sites), $date] : [$date],
];
$response = Transport::call($params);
var_dump($response);
exit;
if (empty($response[0]->values)) {
return null;
}
$model = new Employee();
$model->fill(self::mapObject($response[0]->values));
return $model;
}
}

View file

@ -1,48 +0,0 @@
<?php
namespace Uwil\ArcadiaGenesisPhpClient\Datas;
use Uwil\ArcadiaGenesisPhpClient\Models\Inco;
use Uwil\ArcadiaGenesisPhpClient\Traits\ArcadiaTrait;
use Uwil\ArcadiaGenesisPhpClient\Transport;
class IncoDatas
{
use ArcadiaTrait;
private static string $table = "INCO";
private static array $definition = [
'id' => 'NO_ART',
'description' => 'DESCRIPTIF',
'tips' => 'ASTUCES',
'ingredients' => 'LST_ING',
'portions' => 'NB_PORTION',
'portion_type' => 'TYP_PORTION', // FIXME: Lié à une autre table ?
'allergens' => 'LST_ALLERGENE', // FIXME: Lié à une autre table ?
'brand' => 'MARQUE', // FIXME: Lié à une autre table ?
'created_at' => 'DAT_CRE',
'updated_at' => 'DAT_MAJ',
];
public static function getById(string $id)
{
$params = [
'TABLE' => self::$table,
'S_COLS' => self::getArcadiaDefinition(),
'C_COLS' => self::getArcadiaDefinition(['id']),
'C_OPES' => ['='],
'C_VALS' => [$id],
];
$response = Transport::call($params);
if (empty($response[0]->values)) {
return null;
}
$model = new Inco();
$model->fill(self::mapObject($response[0]->values));
return $model;
}
}

16
src/Enum/HttpMethod.php Normal file
View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Enum;
enum HttpMethod: string
{
case GET = 'GET';
case POST = 'POST';
case PUT = 'PUT';
case PATCH = 'PATCH';
case DELETE = 'DELETE';
case HEAD = 'HEAD';
case OPTIONS = 'OPTIONS';
}

View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Error;
abstract readonly class AbstractApiError implements ApiError
{
public function __construct(
public string $message,
public int $statusCode = 0,
public ?\Throwable $previous = null,
) {
}
public function message(): string
{
return $this->message;
}
public function statusCode(): int
{
return $this->statusCode;
}
public function previous(): ?\Throwable
{
return $this->previous;
}
}

14
src/Error/ApiError.php Normal file
View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Error;
interface ApiError
{
public function message(): string;
public function statusCode(): int;
public function previous(): ?\Throwable;
}

View file

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Error;
final readonly class AuthenticationError extends AbstractApiError
{
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Error;
final readonly class NetworkError extends AbstractApiError
{
public function __construct(
string $message,
?\Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Error;
final readonly class RateLimitError extends AbstractApiError
{
public function __construct(
string $message,
public ?int $retryAfter = null,
?\Throwable $previous = null,
) {
parent::__construct($message, 429, $previous);
}
}

View file

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Error;
final readonly class ServerError extends AbstractApiError
{
}

View file

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Error;
final readonly class UnexpectedApiError extends AbstractApiError
{
}

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Error;
final readonly class ValidationError extends AbstractApiError
{
/**
* @param array<string, string> $violations Map of invalid parameter => reason
*/
public function __construct(
string $message,
public array $violations = [],
?\Throwable $previous = null,
) {
parent::__construct($message, 422, $previous);
}
}

View file

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Factory;
use GuzzleHttp\Client;
use Microsoft\Kiota\Bundle\DefaultRequestAdapter;
use Microsoft\Kiota\Http\KiotaClientFactory;
use Microsoft\Kiota\Http\Middleware\KiotaMiddleware;
use Microsoft\Kiota\Http\Middleware\Options\RetryOption;
use Microsoft\Kiota\Http\Middleware\Options\UserAgentHandlerOption;
use Microsoft\Kiota\Http\Middleware\RetryHandler;
use Microsoft\Kiota\Http\Middleware\UserAgentHandler;
use Uwil\ArcadiaApiPhpClient\Auth\ApiMandatoryHeadersMiddleware;
use Uwil\ArcadiaApiPhpClient\Auth\RequestSignatureCalculator;
use Uwil\ArcadiaApiPhpClient\Lib\ApiConfig;
use Uwil\ArcadiaApiPhpClient\Lib\LibProperties;
use Uwil\ArcadiaApiPhpClient\Logger\ApiLoggerMiddleware;
use Uwil\ArcadiaApiPhpClient\Observability\ObservabilityOption;
use Uwil\ArcadiaKiotaOpenApiClient\ApiClient;
/**
* @internal
*/
final class ApiClientFactory
{
public function __construct(
private ApiConfig $apiConfig
) {
}
public function createClient(): ApiClient
{
$signatureCalculator = new RequestSignatureCalculator();
$libProperties = new LibProperties();
$uaOptions = new UserAgentHandlerOption();
$uaOptions->setProductName($libProperties->productName);
$uaOptions->setProductVersion($libProperties->productVersion);
$handlerStack = KiotaClientFactory::getDefaultHandlerStack();
if ($this->apiConfig->logger !== null) {
$handlerStack->push(
ApiLoggerMiddleware::log($this->apiConfig->logger),
ApiLoggerMiddleware::HANDLER_NAME
);
}
$handlerStack->push(
ApiMandatoryHeadersMiddleware::signRequest(
apiKey: $this->apiConfig->apiKey,
withSecret: $this->apiConfig->secret,
signatureCalculator: $signatureCalculator
),
ApiMandatoryHeadersMiddleware::HANDLER_NAME
);
$handlerStack->push(
KiotaMiddleware::userAgent($uaOptions),
UserAgentHandler::HANDLER_NAME
);
$handlerStack->push(
KiotaMiddleware::retry(
new RetryOption(
maxRetries: $this->apiConfig->maxRetries,
delaySeconds: $this->apiConfig->retryDelaySeconds
)
),
RetryHandler::HANDLER_NAME
);
$guzzleClient = new Client([
'base_uri' => $this->apiConfig->endpoint,
'timeout' => $this->apiConfig->curlTimeOut,
'connect_timeout' => min(5.0, $this->apiConfig->curlTimeOut),
'handler' => $handlerStack,
'verify' => ! $this->apiConfig->ignoreSelfSignedCert,
]);
$observabilityOption = new ObservabilityOption(
$libProperties,
$this->apiConfig->tracer
);
$adapter = DefaultRequestAdapter::builder()
->withGuzzleClient($guzzleClient)
->withObservabilityOption($observabilityOption)
->build();
$adapter->setBaseUrl($this->apiConfig->endpoint->__toString());
return new ApiClient($adapter);
}
}

View file

@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Factory;
use Uwil\ArcadiaApiPhpClient\Lib\ApiConfig;
use Uwil\ArcadiaApiPhpClient\Mapper\BrandMapper;
use Uwil\ArcadiaApiPhpClient\Mapper\EmployeeMapper;
use Uwil\ArcadiaApiPhpClient\Mapper\IncoCompoMapper;
use Uwil\ArcadiaApiPhpClient\Mapper\IncoMapper;
use Uwil\ArcadiaApiPhpClient\Mapper\IncoShortMapper;
use Uwil\ArcadiaApiPhpClient\Repository\BrandApiRepository;
use Uwil\ArcadiaApiPhpClient\Repository\EmployeeApiRepository;
use Uwil\ArcadiaApiPhpClient\Repository\IncoApiRepository;
use Uwil\ArcadiaApiPhpClient\Repository\IncoCompoApiRepository;
use Uwil\ArcadiaApiPhpClient\Repository\IncoShortApiRepository;
use Uwil\ArcadiaApiPhpClient\Service\ApiService;
use Uwil\ArcadiaApiPhpClient\Service\ArcadiaApiService;
use Uwil\ArcadiaApiPhpClient\Service\ArcadiaBrandService;
use Uwil\ArcadiaApiPhpClient\Service\ArcadiaEmployeeService;
use Uwil\ArcadiaApiPhpClient\Service\ArcadiaIncoService;
use Uwil\ArcadiaApiPhpClient\Service\BrandService;
use Uwil\ArcadiaApiPhpClient\Service\EmployeeService;
use Uwil\ArcadiaApiPhpClient\Service\IncoService;
use Uwil\ArcadiaKiotaOpenApiClient\ApiClient;
/**
* @internal
*/
final class ServiceFactory
{
private ?ApiService $apiService = null;
private ?EmployeeService $employeeService = null;
private ?IncoService $incoService = null;
private ?BrandService $brandService = null;
public function __construct(
private readonly ApiClient $apiClient,
private readonly ApiConfig $apiConfig
) {
}
public function createApiService(): ApiService
{
return $this->apiService ??= new ArcadiaApiService(
$this->apiConfig
);
}
public function createEmployeeService(): EmployeeService
{
return $this->employeeService ??= new ArcadiaEmployeeService(
new EmployeeApiRepository(
$this->apiClient,
new EmployeeMapper()
)
);
}
public function createIncoService(): IncoService
{
return $this->incoService ??= new ArcadiaIncoService(
new IncoApiRepository(
$this->apiClient,
new IncoMapper()
),
new IncoShortApiRepository(
$this->apiClient,
new IncoShortMapper()
),
new IncoCompoApiRepository(
$this->apiClient,
new IncoCompoMapper()
)
);
}
public function createBrandService(): BrandService
{
return $this->brandService ??= new ArcadiaBrandService(
new BrandApiRepository(
$this->apiClient,
new BrandMapper()
)
);
}
public function employeeService(): EmployeeService
{
return $this->createEmployeeService();
}
public function incoService(): IncoService
{
return $this->createIncoService();
}
public function brandService(): BrandService
{
return $this->createBrandService();
}
}

42
src/Lib/ApiConfig.php Normal file
View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib;
use OpenTelemetry\API\Trace\TracerInterface;
use Psr\Http\Message\UriInterface;
use Psr\Log\LoggerInterface;
use Uwil\ArcadiaApiPhpClient\Auth\ClientIdentifier;
use Uwil\ArcadiaApiPhpClient\Auth\ClientSecret;
final readonly class ApiConfig
{
public function __construct(
#[\SensitiveParameter]
public UriInterface $endpoint,
#[\SensitiveParameter]
public ClientIdentifier $apiKey,
#[\SensitiveParameter]
public ClientSecret $secret,
public float $curlTimeOut = 20.0,
public int $maxRetries = 0,
public int $retryDelaySeconds = 1,
public bool $ignoreSelfSignedCert = false,
public ?LoggerInterface $logger = null,
public ?TracerInterface $tracer = null,
) {
if (! in_array(strtolower($endpoint->getScheme()), ['http', 'https'])) {
throw new \InvalidArgumentException('endpoint must have http or https scheme');
}
if ($curlTimeOut <= 0) {
throw new \InvalidArgumentException('curlTimeOut must be positive');
}
if ($maxRetries < 0) {
throw new \InvalidArgumentException('maxRetries must be non-negative');
}
if ($retryDelaySeconds < 0) {
throw new \InvalidArgumentException('retryDelaySeconds must be non-negative');
}
}
}

9
src/Lib/ApiDto/Dto.php Normal file
View file

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto;
interface Dto extends \JsonSerializable
{
}

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\BrandCode;
final readonly class Brand implements Dto
{
public function __construct(
public BrandCode $code,
public string $label,
public ?string $arcadiaId = null,
public ?\DateTimeInterface $createdAt = null,
) {
if (empty(mb_trim($label))) {
throw new \DomainException(
"Invalid label '{$label}' should not be empty"
);
}
}
public function jsonSerialize(): mixed
{
return [
'code' => $this->code->value,
'label' => $this->label,
'arcadiaId' => $this->arcadiaId,
'createdAt' => $this->createdAt?->format(\DateTimeInterface::ATOM),
];
}
}

View file

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
use DateTimeInterface;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\ArcadiaEmployeeId;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\EstablishmentCode;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\ServiceId;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\SiteId;
final readonly class Employee implements Dto
{
public function __construct(
public ArcadiaEmployeeId $matriculePublique,
public string $lastname,
public string $firstname,
public DateTimeInterface $start_date,
public ?DateTimeInterface $end_date,
public ?DateTimeInterface $planned_end_date,
public EstablishmentCode $establishment,
public SiteId $site,
public ServiceId $service,
public DateTimeInterface $created_at,
public ?DateTimeInterface $updated_at,
) {
}
public function jsonSerialize(): mixed
{
return [
'matriculePublique' => $this->matriculePublique->value,
'lastname' => $this->lastname,
'firstname' => $this->firstname,
'start_date' => $this->start_date->format(DATE_RFC3339),
'end_date' => $this->end_date?->format(DATE_RFC3339),
'planned_end_date' => $this->planned_end_date?->format(DATE_RFC3339),
'establishment' => $this->establishment->value,
'site' => $this->site->value,
'service' => $this->service->value,
'created_at' => $this->created_at->format(DATE_RFC3339),
'updated_at' => $this->updated_at?->format(DATE_RFC3339),
];
}
}

View file

@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco\Additive;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco\Allegations;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco\Allergen;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco\IncoWeight;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco\Ingredients;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco\Nutriscore;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco\PortionAmount;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco\StorageCondition;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\IncoCompoId;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\IncoId;
/**
* Represents an Inco sheet (ingredients / recipe).
*/
final readonly class Inco implements Dto
{
/**
* @param Allergen[] $allergens
* @param Additive[] $additives
*/
public function __construct(
public IncoId $id,
public string $name,
public string $libelle,
public string $description,
public Allegations $allegations,
public string $sellArguments,
public string $preparationAdvices,
public ?string $tips,
public ?string $sellFamilly,
public Ingredients $ingredients,
public ?PortionAmount $portions,
public array $allergens,
public Brand $brand,
public bool $isSeasonal,
public ?IncoWeight $weight,
public ?Nutriscore $nutriscore,
public ?StorageCondition $storageCondition,
public array $additives,
public ?IncoCompoId $compo,
public ?\DateTimeInterface $createdAt,
public ?\DateTimeInterface $updatedAt,
) {
foreach ($this->allergens as $allergen) {
if (! is_object($allergen) || ! ($allergen instanceof Allergen)) {
throw new \InvalidArgumentException('Allergens shall be instance of ' . Allergen::class);
}
}
foreach ($this->additives as $additive) {
if (! is_object($additive) || ! ($additive instanceof Additive)) {
throw new \InvalidArgumentException('Additives shall be instance of ' . Additive::class);
}
}
}
public function jsonSerialize(): mixed
{
return [
'id' => $this->id->value,
'name' => $this->name,
'libelle' => $this->libelle,
'description' => $this->description,
'allegations' => $this->allegations,
'sellArguments' => $this->sellArguments,
'preparationAdvices' => $this->preparationAdvices,
'tips' => $this->tips,
'sellFamilly' => $this->sellFamilly,
'ingredients' => $this->ingredients,
'portions' => $this->portions,
'allergens' => $this->allergens,
'brand' => [
'code' => $this->brand->code,
'label' => $this->brand->label,
],
'isSeasonal' => $this->isSeasonal,
'weight' => $this->weight,
'nutriscore' => $this->nutriscore,
'storageCondition' => $this->storageCondition,
'additives' => $this->additives,
'compo' => $this->compo?->value,
'created_at' => $this->createdAt,
'updated_at' => $this->updatedAt,
];
}
}

View file

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
/**
* Represents a food additive identified by its E-number code.
*
* Maps to a single entry of LST_ADDITIF.
*
* Valid formats: E300, E471a, E160ai, E160a(i), E160a(ii), E1404, E1442
*
* @see https://en.wikipedia.org/wiki/E_number
*/
final readonly class Additive implements Dto
{
public readonly string $code;
public function __construct(
string $code,
public readonly string $label,
) {
$this->code = $code;
$trimmedLabel = mb_trim($label);
if (! preg_match('/^E\d+(?:[a-z]+(?:\([ivx]+\))?)?$/', $this->code)) {
throw new \DomainException(
"Invalid additive code '{$code}': must match E-number format (e.g. E300, E471a, E160ai, E160a(ii), E1404)"
);
}
if ($trimmedLabel === '') {
throw new \DomainException(
"Invalid label for additive '{$code}': must not be empty"
);
}
}
public function jsonSerialize(): mixed
{
return [
'code' => $this->code,
'label' => $this->label,
];
}
}

View file

@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
final readonly class Allegations implements Dto
{
/** @var string[] */
private array $allegations;
/** @var string[] */
private array $health;
/** @var string[] */
private array $nutrition;
/** @var string[] */
private array $other;
/**
* ! All array are filtered keeping only strings.
*
* @param string[] $allegations Allegations.
* @param string[] $health Health allegations.
* @param string[] $nutrition Nutrition allegations.
* @param string[] $other Other allegations.
* @return void
*/
public function __construct(
array $allegations = [],
array $health = [],
array $nutrition = [],
array $other = []
) {
$this->allegations = \array_filter($allegations, fn (string $el) => ! empty($el));
$this->health = \array_filter($health, fn (string $el) => ! empty($el));
$this->nutrition = \array_filter($nutrition, fn (string $el) => ! empty($el));
$this->other = \array_filter($other, fn (string $el) => ! empty($el));
}
// * Methods
/**
* @return string[]
*/
public function allegations(): array
{
return [...$this->allegations];
}
/**
* @return string[]
*/
public function health(): array
{
return [...$this->health];
}
/**
* @return string[]
*/
public function nutrition(): array
{
return [...$this->nutrition];
}
/**
* @return string[]
*/
public function other(): array
{
return [...$this->other];
}
/**
* Get all
*
* @return array<string,string[]>
*/
public function all(): array
{
return $this->toArray();
}
// * Implements Interfaces
/**
* Get the instance as an array.
*
* @return array{'allegations':string[],'health':string[],'nutrition':string[],'other':string[]}
*/
public function toArray()
{
return [
'allegations' => [...$this->allegations],
'health' => [...$this->health],
'nutrition' => [...$this->nutrition],
'other' => [...$this->other],
];
}
public function jsonSerialize(): mixed
{
return $this->toArray();
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
final readonly class Allergen implements Dto
{
public function __construct(
public readonly int $code,
public readonly string $label
) {
if ($code <= 0) {
throw new \DomainException(
"Invalid code '{$code}' shall be greater than 0"
);
}
if (empty(mb_trim($label))) {
throw new \DomainException(
"Invalid label '{$label}' should not be empty"
);
}
}
public function jsonSerialize(): mixed
{
return [
'code' => $this->code,
'label' => $this->label,
];
}
}

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
final readonly class IncoWeight implements Dto
{
/**
* @param int $netUvc In grams.
* @param int $brutUvc In grams.
* @param int $moyTheo In grams.
*/
public function __construct(
public readonly int $netUvc,
public readonly int $brutUvc,
public readonly int $moyTheo,
) {
}
public function jsonSerialize(): mixed
{
return [
'netUvc' => $this->netUvc,
'brutUvc' => $this->brutUvc,
'moyTheo' => $this->moyTheo,
];
}
}

View file

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
/**
* Represents a structured and parsed ingredient list.
*
* Each entry is either:
* - an indexed group (int key) -> list of ingredient strings without a section label
* - a labelled group (string key) -> list of ingredient strings under a section label
* (e.g. "Ingrédients", "Traces possibles")
*
* @phpstan-type IngredientGroup array<int|string,string[]>
*/
final readonly class Ingredients implements Dto
{
/**
* @param array<int|string,string[]> $groups
*/
public function __construct(
public readonly array $groups
) {
foreach ($groups as $key => $lines) {
if (! is_array($lines)) {
throw new \DomainException(
"Invalid ingredient group at key '{$key}': value must be an array of strings"
);
}
foreach ($lines as $i => $line) {
if (! is_string($line)) {
throw new \DomainException(
"Invalid ingredient at group '{$key}', index '{$i}': value must be a string"
);
}
if (empty(mb_trim($line))) {
throw new \DomainException(
"Invalid ingredient at group '{$key}', index '{$i}': string should not be empty"
);
}
}
}
}
/**
* Returns all ingredient strings flattened into a single list, regardless of group.
*
* @return string[]
*/
public function flatten(): array
{
return array_merge(...array_values($this->groups));
}
/**
* Returns whether the ingredient list is empty (no groups or all groups are empty).
*/
public function isEmpty(): bool
{
return empty($this->flatten());
}
public function jsonSerialize(): mixed
{
return $this->groups;
}
}

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
/**
* Represents the Nutri-Score grade of a product as displayed on pack.
*
* Valid grades are A, B, C, D and E.
*/
final readonly class Nutriscore implements Dto
{
public const array VALID_GRADES = ['A', 'B', 'C', 'D', 'E'];
public function __construct(
public readonly string $grade,
) {
if (! in_array($this->grade, self::VALID_GRADES, true)) {
throw new \DomainException(
sprintf(
"Invalid Nutri-Score grade '%s': must be one of %s",
$grade,
implode(', ', self::VALID_GRADES)
)
);
}
}
public function jsonSerialize(): mixed
{
return $this->grade;
}
}

View file

@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
/**
* Si le type de portion est exact, seul la partie basse est utilisée,
* Si le type de portion est environ, la partie basse seule ou les deux peuvent
* etre utilisés.
*/
final readonly class PortionAmount implements \Stringable, Dto
{
/**
* Valeur minimale, ou exacte
*/
private readonly int $lowPart;
/**
* Valeur maximale
*/
private readonly int $highPart;
/**
* The portion is approximative
*/
private readonly bool $approximative;
/**
* The number of decimals allowed, this should not change
*/
private const ALLOWED_DECIMAL_SIZE = 2;
/**
* NbPortion
*
* @param float $lowPart Min value is 0, max depends on lowPart mask.
* @param float $highPart Min value is 0, max depends on lowPart mask.
* @return void
*
* @throws \InvalidArgumentException If lowPart part does not fit in.
* @throws \InvalidArgumentException If highPart part does not fit in.
*/
public function __construct(float $lowPart, float $highPart = 0, bool $approximative = false)
{
$maxMask = self::getMasks()['lowPart'];
$scale = (int) pow(10, self::ALLOWED_DECIMAL_SIZE);
$maxValue = (float) bcdiv((string) $maxMask, (string) $scale, self::ALLOWED_DECIMAL_SIZE);
if ($lowPart < 0.0 || ($lowPart > $maxValue)) {
throw new \InvalidArgumentException("lowPart must be between 0 and '{$maxValue}', given '{$lowPart}'");
}
if ($highPart < 0.0 || ($highPart > $maxValue)) {
throw new \InvalidArgumentException("highPart must be between 0 and '{$maxValue}', given '{$highPart}'");
}
$this->lowPart = (int) bcmul((string) $lowPart, (string) $scale, 0);
$this->highPart = (int) bcmul((string) $highPart, (string) $scale, 0);
$this->approximative = $approximative;
}
// * Casts
public function toIncoValue(): int
{
return $this->lowPart |
$this->highPart << 16 |
($this->approximative ? self::getMasks()['approximative'] : 0);
}
public static function fromIncoValue(int $databaseValue): self
{
$scale = (int) pow(10, self::ALLOWED_DECIMAL_SIZE);
$lowInt = $databaseValue & self::getMasks()['lowPpart'];
$highInt = ($databaseValue & self::getMasks()['highPart']) >> 16;
$approx = (bool) ($databaseValue & self::getMasks()['approximative']);
$low = (float) bcdiv((string) $lowInt, (string) $scale, self::ALLOWED_DECIMAL_SIZE);
$high = (float) bcdiv((string) $highInt, (string) $scale, self::ALLOWED_DECIMAL_SIZE);
return new self($low, $high, $approx);
}
/**
* @param array<string,int|bool> $jsonArray
*
* @phpcs:disable Generic.Metrics.CyclomaticComplexity.TooHigh
*/
public static function fromJson(array $jsonArray): self
{
// phpcs:enable
$scale = (int) pow(10, self::ALLOWED_DECIMAL_SIZE);
$lowRaw = $jsonArray['lowPart'] ?? $jsonArray['lowPart'] ?? 0;
$highRaw = $jsonArray['highPart'] ?? $jsonArray['highPart'] ?? 0;
$approx = (bool) ($jsonArray['approximative'] ?? $jsonArray['approximative'] ?? false);
$lowVal = is_numeric($lowRaw) ? (float) $lowRaw : 0.0;
$highVal = is_numeric($highRaw) ? (float) $highRaw : 0.0;
if (is_int($lowRaw) && $lowRaw > $scale) {
$lowVal = (float) bcdiv((string) $lowRaw, (string) $scale, self::ALLOWED_DECIMAL_SIZE);
}
if (is_int($highRaw) && $highRaw > $scale) {
$highVal = (float) bcdiv((string) $highRaw, (string) $scale, self::ALLOWED_DECIMAL_SIZE);
}
return new self($lowVal, $highVal, $approx);
}
// * STATIC Methods
/**
* Binary masks to store and retrieve values
*
* @return array<string,int>
*/
public static function getMasks(): array
{
return [
'lowPart' => ((1 << 15) - 1),
'highPart' => (1 << 31) - (1 << 16),
'approximative' => 1 << 31,
];
}
// * Methods
public function getLowValue(): float
{
$scale = (int) pow(10, self::ALLOWED_DECIMAL_SIZE);
return (float) bcdiv((string) $this->lowPart, (string) $scale, self::ALLOWED_DECIMAL_SIZE);
}
public function getHighValue(): float
{
$scale = (int) pow(10, self::ALLOWED_DECIMAL_SIZE);
return (float) bcdiv((string) $this->highPart, (string) $scale, self::ALLOWED_DECIMAL_SIZE);
}
public function getType(): PortionType
{
return match ($this->approximative) {
true => PortionType::approximative,
false => PortionType::exact
};
}
public function toJson(int $options = 0): string
{
return (string) \json_encode($this->jsonSerialize(), $options);
}
/**
* @return array{lowPart: int, highPart: int, approximative: bool}
*/
public function jsonSerialize(): array
{
return $this->toArray();
}
/**
* @return array{lowPart: int, highPart: int, approximative: bool}
*/
public function toArray(): array
{
return [
'lowPart' => $this->lowPart,
'highPart' => $this->highPart,
'approximative' => $this->approximative,
];
}
public function __toString(): string
{
$low = $this->formatNumberForDisplay($this->getLowValue());
$high = $this->formatNumberForDisplay($this->getHighValue());
if ($this->highPart) {
return sprintf('entre %s et %s', $low, $high);
}
if ($this->approximative) {
return sprintf('environ %s', $low);
}
return $low;
}
protected function formatNumberForDisplay(float $value): string
{
$decimals = self::ALLOWED_DECIMAL_SIZE;
$format = number_format($value, $decimals, '.', '');
return mb_rtrim(mb_rtrim($format, '0'), '.');
}
}

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
enum PortionType: int implements Dto
{
case unknown = -1;
case approximative = 0;
case exact = 1;
public function description(): string
{
return match ($this->value) {
self::approximative->name => 'approximatif',
self::exact->name => 'exact',
self::unknown->name => 'inconnue',
default => 'inconnue',
};
}
public function jsonSerialize(): mixed
{
return [
'value' => $this->value,
'name' => $this->name,
'description' => $this->description(),
];
}
}

View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Inco;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
/**
* Represents the storage/conservation conditions of a product.
*
* Maps to CONDIT_CONSERV.
*/
final readonly class StorageCondition implements Dto
{
public function __construct(
public readonly string $instructions,
) {
if (empty(mb_trim($this->instructions))) {
throw new \DomainException(
'Invalid instructions: must not be empty'
);
}
}
public function jsonSerialize(): mixed
{
return $this->instructions;
}
}

View file

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo\AcideGras;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo\AcidesAmines;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo\ApportsReference;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo\ComposesSpeciaux;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo\Energie;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo\FruitLegumeNoix;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo\GlucidesDetail;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo\Identification;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo\MacronutrimentsPortion;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo\MacronutrimentsRef;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo\Mineraux;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo\NutriScore;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo\PointsNutriScore;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo\Vitamines;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\IncoCompoId;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\IncoId;
/**
* Agrégat racine regroupant toutes les sous-sections nutritionnelles d'une fiche INCO.
*/
final readonly class IncoCompo implements Dto
{
public function __construct(
public IncoCompoId $id,
public IncoId $idInco,
public Identification $identification,
public Energie $energie,
public MacronutrimentsRef $macronutrimentsRef,
public MacronutrimentsPortion $macronutrimentsPortion,
public FruitLegumeNoix $fnl,
public ApportsReference $apportsReference,
public NutriScore $nutriScore,
public PointsNutriScore $pointsNutriScore,
public Mineraux $mineraux,
public Vitamines $vitamines,
public GlucidesDetail $glucidesDetail,
public AcideGras $acideGras,
public AcidesAmines $acidesAmines,
public ComposesSpeciaux $composesSpeciaux,
) {
}
public function jsonSerialize(): mixed
{
return [
'id' => $this->id->value,
'idInco' => $this->idInco->value,
'identification' => $this->identification,
'energie' => $this->energie,
'macronutrimentsRef' => $this->macronutrimentsRef,
'macronutrimentsPortion' => $this->macronutrimentsPortion,
'fnl' => $this->fnl,
'apportsReference' => $this->apportsReference,
'nutriScore' => $this->nutriScore,
'pointsNutriScore' => $this->pointsNutriScore,
'mineraux' => $this->mineraux,
'vitamines' => $this->vitamines,
'glucidesDetail' => $this->glucidesDetail,
'acideGras' => $this->acideGras,
'acidesAmines' => $this->acidesAmines,
'composesSpeciaux' => $this->composesSpeciaux,
];
}
}

View file

@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo;
/**
* Base implementation for NutriDto.
*
* Concrete classes must implement:
* - fieldLabels(): array<string, string> - [ propertyName => 'Human label' ]
* - fieldUnits(): array<string, string|null> - [ propertyName => 'unit'|null ]
*
* toArray() is built automatically via reflection on public properties.
*/
abstract readonly class AbstractIncoCompoPartDto implements IncoCompoPartDto
{
/**
* Map of [ propertyName => 'Human readable label' ].
*
* @return array<string, string>
*/
abstract protected static function fieldLabels(): array;
/**
* Map of [ propertyName => 'unit string or null' ].
*
* @return array<string, string|null>
*/
abstract protected static function fieldUnits(): array;
// -------------------------------------------------------------------------
/**
* {@inheritDoc}
*/
public function toArray(): array
{
/** @var array<string, float|string|null> $result */
$result = [];
foreach (get_object_vars($this) as $name => $value) {
if (\is_float($value) || \is_string($value) || $value === null) {
$result[(string) $name] = $value;
}
}
return $result;
}
/**
* {@inheritDoc}
*/
public function humanReadableValues(): array
{
$values = $this->toArray();
$out = [];
foreach (static::fieldLabels() as $property => $label) {
$out[$label] = $values[$property] ?? null;
}
return $out;
}
/**
* {@inheritDoc}
*/
public static function units(): array
{
return static::fieldUnits();
}
/**
* {@inheritDoc}
*/
public static function humanReadableUnits(): array
{
$out = [];
foreach (static::fieldLabels() as $property => $label) {
$units = static::fieldUnits();
if (\array_key_exists($property, $units)) {
$out[$label] = $units[$property];
}
}
return $out;
}
/**
* {@inheritDoc}
*
* @return array<string, float|string|null>
*/
public function jsonSerialize(): array
{
return array_filter(
$this->toArray(),
static fn (mixed $value): bool => $value !== null,
);
}
/**
* {@inheritDoc}
*/
public function __toString(): string
{
$values = $this->humanReadableValues();
$units = static::humanReadableUnits();
$lines = [];
foreach ($values as $label => $value) {
$unit = $units[$label] ?? null;
$lines[] = $label . ' : ' . $value . ($unit !== null ? ' ' . $unit : '');
}
return \implode("\n", $lines);
}
}

View file

@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo;
final readonly class AcideGras extends AbstractIncoCompoPartDto
{
public function __construct(
// Lipides généraux
public string $nutFd,
public string $nutChol,
public string $nutAgt,
// AGS - chaîne courte à longue (classés par nb de carbones)
public string $nutFformiq,
public string $nutFacetiq,
public string $nutFpropioniq,
public string $nutFbutyriq,
public string $nutFvaleriq,
public string $nutFcaproiq,
public string $nutFenatiq,
public string $nutFcapryliq,
public string $nutFpelargoniq,
public string $nutFcapriq,
public string $nutFundecyliq,
public string $nutFlauriq,
public string $nutFtridecyliq,
public string $nutFmyristiq,
public string $nutFpentadecyliq,
public string $nutF16d0,
public string $nutFmargariq,
public string $nutFsteariq,
public string $nutFarachidiq,
public string $nutFheneicosyliq,
public string $nutFbeheniq,
public string $nutFtricosiliq,
public string $nutFlignoceriq,
public string $nutFcarboceriq,
public string $nutFcerotiq,
public string $nutFceroplastiq,
public string $nutFmontaniq,
public string $nutFnonacosyliq,
public string $nutFmelissiq,
public string $nutFlaceroiq,
public string $nutFhentriacontyliq,
public string $nutFhextriacontyliq,
public string $nutFheptatriacontyliq,
public string $nutFoctatriacontyliq,
public string $nutFnonadylique,
public string $nutFpsylliq,
// AGMI
public string $nutFpalm,
public string $nutFoleiq,
public string $nutF18d,
public string $nutF19d,
public string $nutF11eico,
public string $nutFgeddiq,
public string $nutFeruciq,
public string $nutFnervoniq,
public string $nutFhyeniq,
public string $nutFamscis,
// AGPI
public string $nutF18d2cn6,
public string $nutF18d3n3,
public string $nutF20d4,
public string $nutF20d5n3,
public string $nutF22d6n3,
public string $nutFapun3,
public string $nutFapun6,
public string $nutFapun9,
public string $nutFapucis,
public string $nutFapulc,
) {
}
protected static function fieldLabels(): array
{
return [
// Lipides généraux
'nutFd' => 'Lipides totaux',
'nutChol' => 'Cholestérol',
'nutAgt' => 'Acides gras trans',
// AGS
'nutFformiq' => 'Acide formique (C1:0)',
'nutFacetiq' => 'Acide acétique (C2:0)',
'nutFpropioniq' => 'Acide propionique (C3:0)',
'nutFbutyriq' => 'Acide butyrique (C4:0)',
'nutFvaleriq' => 'Acide valérique (C5:0)',
'nutFcaproiq' => 'Acide caproïque (C6:0)',
'nutFenatiq' => 'Acide énanthique (C7:0)',
'nutFcapryliq' => 'Acide caprylique (C8:0)',
'nutFpelargoniq' => 'Acide pélargonique (C9:0)',
'nutFcapriq' => 'Acide caprique (C10:0)',
'nutFundecyliq' => 'Acide undécylique (C11:0)',
'nutFlauriq' => 'Acide laurique (C12:0)',
'nutFtridecyliq' => 'Acide tridécylique (C13:0)',
'nutFmyristiq' => 'Acide myristique (C14:0)',
'nutFpentadecyliq' => 'Acide pentadécylique (C15:0)',
'nutF16d0' => 'Acide palmitique (C16:0)',
'nutFmargariq' => 'Acide margarique (C17:0)',
'nutFsteariq' => 'Acide stéarique (C18:0)',
'nutFarachidiq' => 'Acide arachidique (C20:0)',
'nutFheneicosyliq' => 'Acide héneicosylique (C21:0)',
'nutFbeheniq' => 'Acide béhénique (C22:0)',
'nutFtricosiliq' => 'Acide tétracosanoïque (C23:0)',
'nutFlignoceriq' => 'Acide lignocérique (C24:0)',
'nutFcarboceriq' => 'Acide carbocérique (C26:0)',
'nutFcerotiq' => 'Acide cérotique (C26:0 alt.)',
'nutFceroplastiq' => 'Acide céroplastique',
'nutFmontaniq' => 'Acide montanique (C28:0)',
'nutFnonacosyliq' => 'Acide nonacosylique (C29:0)',
'nutFmelissiq' => 'Acide mélissique (C30:0)',
'nutFlaceroiq' => 'Acide lacéroïque (C32:0)',
'nutFhentriacontyliq' => 'Acide hentriacontylique (C31:0)',
'nutFhextriacontyliq' => 'Acide hexatriacontylique (C36:0)',
'nutFheptatriacontyliq' => 'Acide heptatriacontylique (C37:0)',
'nutFoctatriacontyliq' => 'Acide octariacontylique (C38:0)',
'nutFnonadylique' => 'Acide nonadylique',
'nutFpsylliq' => 'Acide psyllique',
// AGMI
'nutFpalm' => 'Acide palmitoléique (C16:1)',
'nutFoleiq' => 'Acide oléique (C18:1 ω-9)',
'nutF18d' => 'C18 insaturé (générique)',
'nutF19d' => 'C19 insaturé',
'nutF11eico' => 'Acide gondoïque (C20:1)',
'nutFgeddiq' => 'Acide geddique (C20:1 alt.)',
'nutFeruciq' => 'Acide érucique (C22:1)',
'nutFnervoniq' => 'Acide nervonique (C24:1)',
'nutFhyeniq' => 'Acide hyénique',
'nutFamscis' => 'AG monoinsaturés cis (total)',
// AGPI
'nutF18d2cn6' => 'Acide linoléique (C18:2 ω-6)',
'nutF18d3n3' => 'Acide α-linolénique (C18:3 ω-3)',
'nutF20d4' => 'Acide arachidonique (C20:4)',
'nutF20d5n3' => 'EPA (C20:5 ω-3)',
'nutF22d6n3' => 'DHA (C22:6 ω-3)',
'nutFapun3' => 'AGPI ω-3 (total)',
'nutFapun6' => 'AGPI ω-6 (total)',
'nutFapun9' => 'AGPI ω-9 (total)',
'nutFapucis' => 'AGPI cis (total)',
'nutFapulc' => 'AGPI longue chaîne (total)',
];
}
protected static function fieldUnits(): array
{
// All fatty acid values are in g / 100g
$fields = \array_keys(self::fieldLabels());
return \array_combine(
$fields,
\array_map(static fn (string $field): string => match ($field) {
'nutChol' => 'mg',
default => 'g',
}, $fields),
);
}
}

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo;
final readonly class AcidesAmines extends AbstractIncoCompoPartDto
{
public function __construct(
public string $nutAae,
public string $nutCasn,
public string $nutWhey,
) {
}
protected static function fieldLabels(): array
{
return [
'nutAae' => 'Acides aminés essentiels (total)',
'nutCasn' => 'Caséines',
'nutWhey' => 'Protéines de lactosérum (whey)',
];
}
protected static function fieldUnits(): array
{
return [
'nutAae' => 'g',
'nutCasn' => 'g',
'nutWhey' => 'g',
];
}
}

View file

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo;
final readonly class ApportsReference extends AbstractIncoCompoPartDto
{
public function __construct(
public string $pourcentApportRefE14P,
public string $pourcentApportRefKjoP,
public string $pourcentApportRefFatP,
public string $pourcentApportRefFatsatP,
public string $pourcentApportRefChovalP,
public string $pourcentApportRefSugarP,
public string $pourcentApportRefProP,
public string $pourcentApportRefSalteqP,
public string $apportPort,
public string $pSurL,
) {
}
protected static function fieldLabels(): array
{
return [
'pourcentApportRefE14P' => '% AR Énergie E14 / portion',
'pourcentApportRefKjoP' => '% AR Énergie kJ / portion',
'pourcentApportRefFatP' => '% AR Matières grasses / portion',
'pourcentApportRefFatsatP' => '% AR Graisses saturées / portion',
'pourcentApportRefChovalP' => '% AR Glucides / portion',
'pourcentApportRefSugarP' => '% AR Sucres / portion',
'pourcentApportRefProP' => '% AR Protéines / portion',
'pourcentApportRefSalteqP' => '% AR Sel / portion',
'apportPort' => 'Apport par portion',
'pSurL' => 'Ratio P/L',
];
}
protected static function fieldUnits(): array
{
return [
'pourcentApportRefE14P' => '%',
'pourcentApportRefKjoP' => '%',
'pourcentApportRefFatP' => '%',
'pourcentApportRefFatsatP' => '%',
'pourcentApportRefChovalP' => '%',
'pourcentApportRefSugarP' => '%',
'pourcentApportRefProP' => '%',
'pourcentApportRefSalteqP' => '%',
'apportPort' => 'g',
'pSurL' => null,
];
}
}

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo;
final readonly class ComposesSpeciaux extends AbstractIncoCompoPartDto
{
public function __construct(
public string $nutCholn,
public string $nutCarnitine,
public string $nutTau,
public string $nutNucleotide,
) {
}
protected static function fieldLabels(): array
{
return [
'nutCholn' => 'Choline',
'nutCarnitine' => 'Carnitine',
'nutTau' => 'Taurine',
'nutNucleotide' => 'Nucléotides',
];
}
protected static function fieldUnits(): array
{
return [
'nutCholn' => 'mg',
'nutCarnitine' => 'mg',
'nutTau' => 'mg',
'nutNucleotide' => 'mg',
];
}
}

View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo;
final readonly class Energie extends AbstractIncoCompoPartDto
{
public function __construct(
// Par 100g/ml (référence)
public string $tailleReference,
public string $valEnergKjReference,
public string $valEnergKcalReference,
// Par portion
public string $taillePortion,
public string $valEnergKjPortion,
public string $valEnergKcalPortion,
) {
}
protected static function fieldLabels(): array
{
return [
'tailleReference' => 'Taille de référence',
'valEnergKjReference' => 'Énergie (kJ) / 100g',
'valEnergKcalReference' => 'Énergie (kcal) / 100g',
'taillePortion' => 'Taille de la portion',
'valEnergKjPortion' => 'Énergie (kJ) / portion',
'valEnergKcalPortion' => 'Énergie (kcal) / portion',
];
}
protected static function fieldUnits(): array
{
return [
'tailleReference' => 'g',
'valEnergKjReference' => 'kJ',
'valEnergKcalReference' => 'kcal',
'taillePortion' => 'g',
'valEnergKjPortion' => 'kJ',
'valEnergKcalPortion' => 'kcal',
];
}
}

View file

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo;
final readonly class FruitLegumeNoix extends AbstractIncoCompoPartDto
{
public function __construct(
public string $fruitsReference,
public string $fruitsCoqueSechReference,
public string $fruitsPortion,
public string $fruitsCoqueSechPortion,
public string $fruitLegumeNoixFrais,
public string $fruitLegumeNoixFraisPortion,
public string $pourcentFruitsLegumesNoixFrais,
public string $pourcentFruits,
) {
}
protected static function fieldLabels(): array
{
return [
'fruitsReference' => 'Fruits / 100g',
'fruitsCoqueSechReference' => 'Fruits à coque et séchés / 100g',
'fruitsPortion' => 'Fruits / portion',
'fruitsCoqueSechPortion' => 'Fruits à coque et séchés / portion',
'fruitLegumeNoixFrais' => 'FNL frais / 100g',
'fruitLegumeNoixFraisPortion' => 'FNL frais / portion',
'pourcentFruitsLegumesNoixFrais' => '% FNL frais',
'pourcentFruits' => '% Fruits',
];
}
protected static function fieldUnits(): array
{
return [
'fruitsReference' => 'g',
'fruitsCoqueSechReference' => 'g',
'fruitsPortion' => 'g',
'fruitsCoqueSechPortion' => 'g',
'fruitLegumeNoixFrais' => 'g',
'fruitLegumeNoixFraisPortion' => 'g',
'pourcentFruitsLegumesNoixFrais' => '%',
'pourcentFruits' => '%',
];
}
}

View file

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo;
final readonly class GlucidesDetail extends AbstractIncoCompoPartDto
{
public function __construct(
public string $nutStarch,
public string $nutMaltdex,
public string $nutSucs,
public string $nutGlus,
public string $nutLacs,
public string $nutMals,
public string $nutGalfb,
public string $nutGHc,
public string $nutFrufb,
public string $nutFrus,
public string $nutOlsac,
public string $nutPolyl,
public string $nutFibsol,
public string $nutInotl,
) {
}
protected static function fieldLabels(): array
{
return [
'nutStarch' => 'Amidon',
'nutMaltdex' => 'Maltodextrines',
'nutSucs' => 'Saccharose',
'nutGlus' => 'Glucose',
'nutLacs' => 'Lactose',
'nutMals' => 'Maltose',
'nutGalfb' => 'Galactose',
'nutGHc' => 'Glucides hydrates de carbone',
'nutFrufb' => 'Fructose lié',
'nutFrus' => 'Fructose libre',
'nutOlsac' => 'Oligosaccharides',
'nutPolyl' => 'Polyols',
'nutFibsol' => 'Fibres solubles',
'nutInotl' => 'Inositol',
];
}
protected static function fieldUnits(): array
{
return [
'nutStarch' => 'g',
'nutMaltdex' => 'g',
'nutSucs' => 'g',
'nutGlus' => 'g',
'nutLacs' => 'g',
'nutMals' => 'g',
'nutGalfb' => 'g',
'nutGHc' => 'g',
'nutFrufb' => 'g',
'nutFrus' => 'g',
'nutOlsac' => 'g',
'nutPolyl' => 'g',
'nutFibsol' => 'g',
'nutInotl' => 'mg',
];
}
}

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo;
final readonly class Identification extends AbstractIncoCompoPartDto
{
public function __construct(
public string $nom,
public string $numero,
public string $source,
) {
}
protected static function fieldLabels(): array
{
return [
'nom' => 'Nom',
'numero' => 'Numéro',
'source' => 'Source',
];
}
protected static function fieldUnits(): array
{
return [
'nom' => null,
'numero' => null,
'source' => null,
];
}
}

View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
/**
* Interface for nutritional DTOs that can provide units, human-readable labels,
* and be serialized to string / JSON.
*/
interface IncoCompoPartDto extends Dto
{
/**
* Returns field values indexed by their internal property name.
*
* @return array<string, float|string|null>
*/
public function toArray(): array;
/**
* Returns field values indexed by their human-readable label.
*
* @return array<string, float|string|null>
*/
public function humanReadableValues(): array;
/**
* Returns units indexed by internal property name.
*
* @return array<string, string|null>
*/
public static function units(): array;
/**
* Returns units indexed by human-readable label.
*
* @return array<string, string|null>
*/
public static function humanReadableUnits(): array;
}

View file

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo;
final readonly class MacronutrimentsPortion extends AbstractIncoCompoPartDto
{
public function __construct(
public string $matGrassPortion,
public string $matGrassPortionPrec,
public string $acideGrasSaturePortion,
public string $acideGrasSaturePortionPrec,
public string $glucidesPortion,
public string $glucidesPortionPrec,
public string $sucrePortion,
public string $sucrePortionPrec,
public string $fibAlimPortion,
public string $fibAlimPortionPrec,
public string $proteinePortion,
public string $proteinePortionPrec,
public string $selPortion,
public string $selPortionPrec,
public string $sodiumPortion,
) {
}
protected static function fieldLabels(): array
{
return [
'matGrassPortion' => 'Matières grasses / portion',
'matGrassPortionPrec' => 'Matières grasses précision / portion',
'acideGrasSaturePortion' => 'Acides gras saturés / portion',
'acideGrasSaturePortionPrec' => 'Acides gras saturés précision / portion',
'glucidesPortion' => 'Glucides / portion',
'glucidesPortionPrec' => 'Glucides précision / portion',
'sucrePortion' => 'Sucres / portion',
'sucrePortionPrec' => 'Sucres précision / portion',
'fibAlimPortion' => 'Fibres alimentaires / portion',
'fibAlimPortionPrec' => 'Fibres alimentaires précision / portion',
'proteinePortion' => 'Protéines / portion',
'proteinePortionPrec' => 'Protéines précision / portion',
'selPortion' => 'Sel / portion',
'selPortionPrec' => 'Sel précision / portion',
'sodiumPortion' => 'Sodium / portion',
];
}
protected static function fieldUnits(): array
{
return [
'matGrassPortion' => 'g',
'matGrassPortionPrec' => null,
'acideGrasSaturePortion' => 'g',
'acideGrasSaturePortionPrec' => null,
'glucidesPortion' => 'g',
'glucidesPortionPrec' => null,
'sucrePortion' => 'g',
'sucrePortionPrec' => null,
'fibAlimPortion' => 'g',
'fibAlimPortionPrec' => null,
'proteinePortion' => 'g',
'proteinePortionPrec' => null,
'selPortion' => 'g',
'selPortionPrec' => null,
'sodiumPortion' => 'mg',
];
}
}

View file

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo;
final readonly class MacronutrimentsRef extends AbstractIncoCompoPartDto
{
public function __construct(
public string $matGrassReference,
public string $matGrassReferencePrec,
public string $acideGrasSatureReference,
public string $acideGrasSatureReferencePrec,
public string $glucidesReference,
public string $glucidesReferencePrec,
public string $sucreReference,
public string $sucreReferencePrec,
public string $fibAlimReference,
public string $fibAlimReferencePrec,
public string $proteineReference,
public string $proteineReferencePrec,
public string $selReference,
public string $selReferencePrec,
public string $sodiumReference,
) {
}
protected static function fieldLabels(): array
{
return [
'matGrassReference' => 'Matières grasses / 100g',
'matGrassReferencePrec' => 'Matières grasses précision / 100g',
'acideGrasSatureReference' => 'Acides gras saturés / 100g',
'acideGrasSatureReferencePrec' => 'Acides gras saturés précision / 100g',
'glucidesReference' => 'Glucides / 100g',
'glucidesReferencePrec' => 'Glucides précision / 100g',
'sucreReference' => 'Sucres / 100g',
'sucreReferencePrec' => 'Sucres précision / 100g',
'fibAlimReference' => 'Fibres alimentaires / 100g',
'fibAlimReferencePrec' => 'Fibres alimentaires précision / 100g',
'proteineReference' => 'Protéines / 100g',
'proteineReferencePrec' => 'Protéines précision / 100g',
'selReference' => 'Sel / 100g',
'selReferencePrec' => 'Sel précision / 100g',
'sodiumReference' => 'Sodium / 100g',
];
}
protected static function fieldUnits(): array
{
return [
'matGrassReference' => 'g',
'matGrassReferencePrec' => null,
'acideGrasSatureReference' => 'g',
'acideGrasSatureReferencePrec' => null,
'glucidesReference' => 'g',
'glucidesReferencePrec' => null,
'sucreReference' => 'g',
'sucreReferencePrec' => null,
'fibAlimReference' => 'g',
'fibAlimReferencePrec' => null,
'proteineReference' => 'g',
'proteineReferencePrec' => null,
'selReference' => 'g',
'selReferencePrec' => null,
'sodiumReference' => 'mg',
];
}
}

View file

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo;
final readonly class Mineraux extends AbstractIncoCompoPartDto
{
public function __construct(
public string $nutCa,
public string $nutMg,
public string $nutK,
public string $nutP,
public string $nutCld,
public string $nutFe,
public string $nutZn,
public string $nutCu,
public string $nutMn,
public string $nutSe,
public string $nutId,
public string $nutCr,
public string $nutMo,
public string $nutS6,
public string $nutNitra,
) {
}
protected static function fieldLabels(): array
{
return [
'nutCa' => 'Calcium',
'nutMg' => 'Magnésium',
'nutK' => 'Potassium',
'nutP' => 'Phosphore',
'nutCld' => 'Chlorure',
'nutFe' => 'Fer',
'nutZn' => 'Zinc',
'nutCu' => 'Cuivre',
'nutMn' => 'Manganèse',
'nutSe' => 'Sélénium',
'nutId' => 'Iode',
'nutCr' => 'Chrome',
'nutMo' => 'Molybdène',
'nutS6' => 'Soufre',
'nutNitra' => 'Nitrates',
];
}
protected static function fieldUnits(): array
{
return [
'nutCa' => 'mg',
'nutMg' => 'mg',
'nutK' => 'mg',
'nutP' => 'mg',
'nutCld' => 'mg',
'nutFe' => 'mg',
'nutZn' => 'mg',
'nutCu' => 'mg',
'nutMn' => 'mg',
'nutSe' => 'µg',
'nutId' => 'µg',
'nutCr' => 'µg',
'nutMo' => 'µg',
'nutS6' => 'mg',
'nutNitra' => 'mg',
];
}
}

View file

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo;
final readonly class NutriScore extends AbstractIncoCompoPartDto
{
public function __construct(
public string $composanteN,
public string $composanteN2022,
public string $composanteP,
public string $composanteP2022,
public string $scoreNutri,
public string $scoreNutri2022,
public string $classement,
public string $nutriScore2022,
public string $nutriAArt,
public string $viandeRouge,
) {
}
protected static function fieldLabels(): array
{
return [
'composanteN' => 'Composante négative',
'composanteN2022' => 'Composante négative 2022',
'composanteP' => 'Composante positive',
'composanteP2022' => 'Composante positive 2022',
'scoreNutri' => 'Score Nutri-Score',
'scoreNutri2022' => 'Score Nutri-Score 2022',
'classement' => 'Classement (A-E)',
'nutriScore2022' => 'Nutri-Score 2022 (lettre)',
'nutriAArt' => 'Article Nutri-Score A',
'viandeRouge' => 'Viande rouge',
];
}
protected static function fieldUnits(): array
{
return [
'composanteN' => null,
'composanteN2022' => null,
'composanteP' => null,
'composanteP2022' => null,
'scoreNutri' => null,
'scoreNutri2022' => null,
'classement' => null,
'nutriScore2022' => null,
'nutriAArt' => null,
'viandeRouge' => null,
];
}
}

View file

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo;
final readonly class PointsNutriScore extends AbstractIncoCompoPartDto
{
public function __construct(
public string $pointsEnergie,
public string $pointsAcideGrasSature,
public string $pointsSucres,
public string $pointsSel,
public string $pointsFibres,
public string $pointsProteines,
public string $pointsFruitsLegumesNoixFrais,
) {
}
protected static function fieldLabels(): array
{
return [
'pointsEnergie' => 'Points énergie',
'pointsAcideGrasSature' => 'Points acides gras saturés',
'pointsSucres' => 'Points sucres',
'pointsSel' => 'Points sel',
'pointsFibres' => 'Points fibres',
'pointsProteines' => 'Points protéines',
'pointsFruitsLegumesNoixFrais' => 'Points FNL frais',
];
}
protected static function fieldUnits(): array
{
return [
'pointsEnergie' => null,
'pointsAcideGrasSature' => null,
'pointsSucres' => null,
'pointsSel' => null,
'pointsFibres' => null,
'pointsProteines' => null,
'pointsFruitsLegumesNoixFrais' => null,
];
}
}

View file

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\IncoCompo;
final readonly class Vitamines extends AbstractIncoCompoPartDto
{
public function __construct(
public string $nutVita,
public string $nutVitb6,
public string $nutVitb12,
public string $nutVitc,
public string $nutVitd,
public string $nutVite,
public string $nutVitk,
public string $nutChocal,
public string $nutNia,
public string $nutRibf,
public string $nutThia,
public string $nutPantac,
public string $nutBiot,
public string $nutFoldfe,
) {
}
protected static function fieldLabels(): array
{
return [
'nutVita' => 'Vitamine A',
'nutVitb6' => 'Vitamine B6',
'nutVitb12' => 'Vitamine B12',
'nutVitc' => 'Vitamine C',
'nutVitd' => 'Vitamine D',
'nutVite' => 'Vitamine E',
'nutVitk' => 'Vitamine K',
'nutChocal' => 'Cholécalciférol (D3)',
'nutNia' => 'Niacine (B3)',
'nutRibf' => 'Riboflavine (B2)',
'nutThia' => 'Thiamine (B1)',
'nutPantac' => 'Acide pantothénique (B5)',
'nutBiot' => 'Biotine (B8)',
'nutFoldfe' => 'Folates équivalents (B9)',
];
}
protected static function fieldUnits(): array
{
return [
'nutVita' => 'µg',
'nutVitb6' => 'mg',
'nutVitb12' => 'µg',
'nutVitc' => 'mg',
'nutVitd' => 'µg',
'nutVite' => 'mg',
'nutVitk' => 'µg',
'nutChocal' => 'µg',
'nutNia' => 'mg',
'nutRibf' => 'mg',
'nutThia' => 'mg',
'nutPantac' => 'mg',
'nutBiot' => 'µg',
'nutFoldfe' => 'µg',
];
}
}

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\IncoId;
/**
* Represents an Inco sheet resumed info
*/
final readonly class IncoShort implements Dto
{
public function __construct(
public IncoId $id,
public string $name,
public string $libelle,
public string $description,
public ?\DateTimeInterface $created_at,
public ?\DateTimeInterface $updated_at,
) {
}
public function jsonSerialize(): mixed
{
return [
'id' => $this->id->value,
'name' => $this->name,
'libelle' => $this->libelle,
'description' => $this->description,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

View file

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
/**
* A page of items with pagination metadata.
*
* @template T
*/
final readonly class PageOf implements Dto
{
/** @var array<int,T> */
private array $items;
/**
* @param array<int, T> $items
*/
public function __construct(
private PaginationDto $pagination,
array $items
) {
// * Shallow copy
$this->items = array_values($items);
}
/** @return array<int, T> $items */
public function items(): array
{
return [...$this->items];
}
public function pagination(): PaginationDto
{
return $this->pagination;
}
public function jsonSerialize(): mixed
{
return [
'pagination' => $this->pagination,
'items' => $this->items,
];
}
}

View file

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto;
final readonly class PaginationDto implements Dto
{
public function __construct(
public int $page,
public int $pageSize,
public int $totalItems,
public int $totalPages,
) {
}
public function hasNextPage(): bool
{
return $this->page < $this->totalPages;
}
public function hasPreviousPage(): bool
{
return $this->page > 1;
}
public function nextPage(): ?int
{
return $this->hasNextPage() ? $this->page + 1 : null;
}
public function previousPage(): ?int
{
return $this->hasPreviousPage() ? $this->page - 1 : null;
}
public function jsonSerialize(): mixed
{
return [
'page' => $this->page,
'pageSize' => $this->pageSize,
'totalItems' => $this->totalItems,
'totalPages' => $this->totalPages,
];
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiSorter;
enum EmployeeSortColumn implements SortColumn
{
case arcadiaId;
case startDate;
case endDate;
case plannedEndDate;
case createdAt;
case updatedAt;
}

View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiSorter;
final readonly class EmployeeSorter
{
public function __construct(
public EmployeeSortColumn $sortBy,
public \SortDirection $direction = \SortDirection::Descending
) {
}
}

View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiSorter;
enum IncoSortColumn implements SortColumn
{
case id;
case createdAt;
case updatedAt;
}

View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiSorter;
final readonly class IncoSorter
{
public function __construct(
public IncoSortColumn $sortBy,
public \SortDirection $direction = \SortDirection::Descending
) {
}
}

View file

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiSorter;
interface SortColumn extends \UnitEnum
{
}

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject;
use Uwil\ArcadiaApiPhpClient\Lib\ValueObjectId;
final readonly class ArcadiaEmployeeId implements ValueObjectId
{
private const PATTERN = '/^\d{7,8}$/';
public function __construct(
public readonly string $value
) {
if (! preg_match(self::PATTERN, $value)) {
throw new \DomainException(
"Invalid value '{$value}' does not match pattern " . self::PATTERN
);
}
}
}

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject;
use Uwil\ArcadiaApiPhpClient\Lib\ValueObjectId;
/**
* Unique public brand code (e.g. "LGL01", "ARC01").
*/
final readonly class BrandCode implements ValueObjectId
{
public function __construct(
public string $value
) {
if (! preg_match('/^[A-Z]{3}\d{2}$/', $value)) {
throw new \DomainException(
"Invalid code '{$value}' does not match pattern /^[A-Z]{3}\d{2}$/"
);
}
}
}

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject;
use Uwil\ArcadiaApiPhpClient\Lib\ValueObjectId;
final readonly class EstablishmentCode implements ValueObjectId
{
private const PATTERN = '/^[\dA-Z]{2,}$/';
public function __construct(
public readonly string $value
) {
if (! preg_match(self::PATTERN, $value)) {
throw new \DomainException(
"Invalid value '{$value}' does not match pattern " . self::PATTERN
);
}
}
}

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject;
use Uwil\ArcadiaApiPhpClient\Lib\ValueObjectId;
final readonly class IncoCompoId implements ValueObjectId
{
private const PATTERN = '/^\d{1,}$/';
public function __construct(
public readonly string $value
) {
if (! preg_match(self::PATTERN, $value)) {
throw new \DomainException(
"Invalid value '{$value}' does not match pattern " . self::PATTERN
);
}
}
}

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject;
use Uwil\ArcadiaApiPhpClient\Lib\ValueObjectId;
final readonly class IncoId implements ValueObjectId
{
private const PATTERN = '/^\d{1,}$/';
public function __construct(
public readonly string $value
) {
if (! preg_match(self::PATTERN, $value)) {
throw new \DomainException(
"Invalid value '{$value}' does not match pattern " . self::PATTERN
);
}
}
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject;
use Uwil\ArcadiaApiPhpClient\Lib\ValueObjectId;
/**
* eg: 097
*/
final readonly class ServiceId implements ValueObjectId
{
private const PATTERN = '/^\d{1,}$/';
public function __construct(
public readonly string $value
) {
if (! preg_match(self::PATTERN, $value)) {
throw new \DomainException(
"Invalid value '{$value}' does not match pattern " . self::PATTERN
);
}
}
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject;
use Uwil\ArcadiaApiPhpClient\Lib\ValueObjectId;
/**
* eg: 'LDCSER', 'SLELA', 'DDE'
*/
final readonly class SiteId implements ValueObjectId
{
private const PATTERN = '/^[A-Z]{2,}$/';
public function __construct(
public readonly string $value
) {
if (! preg_match(self::PATTERN, $value)) {
throw new \DomainException(
"Invalid value '{$value}' does not match pattern " . self::PATTERN
);
}
}
}

10
src/Lib/Equatable.php Normal file
View file

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib;
interface Equatable
{
public function equals(mixed $other): bool;
}

17
src/Lib/LibMiddleware.php Normal file
View file

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib;
use GuzzleHttp\Promise\PromiseInterface;
use Psr\Http\Message\RequestInterface;
/**
* @internal
*/
interface LibMiddleware
{
/** @param array<string, mixed> $options */
public function __invoke(RequestInterface $request, array $options): PromiseInterface;
}

30
src/Lib/LibProperties.php Normal file
View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib;
use Composer\InstalledVersions;
final readonly class LibProperties
{
public const string PACKAGE_NAME = 'uwil/arcadia-api-php-client';
public string $productName;
public string $productVersion;
public function __construct()
{
$version = InstalledVersions::getPrettyVersion(self::PACKAGE_NAME);
if ($version === null) {
throw new \UnexpectedValueException(
sprintf("Unable to determine the version for package '%s'.", self::PACKAGE_NAME)
);
}
$this->productName = self::PACKAGE_NAME;
$this->productVersion = $version;
}
}

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\Pagination;
use Uwil\ArcadiaApiPhpClient\Lib\Equatable;
/**
* Value object representing a page number in pagination (1-indexed).
*/
final readonly class PageNumber implements \JsonSerializable, \Stringable, Equatable
{
public const int DEFAULT_PAGE = 1;
public function __construct(
public int $value = self::DEFAULT_PAGE,
) {
if ($this->value <= 0) {
throw new \InvalidArgumentException('Page number must be greater than 0');
}
}
public static function of(int $value): self
{
return new self($value);
}
public static function default(): self
{
return new self(self::DEFAULT_PAGE);
}
public function value(): int
{
return $this->value;
}
public function equals(mixed $other): bool
{
return $other instanceof self && $this->value === $other->value;
}
public function __toString(): string
{
return (string) $this->value;
}
public function jsonSerialize(): int
{
return $this->value;
}
}

View file

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\Pagination;
use Uwil\ArcadiaApiPhpClient\Lib\Equatable;
/**
* Value object representing a page size in pagination.
*/
final readonly class PageSize implements \JsonSerializable, \Stringable, Equatable
{
public const int DEFAULT_SIZE = 50;
/** ERR_TOO_MANY_RECORDS fixed to 10k */
public const int MAX_PAGE_SIZE = 10000;
public function __construct(
public int $value = self::DEFAULT_SIZE,
) {
if ($this->value <= 0) {
throw new \InvalidArgumentException('Page size must be greater than 0');
}
if ($this->value > self::MAX_PAGE_SIZE) {
throw new \InvalidArgumentException('Page size must be less than or equal to ' . self::MAX_PAGE_SIZE);
}
}
public static function of(int $value): self
{
return new self($value);
}
public static function default(): self
{
return new self(self::DEFAULT_SIZE);
}
public function value(): int
{
return $this->value;
}
public function equals(mixed $other): bool
{
return $other instanceof self && $this->value === $other->value;
}
public function __toString(): string
{
return (string) $this->value;
}
public function jsonSerialize(): int
{
return $this->value;
}
}

View file

@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\Pagination;
use Uwil\ArcadiaApiPhpClient\Lib\Equatable;
/**
* Immutable value object representing a pagination range.
*/
final class Pagination implements Equatable
{
/** ERR_TOO_MANY_RECORDS fixed to 10k */
public const int MAX_PAGE_SIZE = PageSize::MAX_PAGE_SIZE;
public function __construct(
private readonly int $from,
private readonly int $to,
) {
if ($from < 0) {
throw new \InvalidArgumentException('From index must be non-negative');
}
if ($to < 0) {
throw new \InvalidArgumentException('To index must be non-negative');
}
if ($to <= $from) {
throw new \InvalidArgumentException('To index must be greater than from index');
}
if (($to - $from + 1) > self::MAX_PAGE_SIZE) {
throw new \InvalidArgumentException('Page size must be less than or equal to ' . self::MAX_PAGE_SIZE);
}
}
public function from(): int
{
return $this->from;
}
public function to(): int
{
return $this->to;
}
public function pageSize(): int
{
return $this->to - $this->from + 1;
}
public function pageNumber(): int
{
return (int) ($this->from / $this->pageSize()) + 1;
}
public function pageSizeVo(): PageSize
{
return new PageSize($this->pageSize());
}
public function pageNumberVo(): PageNumber
{
return new PageNumber($this->pageNumber());
}
public function equals(mixed $other): bool
{
return $other instanceof self && $this->from === $other->from && $this->to === $other->to;
}
public static function ofIndexes(int $from, int $to): self
{
return new self($from, $to);
}
public static function ofPage(PageNumber $pageNumber, PageSize $pageSize = new PageSize()): self
{
$fromIndex = ($pageNumber->value - 1) * $pageSize->value;
$toIndex = max($pageNumber->value * $pageSize->value - 1, 1);
return new self($fromIndex, $toIndex);
}
public static function ofOffsetAndLimit(int $offset, int $limit): self
{
if ($offset < 0) {
throw new \InvalidArgumentException('Offset must be non-negative');
}
if ($limit <= 0) {
throw new \InvalidArgumentException('Limit must be positive and non-zero');
}
$fromIndex = $offset;
$toIndex = max($offset + $limit - 1, 1);
return new self($fromIndex, $toIndex);
}
public static function firstPage(PageSize $pageSize = new PageSize()): self
{
return self::ofPage(new PageNumber(1), $pageSize);
}
public function __toString(): string
{
return sprintf('Pagination{from=%d, to=%d}', $this->from, $this->to);
}
}

View file

@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib\Pagination;
use Uwil\ArcadiaApiPhpClient\Error\ApiError;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\PaginationDto;
/**
* Lazy stream / cursor iterator over paginated API resources.
*
* Provides continuous O(1) memory iteration across all pages.
*
* @template T
*
* @implements \IteratorAggregate<int, T>
*/
final class StreamOf implements \IteratorAggregate
{
private ?ApiError $lastError = null;
private ?PaginationDto $firstPagePagination = null;
/**
* @param \Closure(Pagination):(\Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\PageOf<T>|ApiError) $pageFetcher
*/
public function __construct(
private readonly \Closure $pageFetcher,
private readonly PageSize $pageSize = new PageSize(),
) {
}
/**
* Iterate over individual items of type T.
*
* @return \Generator<int, T, mixed, ?ApiError>
*/
public function getIterator(): \Generator
{
foreach ($this->pages() as $page) {
foreach ($page->items() as $item) {
yield $item;
}
}
return $this->lastError;
}
/**
* Iterate page by page (useful for batching operations).
*
* @return \Generator<int,\Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\PageOf<T>, mixed, ?ApiError>
*/
public function pages(): \Generator
{
$this->lastError = null;
$pageNumber = 1;
do {
$pagination = Pagination::ofPage(new PageNumber($pageNumber), $this->pageSize);
$result = ($this->pageFetcher)($pagination);
if ($result instanceof ApiError) {
$this->lastError = $result;
return $result;
}
if ($this->firstPagePagination === null) {
$this->firstPagePagination = $result->pagination();
}
yield $result;
if (! $result->pagination()->hasNextPage()) {
break;
}
$pageNumber++;
} while (true);
return null;
}
/**
* Execute a callback on each item.
* Stops and returns the ApiError if an error occurs, or stops if the callback returns false.
*
* @param callable(T): (bool|void) $callback
*/
public function each(callable $callback): ?ApiError
{
foreach ($this->getIterator() as $item) {
if ($callback($item) === false) {
break;
}
}
return $this->lastError;
}
/**
* Yield at most $limit items without requesting subsequent pages unnecessarily.
*
* @return \Generator<int, T>
*/
public function take(int $limit): \Generator
{
if ($limit <= 0) {
return;
}
$count = 0;
foreach ($this->getIterator() as $item) {
yield $item;
$count++;
if ($count >= $limit) {
break;
}
}
}
/**
* Get the first item of the stream, or null if empty, or ApiError on failure.
*
* @return T|ApiError|null
*/
public function first(): mixed
{
foreach ($this->take(1) as $item) {
return $item;
}
return $this->lastError;
}
public function lastError(): ?ApiError
{
return $this->lastError;
}
public function hasError(): bool
{
return $this->lastError !== null;
}
/**
* Total number of items reported by the API (available once at least one page is fetched).
*/
public function totalCount(): ?int
{
return $this->firstPagePagination?->totalItems;
}
/**
* Total number of pages reported by the API (available once at least one page is fetched).
*/
public function totalPages(): ?int
{
return $this->firstPagePagination?->totalPages;
}
}

View file

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Lib;
interface ValueObjectId
{
}

View file

@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Logger;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Promise\Create;
use GuzzleHttp\Promise\PromiseInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Log\LoggerInterface;
use Uwil\ArcadiaApiPhpClient\Auth\ApiMandatoryHeadersMiddleware;
use Uwil\ArcadiaApiPhpClient\Lib\LibMiddleware;
/**
* @internal
*/
final class ApiLoggerMiddleware implements LibMiddleware
{
public const string HANDLER_NAME = 'Arcadia API Logger';
public const string HEADER_AUTHORIZATION = 'Authorization';
/** @var string[] */
public const array SENSITIVE_HEADERS = [
self::HEADER_AUTHORIZATION,
ApiMandatoryHeadersMiddleware::HEADER_SIGNATURE,
ApiMandatoryHeadersMiddleware::HEADER_CLIENT_IDENTIFIER,
];
/**
* Creates the middleware handler closure.
*/
public static function log(LoggerInterface $logger): callable
{
return static function (callable $handler) use ($logger): self {
return new self($handler, $logger);
};
}
/**
* @param callable(RequestInterface, array<string, mixed>): PromiseInterface $nextHandler Next handler to invoke.
*/
public function __construct(
private $nextHandler,
private readonly LoggerInterface $logger,
) {
}
/** @param array<string, mixed> $options */
public function __invoke(RequestInterface $request, array $options): PromiseInterface
{
$fn = $this->nextHandler;
$startTime = hrtime(true);
return $fn($request, $options)->then(
function (ResponseInterface $response) use ($request, $startTime): ResponseInterface {
$durationMs = (hrtime(true) - $startTime) / 1_000_000;
$this->logResponse($request, $response, $durationMs);
return $response;
},
function (\Throwable $reason) use ($request, $startTime): PromiseInterface {
$durationMs = (hrtime(true) - $startTime) / 1_000_000;
$this->logFailure($request, $reason, $durationMs);
return Create::rejectionFor($reason);
}
);
}
private function logResponse(RequestInterface $request, ResponseInterface $response, float $durationMs): void
{
$statusCode = $response->getStatusCode();
$roundedDuration = round($durationMs, 2);
$context = [
'method' => $request->getMethod(),
'uri' => (string) $request->getUri(),
'status_code' => $statusCode,
'duration_ms' => $roundedDuration,
'headers' => $this->sanitizeHeaders($request->getHeaders()),
];
$message = sprintf(
'Arcadia API [%s] %s -> %d %s (%.2fms)',
$request->getMethod(),
(string) $request->getUri(),
$statusCode,
$response->getReasonPhrase(),
$roundedDuration
);
if ($statusCode >= 500) {
$this->logger->error($message, $context);
} elseif ($statusCode >= 400) {
$this->logger->warning($message, $context);
} else {
$this->logger->info($message, $context);
}
}
private function logFailure(RequestInterface $request, \Throwable $reason, float $durationMs): void
{
$roundedDuration = round($durationMs, 2);
$context = [
'method' => $request->getMethod(),
'uri' => (string) $request->getUri(),
'duration_ms' => $roundedDuration,
'headers' => $this->sanitizeHeaders($request->getHeaders()),
'error_class' => $reason::class,
'error_message' => $reason->getMessage(),
];
if ($reason instanceof RequestException && $reason->getResponse() !== null) {
$response = $reason->getResponse();
$statusCode = $response->getStatusCode();
$context['status_code'] = $statusCode;
$message = sprintf(
'Arcadia API [%s] %s -> %d %s (%.2fms)',
$request->getMethod(),
(string) $request->getUri(),
$statusCode,
$response->getReasonPhrase(),
$roundedDuration
);
if ($statusCode >= 500) {
$this->logger->error($message, $context);
} else {
$this->logger->warning($message, $context);
}
return;
}
$message = sprintf(
'Arcadia API [%s] %s -> Failed: %s (%.2fms)',
$request->getMethod(),
(string) $request->getUri(),
$reason->getMessage(),
$roundedDuration
);
$this->logger->error($message, $context);
}
/**
* @param array<array-key, array<string>> $headers
* @return array<string, string>
*/
private function sanitizeHeaders(array $headers): array
{
$sanitized = [];
foreach ($headers as $name => $values) {
$stringName = (string) $name;
$value = implode(', ', $values);
if ($this->isSensitiveHeader($stringName)) {
$sanitized[$stringName] = strlen($value) > 8
? substr($value, 0, 4) . '***[REDACTED]'
: '[REDACTED]';
} else {
$sanitized[$stringName] = $value;
}
}
return $sanitized;
}
private function isSensitiveHeader(string $headerName): bool
{
foreach (self::SENSITIVE_HEADERS as $sensitive) {
if (strcasecmp($headerName, $sensitive) === 0) {
return true;
}
}
return false;
}
}

View file

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Mapper;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Brand;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\PageOf;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\PaginationDto;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\BrandCode;
use Uwil\ArcadiaApiPhpClient\Lib\Pagination\Pagination;
use Uwil\ArcadiaKiotaOpenApiClient\Models\Brand as KiotaBrand;
use Uwil\ArcadiaKiotaOpenApiClient\Models\BrandListResponse;
use Uwil\ArcadiaKiotaOpenApiClient\Models\PaginationMeta;
/**
* @internal
*/
class BrandMapper
{
public function toDto(KiotaBrand $model): Brand
{
$code = $model->getCode() ?? '';
$name = $model->getName() ?? '';
$label = mb_trim($name) !== '' ? $name : $code;
return new Brand(
code: new BrandCode($code),
label: $label,
arcadiaId: $model->getArcadiaId(),
createdAt: $model->getCreatedAt(),
);
}
/**
* @return PageOf<Brand>
*/
public function toPage(BrandListResponse $response, Pagination $pagination): PageOf
{
$items = [];
foreach ($response->getBrands() ?? [] as $brand) {
$items[] = $this->toDto($brand);
}
$paginationDto = $this->paginationDto(
$response->getPagination(),
$pagination
);
return new PageOf(
$paginationDto,
$items
);
}
private function paginationDto(?PaginationMeta $meta, Pagination $pagination): PaginationDto
{
return new PaginationDto(
page: $meta?->getPage() ?? $pagination->pageNumber(),
pageSize: $meta?->getPageSize() ?? $pagination->pageSize(),
totalItems: $meta?->getTotalItems() ?? 0,
totalPages: $meta?->getTotalPages() ?? 0,
);
}
}

View file

@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace Uwil\ArcadiaApiPhpClient\Mapper;
use Microsoft\Kiota\Abstractions\Types\Date;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\Employee;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\PageOf;
use Uwil\ArcadiaApiPhpClient\Lib\ApiDto\Dto\PaginationDto;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\ArcadiaEmployeeId;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\EstablishmentCode;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\ServiceId;
use Uwil\ArcadiaApiPhpClient\Lib\ApiValueObject\SiteId;
use Uwil\ArcadiaApiPhpClient\Lib\Pagination\Pagination;
use Uwil\ArcadiaKiotaOpenApiClient\Models\Employee as KiotaEmployee;
use Uwil\ArcadiaKiotaOpenApiClient\Models\EmployeeListResponse;
use Uwil\ArcadiaKiotaOpenApiClient\Models\PaginationMeta;
/**
* @internal
*/
class EmployeeMapper
{
public function toDto(KiotaEmployee $model): Employee
{
$matricule = $model->getMatricule() ?? '';
$establishment = $model->getEstablishment() ?? '';
$site = $model->getSite() ?? '';
$service = $model->getService() ?? '';
$startDate = $this->dateToDateTime($model->getStartDate()) ?? new \DateTimeImmutable('today');
$createdAt = $model->getCreatedAt() ?? new \DateTimeImmutable();
return new Employee(
matriculePublique: new ArcadiaEmployeeId($matricule),
lastname: $model->getLastName() ?? '',
firstname: $model->getFirstName() ?? '',
start_date: $startDate,
end_date: $this->dateToDateTime($model->getEndDate()),
planned_end_date: $this->dateToDateTime($model->getPlannedEndDate()),
establishment: new EstablishmentCode($establishment),
site: new SiteId($site),
service: new ServiceId($service),
created_at: $createdAt,
updated_at: $model->getUpdatedAt(),
);
}
/**
* @return PageOf<Employee>
*/
public function toPage(EmployeeListResponse $response, Pagination $pagination): PageOf
{
$items = [];
foreach ($response->getEmployees() ?? [] as $employeeModel) {
$items[] = $this->toDto($employeeModel);
}
$paginationDto = $this->paginationDto(
$response->getPagination(),
$pagination
);
return new PageOf(
$paginationDto,
$items
);
}
private function paginationDto(?PaginationMeta $meta, Pagination $pagination): PaginationDto
{
return new PaginationDto(
page: $meta?->getPage() ?? $pagination->pageNumber(),
pageSize: $meta?->getPageSize() ?? $pagination->pageSize(),
totalItems: $meta?->getTotalItems() ?? 0,
totalPages: $meta?->getTotalPages() ?? 0,
);
}
private function dateToDateTime(?Date $date): ?\DateTimeInterface
{
if ($date === null) {
return null;
}
return new \DateTimeImmutable((string) $date);
}
}

Some files were not shown because too many files have changed in this diff Show more