feat(infra): add mappers and parsers for replicated Arcadia Oracle tables
- Add IncoStringParser, IncoIngredientParser, IncoParser, and IncoCompositionParser - Add ArcadiaBrandMapper, EmployeeMapper, IncoCompositionMapper, IncoShortMapper, and IncoMapper - Add complete unit test suites for all parsers and mappers
This commit is contained in:
parent
f7db5105ad
commit
cf918ccd30
18 changed files with 2584 additions and 0 deletions
34
src/Infra/Adapters/Mappers/ArcadiaBrandMapper.php
Normal file
34
src/Infra/Adapters/Mappers/ArcadiaBrandMapper.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infra\Adapters\Mappers;
|
||||
|
||||
use Domain\Models\ValueObjects\Brand\ArcadiaBrand;
|
||||
use Domain\Models\ValueObjects\Brand\ArcadiaId;
|
||||
use Domain\Models\ValueObjects\Brand\BrandName;
|
||||
|
||||
final readonly class ArcadiaBrandMapper
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed>|object $row
|
||||
*/
|
||||
public static function toDomain(array | object $row): ArcadiaBrand
|
||||
{
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = is_object($row) ? (array) $row : $row;
|
||||
|
||||
$code = self::castString($data['COD_MARQ'] ?? $data['cod_marq'] ?? $data['code'] ?? null, '0');
|
||||
$libelle = self::castString($data['LIB_MARQ'] ?? $data['lib_marq'] ?? $data['libelle'] ?? null, '');
|
||||
|
||||
return new ArcadiaBrand(
|
||||
id: new ArcadiaId($code),
|
||||
name: new BrandName($libelle !== '' ? $libelle : "Marque {$code}"),
|
||||
);
|
||||
}
|
||||
|
||||
private static function castString(mixed $val, string $default = ''): string
|
||||
{
|
||||
return is_scalar($val) ? (string) $val : $default;
|
||||
}
|
||||
}
|
||||
83
src/Infra/Adapters/Mappers/EmployeeMapper.php
Normal file
83
src/Infra/Adapters/Mappers/EmployeeMapper.php
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infra\Adapters\Mappers;
|
||||
|
||||
use Domain\Models\Entities\Employee;
|
||||
use Domain\Models\ValueObjects\Employees\ArcadiaEmployeeId;
|
||||
use Domain\Models\ValueObjects\EstablishmentCode;
|
||||
use Domain\Models\ValueObjects\ServiceId;
|
||||
use Domain\Models\ValueObjects\SiteId;
|
||||
use Shared\Helpers\DatesHelper;
|
||||
|
||||
final readonly class EmployeeMapper
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed>|object $row
|
||||
*/
|
||||
// phpcs:disable Generic.Metrics.CyclomaticComplexity
|
||||
public static function toDomain(array | object $row): Employee
|
||||
{
|
||||
// phpcs:enable Generic.Metrics.CyclomaticComplexity
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = is_object($row) ? (array) $row : $row;
|
||||
|
||||
$rawMatricule = self::castString(
|
||||
$data['MATRICULE_FIC'] ?? $data['matricule_fic'] ?? $data['public_employee_number'] ?? null,
|
||||
''
|
||||
);
|
||||
// Garantir un format 7 ou 8 chiffres pour ArcadiaEmployeeId
|
||||
if (is_numeric($rawMatricule) && strlen($rawMatricule) < 7) {
|
||||
$rawMatricule = str_pad($rawMatricule, 7, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
$lastName = self::castString($data['NOM_FIC'] ?? $data['nom_fic'] ?? $data['NOM_PERSONNE'] ?? $data['last_name'] ?? null, '');
|
||||
$firstName = self::castString($data['PRENOM_FIC'] ?? $data['prenom_fic'] ?? $data['PRENOM_PERSONNE'] ?? $data['first_name'] ?? null, '');
|
||||
|
||||
$startDate = self::parseDate(
|
||||
$data['DATE_DEB_FIC'] ?? $data['date_deb_fic'] ?? $data['DATE_ENTREE'] ?? $data['start_date'] ?? null
|
||||
) ?? new \DateTimeImmutable('2000-01-01');
|
||||
$endDate = self::parseDate($data['DATE_FIN_FIC'] ?? $data['date_fin_fic'] ?? $data['end_date'] ?? null);
|
||||
$plannedEndDate = self::parseDate($data['DF_CARRIERE'] ?? $data['df_carriere'] ?? $data['planned_end_date'] ?? null);
|
||||
|
||||
$establishment = self::castString($data['ETAB_FIC'] ?? $data['etab_fic'] ?? $data['ETABLISSEMENT'] ?? $data['establishment'] ?? null, '01');
|
||||
$site = self::castString($data['SITE_FIC'] ?? $data['site_fic'] ?? $data['site'] ?? null, 'DEFAULT');
|
||||
$service = self::castString($data['SERVICE_FIC'] ?? $data['service_fic'] ?? $data['NUM_SERV'] ?? $data['service'] ?? null, '001');
|
||||
|
||||
$createdAt = self::parseDate($data['DAT_CRE'] ?? $data['dat_cre'] ?? $data['created_at'] ?? null) ?? $startDate;
|
||||
$updatedAt = self::parseDate($data['DAT_MAJ'] ?? $data['dat_maj'] ?? $data['updated_at'] ?? null);
|
||||
|
||||
return Employee::hydrate(
|
||||
publicEmployeeNumber: new ArcadiaEmployeeId($rawMatricule),
|
||||
lastName: $lastName,
|
||||
firstName: $firstName,
|
||||
startDate: $startDate,
|
||||
endDate: $endDate,
|
||||
plannedEndDate: $plannedEndDate,
|
||||
establishment: new EstablishmentCode($establishment),
|
||||
site: new SiteId($site),
|
||||
service: new ServiceId($service),
|
||||
createdAt: $createdAt,
|
||||
updatedAt: $updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
private static function castString(mixed $val, string $default = ''): string
|
||||
{
|
||||
return is_scalar($val) ? (string) $val : $default;
|
||||
}
|
||||
|
||||
private static function parseDate(mixed $value): ?\DateTimeInterface
|
||||
{
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
return DatesHelper::parseDateTime($value);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
49
src/Infra/Adapters/Mappers/IncoCompositionMapper.php
Normal file
49
src/Infra/Adapters/Mappers/IncoCompositionMapper.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infra\Adapters\Mappers;
|
||||
|
||||
use Domain\Models\Entities\Inco\Composition\IncoComposition;
|
||||
use Domain\Models\ValueObjects\Inco\Composition\IncoCompositionId;
|
||||
use Domain\Models\ValueObjects\Inco\IncoId;
|
||||
use Infra\Adapters\Parsers\IncoCompositionParser;
|
||||
|
||||
final readonly class IncoCompositionMapper
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed>|object $row
|
||||
*/
|
||||
public static function toDomain(array | object $row): IncoComposition
|
||||
{
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = is_object($row) ? (array) $row : $row;
|
||||
|
||||
$id = self::castString($data['ID'] ?? $data['id'] ?? null, '0');
|
||||
$idInco = self::castString($data['ID_INCO'] ?? $data['id_inco'] ?? null, $id);
|
||||
|
||||
return IncoComposition::builder()
|
||||
->withId(new IncoCompositionId($id))
|
||||
->withIdInco(new IncoId($idInco))
|
||||
->withIdentification(IncoCompositionParser::toIdentification($data))
|
||||
->withEnergy(IncoCompositionParser::toEnergy($data))
|
||||
->withReferenceMacronutrients(IncoCompositionParser::toReferenceMacronutrients($data))
|
||||
->withPortionMacronutrients(IncoCompositionParser::toPortionMacronutrients($data))
|
||||
->withFreshProduceAndNuts(IncoCompositionParser::toFreshProduceAndNuts($data))
|
||||
->withReferenceIntakes(IncoCompositionParser::toReferenceIntakes($data))
|
||||
->withNutriScore(IncoCompositionParser::toNutriScore($data))
|
||||
->withNutriScorePoints(IncoCompositionParser::toNutriScorePoints($data))
|
||||
->withMinerals(IncoCompositionParser::toMinerals($data))
|
||||
->withVitamins(IncoCompositionParser::toVitamines($data))
|
||||
->withCarbohydratesDetail(IncoCompositionParser::toCarbohydratesDetail($data))
|
||||
->withFattyAcids(IncoCompositionParser::toFattyAcids($data))
|
||||
->withAminoAcids(IncoCompositionParser::toAminoAcids($data))
|
||||
->withSpecialCompounds(IncoCompositionParser::toSpecialCompounds($data))
|
||||
->build();
|
||||
}
|
||||
|
||||
private static function castString(mixed $val, string $default = ''): string
|
||||
{
|
||||
return is_scalar($val) ? (string) $val : $default;
|
||||
}
|
||||
}
|
||||
180
src/Infra/Adapters/Mappers/IncoMapper.php
Normal file
180
src/Infra/Adapters/Mappers/IncoMapper.php
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infra\Adapters\Mappers;
|
||||
|
||||
use Domain\Models\Entities\Inco\IncoSheet;
|
||||
use Domain\Models\ValueObjects\Brand\ArcadiaId;
|
||||
use Domain\Models\ValueObjects\Inco\Composition\IncoCompositionId;
|
||||
use Domain\Models\ValueObjects\Inco\IncoBrand;
|
||||
use Domain\Models\ValueObjects\Inco\IncoId;
|
||||
use Domain\Models\ValueObjects\Inco\Ingredients;
|
||||
use Domain\Services\BrandResolver;
|
||||
use Infra\Adapters\Parsers\IncoIngredientParser;
|
||||
use Infra\Adapters\Parsers\IncoParser;
|
||||
use Infra\Adapters\Parsers\IncoStringParser;
|
||||
use Shared\Helpers\DatesHelper;
|
||||
|
||||
final readonly class IncoMapper
|
||||
{
|
||||
public function __construct(
|
||||
private BrandResolver $brandResolver,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|object $row
|
||||
*/
|
||||
// phpcs:disable Generic.Metrics.CyclomaticComplexity
|
||||
public function toDomain(array | object $row): IncoSheet
|
||||
{
|
||||
// phpcs:enable Generic.Metrics.CyclomaticComplexity
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = is_object($row) ? (array) $row : $row;
|
||||
|
||||
$rawId = self::castString($data['NO_ART'] ?? $data['no_art'] ?? $data['id'] ?? null, '0');
|
||||
$rawDenom = self::castString($data['DENOM_LEG'] ?? $data['denom_leg'] ?? $data['label'] ?? null, '');
|
||||
$rawDesc = self::castString($data['DESCRIPTIF'] ?? $data['descriptif'] ?? $data['description'] ?? null, '');
|
||||
$rawTips = $data['ASTUCES'] ?? $data['astuces'] ?? $data['tips'] ?? null;
|
||||
$rawPrepa = self::castString($data['CONSEIL_PREPA'] ?? $data['conseil_prepa'] ?? $data['preparation_advices'] ?? null, '');
|
||||
$rawArgVte = self::castString($data['ARG_VENTE'] ?? $data['arg_vente'] ?? $data['sell_arguments'] ?? null, '');
|
||||
$rawAlleg = $data['ALLEGATIONS'] ?? $data['allegations'] ?? null;
|
||||
$rawAllegSante = $data['ALLEGAT_SANTE'] ?? $data['allegat_sante'] ?? null;
|
||||
$rawAllegNutrit = $data['ALLEGAT_NUTRIT'] ?? $data['allegat_nutrit'] ?? null;
|
||||
$rawAllegAutre = $data['ALLEGAT_AUTRE'] ?? $data['allegat_autre'] ?? null;
|
||||
$rawIng = $data['LST_ING'] ?? $data['lst_ing'] ?? $data['ingredients'] ?? null;
|
||||
$rawPortion = $data['NB_PORTION'] ?? $data['nb_portion'] ?? $data['portions'] ?? null;
|
||||
$rawPortionType = $data['TYP_PORTION'] ?? $data['typ_portion'] ?? null;
|
||||
$rawAllergene = $data['LST_ALLERGENE'] ?? $data['lst_allergene'] ?? $data['allergens'] ?? null;
|
||||
$rawMarque = $data['MARQUE'] ?? $data['marque'] ?? $data['brand'] ?? '1';
|
||||
$rawNutriscore = $data['NUTRISCORE_ON_PACK'] ?? $data['nutriscore_on_pack'] ?? $data['nutriscore'] ?? null;
|
||||
$rawNet = $data['PDS_NET_UVC'] ?? $data['pds_net_uvc'] ?? null;
|
||||
$rawBrut = $data['PDS_BRUT_UVC'] ?? $data['pds_brut_uvc'] ?? null;
|
||||
$rawMoy = $data['PDS_MOY_THEO'] ?? $data['pds_moy_theo'] ?? null;
|
||||
$rawConserv = $data['CONDIT_CONSERV'] ?? $data['condit_conserv'] ?? $data['storage_condition'] ?? null;
|
||||
$rawAdditif = $data['LST_ADDITIF'] ?? $data['lst_additif'] ?? $data['additives'] ?? null;
|
||||
$rawFamille = $data['FAMILLE_VTE_LIBELLE']
|
||||
?? $data['LIB_FAMVTE']
|
||||
?? $data['FAMILLE_VTE']
|
||||
?? $data['famille_vte']
|
||||
?? $data['COD_FAMVTE']
|
||||
?? $data['sell_familly']
|
||||
?? null;
|
||||
$rawSaison = $data['PROD_SAISON'] ?? $data['prod_saison'] ?? $data['is_seasonal'] ?? false;
|
||||
$rawCompo = $data['REF_COMPO'] ?? $data['ref_compo'] ?? $data['compo_id'] ?? null;
|
||||
$rawCreated = $data['DAT_CRE'] ?? $data['dat_cre'] ?? $data['created_at'] ?? null;
|
||||
$rawUpdated = $data['DAT_MAJ'] ?? $data['dat_maj'] ?? $data['updated_at'] ?? null;
|
||||
|
||||
$incoId = new IncoId($rawId);
|
||||
|
||||
$description = IncoStringParser::beautifyText($rawDesc) ?? '';
|
||||
|
||||
$name = IncoStringParser::beautifyText($rawDesc);
|
||||
if ($name === null || trim($name) === '') {
|
||||
$name = $rawDenom !== '' ? IncoStringParser::words($rawDenom, 4, '') : "Produit {$incoId->value}";
|
||||
}
|
||||
|
||||
$label = IncoStringParser::beautifyName($rawDenom !== '' ? $rawDenom : ($name !== '' ? $name : "Produit {$incoId->value}"));
|
||||
$tips = is_string($rawTips) ? IncoStringParser::beautifyText($rawTips) : null;
|
||||
$sellArguments = IncoStringParser::beautifyText($rawArgVte) ?? '';
|
||||
$preparationAdvices = IncoStringParser::beautifyText($rawPrepa) ?? '';
|
||||
|
||||
$allegations = IncoParser::toAllegations($rawAlleg, $rawAllegSante, $rawAllegNutrit, $rawAllegAutre);
|
||||
|
||||
$ingredients = is_string($rawIng)
|
||||
? IncoIngredientParser::parse($rawIng)
|
||||
: (is_array($rawIng) ? self::parseArrayIngredients($rawIng) : new Ingredients([]));
|
||||
|
||||
$portions = IncoParser::toPortionAmount($rawPortion, $rawPortionType);
|
||||
$allergens = IncoParser::toAllergens($rawAllergene);
|
||||
$brand = $this->resolveBrand($rawMarque);
|
||||
$isSeasonal = (bool) $rawSaison;
|
||||
$weight = IncoParser::toWeight($rawNet, $rawBrut, $rawMoy);
|
||||
$nutriscore = IncoParser::toNutriscore($rawNutriscore);
|
||||
$storageCondition = IncoParser::toStorageCondition($rawConserv);
|
||||
$additives = IncoParser::toAdditives($rawAdditif);
|
||||
$sellFamilly = is_scalar($rawFamille) && trim((string) $rawFamille) !== '' ? IncoStringParser::toPhrase((string) $rawFamille) : null;
|
||||
$compo = is_scalar($rawCompo) && trim((string) $rawCompo) !== '' ? new IncoCompositionId((string) $rawCompo) : null;
|
||||
|
||||
$createdAt = self::parseDate($rawCreated);
|
||||
$updatedAt = self::parseDate($rawUpdated);
|
||||
|
||||
return IncoSheet::builder()
|
||||
->withId($incoId)
|
||||
->withName($name)
|
||||
->withLabel($label)
|
||||
->withDescription($description)
|
||||
->withAllegations($allegations)
|
||||
->withSellArguments($sellArguments)
|
||||
->withPreparationAdvices($preparationAdvices)
|
||||
->withTips($tips)
|
||||
->withSellFamilly($sellFamilly)
|
||||
->withIngredients($ingredients)
|
||||
->withPortions($portions)
|
||||
->withAllergens(...$allergens)
|
||||
->withBrand($brand)
|
||||
->withIsSeasonal($isSeasonal)
|
||||
->withWeight($weight)
|
||||
->withNutriscore($nutriscore)
|
||||
->withStorageCondition($storageCondition)
|
||||
->withAdditives(...$additives)
|
||||
->withCompo($compo)
|
||||
->withCreatedAt($createdAt)
|
||||
->withUpdatedAt($updatedAt)
|
||||
->build();
|
||||
}
|
||||
|
||||
private function resolveBrand(mixed $rawBrand): IncoBrand
|
||||
{
|
||||
$code = is_array($rawBrand) ? ($rawBrand['code'] ?? '1') : $rawBrand;
|
||||
$codeStr = self::castString($code, '1');
|
||||
|
||||
$arcadiaId = new ArcadiaId($codeStr !== '' ? $codeStr : '1');
|
||||
$brand = $this->brandResolver->resolve($arcadiaId);
|
||||
|
||||
return new IncoBrand(
|
||||
name: $brand->name,
|
||||
code: $brand->code,
|
||||
arcadiaBrandCode: $brand->arcadiaId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $raw
|
||||
*/
|
||||
private static function parseArrayIngredients(array $raw): Ingredients
|
||||
{
|
||||
/** @var array<string|int, list<string>> $clean */
|
||||
$clean = [];
|
||||
foreach ($raw as $key => $values) {
|
||||
$k = is_string($key) ? $key : (string) $key;
|
||||
if (is_array($values)) {
|
||||
$clean[$k] = array_values(array_filter(
|
||||
array_map(static fn (mixed $v): string => is_scalar($v) ? (string) $v : '', $values),
|
||||
static fn (string $v): bool => $v !== ''
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return new Ingredients($clean);
|
||||
}
|
||||
|
||||
private static function castString(mixed $val, string $default = ''): string
|
||||
{
|
||||
return is_scalar($val) ? (string) $val : $default;
|
||||
}
|
||||
|
||||
private static function parseDate(mixed $value): ?\DateTimeInterface
|
||||
{
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
return DatesHelper::parseDateTime($value);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
77
src/Infra/Adapters/Mappers/IncoShortMapper.php
Normal file
77
src/Infra/Adapters/Mappers/IncoShortMapper.php
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infra\Adapters\Mappers;
|
||||
|
||||
use Domain\Models\Entities\Inco\IncoShortSheet;
|
||||
use Domain\Models\ValueObjects\Inco\IncoId;
|
||||
use Infra\Adapters\Parsers\IncoStringParser;
|
||||
use Shared\Helpers\DatesHelper;
|
||||
|
||||
final readonly class IncoShortMapper
|
||||
{
|
||||
/** @var list<string> */
|
||||
public const array SELECT_COLUMNS = [
|
||||
'NO_ART',
|
||||
'DENOM_LEG',
|
||||
'DESCRIPTIF',
|
||||
'DAT_CRE',
|
||||
'DAT_MAJ',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|object $row
|
||||
*/
|
||||
// phpcs:disable Generic.Metrics.CyclomaticComplexity
|
||||
public static function toDomain(array | object $row): IncoShortSheet
|
||||
{
|
||||
// phpcs:enable Generic.Metrics.CyclomaticComplexity
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = is_object($row) ? (array) $row : $row;
|
||||
|
||||
$rawId = self::castString($data['NO_ART'] ?? $data['no_art'] ?? $data['id'] ?? null, '0');
|
||||
$rawDenom = self::castString($data['DENOM_LEG'] ?? $data['denom_leg'] ?? $data['label'] ?? null, '');
|
||||
$rawDesc = self::castString($data['DESCRIPTIF'] ?? $data['descriptif'] ?? $data['description'] ?? null, '');
|
||||
$rawCreated = $data['DAT_CRE'] ?? $data['dat_cre'] ?? $data['created_at'] ?? null;
|
||||
$rawUpdated = $data['DAT_MAJ'] ?? $data['dat_maj'] ?? $data['updated_at'] ?? null;
|
||||
|
||||
$incoId = new IncoId($rawId);
|
||||
|
||||
$name = IncoStringParser::beautifyText($rawDesc);
|
||||
if ($name === null || trim($name) === '') {
|
||||
$name = $rawDenom !== '' ? IncoStringParser::words($rawDenom, 4, '') : "Produit {$incoId->value}";
|
||||
}
|
||||
|
||||
$label = IncoStringParser::beautifyName($rawDenom !== '' ? $rawDenom : ($name !== '' ? $name : "Produit {$incoId->value}"));
|
||||
|
||||
$createdAt = self::parseDate($rawCreated);
|
||||
$updatedAt = self::parseDate($rawUpdated);
|
||||
|
||||
return IncoShortSheet::hydrate(
|
||||
id: $incoId,
|
||||
name: $name,
|
||||
label: $label,
|
||||
createdAt: $createdAt,
|
||||
updatedAt: $updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
private static function castString(mixed $val, string $default = ''): string
|
||||
{
|
||||
return is_scalar($val) ? (string) $val : $default;
|
||||
}
|
||||
|
||||
private static function parseDate(mixed $value): ?\DateTimeInterface
|
||||
{
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
return DatesHelper::parseDateTime($value);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
348
src/Infra/Adapters/Parsers/IncoCompositionParser.php
Normal file
348
src/Infra/Adapters/Parsers/IncoCompositionParser.php
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infra\Adapters\Parsers;
|
||||
|
||||
use Domain\Models\ValueObjects\Inco\Composition\AminoAcids;
|
||||
use Domain\Models\ValueObjects\Inco\Composition\CarbohydratesDetail;
|
||||
use Domain\Models\ValueObjects\Inco\Composition\Energy;
|
||||
use Domain\Models\ValueObjects\Inco\Composition\EnergyValues;
|
||||
use Domain\Models\ValueObjects\Inco\Composition\FattyAcids;
|
||||
use Domain\Models\ValueObjects\Inco\Composition\FreshProduceAndNuts;
|
||||
use Domain\Models\ValueObjects\Inco\Composition\FruitAndNutAmount;
|
||||
use Domain\Models\ValueObjects\Inco\Composition\Identification;
|
||||
use Domain\Models\ValueObjects\Inco\Composition\Minerals;
|
||||
use Domain\Models\ValueObjects\Inco\Composition\NutrientValue;
|
||||
use Domain\Models\ValueObjects\Inco\Composition\NutriScore;
|
||||
use Domain\Models\ValueObjects\Inco\Composition\NutriScorePoints;
|
||||
use Domain\Models\ValueObjects\Inco\Composition\PercentageValue;
|
||||
use Domain\Models\ValueObjects\Inco\Composition\PortionMacronutrients;
|
||||
use Domain\Models\ValueObjects\Inco\Composition\ReferenceIntakes;
|
||||
use Domain\Models\ValueObjects\Inco\Composition\ReferenceMacronutrients;
|
||||
use Domain\Models\ValueObjects\Inco\Composition\SpecialCompounds;
|
||||
use Domain\Models\ValueObjects\Inco\Composition\Vitamins;
|
||||
|
||||
final readonly class IncoCompositionParser
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
public static function toIdentification(array $row): Identification
|
||||
{
|
||||
$nom = self::getString($row, 'NOM', 'name', self::getString($row, 'nom', '', 'Inconnu'));
|
||||
$numero = self::getString($row, 'NUMERO', 'numero', self::getString($row, 'ID_INCO', 'id_inco', '1'));
|
||||
$source = self::getString($row, 'SOURCE', 'source', 'System');
|
||||
|
||||
return new Identification(
|
||||
name: $nom !== '' ? $nom : 'Inconnu',
|
||||
number: $numero !== '' ? $numero : '1',
|
||||
source: $source !== '' ? $source : 'System',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
public static function toEnergy(array $row): Energy
|
||||
{
|
||||
$refSize = self::getString($row, 'TAILLE_REF', 'taille_ref', '100g');
|
||||
$refKj = self::getString($row, 'VAL_ENERG_KJ_R', 'val_energ_kj_r', '0');
|
||||
$refKcal = self::getString($row, 'VAL_ENERG_KCAL_R', 'val_energ_kcal_r', '0');
|
||||
|
||||
$portSize = self::getString($row, 'TAILLE_PORT', 'taille_port', '100g');
|
||||
$portKj = self::getString($row, 'VAL_ENERG_KJ_P', 'val_energ_kj_p', '0');
|
||||
$portKcal = self::getString($row, 'VAL_ENERG_KCAL_P', 'val_energ_kcal_p', '0');
|
||||
|
||||
return new Energy(
|
||||
reference: new EnergyValues(
|
||||
size: $refSize !== '' ? $refSize : '100g',
|
||||
energyKj: $refKj,
|
||||
energyKcal: $refKcal,
|
||||
),
|
||||
portion: new EnergyValues(
|
||||
size: $portSize !== '' ? $portSize : '100g',
|
||||
energyKj: $portKj,
|
||||
energyKcal: $portKcal,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
public static function toReferenceMacronutrients(array $row): ReferenceMacronutrients
|
||||
{
|
||||
return new ReferenceMacronutrients(
|
||||
fat: new NutrientValue(self::getString($row, 'MAT_GRASS_R', 'mat_grass_r')),
|
||||
saturatedFat: new NutrientValue(self::getString($row, 'AGS_R', 'ags_r')),
|
||||
carbohydrates: new NutrientValue(self::getString($row, 'GLUCIDES_R', 'glucides_r')),
|
||||
sugars: new NutrientValue(self::getString($row, 'SUCRE_R', 'sucre_r')),
|
||||
fiber: new NutrientValue(self::getString($row, 'FIB_ALIM_R', 'fib_alim_r')),
|
||||
protein: new NutrientValue(self::getString($row, 'PROTEINE_R', 'proteine_r')),
|
||||
salt: new NutrientValue(self::getString($row, 'SEL_R', 'sel_r')),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
public static function toPortionMacronutrients(array $row): PortionMacronutrients
|
||||
{
|
||||
return new PortionMacronutrients(
|
||||
fat: new NutrientValue(self::getString($row, 'MAT_GRASS_P', 'mat_grass_p')),
|
||||
saturatedFat: new NutrientValue(self::getString($row, 'AGS_P', 'ags_p')),
|
||||
carbohydrates: new NutrientValue(self::getString($row, 'GLUCIDES_P', 'glucides_p')),
|
||||
sugars: new NutrientValue(self::getString($row, 'SUCRE_P', 'sucre_p')),
|
||||
fiber: new NutrientValue(self::getString($row, 'FIB_ALIM_P', 'fib_alim_p')),
|
||||
protein: new NutrientValue(self::getString($row, 'PROTEINE_P', 'proteine_p')),
|
||||
salt: new NutrientValue(self::getString($row, 'SEL_P', 'sel_p')),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
public static function toFreshProduceAndNuts(array $row): FreshProduceAndNuts
|
||||
{
|
||||
$refFnl = new FruitAndNutAmount(
|
||||
fruits: self::getString($row, 'FRUITS_R', 'fruits_r'),
|
||||
nuts: self::getString($row, 'FRUITS_COQUE_SECH_R', 'fruits_coque_sech_r'),
|
||||
);
|
||||
$porFnl = new FruitAndNutAmount(
|
||||
fruits: self::getString($row, 'FRUITS_P', 'fruits_p'),
|
||||
nuts: self::getString($row, 'FRUITS_COQUE_SECH_P', 'fruits_coque_sech_p'),
|
||||
);
|
||||
|
||||
return new FreshProduceAndNuts(
|
||||
reference: $refFnl,
|
||||
portion: $porFnl,
|
||||
freshFnlPercentage: new PercentageValue(self::getString($row, 'POURCENT_FNL_FRAIS', 'pourcent_fnl_frais')),
|
||||
fruitsPercentage: new PercentageValue(self::getString($row, 'POURCENT_FRUITS', 'pourcent_fruits')),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
public static function toReferenceIntakes(array $row): ReferenceIntakes
|
||||
{
|
||||
return new ReferenceIntakes(
|
||||
energyE14: new PercentageValue(self::getString($row, 'POURCENT_APPORT_REF_E14_P', 'pourcent_apport_ref_e14_p')),
|
||||
energyKj: new PercentageValue(self::getString($row, 'POURCENT_APPORT_REF_KJO_P', 'pourcent_apport_ref_kjo_p')),
|
||||
fat: new PercentageValue(self::getString($row, 'POURCENT_APPORT_REF_FAT_P', 'pourcent_apport_ref_fat_p')),
|
||||
saturatedFat: new PercentageValue(self::getString($row, 'POURCENT_APPORT_REF_FATSAT_P', 'pourcent_apport_ref_fatsat_p')),
|
||||
carbohydrates: new PercentageValue(self::getString($row, 'POURCENT_APPORT_REF_CHOVAL_P', 'pourcent_apport_ref_choval_p')),
|
||||
sugars: new PercentageValue(self::getString($row, 'POURCENT_APPORT_REF_SUGAR_P', 'pourcent_apport_ref_sugar_p')),
|
||||
protein: new PercentageValue(self::getString($row, 'POURCENT_APPORT_REF_PRO_P', 'pourcent_apport_ref_pro_p')),
|
||||
salt: new PercentageValue(self::getString($row, 'POURCENT_APPORT_REF_SALTEQ_P', 'pourcent_apport_ref_salteq_p')),
|
||||
portionAmount: self::getString($row, 'APPORT_PORT', 'apport_port', '100g'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
public static function toNutriScore(array $row): NutriScore
|
||||
{
|
||||
$grade = self::getString($row, 'CLASSEMENT', 'classement', 'A');
|
||||
if ($grade === '') {
|
||||
$grade = 'A';
|
||||
}
|
||||
|
||||
return new NutriScore(
|
||||
negativeComponent: self::getString($row, 'COMPOSANTE_N', 'composante_n'),
|
||||
score: self::getString($row, 'SCORE_NUTRI', 'score_nutri'),
|
||||
grade: $grade,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
public static function toNutriScorePoints(array $row): NutriScorePoints
|
||||
{
|
||||
return new NutriScorePoints(
|
||||
energyPoints: self::getString($row, 'POINTS_ENERGIE', 'points_energie'),
|
||||
saturatedFatPoints: self::getString($row, 'POINTS_AGS', 'points_ags'),
|
||||
sugarsPoints: self::getString($row, 'POINTS_SUCRES', 'points_sucres'),
|
||||
saltPoints: self::getString($row, 'POINTS_SEL', 'points_sel'),
|
||||
fiberPoints: self::getString($row, 'POINTS_FIBRES', 'points_fibres'),
|
||||
proteinPoints: self::getString($row, 'POINTS_PROTEINES', 'points_proteines'),
|
||||
freshFnlPoints: self::getString($row, 'POINTS_FNL_FRAIS', 'points_fnl_frais'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
public static function toMinerals(array $row): Minerals
|
||||
{
|
||||
return new Minerals(
|
||||
calcium: self::getString($row, 'NUT_CA', 'nut_ca'),
|
||||
magnesium: self::getString($row, 'NUT_MG', 'nut_mg'),
|
||||
potassium: self::getString($row, 'NUT_K', 'nut_k'),
|
||||
phosphorus: self::getString($row, 'NUT_P', 'nut_p'),
|
||||
chloride: self::getString($row, 'NUT_CLD', 'nut_cld'),
|
||||
iron: self::getString($row, 'NUT_FE', 'nut_fe'),
|
||||
zinc: self::getString($row, 'NUT_ZN', 'nut_zn'),
|
||||
copper: self::getString($row, 'NUT_CU', 'nut_cu'),
|
||||
manganese: self::getString($row, 'NUT_MN', 'nut_mn'),
|
||||
selenium: self::getString($row, 'NUT_SE', 'nut_se'),
|
||||
iodine: self::getString($row, 'NUT_ID', 'nut_id'),
|
||||
chromium: self::getString($row, 'NUT_CR', 'nut_cr'),
|
||||
molybdenum: self::getString($row, 'NUT_MO', 'nut_mo'),
|
||||
sulfur: self::getString($row, 'NUT_S6', 'nut_s6'),
|
||||
nitrates: self::getString($row, 'NUT_NITRA', 'nut_nitra'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
public static function toVitamines(array $row): Vitamins
|
||||
{
|
||||
return new Vitamins(
|
||||
vitaminA: self::getString($row, 'NUT_VITA', 'nut_vita'),
|
||||
vitaminB6: self::getString($row, 'NUT_VITB6', 'nut_vitb6'),
|
||||
vitaminB12: self::getString($row, 'NUT_VITB12', 'nut_vitb12'),
|
||||
vitaminC: self::getString($row, 'NUT_VITC', 'nut_vitc'),
|
||||
vitaminD: self::getString($row, 'NUT_VITD', 'nut_vitd'),
|
||||
vitaminE: self::getString($row, 'NUT_VITE', 'nut_vite'),
|
||||
vitaminK: self::getString($row, 'NUT_VITK', 'nut_vitk'),
|
||||
cholecalciferol: self::getString($row, 'NUT_CHOCAL', 'nut_chocal'),
|
||||
niacin: self::getString($row, 'NUT_NIA', 'nut_nia'),
|
||||
riboflavin: self::getString($row, 'NUT_RIBF', 'nut_ribf'),
|
||||
thiamine: self::getString($row, 'NUT_THIA', 'nut_thia'),
|
||||
pantothenicAcid: self::getString($row, 'NUT_PANTAC', 'nut_pantac'),
|
||||
biotin: self::getString($row, 'NUT_BIOT', 'nut_biot'),
|
||||
folates: self::getString($row, 'NUT_FOLDFE', 'nut_foldfe'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
public static function toCarbohydratesDetail(array $row): CarbohydratesDetail
|
||||
{
|
||||
return new CarbohydratesDetail(
|
||||
starch: self::getString($row, 'NUT_STARCH', 'nut_starch'),
|
||||
maltodextrin: self::getString($row, 'NUT_MALTDEX', 'nut_maltdex'),
|
||||
sucrose: self::getString($row, 'NUT_SUCS', 'nut_sucs'),
|
||||
glucose: self::getString($row, 'NUT_GLUS', 'nut_glus'),
|
||||
lactose: self::getString($row, 'NUT_LACS', 'nut_lacs'),
|
||||
maltose: self::getString($row, 'NUT_MALS', 'nut_mals'),
|
||||
galactose: self::getString($row, 'NUT_GALFB', 'nut_galfb'),
|
||||
carbohydrateHydrates: self::getString($row, 'NUT_G_HC', 'nut_g_hc'),
|
||||
boundFructose: self::getString($row, 'NUT_FRUFB', 'nut_frufb'),
|
||||
freeFructose: self::getString($row, 'NUT_FRUS', 'nut_frus'),
|
||||
oligosaccharides: self::getString($row, 'NUT_OLSAC', 'nut_olsac'),
|
||||
polyols: self::getString($row, 'NUT_POLYL', 'nut_polyl'),
|
||||
solubleFiber: self::getString($row, 'NUT_FIBSOL', 'nut_fibsol'),
|
||||
inositol: self::getString($row, 'NUT_INOTL', 'nut_inotl'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
public static function toFattyAcids(array $row): FattyAcids
|
||||
{
|
||||
return new FattyAcids(
|
||||
fd: self::getString($row, 'NUT_FD', 'nut_fd'),
|
||||
cholesterol: self::getString($row, 'NUT_CHOL', 'nut_chol'),
|
||||
transFat: self::getString($row, 'NUT_AGT', 'nut_agt'),
|
||||
formic: self::getString($row, 'NUT_FFORMIQ', 'nut_fformiq'),
|
||||
acetic: self::getString($row, 'NUT_FACETIQ', 'nut_facetiq'),
|
||||
propionic: self::getString($row, 'NUT_FPROPIONIQ', 'nut_fpropioniq'),
|
||||
butyric: self::getString($row, 'NUT_FBUTYRIQ', 'nut_fbutyriq'),
|
||||
valeric: self::getString($row, 'NUT_FVALERIQ', 'nut_fvaleriq'),
|
||||
caproic: self::getString($row, 'NUT_FCAPROIQ', 'nut_fcaproiq'),
|
||||
enanthic: self::getString($row, 'NUT_FENATIQ', 'nut_fenatiq'),
|
||||
caprylic: self::getString($row, 'NUT_FCAPRYLIQ', 'nut_fcapryliq'),
|
||||
pelargonic: self::getString($row, 'NUT_FPELARGONIQ', 'nut_fpelargoniq'),
|
||||
capric: self::getString($row, 'NUT_FCAPRIQ', 'nut_fcapriq'),
|
||||
undecylic: self::getString($row, 'NUT_FUNDECYLIQ', 'nut_fundecyliq'),
|
||||
lauric: self::getString($row, 'NUT_FLAURIQ', 'nut_flauriq'),
|
||||
tridecylic: self::getString($row, 'NUT_FTRIDECYLIQ', 'nut_ftridecyliq'),
|
||||
myristic: self::getString($row, 'NUT_FMYRISTIQ', 'nut_fmyristiq'),
|
||||
pentadecylic: self::getString($row, 'NUT_FPENTADECYLIQ', 'nut_fpentadecyliq'),
|
||||
f16d0: self::getString($row, 'NUT_F16D0', 'nut_f16d0'),
|
||||
margaric: self::getString($row, 'NUT_FMARGARIQ', 'nut_fmargariq'),
|
||||
stearic: self::getString($row, 'NUT_FSTEARIQ', 'nut_fsteariq'),
|
||||
arachidic: self::getString($row, 'NUT_FARACHIDIQ', 'nut_farachidiq'),
|
||||
heneicosylic: self::getString($row, 'NUT_FHENEICOSYLIQ', 'nut_fheneicosyliq'),
|
||||
behenic: self::getString($row, 'NUT_FBEHENIQ', 'nut_fbeheniq'),
|
||||
tricosylic: self::getString($row, 'NUT_FTRICOSILIQ', 'nut_ftricosiliq'),
|
||||
lignoceric: self::getString($row, 'NUT_FLIGNOCERIQ', 'nut_flignoceriq'),
|
||||
carboceric: self::getString($row, 'NUT_FCARBOCERIQ', 'nut_fcarboceriq'),
|
||||
cerotic: self::getString($row, 'NUT_FCEROTIQ', 'nut_fcerotiq'),
|
||||
ceroplastic: self::getString($row, 'NUT_FCEROPLASTIQ', 'nut_fceroplastiq'),
|
||||
montanic: self::getString($row, 'NUT_FMONTANIQ', 'nut_fmontaniq'),
|
||||
nonacosylic: self::getString($row, 'NUT_FNONACOSYLIQ', 'nut_fnonacosyliq'),
|
||||
melissic: self::getString($row, 'NUT_FMELISSIQ', 'nut_fmelissiq'),
|
||||
laceric: self::getString($row, 'NUT_FLACEROIQ', 'nut_flaceroiq'),
|
||||
hentriacontylic: self::getString($row, 'NUT_FHENTRIACONTYLIQ', 'nut_fhentriacontyliq'),
|
||||
hextriacontylic: self::getString($row, 'NUT_FHEXTRIACONTYLIQ', 'nut_fhextriacontyliq'),
|
||||
heptatriacontylic: self::getString($row, 'NUT_FHEPTATRIACONTYLIQ', 'nut_fheptatriacontyliq'),
|
||||
octatriacontylic: self::getString($row, 'NUT_FOCTATRIACONTYLIQ', 'nut_foctatriacontyliq'),
|
||||
nonadylie: self::getString($row, 'NUT_FNONADYLIQUE', 'nut_fnonadylique'),
|
||||
psyllic: self::getString($row, 'NUT_FPSYLLIQ', 'nut_fpsylliq'),
|
||||
palmitic: self::getString($row, 'NUT_FPALM', 'nut_fpalm'),
|
||||
oleic: self::getString($row, 'NUT_FOLEIQ', 'nut_foleiq'),
|
||||
f18d: self::getString($row, 'NUT_F18D', 'nut_f18d'),
|
||||
f19d: self::getString($row, 'NUT_F19D', 'nut_f19d'),
|
||||
f11eico: self::getString($row, 'NUT_F11EICO', 'nut_f11eico'),
|
||||
geddic: self::getString($row, 'NUT_FGEDDIQ', 'nut_fgeddiq'),
|
||||
erucic: self::getString($row, 'NUT_FERUCIQ', 'nut_feruciq'),
|
||||
nervonic: self::getString($row, 'NUT_FNERVONIQ', 'nut_fnervoniq'),
|
||||
hyenic: self::getString($row, 'NUT_FHYENIQ', 'nut_fhyeniq'),
|
||||
amscis: self::getString($row, 'NUT_FAMSCIS', 'nut_famscis'),
|
||||
f18d2cn6: self::getString($row, 'NUT_F18D2CN6', 'nut_f18d2cn6'),
|
||||
f18d3n3: self::getString($row, 'NUT_F18D3N3', 'nut_f18d3n3'),
|
||||
f20d4: self::getString($row, 'NUT_F20D4', 'nut_f20d4'),
|
||||
f20d5n3: self::getString($row, 'NUT_F20D5N3', 'nut_f20d5n3'),
|
||||
f22d6n3: self::getString($row, 'NUT_F22D6N3', 'nut_f22d6n3'),
|
||||
fapun3: self::getString($row, 'NUT_FAPUN3', 'nut_fapun3'),
|
||||
fapun6: self::getString($row, 'NUT_FAPUN6', 'nut_fapun6'),
|
||||
fapun9: self::getString($row, 'NUT_FAPUN9', 'nut_fapun9'),
|
||||
fapucis: self::getString($row, 'NUT_FAPUCIS', 'nut_fapucis'),
|
||||
fapulc: self::getString($row, 'NUT_FAPULC', 'nut_fapulc'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
public static function toAminoAcids(array $row): AminoAcids
|
||||
{
|
||||
return new AminoAcids(
|
||||
essentialAminoAcids: self::getString($row, 'NUT_AAE', 'nut_aae'),
|
||||
caseins: self::getString($row, 'NUT_CASN', 'nut_casn'),
|
||||
wheyProtein: self::getString($row, 'NUT_WHEY', 'nut_whey'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
public static function toSpecialCompounds(array $row): SpecialCompounds
|
||||
{
|
||||
return new SpecialCompounds(
|
||||
choline: self::getString($row, 'NUT_CHOLN', 'nut_choln'),
|
||||
carnitine: self::getString($row, 'NUT_CARNITINE', 'nut_carnitine'),
|
||||
taurine: self::getString($row, 'NUT_TAU', 'nut_tau'),
|
||||
nucleotides: self::getString($row, 'NUT_NUCLEOTIDE', 'nut_nucleotide'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
private static function getString(array $row, string $key, string $fallbackKey = '', string $default = '0'): string
|
||||
{
|
||||
$val = $row[$key] ?? ($fallbackKey !== '' ? ($row[$fallbackKey] ?? null) : null);
|
||||
|
||||
return is_scalar($val) ? (string) $val : $default;
|
||||
}
|
||||
}
|
||||
359
src/Infra/Adapters/Parsers/IncoIngredientParser.php
Normal file
359
src/Infra/Adapters/Parsers/IncoIngredientParser.php
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infra\Adapters\Parsers;
|
||||
|
||||
use Domain\Models\ValueObjects\Inco\Ingredients;
|
||||
|
||||
final readonly class IncoIngredientParser
|
||||
{
|
||||
/**
|
||||
* Analyse et formate la chaîne brute des ingrédients Oracle (LST_ING)
|
||||
* en une structure d'ingrédients groupés et typés (Ingredients VO).
|
||||
*
|
||||
* @phpcs:disable Generic.Metrics.CyclomaticComplexity.TooHigh
|
||||
*/
|
||||
public static function parse(?string $rawIngredients): Ingredients
|
||||
{
|
||||
// phpcs:enable
|
||||
$ingredientString = strip_tags((string) ($rawIngredients ?? ''));
|
||||
|
||||
if (trim($ingredientString) === '' || mb_strlen($ingredientString, 'UTF-8') < 2) {
|
||||
return new Ingredients([]);
|
||||
}
|
||||
|
||||
// Remplacement des séparateurs de nombres flottants avec un caractère de contrôle ACK chr(6)
|
||||
$floatingPointEsc = chr(6);
|
||||
$ingredientString = (string) preg_replace(
|
||||
'/(?:([0-9]+),([0-9]+))/',
|
||||
'$1' . $floatingPointEsc . '$2',
|
||||
$ingredientString
|
||||
);
|
||||
$ingredientString = (string) preg_replace(
|
||||
'/(?:([0-9]+)\.([0-9]+))/',
|
||||
'$1' . $floatingPointEsc . '$2',
|
||||
$ingredientString
|
||||
);
|
||||
|
||||
// Correction des parenthèses non fermées
|
||||
try {
|
||||
$ingredientString = self::fixParentheses($ingredientString);
|
||||
} catch (\RuntimeException) {
|
||||
$parts = array_filter(explode('.', $ingredientString));
|
||||
$fixedParts = array_map(
|
||||
static fn (string $part): string => self::removeNonOpenedParentheses(self::fixParentheses($part . ')')),
|
||||
$parts
|
||||
);
|
||||
$ingredientString = implode('.', $fixedParts);
|
||||
}
|
||||
|
||||
// Tokenisation par '.'
|
||||
$tokenizedString = self::buildTokenizedString($ingredientString);
|
||||
|
||||
$actualKey = null;
|
||||
/** @var array<string|int, list<string>> $triaged */
|
||||
$triaged = [];
|
||||
|
||||
foreach ($tokenizedString as $index => $tokenArray) {
|
||||
if (is_string($index)) {
|
||||
$actualKey = $index;
|
||||
}
|
||||
if ($actualKey === null) {
|
||||
$triaged[] = $tokenArray;
|
||||
|
||||
continue;
|
||||
}
|
||||
if (empty($triaged[$actualKey])) {
|
||||
$triaged[$actualKey] = [];
|
||||
}
|
||||
$triaged[$actualKey] = array_merge($triaged[$actualKey], $tokenArray);
|
||||
}
|
||||
|
||||
// Restauration des séparateurs décimaux
|
||||
array_walk_recursive($triaged, static function (string &$ingredient) use ($floatingPointEsc): void {
|
||||
$ingredient = str_replace($floatingPointEsc, '.', $ingredient);
|
||||
});
|
||||
|
||||
// Suppression des parenthèses fermantes orphelines
|
||||
array_walk_recursive($triaged, static function (string &$ingredient): void {
|
||||
$ingredient = self::removeNonOpenedParentheses($ingredient);
|
||||
});
|
||||
|
||||
// Remplacement normalisé : "Informations en GRAS" -> "Informations en MAJUSCULE"
|
||||
array_walk_recursive($triaged, static function (string &$ingredient): void {
|
||||
$ingredient = str_ireplace(
|
||||
'Informations en GRAS',
|
||||
'Informations en MAJUSCULE',
|
||||
$ingredient
|
||||
);
|
||||
});
|
||||
|
||||
// Majuscules pour les ingrédients sous "Traces..."
|
||||
/** @var array<string, list<string>> $cleanResult */
|
||||
$cleanResult = [];
|
||||
foreach ($triaged as $idx => $val) {
|
||||
$keyName = is_string($idx) ? $idx : (string) $idx;
|
||||
if (preg_match('/^Traces?/ui', $keyName)) {
|
||||
$cleanResult[$keyName] = array_map(static fn (string $line): string => mb_strtoupper($line, 'UTF-8'), $val);
|
||||
} else {
|
||||
$cleanResult[$keyName] = $val;
|
||||
}
|
||||
}
|
||||
|
||||
return new Ingredients($cleanResult);
|
||||
}
|
||||
|
||||
private static function removeNonOpenedParentheses(string $ingredientPart): string
|
||||
{
|
||||
$outString = '';
|
||||
$inParentheses = 0;
|
||||
|
||||
/** @var list<string> $chars */
|
||||
$chars = mb_str_split($ingredientPart, 1, 'UTF-8');
|
||||
|
||||
foreach ($chars as $char) {
|
||||
if ($char === '(') {
|
||||
$inParentheses++;
|
||||
}
|
||||
if ($char === ')') {
|
||||
if ($inParentheses === 0) {
|
||||
continue;
|
||||
}
|
||||
$inParentheses--;
|
||||
}
|
||||
$outString .= $char;
|
||||
}
|
||||
|
||||
return $outString;
|
||||
}
|
||||
|
||||
private static function fixParentheses(string $ingredientPart): string
|
||||
{
|
||||
$inParentheses = 0;
|
||||
$lastCloseIndex = 0;
|
||||
/** @var list<string> $chars */
|
||||
$chars = mb_str_split($ingredientPart, 1, 'UTF-8');
|
||||
|
||||
foreach ($chars as $index => $char) {
|
||||
if ($char === '(') {
|
||||
$inParentheses++;
|
||||
}
|
||||
if ($char === ')') {
|
||||
$inParentheses = $inParentheses === 0 ? 0 : $inParentheses - 1;
|
||||
$lastCloseIndex = (($chars[$index + 1] ?? null) === '.' ? $index + 2 : $index);
|
||||
}
|
||||
}
|
||||
|
||||
if ($inParentheses > 1) {
|
||||
throw new \RuntimeException("Seriously malformed parentheses in: {$ingredientPart}");
|
||||
}
|
||||
|
||||
if ($inParentheses > 0) {
|
||||
$closeAfter = mb_strpos($ingredientPart, '.', $lastCloseIndex, 'UTF-8');
|
||||
if ($closeAfter === false) {
|
||||
$closeAfter = mb_strlen($ingredientPart, 'UTF-8');
|
||||
}
|
||||
$ingredientPart = mb_substr($ingredientPart, 0, (int) $closeAfter, 'UTF-8')
|
||||
. ')'
|
||||
. mb_substr($ingredientPart, (int) $closeAfter, null, 'UTF-8');
|
||||
}
|
||||
|
||||
return $ingredientPart;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int|string, list<string>>
|
||||
*/
|
||||
private static function buildTokenizedString(string $ingredientString): array
|
||||
{
|
||||
$sentenceTokens = self::tokenizeIngredientPart($ingredientString, '.');
|
||||
$result = [];
|
||||
|
||||
foreach ($sentenceTokens as $index => $tokenString) {
|
||||
$elements = self::tokenizeIngredientPart($tokenString, ':', 2);
|
||||
|
||||
$labelRaw = $elements[0] ?? '';
|
||||
$labelSlug = IncoStringParser::slugify($labelRaw);
|
||||
$hasLabel = count($elements) > 1;
|
||||
|
||||
$trimmedLabel = trim($labelRaw);
|
||||
$labelIsRatio = (bool) preg_match('/[\d%]/', $trimmedLabel);
|
||||
$labelHasParen = (bool) preg_match('/[()]/', $trimmedLabel);
|
||||
$labelIsTooLong = mb_strlen($trimmedLabel, 'UTF-8') > 40;
|
||||
|
||||
$tokenSlug = IncoStringParser::slugify($tokenString);
|
||||
$isInfoLine = str_starts_with($tokenSlug, 'fabrique dans')
|
||||
|| str_starts_with($tokenSlug, 'certifie')
|
||||
|| str_starts_with($tokenSlug, 'a consommer')
|
||||
|| str_starts_with($tokenSlug, 'produit fabrique');
|
||||
|
||||
if ($isInfoLine) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($hasLabel && str_contains($labelSlug, 'ingredient')) {
|
||||
$ingredientPart = $elements[1] ?? '';
|
||||
$result[$index] = self::flattenIngredientTokens(
|
||||
self::tokenizeIngredientPart($ingredientPart)
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$isNamedSpecialGroup = $hasLabel && (
|
||||
str_starts_with($labelSlug, 'trace') || str_starts_with($labelSlug, 'decor') || str_starts_with($labelSlug, 'allergene')
|
||||
);
|
||||
|
||||
if ($isNamedSpecialGroup) {
|
||||
$label = trim((string) preg_replace('/\s+/u', ' ', mb_strtolower($labelRaw, 'UTF-8')));
|
||||
$label = mb_strtoupper(mb_substr($label, 0, 1, 'UTF-8'), 'UTF-8') . mb_substr($label, 1, null, 'UTF-8');
|
||||
$ingredientPart = $elements[1] ?? '';
|
||||
$result[$label] = self::flattenIngredientTokens(
|
||||
self::tokenizeIngredientPart($ingredientPart)
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($hasLabel && ! $labelIsRatio && ! $labelHasParen && ! $labelIsTooLong && ! str_starts_with($labelSlug, 'fabrique')) {
|
||||
$label = trim((string) preg_replace('/\s+/u', ' ', mb_strtolower($labelRaw, 'UTF-8')));
|
||||
$label = mb_strtoupper(mb_substr($label, 0, 1, 'UTF-8'), 'UTF-8') . mb_substr($label, 1, null, 'UTF-8');
|
||||
$ingredientPart = $elements[1] ?? '';
|
||||
$result[$label] = self::flattenIngredientTokens(
|
||||
self::tokenizeIngredientPart($ingredientPart)
|
||||
);
|
||||
} else {
|
||||
$result[$index] = self::flattenIngredientTokens(
|
||||
self::tokenizeIngredientPart($tokenString)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function tokenizeIngredientPart(
|
||||
string $ingredientPartString,
|
||||
string $charToEscape = ',',
|
||||
int $limit = -1
|
||||
): array {
|
||||
$floatingPointEsc = chr(21);
|
||||
$ingredientPartString = (string) preg_replace(
|
||||
'/(?:([0-9]+),([0-9]+))/',
|
||||
'$1' . $floatingPointEsc . '$2',
|
||||
$ingredientPartString
|
||||
);
|
||||
$ingredientPartString = (string) preg_replace(
|
||||
'/(?:([0-9]+)\.([0-9]+))/',
|
||||
'$1' . $floatingPointEsc . '$2',
|
||||
$ingredientPartString
|
||||
);
|
||||
|
||||
$escapeChar = chr(127);
|
||||
$inParentheses = 0;
|
||||
/** @var list<string> $chars */
|
||||
$chars = mb_str_split($ingredientPartString, 1, 'UTF-8');
|
||||
$result = [];
|
||||
|
||||
foreach ($chars as $char) {
|
||||
if (in_array($char, ['(', '['], true)) {
|
||||
$inParentheses++;
|
||||
}
|
||||
if (in_array($char, [')', ']'], true)) {
|
||||
$inParentheses = $inParentheses === 0 ? 0 : $inParentheses - 1;
|
||||
}
|
||||
if ($inParentheses > 0 && $char === $charToEscape) {
|
||||
$result[] = $escapeChar;
|
||||
} else {
|
||||
$result[] = $char;
|
||||
}
|
||||
}
|
||||
|
||||
$ingredientPartString = implode('', $result);
|
||||
|
||||
$delimiters = "\n\t{$charToEscape}";
|
||||
$tok = IncoStringParser::mbStrtok($delimiters, $ingredientPartString);
|
||||
$parts = [];
|
||||
|
||||
while ($tok !== false) {
|
||||
$parts[] = $tok;
|
||||
$tok = IncoStringParser::mbStrtok($delimiters);
|
||||
}
|
||||
|
||||
$limitChar = chr(27);
|
||||
if ($limit !== -1) {
|
||||
$joined = implode($limitChar, $parts);
|
||||
$split = explode($limitChar, $joined, $limit);
|
||||
$parts = array_map(
|
||||
static fn (string $part): string => str_replace([$limitChar, $floatingPointEsc], ['', '.'], $part),
|
||||
$split
|
||||
);
|
||||
}
|
||||
|
||||
$parts = array_filter($parts, static fn (string $p): bool => trim($p) !== '');
|
||||
$parts = array_map(static function (string $ingredient) use ($escapeChar, $charToEscape, $floatingPointEsc): string {
|
||||
$ingredient = str_replace($escapeChar, $charToEscape, $ingredient);
|
||||
$ingredient = str_replace($floatingPointEsc, '.', $ingredient);
|
||||
$ingredient = trim((string) preg_replace('/\s+/u', ' ', $ingredient));
|
||||
$ingredient = rtrim($ingredient, '.');
|
||||
|
||||
return self::ucFirstOnWordChar($ingredient);
|
||||
}, $parts);
|
||||
|
||||
return array_values($parts);
|
||||
}
|
||||
|
||||
private static function ucFirstOnWordChar(string $string): string
|
||||
{
|
||||
if (preg_match('/\p{L}/u', $string, $matches, PREG_OFFSET_CAPTURE) !== 1) {
|
||||
return $string;
|
||||
}
|
||||
|
||||
$match = $matches[0];
|
||||
$byteOffset = (int) $match[1];
|
||||
|
||||
if ($byteOffset > 0 && ctype_digit(mb_substr($string, $byteOffset - 1, 1, 'UTF-8'))) {
|
||||
return $string;
|
||||
}
|
||||
|
||||
$firstChar = mb_strtoupper((string) $match[0], 'UTF-8');
|
||||
|
||||
return mb_substr($string, 0, $byteOffset, 'UTF-8')
|
||||
. $firstChar
|
||||
. mb_substr($string, $byteOffset + mb_strlen((string) $match[0], 'UTF-8'), null, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $tokens
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function flattenIngredientTokens(array $tokens): array
|
||||
{
|
||||
$flattened = [];
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
$trimmed = trim($token);
|
||||
|
||||
if (str_starts_with($trimmed, '(') && str_ends_with($trimmed, ')')) {
|
||||
$inner = mb_substr($trimmed, 1, mb_strlen($trimmed, 'UTF-8') - 2, 'UTF-8');
|
||||
$subItems = self::tokenizeIngredientPart($inner);
|
||||
|
||||
foreach ($subItems as $subItem) {
|
||||
if (trim($subItem) !== '') {
|
||||
$flattened[] = $subItem;
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$flattened[] = $token;
|
||||
}
|
||||
|
||||
return $flattened;
|
||||
}
|
||||
}
|
||||
374
src/Infra/Adapters/Parsers/IncoParser.php
Normal file
374
src/Infra/Adapters/Parsers/IncoParser.php
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infra\Adapters\Parsers;
|
||||
|
||||
use Domain\Models\ValueObjects\Inco\Additive;
|
||||
use Domain\Models\ValueObjects\Inco\Allegations;
|
||||
use Domain\Models\ValueObjects\Inco\Allergen;
|
||||
use Domain\Models\ValueObjects\Inco\IncoWeight;
|
||||
use Domain\Models\ValueObjects\Inco\Nutriscore;
|
||||
use Domain\Models\ValueObjects\Inco\PortionAmount;
|
||||
use Domain\Models\ValueObjects\Inco\PortionType;
|
||||
use Domain\Models\ValueObjects\Inco\StorageCondition;
|
||||
|
||||
final readonly class IncoParser
|
||||
{
|
||||
/**
|
||||
* Parse les allégations Oracle (ALLEGATIONS, ALLEGAT_SANTE, ALLEGAT_NUTRIT, ALLEGAT_AUTRE).
|
||||
*/
|
||||
public static function toAllegations(
|
||||
mixed $allegations,
|
||||
mixed $sante = null,
|
||||
mixed $nutrit = null,
|
||||
mixed $autre = null
|
||||
): Allegations {
|
||||
$rawCategories = [
|
||||
'allegations' => is_string($allegations) ? $allegations : '',
|
||||
'other' => is_string($autre) ? $autre : '',
|
||||
'nutrition' => is_string($nutrit) ? $nutrit : '',
|
||||
'health' => is_string($sante) ? $sante : '',
|
||||
];
|
||||
|
||||
$parsed = [];
|
||||
|
||||
foreach ($rawCategories as $cat => $rawText) {
|
||||
if (trim($rawText) === '') {
|
||||
$parsed[$cat] = [];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$delimiters = '–,';
|
||||
$tok = IncoStringParser::mbStrtok($delimiters, $rawText);
|
||||
$parts = [];
|
||||
|
||||
while ($tok !== false) {
|
||||
$parts[] = $tok;
|
||||
$tok = IncoStringParser::mbStrtok($delimiters);
|
||||
}
|
||||
|
||||
$parts = array_values(array_filter($parts, static function (string $item): bool {
|
||||
$lower = mb_strtolower(trim($item), 'UTF-8');
|
||||
|
||||
return ! str_starts_with($lower, 'aucun');
|
||||
}));
|
||||
|
||||
$parts = array_map(static function (string $item): string {
|
||||
$value = IncoStringParser::toPhrase($item);
|
||||
$cleaned = IncoStringParser::protectiveDataFilter($value);
|
||||
|
||||
return $cleaned ?? '';
|
||||
}, $parts);
|
||||
|
||||
$parsed[$cat] = array_values(array_filter($parts, static fn (string $item): bool => $item !== ''));
|
||||
}
|
||||
|
||||
return new Allegations(
|
||||
allegations: $parsed['allegations'],
|
||||
health: $parsed['health'],
|
||||
nutrition: $parsed['nutrition'],
|
||||
other: $parsed['other'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse le champ portions (NB_PORTION, TYP_PORTION).
|
||||
*
|
||||
* @phpcs:disable Generic.Metrics.CyclomaticComplexity.TooHigh
|
||||
*/
|
||||
public static function toPortionAmount(mixed $rawPortion, mixed $rawType = null): ?PortionAmount
|
||||
{
|
||||
// phpcs:enable
|
||||
if ($rawPortion === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rawStr = trim(self::castString($rawPortion));
|
||||
if ($rawStr === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = str_replace(',', '.', $rawStr);
|
||||
$type = self::parsePortionType(is_string($rawType) ? $rawType : null);
|
||||
|
||||
$tokens = self::tokenize($normalized);
|
||||
$numeric = array_values(array_filter($tokens, static fn (string $p): bool => is_numeric($p)));
|
||||
|
||||
if (count($numeric) === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$low = (float) $numeric[0];
|
||||
$high = isset($numeric[1]) && is_numeric($numeric[1]) ? (float) $numeric[1] : 0.0;
|
||||
|
||||
if ($high > 0.0) {
|
||||
if ($low === $high) {
|
||||
$high = 0.0;
|
||||
} elseif ($low > $high) {
|
||||
[$low, $high] = [$high, $low];
|
||||
$type = PortionType::approximative;
|
||||
} else {
|
||||
$type = PortionType::approximative;
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === null && $low > 0.0 && $high === 0.0) {
|
||||
$type = PortionType::exact;
|
||||
}
|
||||
|
||||
$isApprox = $type === PortionType::approximative || $high > 0.0;
|
||||
|
||||
try {
|
||||
return new PortionAmount(
|
||||
lowPart: $low,
|
||||
highPart: $high > 0.0 ? $high : $low,
|
||||
approximative: $isApprox,
|
||||
);
|
||||
} catch (\DomainException $e) {
|
||||
unset($e);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse le Nutri-Score (NUTRISCORE_ON_PACK).
|
||||
*/
|
||||
public static function toNutriscore(mixed $raw): ?Nutriscore
|
||||
{
|
||||
if ($raw === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rawStr = mb_strtoupper(trim(self::castString($raw)), 'UTF-8');
|
||||
if ($rawStr === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
while (strlen($rawStr) > 1) {
|
||||
if (in_array($rawStr[0], Nutriscore::VALID_GRADES, true)) {
|
||||
$rawStr = $rawStr[0];
|
||||
|
||||
break;
|
||||
}
|
||||
$rawStr = substr($rawStr, 1);
|
||||
}
|
||||
|
||||
if (! in_array($rawStr, Nutriscore::VALID_GRADES, true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Nutriscore($rawStr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse les poids UVC (PDS_NET_UVC, PDS_BRUT_UVC, PDS_MOY_THEO).
|
||||
*/
|
||||
public static function toWeight(mixed $net, mixed $brut, mixed $moy): ?IncoWeight
|
||||
{
|
||||
if ($net === null && $brut === null && $moy === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$netVal = is_numeric($net) ? max(0, (int) $net) : 0;
|
||||
$brutVal = is_numeric($brut) ? max(0, (int) $brut) : 0;
|
||||
$moyVal = is_numeric($moy) ? max(0, (int) $moy) : 0;
|
||||
|
||||
try {
|
||||
return new IncoWeight(
|
||||
netWeight: $netVal,
|
||||
grossWeight: $brutVal,
|
||||
theoreticalAverage: $moyVal,
|
||||
);
|
||||
} catch (\DomainException $e) {
|
||||
unset($e);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse les conditions de conservation (CONDIT_CONSERV).
|
||||
*/
|
||||
public static function toStorageCondition(mixed $raw): ?StorageCondition
|
||||
{
|
||||
if ($raw === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$str = trim(self::castString($raw));
|
||||
if ($str === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cleaned = IncoStringParser::protectiveDataFilter($str);
|
||||
if ($cleaned === null || trim($cleaned) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new StorageCondition($cleaned);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse les additifs (LST_ADDITIF).
|
||||
*
|
||||
* @return array<Additive>
|
||||
*/
|
||||
// phpcs:disable Generic.Metrics.CyclomaticComplexity
|
||||
public static function toAdditives(mixed $raw): array
|
||||
{
|
||||
// phpcs:enable Generic.Metrics.CyclomaticComplexity
|
||||
if ($raw === null || $raw === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (is_array($raw)) {
|
||||
$additives = [];
|
||||
foreach ($raw as $entry) {
|
||||
if (is_array($entry) && isset($entry['code'], $entry['libelle']) && is_string($entry['code']) && is_string($entry['libelle'])) {
|
||||
$cleanedCode = self::normalizeAdditiveCode($entry['code']);
|
||||
$cleanedLabel = IncoStringParser::toPhrase($entry['libelle']);
|
||||
if ($cleanedCode !== '' && $cleanedLabel !== '') {
|
||||
try {
|
||||
$additives[] = new Additive($cleanedCode, $cleanedLabel);
|
||||
} catch (\DomainException $e) {
|
||||
unset($e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $additives;
|
||||
}
|
||||
|
||||
// Si c'est une chaîne délimitée issue d'Oracle
|
||||
$rawStr = self::castString($raw);
|
||||
$tokens = array_filter(array_map('trim', explode(';', $rawStr)));
|
||||
$result = [];
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
$parts = explode(':', $token, 2);
|
||||
$code = self::normalizeAdditiveCode($parts[0] ?? '');
|
||||
$label = IncoStringParser::toPhrase($parts[1] ?? $code);
|
||||
|
||||
if ($code !== '' && $label !== '') {
|
||||
try {
|
||||
$result[] = new Additive($code, $label);
|
||||
} catch (\DomainException $e) {
|
||||
unset($e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse les allergènes (LST_ALLERGENE).
|
||||
*
|
||||
* @return array<Allergen>
|
||||
*/
|
||||
// phpcs:disable Generic.Metrics.CyclomaticComplexity
|
||||
public static function toAllergens(mixed $raw): array
|
||||
{
|
||||
// phpcs:enable Generic.Metrics.CyclomaticComplexity
|
||||
if ($raw === null || $raw === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (is_array($raw)) {
|
||||
$allergens = [];
|
||||
foreach ($raw as $entry) {
|
||||
if (is_array($entry) && isset($entry['code'], $entry['libelle']) && is_numeric($entry['code']) && is_string($entry['libelle'])) {
|
||||
$code = (int) $entry['code'];
|
||||
$label = IncoStringParser::toPhrase($entry['libelle']);
|
||||
if ($code > 0 && $label !== '') {
|
||||
try {
|
||||
$allergens[] = new Allergen($code, $label);
|
||||
} catch (\DomainException $e) {
|
||||
unset($e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $allergens;
|
||||
}
|
||||
|
||||
// Chaîne délimitée "code:libelle;code:libelle"
|
||||
$rawStr = self::castString($raw);
|
||||
$tokens = array_filter(array_map('trim', explode(';', $rawStr)));
|
||||
$result = [];
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
$parts = explode(':', $token, 2);
|
||||
$code = isset($parts[0]) && is_numeric($parts[0]) ? (int) $parts[0] : 0;
|
||||
$label = IncoStringParser::toPhrase($parts[1] ?? '');
|
||||
|
||||
if ($code > 0 && $label !== '') {
|
||||
try {
|
||||
$result[] = new Allergen($code, $label);
|
||||
} catch (\DomainException $e) {
|
||||
unset($e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private static function castString(mixed $val, string $default = ''): string
|
||||
{
|
||||
return is_scalar($val) ? (string) $val : $default;
|
||||
}
|
||||
|
||||
private static function normalizeAdditiveCode(string $code): string
|
||||
{
|
||||
$code = mb_strtoupper(trim($code), 'UTF-8');
|
||||
$code = (string) preg_replace('/^A(.*)/', 'E$1', $code);
|
||||
$code = strtoupper(trim(str_replace(' ', '', $code)));
|
||||
|
||||
// Remettre en minuscules uniquement le suffixe alpha après les chiffres (ex: E160ai)
|
||||
return (string) preg_replace_callback(
|
||||
'/^(E\d+)([A-Z]+(?:\([IVX]+\))?)$/',
|
||||
static fn (array $m): string => $m[1] . strtolower((string) $m[2]),
|
||||
$code
|
||||
);
|
||||
}
|
||||
|
||||
private static function parsePortionType(?string $typePortion): ?PortionType
|
||||
{
|
||||
if ($typePortion === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$needle = mb_strtolower(trim($typePortion), 'UTF-8');
|
||||
|
||||
foreach (PortionType::cases() as $case) {
|
||||
if ($case->description() === $needle) {
|
||||
return $case;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function tokenize(string $value): array
|
||||
{
|
||||
$delimiters = " \n\tà/\\-_de";
|
||||
$tok = IncoStringParser::mbStrtok($delimiters, $value);
|
||||
$parts = [];
|
||||
|
||||
while ($tok !== false) {
|
||||
$parts[] = $tok;
|
||||
$tok = IncoStringParser::mbStrtok($delimiters);
|
||||
}
|
||||
|
||||
return $parts;
|
||||
}
|
||||
}
|
||||
256
src/Infra/Adapters/Parsers/IncoStringParser.php
Normal file
256
src/Infra/Adapters/Parsers/IncoStringParser.php
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infra\Adapters\Parsers;
|
||||
|
||||
final readonly class IncoStringParser
|
||||
{
|
||||
/**
|
||||
* Nettoie et normalise une ligne texte (pas de HTML, pas de caractères de contrôle, UTF-8 valide).
|
||||
*/
|
||||
public static function protectiveDataFilter(?string $line): ?string
|
||||
{
|
||||
if ($line === null || $line === '') {
|
||||
return $line;
|
||||
}
|
||||
|
||||
// Decode HTML entities then strip tags
|
||||
$line = html_entity_decode($line, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$line = strip_tags($line);
|
||||
|
||||
// Normalize newlines, temporarily convert to <br /> to preserve paragraph breaks
|
||||
$line = str_replace(["\r\n", "\r"], "\n", $line);
|
||||
$line = str_replace("\n", '<br />', $line);
|
||||
|
||||
// Ensure valid UTF-8 (handling Windows-1252 / ISO-8859-1 legacy strings)
|
||||
$line = self::ensureUtf8($line);
|
||||
|
||||
// Remove astral symbols (4-byte UTF-8) that may break storage/processing
|
||||
$line = preg_replace('/[\x{10000}-\x{10FFFF}]/u', '', $line) ?? '';
|
||||
|
||||
// Remove control/non-printable characters but keep TAB(0x09), LF(0x0A) and CR(0x0D)
|
||||
$line = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\xA0\xAD]/u', '', $line) ?? '';
|
||||
|
||||
// Revert <br /> back to newlines and final strip_tags as safety
|
||||
$line = str_replace('<br />', "\n", $line);
|
||||
|
||||
return strip_tags($line);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate un libellé en phrase standard (1ère lettre en majuscule, sans point final trailing).
|
||||
*/
|
||||
public static function toPhrase(string $value): string
|
||||
{
|
||||
$squished = trim((string) preg_replace('/\s+/u', ' ', trim($value)));
|
||||
if ($squished === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$lower = mb_strtolower($squished, 'UTF-8');
|
||||
$firstChar = mb_strtoupper(mb_substr($lower, 0, 1, 'UTF-8'), 'UTF-8');
|
||||
$rest = mb_substr($lower, 1, null, 'UTF-8');
|
||||
$ucfirst = $firstChar . $rest;
|
||||
|
||||
if (! str_ends_with($ucfirst, '.')) {
|
||||
$ucfirst .= '.';
|
||||
}
|
||||
|
||||
return mb_substr($ucfirst, 0, mb_strlen($ucfirst, 'UTF-8') - 1, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Embellit et met en Title Case un nom de produit ou libellé commercial.
|
||||
*/
|
||||
public static function beautifyName(string $name): string
|
||||
{
|
||||
$name = self::beautifyText($name) ?? '';
|
||||
$name = trim($name);
|
||||
if ($name === '') {
|
||||
return $name;
|
||||
}
|
||||
|
||||
$name = mb_convert_case(mb_strtolower($name, 'UTF-8'), MB_CASE_TITLE, 'UTF-8');
|
||||
$name = trim((string) preg_replace('/\s+/u', ' ', $name));
|
||||
|
||||
$name = rtrim($name, " \t\n\r\0\x0B");
|
||||
if (! preg_match('/[.:!?\…;,]$/u', $name)) {
|
||||
$name .= '.';
|
||||
}
|
||||
$name = mb_substr($name, 0, mb_strlen($name, 'UTF-8') - 1, 'UTF-8');
|
||||
|
||||
if (str_starts_with($name, ':')) {
|
||||
$name = trim(mb_substr($name, 1, null, 'UTF-8'));
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Embellit un paragraphe descriptif ou argumentaire de vente avec ponctuation et espacement corrigés.
|
||||
*
|
||||
* @phpcs:disable Generic.Metrics.CyclomaticComplexity.TooHigh
|
||||
*/
|
||||
public static function beautifyText(?string $text): ?string
|
||||
{
|
||||
// phpcs:enable
|
||||
if ($text === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$floatingPointEsc = chr(21);
|
||||
$s = preg_replace('/(\d+)[,\.](\d+)/', '$1' . $floatingPointEsc . '$2', $text) ?? '';
|
||||
|
||||
$mbUcfirst = static function (string $str): string {
|
||||
if ($str === '') {
|
||||
return $str;
|
||||
}
|
||||
$first = mb_substr($str, 0, 1, 'UTF-8');
|
||||
$rest = mb_substr($str, 1, null, 'UTF-8');
|
||||
|
||||
return mb_strtoupper($first, 'UTF-8') . $rest;
|
||||
};
|
||||
|
||||
$squish = static fn (string $str): string => trim((string) preg_replace('/\s+/u', ' ', $str));
|
||||
|
||||
$s = preg_replace_callback(
|
||||
'/(\w)\s*\.(\s*)(\w)/mu',
|
||||
static fn (array $m): string => $m[1] . '. ' . $m[2] . mb_strtoupper((string) $m[3], 'UTF-8'),
|
||||
$s
|
||||
) ?? '';
|
||||
|
||||
$s = preg_replace_callback(
|
||||
'/(\w)(\s*\n+\s*)(\p{Lu})/mu',
|
||||
static fn (array $m): string => $m[1] . '.' . $m[2] . $m[3],
|
||||
$s
|
||||
) ?? '';
|
||||
|
||||
/** @var list<string>|false $lines */
|
||||
$lines = preg_split('/\R/u', $s);
|
||||
if ($lines === false) {
|
||||
return $s;
|
||||
}
|
||||
|
||||
$out = [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$phrases = array_filter(array_map('trim', explode('.', $line)), static fn ($p) => $p !== '');
|
||||
$phrased = [];
|
||||
|
||||
foreach ($phrases as $phrase) {
|
||||
$p = $squish($phrase);
|
||||
$p = $mbUcfirst(mb_strtolower($p, 'UTF-8'));
|
||||
|
||||
if (str_starts_with($p, "'") && ! str_ends_with($p, "'")) {
|
||||
$p = mb_substr($p, 1, null, 'UTF-8');
|
||||
}
|
||||
if (! str_starts_with($p, "'") && str_ends_with($p, "'")) {
|
||||
$p = mb_substr($p, 0, -1, 'UTF-8');
|
||||
}
|
||||
|
||||
$wordCount = preg_match_all('/\p{L}+/u', $p);
|
||||
if ($wordCount === 1) {
|
||||
$p = (string) preg_replace('/,\s*et\s+/u', ' et ', $p);
|
||||
$phrased[] = $p;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! preg_match('/[:,\!\?;…]$/u', $p)) {
|
||||
$p .= '.';
|
||||
} else {
|
||||
$p = (string) preg_replace('/:/u', ': ', $p);
|
||||
}
|
||||
|
||||
$p = (string) preg_replace('/,\s*et\s+/u', ' et ', $p);
|
||||
$p = $squish($p);
|
||||
|
||||
$phrased[] = $p;
|
||||
}
|
||||
|
||||
$out[] = implode(' ', $phrased);
|
||||
}
|
||||
|
||||
$result = implode("\n", $out);
|
||||
|
||||
return str_replace($floatingPointEsc, '.', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Multibyte-safe tokenizer similar to strtok.
|
||||
*/
|
||||
public static function mbStrtok(string $delimiters, ?string $str = null): string | false
|
||||
{
|
||||
/** @var int $pos */
|
||||
static $pos = 0;
|
||||
/** @var string $string */
|
||||
static $string = '';
|
||||
|
||||
if ($str !== null) {
|
||||
$pos = 0;
|
||||
$string = $str;
|
||||
}
|
||||
|
||||
$token = '';
|
||||
$len = mb_strlen($string, 'UTF-8');
|
||||
|
||||
while ($pos < $len) {
|
||||
$char = mb_substr($string, $pos, 1, 'UTF-8');
|
||||
$pos++;
|
||||
|
||||
if (mb_strpos($delimiters, $char, 0, 'UTF-8') === false) {
|
||||
$token .= $char;
|
||||
} else {
|
||||
if ($token !== '') {
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($token !== '') {
|
||||
return $token;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slugify a string for token comparisons.
|
||||
*/
|
||||
public static function slugify(string $value): string
|
||||
{
|
||||
$ascii = (string) iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value);
|
||||
|
||||
return strtolower(trim($ascii));
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a string to a max number of words.
|
||||
*/
|
||||
public static function words(string $value, int $words = 100, string $end = ' ...'): string
|
||||
{
|
||||
preg_match('/^\s*+(?:\S+\s*+){1,' . $words . '}/u', $value, $matches);
|
||||
|
||||
if (! isset($matches[0]) || mb_strlen($value, 'UTF-8') === mb_strlen($matches[0], 'UTF-8')) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return rtrim($matches[0]) . $end;
|
||||
}
|
||||
|
||||
private static function ensureUtf8(string $string): string
|
||||
{
|
||||
if ($string === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (mb_check_encoding($string, 'UTF-8')) {
|
||||
return $string;
|
||||
}
|
||||
|
||||
$converted = mb_convert_encoding($string, 'UTF-8', 'Windows-1252, ISO-8859-15, ISO-8859-1');
|
||||
|
||||
return is_string($converted) ? $converted : $string;
|
||||
}
|
||||
}
|
||||
64
tests/Unit/Infra/Adapters/Mappers/ArcadiaBrandMapperTest.php
Normal file
64
tests/Unit/Infra/Adapters/Mappers/ArcadiaBrandMapperTest.php
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Infra\Adapters\Mappers;
|
||||
|
||||
use Domain\Models\ValueObjects\Brand\ArcadiaBrand;
|
||||
use Infra\Adapters\Mappers\ArcadiaBrandMapper;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
covers(ArcadiaBrandMapper::class);
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
describe('ArcadiaBrandMapper', function (): void {
|
||||
it('maps an array row to ArcadiaBrand domain VO', function (): void {
|
||||
$row = [
|
||||
'COD_MARQ' => 1,
|
||||
'LIB_MARQ' => 'LE GAULOIS',
|
||||
];
|
||||
|
||||
$domain = ArcadiaBrandMapper::toDomain($row);
|
||||
|
||||
expect($domain)->toBeInstanceOf(ArcadiaBrand::class)
|
||||
->and((string) $domain->id->value)->toBe('1')
|
||||
->and($domain->name->value)->toBe('LE GAULOIS');
|
||||
});
|
||||
|
||||
it('maps an object row to ArcadiaBrand domain VO', function (): void {
|
||||
$row = (object) [
|
||||
'COD_MARQ' => 2,
|
||||
'LIB_MARQ' => 'MAITRE COQ',
|
||||
];
|
||||
|
||||
$domain = ArcadiaBrandMapper::toDomain($row);
|
||||
|
||||
expect($domain)->toBeInstanceOf(ArcadiaBrand::class)
|
||||
->and((string) $domain->id->value)->toBe('2')
|
||||
->and($domain->name->value)->toBe('MAITRE COQ');
|
||||
});
|
||||
|
||||
it('generates fallback name when libelle is empty', function (): void {
|
||||
$row = [
|
||||
'COD_MARQ' => 99,
|
||||
'LIB_MARQ' => '',
|
||||
];
|
||||
|
||||
$domain = ArcadiaBrandMapper::toDomain($row);
|
||||
|
||||
expect($domain->name->value)->toBe('Marque 99');
|
||||
});
|
||||
|
||||
it('supports alternative lowercase or alias keys', function (): void {
|
||||
$row = [
|
||||
'code' => 3,
|
||||
'libelle' => 'LOUE',
|
||||
];
|
||||
|
||||
$domain = ArcadiaBrandMapper::toDomain($row);
|
||||
|
||||
expect((string) $domain->id->value)->toBe('3')
|
||||
->and($domain->name->value)->toBe('LOUE');
|
||||
});
|
||||
});
|
||||
74
tests/Unit/Infra/Adapters/Mappers/EmployeeMapperTest.php
Normal file
74
tests/Unit/Infra/Adapters/Mappers/EmployeeMapperTest.php
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Infra\Adapters\Mappers;
|
||||
|
||||
use Domain\Models\Entities\Employee;
|
||||
use Infra\Adapters\Mappers\EmployeeMapper;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
covers(EmployeeMapper::class);
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
describe('EmployeeMapper', function (): void {
|
||||
it('maps an array row to Employee entity with all fields', function (): void {
|
||||
$row = [
|
||||
'MATRICULE_FIC' => '01155902',
|
||||
'NOM_FIC' => 'DOE',
|
||||
'PRENOM_FIC' => 'John',
|
||||
'DATE_DEB_FIC' => '2024-01-15',
|
||||
'DATE_FIN_FIC' => null,
|
||||
'DF_CARRIERE' => '2026-12-31',
|
||||
'ETAB_FIC' => '01',
|
||||
'SITE_FIC' => 'LDCSER',
|
||||
'SERVICE_FIC' => '097',
|
||||
'DAT_CRE' => '2024-01-15',
|
||||
'DAT_MAJ' => '2024-06-20',
|
||||
];
|
||||
|
||||
$employee = EmployeeMapper::toDomain($row);
|
||||
|
||||
expect($employee)->toBeInstanceOf(Employee::class)
|
||||
->and((string) $employee->publicEmployeeNumber->value)->toBe('01155902')
|
||||
->and($employee->lastName)->toBe('DOE')
|
||||
->and($employee->firstName)->toBe('John')
|
||||
->and($employee->establishment->value)->toBe('01')
|
||||
->and($employee->site->value)->toBe('LDCSER')
|
||||
->and($employee->service->value)->toBe('097')
|
||||
->and($employee->plannedEndDate?->format('Y-m-d'))->toBe('2026-12-31');
|
||||
});
|
||||
|
||||
it('pads matricule to 7 digits if shorter numeric', function (): void {
|
||||
$row = [
|
||||
'MATRICULE_FIC' => '12345',
|
||||
'NOM_FIC' => 'SMITH',
|
||||
'PRENOM_FIC' => 'Jane',
|
||||
'DATE_DEB_FIC' => '2024-01-01',
|
||||
];
|
||||
|
||||
$employee = EmployeeMapper::toDomain($row);
|
||||
|
||||
expect((string) $employee->publicEmployeeNumber->value)->toBe('0012345');
|
||||
});
|
||||
|
||||
it('supports object input and fallback column names', function (): void {
|
||||
$row = (object) [
|
||||
'public_employee_number' => '1234567',
|
||||
'nom_fic' => 'DUPONT',
|
||||
'prenom_fic' => 'Louis',
|
||||
'start_date' => '2023-05-15',
|
||||
'etablissement' => 'PA',
|
||||
'site' => 'SLELA',
|
||||
'service' => '097',
|
||||
];
|
||||
|
||||
$employee = EmployeeMapper::toDomain($row);
|
||||
|
||||
expect($employee)->toBeInstanceOf(Employee::class)
|
||||
->and((string) $employee->publicEmployeeNumber->value)->toBe('1234567')
|
||||
->and($employee->lastName)->toBe('DUPONT')
|
||||
->and($employee->firstName)->toBe('Louis');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Infra\Adapters\Mappers;
|
||||
|
||||
use Domain\Models\Entities\Inco\Composition\IncoComposition;
|
||||
use Infra\Adapters\Mappers\IncoCompositionMapper;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
covers(IncoCompositionMapper::class);
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
describe('IncoCompositionMapper', function (): void {
|
||||
it('maps an array row to IncoComposition entity', function (): void {
|
||||
$row = [
|
||||
'ID' => 96504,
|
||||
'ID_INCO' => 47789,
|
||||
'NOM' => 'NUGGETS DE POULET DU VOLAILLER LE GAULOIS',
|
||||
'NUMERO' => '96504',
|
||||
'SOURCE' => '1',
|
||||
'TAILLE_REF' => '100g',
|
||||
'VAL_ENERG_KJ_R' => 890,
|
||||
'VAL_ENERG_KCAL_R' => 212,
|
||||
'MAT_GRASS_R' => 9.5,
|
||||
'AGS_R' => 1.8,
|
||||
'GLUCIDES_R' => 16.0,
|
||||
'SUCRE_R' => 0.8,
|
||||
'FIB_ALIM_R' => 1.5,
|
||||
'PROTEINE_R' => 15.0,
|
||||
'SEL_R' => 1.10,
|
||||
'CLASSEMENT' => 'B',
|
||||
'COMPOSANTE_N' => 6,
|
||||
'SCORE_NUTRI' => 1,
|
||||
];
|
||||
|
||||
$entity = IncoCompositionMapper::toDomain($row);
|
||||
|
||||
expect($entity)->toBeInstanceOf(IncoComposition::class)
|
||||
->and((string) $entity->id->value)->toBe('96504')
|
||||
->and((string) $entity->idInco->value)->toBe('47789')
|
||||
->and($entity->identification->name)->toBe('NUGGETS DE POULET DU VOLAILLER LE GAULOIS')
|
||||
->and($entity->energy->reference->energyKcal)->toBe('212')
|
||||
->and($entity->nutriScore->grade)->toBe('B');
|
||||
});
|
||||
|
||||
it('maps an object row to IncoComposition entity', function (): void {
|
||||
$row = (object) [
|
||||
'id' => '124503',
|
||||
'id_inco' => '47996',
|
||||
'nom' => 'GESIERS ET FOIES CONFITS LE GAULOIS',
|
||||
'numero' => '124503',
|
||||
'source' => '1',
|
||||
];
|
||||
|
||||
$entity = IncoCompositionMapper::toDomain($row);
|
||||
|
||||
expect($entity)->toBeInstanceOf(IncoComposition::class)
|
||||
->and((string) $entity->id->value)->toBe('124503')
|
||||
->and((string) $entity->idInco->value)->toBe('47996')
|
||||
->and($entity->identification->name)->toBe('GESIERS ET FOIES CONFITS LE GAULOIS');
|
||||
});
|
||||
});
|
||||
78
tests/Unit/Infra/Adapters/Mappers/IncoMapperTest.php
Normal file
78
tests/Unit/Infra/Adapters/Mappers/IncoMapperTest.php
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Infra\Adapters\Mappers;
|
||||
|
||||
use Domain\Models\Entities\Brand;
|
||||
use Domain\Models\Entities\Inco\IncoSheet;
|
||||
use Domain\Models\ValueObjects\Brand\ArcadiaId;
|
||||
use Domain\Models\ValueObjects\Brand\BrandCode;
|
||||
use Domain\Models\ValueObjects\Brand\BrandId;
|
||||
use Domain\Models\ValueObjects\Brand\BrandName;
|
||||
use Domain\Services\BrandResolver;
|
||||
use Infra\Adapters\Mappers\IncoMapper;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
covers(IncoMapper::class);
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
describe('IncoMapper', function (): void {
|
||||
it('maps an array row to complete IncoSheet entity', function (): void {
|
||||
$brandResolver = $this->createMock(BrandResolver::class);
|
||||
$mockBrand = Brand::hydrate(
|
||||
id: new BrandId(Uuid::fromString('018e6e5b-1111-7000-8000-000000000001')),
|
||||
name: new BrandName('Le Gaulois'),
|
||||
code: new BrandCode('LGL01'),
|
||||
arcadiaId: new ArcadiaId('1'),
|
||||
);
|
||||
|
||||
$brandResolver->expects($this->once())
|
||||
->method('resolve')
|
||||
->willReturn($mockBrand);
|
||||
|
||||
$mapper = new IncoMapper($brandResolver);
|
||||
|
||||
$row = [
|
||||
'NO_ART' => 47789,
|
||||
'DENOM_LEG' => 'Viande de poulet traitée en salaison, panée et cuite',
|
||||
'DESCRIPTIF' => 'Nuggets de poulet du volailler LE GAULOIS',
|
||||
'ASTUCES' => 'Pour plus de croustillance, cuisson au Air Fryer',
|
||||
'CONSEIL_PREPA' => 'Au four chaud pendant 10 minutes',
|
||||
'ARG_VENTE' => 'Une volaille 100% française',
|
||||
'ALLEGATIONS' => 'sans conservateur, sans huile de palme',
|
||||
'LST_ING' => 'Viande de poulet 51%, eau, panure 30%.',
|
||||
'NB_PORTION' => '10',
|
||||
'TYP_PORTION' => 'exact',
|
||||
'LST_ALLERGENE' => '1:Gluten;7:Lait',
|
||||
'MARQUE' => 1,
|
||||
'FAMILLE_VTE_LIBELLE' => 'PRODUITS CUITS & PANES',
|
||||
'PDS_NET_UVC' => 300.0,
|
||||
'PDS_BRUT_UVC' => 320.0,
|
||||
'NUTRISCORE_ON_PACK' => 'B',
|
||||
'CONDIT_CONSERV' => 'A conserver entre 0°C et +4°C',
|
||||
'LST_ADDITIF' => 'E300:Acide ascorbique',
|
||||
'PROD_SAISON' => 0,
|
||||
'REF_COMPO' => 96504,
|
||||
'DAT_CRE' => '2024-01-15',
|
||||
'DAT_MAJ' => '2024-06-20',
|
||||
];
|
||||
|
||||
$sheet = $mapper->toDomain($row);
|
||||
|
||||
expect($sheet)->toBeInstanceOf(IncoSheet::class)
|
||||
->and((string) $sheet->id->value)->toBe('47789')
|
||||
->and($sheet->name)->toBe('Nuggets de poulet du volailler le gaulois.')
|
||||
->and($sheet->label)->toBe('Viande De Poulet Traitée En Salaison, Panée Et Cuite')
|
||||
->and($sheet->brand->code->value)->toBe('LGL01')
|
||||
->and($sheet->brand->name->value)->toBe('Le Gaulois')
|
||||
->and($sheet->sellFamilly)->toBe('Produits cuits & panes')
|
||||
->and($sheet->nutriscore?->grade)->toBe('B')
|
||||
->and((string) $sheet->compo?->value)->toBe('96504')
|
||||
->and($sheet->allergens)->toHaveCount(2)
|
||||
->and($sheet->additives)->toHaveCount(1)
|
||||
->and($sheet->isSeasonal)->toBeFalse();
|
||||
});
|
||||
});
|
||||
47
tests/Unit/Infra/Adapters/Mappers/IncoShortMapperTest.php
Normal file
47
tests/Unit/Infra/Adapters/Mappers/IncoShortMapperTest.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Infra\Adapters\Mappers;
|
||||
|
||||
use Domain\Models\Entities\Inco\IncoShortSheet;
|
||||
use Infra\Adapters\Mappers\IncoShortMapper;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
covers(IncoShortMapper::class);
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
describe('IncoShortMapper', function (): void {
|
||||
it('maps an array row to IncoShortSheet entity', function (): void {
|
||||
$row = [
|
||||
'NO_ART' => 1000101,
|
||||
'DENOM_LEG' => 'Poulet Rôti 1kg',
|
||||
'DESCRIPTIF' => 'Délicieux poulet rôti cuit au four',
|
||||
'DAT_CRE' => '2024-01-15',
|
||||
'DAT_MAJ' => '2024-06-20',
|
||||
];
|
||||
|
||||
$entity = IncoShortMapper::toDomain($row);
|
||||
|
||||
expect($entity)->toBeInstanceOf(IncoShortSheet::class)
|
||||
->and((string) $entity->id->value)->toBe('1000101')
|
||||
->and($entity->name)->toBe('Délicieux poulet rôti cuit au four.')
|
||||
->and($entity->label)->toBe('Poulet Rôti 1Kg')
|
||||
->and($entity->createdAt?->format('Y-m-d'))->toBe('2024-01-15')
|
||||
->and($entity->updatedAt?->format('Y-m-d'))->toBe('2024-06-20');
|
||||
});
|
||||
|
||||
it('maps an object row and derives name when descriptif is missing', function (): void {
|
||||
$row = (object) [
|
||||
'no_art' => '1000102',
|
||||
'denom_leg' => 'Salade Niçoise au Thon',
|
||||
];
|
||||
|
||||
$entity = IncoShortMapper::toDomain($row);
|
||||
|
||||
expect($entity)->toBeInstanceOf(IncoShortSheet::class)
|
||||
->and((string) $entity->id->value)->toBe('1000102')
|
||||
->and($entity->label)->toBe('Salade Niçoise Au Thon');
|
||||
});
|
||||
});
|
||||
172
tests/Unit/Infra/Adapters/Parsers/IncoCompositionParserTest.php
Normal file
172
tests/Unit/Infra/Adapters/Parsers/IncoCompositionParserTest.php
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Infra\Adapters\Parsers;
|
||||
|
||||
use Domain\Models\ValueObjects\Inco\Composition\AminoAcids;
|
||||
use Domain\Models\ValueObjects\Inco\Composition\CarbohydratesDetail;
|
||||
use Domain\Models\ValueObjects\Inco\Composition\Energy;
|
||||
use Domain\Models\ValueObjects\Inco\Composition\FattyAcids;
|
||||
use Domain\Models\ValueObjects\Inco\Composition\FreshProduceAndNuts;
|
||||
use Domain\Models\ValueObjects\Inco\Composition\Identification;
|
||||
use Domain\Models\ValueObjects\Inco\Composition\Minerals;
|
||||
use Domain\Models\ValueObjects\Inco\Composition\NutriScore;
|
||||
use Domain\Models\ValueObjects\Inco\Composition\NutriScorePoints;
|
||||
use Domain\Models\ValueObjects\Inco\Composition\PortionMacronutrients;
|
||||
use Domain\Models\ValueObjects\Inco\Composition\ReferenceIntakes;
|
||||
use Domain\Models\ValueObjects\Inco\Composition\ReferenceMacronutrients;
|
||||
use Domain\Models\ValueObjects\Inco\Composition\SpecialCompounds;
|
||||
use Domain\Models\ValueObjects\Inco\Composition\Vitamins;
|
||||
use Infra\Adapters\Parsers\IncoCompositionParser;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
covers(IncoCompositionParser::class);
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
describe('IncoCompositionParser', function (): void {
|
||||
it('parses identification from row data', function (): void {
|
||||
$row = [
|
||||
'NOM' => 'NUGGETS DE POULET',
|
||||
'NUMERO' => '96504',
|
||||
'SOURCE' => '1',
|
||||
'ID_INCO' => '47789',
|
||||
];
|
||||
|
||||
$vo = IncoCompositionParser::toIdentification($row);
|
||||
|
||||
expect($vo)->toBeInstanceOf(Identification::class)
|
||||
->and($vo->name)->toBe('NUGGETS DE POULET')
|
||||
->and($vo->number)->toBe('96504')
|
||||
->and($vo->source)->toBe('1');
|
||||
});
|
||||
|
||||
it('parses energy values for reference and portion', function (): void {
|
||||
$row = [
|
||||
'TAILLE_REF' => '100g',
|
||||
'VAL_ENERG_KJ_R' => '890',
|
||||
'VAL_ENERG_KCAL_R' => '212',
|
||||
'TAILLE_PORT' => '150g',
|
||||
'VAL_ENERG_KJ_P' => '1335',
|
||||
'VAL_ENERG_KCAL_P' => '318',
|
||||
];
|
||||
|
||||
$vo = IncoCompositionParser::toEnergy($row);
|
||||
|
||||
expect($vo)->toBeInstanceOf(Energy::class)
|
||||
->and($vo->reference->size)->toBe('100g')
|
||||
->and($vo->reference->energyKj)->toBe('890')
|
||||
->and($vo->reference->energyKcal)->toBe('212')
|
||||
->and($vo->portion->size)->toBe('150g')
|
||||
->and($vo->portion->energyKj)->toBe('1335')
|
||||
->and($vo->portion->energyKcal)->toBe('318');
|
||||
});
|
||||
|
||||
it('parses reference and portion macronutrients', function (): void {
|
||||
$row = [
|
||||
'MAT_GRASS_R' => '9.5',
|
||||
'AGS_R' => '1.8',
|
||||
'GLUCIDES_R' => '16.0',
|
||||
'SUCRE_R' => '0.8',
|
||||
'FIB_ALIM_R' => '1.5',
|
||||
'PROTEINE_R' => '15.0',
|
||||
'SEL_R' => '1.10',
|
||||
'MAT_GRASS_P' => '14.25',
|
||||
'AGS_P' => '2.7',
|
||||
'GLUCIDES_P' => '24.0',
|
||||
'SUCRE_P' => '1.2',
|
||||
'FIB_ALIM_P' => '2.25',
|
||||
'PROTEINE_P' => '22.5',
|
||||
'SEL_P' => '1.65',
|
||||
];
|
||||
|
||||
$refMacro = IncoCompositionParser::toReferenceMacronutrients($row);
|
||||
$portMacro = IncoCompositionParser::toPortionMacronutrients($row);
|
||||
|
||||
expect($refMacro)->toBeInstanceOf(ReferenceMacronutrients::class)
|
||||
->and($refMacro->fat->amount)->toBe('9.5')
|
||||
->and($refMacro->protein->amount)->toBe('15.0')
|
||||
->and($portMacro)->toBeInstanceOf(PortionMacronutrients::class)
|
||||
->and($portMacro->fat->amount)->toBe('14.25')
|
||||
->and($portMacro->protein->amount)->toBe('22.5');
|
||||
});
|
||||
|
||||
it('parses fresh produce and nuts amounts', function (): void {
|
||||
$row = [
|
||||
'FRUITS_R' => '10',
|
||||
'FRUITS_COQUE_SECH_R' => '5',
|
||||
'FRUITS_P' => '15',
|
||||
'FRUITS_COQUE_SECH_P' => '7.5',
|
||||
'POURCENT_FNL_FRAIS' => '20',
|
||||
'POURCENT_FRUITS' => '15',
|
||||
];
|
||||
|
||||
$vo = IncoCompositionParser::toFreshProduceAndNuts($row);
|
||||
|
||||
expect($vo)->toBeInstanceOf(FreshProduceAndNuts::class)
|
||||
->and($vo->reference->fruits)->toBe('10')
|
||||
->and($vo->reference->nuts)->toBe('5')
|
||||
->and($vo->freshFnlPercentage->value)->toBe('20');
|
||||
});
|
||||
|
||||
it('parses reference intakes and nutriscore points', function (): void {
|
||||
$row = [
|
||||
'POURCENT_APPORT_REF_E14_P' => '10',
|
||||
'POURCENT_APPORT_REF_KJO_P' => '10',
|
||||
'POURCENT_APPORT_REF_FAT_P' => '12',
|
||||
'POURCENT_APPORT_REF_FATSAT_P' => '14',
|
||||
'POURCENT_APPORT_REF_CHOVAL_P' => '6',
|
||||
'POURCENT_APPORT_REF_SUGAR_P' => '1',
|
||||
'POURCENT_APPORT_REF_PRO_P' => '30',
|
||||
'POURCENT_APPORT_REF_SALTEQ_P' => '18',
|
||||
'APPORT_PORT' => '150g',
|
||||
'POINTS_ENERGIE' => '3',
|
||||
'POINTS_AGS' => '2',
|
||||
'POINTS_SUCRES' => '0',
|
||||
'POINTS_SEL' => '1',
|
||||
'POINTS_FIBRES' => '1',
|
||||
'POINTS_PROTEINES' => '4',
|
||||
'POINTS_FNL_FRAIS' => '0',
|
||||
'COMPOSANTE_N' => '6',
|
||||
'SCORE_NUTRI' => '1',
|
||||
'CLASSEMENT' => 'B',
|
||||
];
|
||||
|
||||
$intakes = IncoCompositionParser::toReferenceIntakes($row);
|
||||
$points = IncoCompositionParser::toNutriScorePoints($row);
|
||||
$score = IncoCompositionParser::toNutriScore($row);
|
||||
|
||||
expect($intakes)->toBeInstanceOf(ReferenceIntakes::class)
|
||||
->and($intakes->portionAmount)->toBe('150g')
|
||||
->and($points)->toBeInstanceOf(NutriScorePoints::class)
|
||||
->and($points->energyPoints)->toBe('3')
|
||||
->and($score)->toBeInstanceOf(NutriScore::class)
|
||||
->and($score->grade)->toBe('B');
|
||||
});
|
||||
|
||||
it('parses minerals, vitamins, fatty acids, amino acids, and special compounds', function (): void {
|
||||
$row = [
|
||||
'NUT_CA' => '120',
|
||||
'NUT_FE' => '2.5',
|
||||
'NUT_VITA' => '80',
|
||||
'NUT_VITC' => '15',
|
||||
'NUT_STARCH' => '10',
|
||||
'NUT_SUCS' => '0.5',
|
||||
'NUT_CHOL' => '45',
|
||||
'NUT_FOLEIQ' => '3.2',
|
||||
'NUT_AAE' => '12',
|
||||
'NUT_CHOLN' => '50',
|
||||
'NUT_CARNITINE' => '10',
|
||||
'NUT_TAU' => '20',
|
||||
'NUT_NUCLEOTIDE' => '5',
|
||||
];
|
||||
|
||||
expect(IncoCompositionParser::toMinerals($row))->toBeInstanceOf(Minerals::class)
|
||||
->and(IncoCompositionParser::toVitamines($row))->toBeInstanceOf(Vitamins::class)
|
||||
->and(IncoCompositionParser::toCarbohydratesDetail($row))->toBeInstanceOf(CarbohydratesDetail::class)
|
||||
->and(IncoCompositionParser::toFattyAcids($row))->toBeInstanceOf(FattyAcids::class)
|
||||
->and(IncoCompositionParser::toAminoAcids($row))->toBeInstanceOf(AminoAcids::class)
|
||||
->and(IncoCompositionParser::toSpecialCompounds($row))->toBeInstanceOf(SpecialCompounds::class);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Infra\Adapters\Parsers;
|
||||
|
||||
use Domain\Models\ValueObjects\Inco\Ingredients;
|
||||
use Infra\Adapters\Parsers\IncoIngredientParser;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
covers(IncoIngredientParser::class);
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
describe('IncoIngredientParser', function (): void {
|
||||
it('returns empty Ingredients VO for null or empty string', function (): void {
|
||||
expect(IncoIngredientParser::parse(null)->groups)->toBeEmpty()
|
||||
->and(IncoIngredientParser::parse('')->groups)->toBeEmpty()
|
||||
->and(IncoIngredientParser::parse(' ')->groups)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('parses a simple flat list of ingredients', function (): void {
|
||||
$raw = 'Viande de poulet 51%, eau, sel, farine de blé, huile de tournesol.';
|
||||
$vo = IncoIngredientParser::parse($raw);
|
||||
|
||||
expect($vo)->toBeInstanceOf(Ingredients::class)
|
||||
->and($vo->groups)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('parses structured ingredients with sub-ingredients in parentheses and ratios', function (): void {
|
||||
$raw = 'Viande de poulet traitée en salaison : 70% (viande de poulet : 51%, eau, sel). Panure : 30% (farine de blé, eau, sel, levure).';
|
||||
$vo = IncoIngredientParser::parse($raw);
|
||||
|
||||
expect($vo->groups)->toBeArray();
|
||||
$keys = array_keys($vo->groups);
|
||||
expect($keys)->toContain('Viande de poulet traitée en salaison')
|
||||
->and($keys)->toContain('Panure');
|
||||
});
|
||||
|
||||
it('handles Traces groups with uppercase normalization', function (): void {
|
||||
$raw = 'Viande de dinde (80%), sel. Traces : gluten, lait, œuf.';
|
||||
$vo = IncoIngredientParser::parse($raw);
|
||||
|
||||
expect($vo->groups)->toHaveKey('Traces');
|
||||
$traces = $vo->groups['Traces'];
|
||||
expect($traces)->toBeArray()
|
||||
->and($traces[0])->toBe(mb_strtoupper($traces[0], 'UTF-8'));
|
||||
});
|
||||
|
||||
it('replaces Informations en GRAS with Informations en MAJUSCULE', function (): void {
|
||||
$raw = 'Farine de blé. Informations en GRAS pour les allergènes.';
|
||||
$vo = IncoIngredientParser::parse($raw);
|
||||
$groups = $vo->groups;
|
||||
|
||||
$allValues = [];
|
||||
array_walk_recursive($groups, static function (string $val) use (&$allValues): void {
|
||||
$allValues[] = $val;
|
||||
});
|
||||
|
||||
$found = false;
|
||||
foreach ($allValues as $val) {
|
||||
if (str_contains($val, 'Informations en MAJUSCULE')) {
|
||||
$found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect($found)->toBeTrue();
|
||||
});
|
||||
});
|
||||
158
tests/Unit/Infra/Adapters/Parsers/IncoParserTest.php
Normal file
158
tests/Unit/Infra/Adapters/Parsers/IncoParserTest.php
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Infra\Adapters\Parsers;
|
||||
|
||||
use Domain\Models\ValueObjects\Inco\Additive;
|
||||
use Domain\Models\ValueObjects\Inco\Allegations;
|
||||
use Domain\Models\ValueObjects\Inco\Allergen;
|
||||
use Domain\Models\ValueObjects\Inco\IncoWeight;
|
||||
use Domain\Models\ValueObjects\Inco\PortionAmount;
|
||||
use Domain\Models\ValueObjects\Inco\StorageCondition;
|
||||
use Infra\Adapters\Parsers\IncoParser;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
covers(IncoParser::class);
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
describe('IncoParser', function (): void {
|
||||
describe('toAllegations', function (): void {
|
||||
it('parses allegations across 4 categories and filters out "aucun"', function (): void {
|
||||
$vo = IncoParser::toAllegations(
|
||||
allegations: 'sans conservateur, aucun colorant',
|
||||
sante: 'riche en fer',
|
||||
nutrit: 'faible en gras',
|
||||
autre: '100% français'
|
||||
);
|
||||
|
||||
expect($vo)->toBeInstanceOf(Allegations::class)
|
||||
->and($vo->allegations())->toContain('Sans conservateur')
|
||||
->and($vo->allegations())->not->toContain('aucun colorant')
|
||||
->and($vo->health())->toContain('Riche en fer')
|
||||
->and($vo->nutrition())->toContain('Faible en gras')
|
||||
->and($vo->other())->toContain('100% français');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toPortionAmount', function (): void {
|
||||
it('parses exact portion amount', function (): void {
|
||||
$vo = IncoParser::toPortionAmount('4', 'exact');
|
||||
|
||||
expect($vo)->toBeInstanceOf(PortionAmount::class)
|
||||
->and($vo?->lowPart())->toBe(400)
|
||||
->and($vo?->highPart())->toBe(400)
|
||||
->and($vo?->approximative())->toBeFalse();
|
||||
});
|
||||
|
||||
it('parses range portion amount like 3/4', function (): void {
|
||||
$vo = IncoParser::toPortionAmount('3/4');
|
||||
|
||||
expect($vo)->toBeInstanceOf(PortionAmount::class)
|
||||
->and($vo?->lowPart())->toBe(300)
|
||||
->and($vo?->highPart())->toBe(400)
|
||||
->and($vo?->approximative())->toBeTrue();
|
||||
});
|
||||
|
||||
it('returns null for null or invalid portion', function (): void {
|
||||
expect(IncoParser::toPortionAmount(null))->toBeNull()
|
||||
->and(IncoParser::toPortionAmount(''))->toBeNull()
|
||||
->and(IncoParser::toPortionAmount('non-numeric'))->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toNutriscore', function (): void {
|
||||
it('parses valid nutriscore grade A to E', function (): void {
|
||||
expect(IncoParser::toNutriscore('A')?->grade)->toBe('A')
|
||||
->and(IncoParser::toNutriscore('b')?->grade)->toBe('B')
|
||||
->and(IncoParser::toNutriscore('NUTRISCORE: C')?->grade)->toBe('C');
|
||||
});
|
||||
|
||||
it('returns null for invalid nutriscore grade', function (): void {
|
||||
expect(IncoParser::toNutriscore(null))->toBeNull()
|
||||
->and(IncoParser::toNutriscore('Z'))->toBeNull()
|
||||
->and(IncoParser::toNutriscore(''))->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toWeight', function (): void {
|
||||
it('parses net, gross, and average weight', function (): void {
|
||||
$vo = IncoParser::toWeight(300, 320, 310);
|
||||
|
||||
expect($vo)->toBeInstanceOf(IncoWeight::class)
|
||||
->and($vo?->netWeight)->toBe(300)
|
||||
->and($vo?->grossWeight)->toBe(320)
|
||||
->and($vo?->theoreticalAverage)->toBe(310);
|
||||
});
|
||||
|
||||
it('returns null when all weights are null', function (): void {
|
||||
expect(IncoParser::toWeight(null, null, null))->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toStorageCondition', function (): void {
|
||||
it('parses and cleans storage condition string', function (): void {
|
||||
$vo = IncoParser::toStorageCondition('<p>A conserver entre 0°C et +4°C</p>');
|
||||
|
||||
expect($vo)->toBeInstanceOf(StorageCondition::class)
|
||||
->and($vo?->instructions)->toBe('A conserver entre 0°C et +4°C');
|
||||
});
|
||||
|
||||
it('returns null for null or empty condition', function (): void {
|
||||
expect(IncoParser::toStorageCondition(null))->toBeNull()
|
||||
->and(IncoParser::toStorageCondition(' '))->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toAdditives', function (): void {
|
||||
it('parses string of delimited additives (code:label;code:label)', function (): void {
|
||||
$raw = 'E250:Nitrite de sodium;E300:Acide ascorbique';
|
||||
$additives = IncoParser::toAdditives($raw);
|
||||
|
||||
expect($additives)->toHaveCount(2)
|
||||
->and($additives[0])->toBeInstanceOf(Additive::class)
|
||||
->and($additives[0]->code)->toBe('E250')
|
||||
->and($additives[0]->label)->toBe('Nitrite de sodium')
|
||||
->and($additives[1]->code)->toBe('E300')
|
||||
->and($additives[1]->label)->toBe('Acide ascorbique');
|
||||
});
|
||||
|
||||
it('parses array of additive entries', function (): void {
|
||||
$raw = [
|
||||
['code' => 'E450', 'libelle' => 'Diphosphates'],
|
||||
['code' => 'E330', 'libelle' => 'Acide citrique'],
|
||||
];
|
||||
$additives = IncoParser::toAdditives($raw);
|
||||
|
||||
expect($additives)->toHaveCount(2)
|
||||
->and($additives[0]->code)->toBe('E450')
|
||||
->and($additives[1]->code)->toBe('E330');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toAllergens', function (): void {
|
||||
it('parses string of delimited allergens (code:label;code:label)', function (): void {
|
||||
$raw = '1:Gluten;7:Lait';
|
||||
$allergens = IncoParser::toAllergens($raw);
|
||||
|
||||
expect($allergens)->toHaveCount(2)
|
||||
->and($allergens[0])->toBeInstanceOf(Allergen::class)
|
||||
->and($allergens[0]->code)->toBe(1)
|
||||
->and($allergens[0]->label)->toBe('Gluten')
|
||||
->and($allergens[1]->code)->toBe(7)
|
||||
->and($allergens[1]->label)->toBe('Lait');
|
||||
});
|
||||
|
||||
it('parses array of allergen entries', function (): void {
|
||||
$raw = [
|
||||
['code' => 3, 'libelle' => 'Œufs'],
|
||||
];
|
||||
$allergens = IncoParser::toAllergens($raw);
|
||||
|
||||
expect($allergens)->toHaveCount(1)
|
||||
->and($allergens[0]->code)->toBe(3)
|
||||
->and($allergens[0]->label)->toBe('Œufs');
|
||||
});
|
||||
});
|
||||
});
|
||||
97
tests/Unit/Infra/Adapters/Parsers/IncoStringParserTest.php
Normal file
97
tests/Unit/Infra/Adapters/Parsers/IncoStringParserTest.php
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Infra\Adapters\Parsers;
|
||||
|
||||
use Infra\Adapters\Parsers\IncoStringParser;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
covers(IncoStringParser::class);
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
describe('IncoStringParser', function (): void {
|
||||
describe('protectiveDataFilter', function (): void {
|
||||
it('returns null when input is null', function (): void {
|
||||
expect(IncoStringParser::protectiveDataFilter(null))->toBeNull();
|
||||
});
|
||||
|
||||
it('returns empty string when input is empty', function (): void {
|
||||
expect(IncoStringParser::protectiveDataFilter(''))->toBe('');
|
||||
});
|
||||
|
||||
it('strips HTML tags and decodes HTML entities', function (): void {
|
||||
$input = '<p>Poulet & <strong>Dinde</strong></p>';
|
||||
expect(IncoStringParser::protectiveDataFilter($input))->toBe('Poulet & Dinde');
|
||||
});
|
||||
|
||||
it('preserves newlines and removes non-printable control characters', function (): void {
|
||||
$input = "Ligne 1\x00\x08\r\nLigne 2\x1F";
|
||||
expect(IncoStringParser::protectiveDataFilter($input))->toBe("Ligne 1\nLigne 2");
|
||||
});
|
||||
});
|
||||
|
||||
describe('toPhrase', function (): void {
|
||||
it('formats string as phrase with capitalized first letter and no trailing period', function (): void {
|
||||
expect(IncoStringParser::toPhrase('VOLAILLE DE QUALITE.'))->toBe('Volaille de qualite')
|
||||
->and(IncoStringParser::toPhrase(' viande de porc '))->toBe('Viande de porc')
|
||||
->and(IncoStringParser::toPhrase(''))->toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('beautifyName', function (): void {
|
||||
it('converts text to title case and trims punctuation', function (): void {
|
||||
expect(IncoStringParser::beautifyName('poulet rôti fermier.'))->toBe('Poulet Rôti Fermier')
|
||||
->and(IncoStringParser::beautifyName(':NUGGETS DE POULET'))->toBe('Nuggets De Poulet')
|
||||
->and(IncoStringParser::beautifyName(''))->toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('beautifyText', function (): void {
|
||||
it('returns null when input is null', function (): void {
|
||||
expect(IncoStringParser::beautifyText(null))->toBeNull();
|
||||
});
|
||||
|
||||
it('properly formats sentences and preserves decimal floating point numbers', function (): void {
|
||||
$input = 'viande de poulet 51,5% et eau 20.5%. cuisson au four.';
|
||||
$result = IncoStringParser::beautifyText($input);
|
||||
|
||||
expect($result)->toContain('51.5%')
|
||||
->and($result)->toContain('20.5%')
|
||||
->and($result)->toContain('Viande de poulet')
|
||||
->and($result)->toContain('Cuisson au four.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mbStrtok', function (): void {
|
||||
it('tokenizes multibyte strings with delimiters', function (): void {
|
||||
$str = 'Gluten;Lait,Œufs';
|
||||
$delim = ';,';
|
||||
$first = IncoStringParser::mbStrtok($delim, $str);
|
||||
$second = IncoStringParser::mbStrtok($delim);
|
||||
$third = IncoStringParser::mbStrtok($delim);
|
||||
$fourth = IncoStringParser::mbStrtok($delim);
|
||||
|
||||
expect($first)->toBe('Gluten')
|
||||
->and($second)->toBe('Lait')
|
||||
->and($third)->toBe('Œufs')
|
||||
->and($fourth)->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('slugify', function (): void {
|
||||
it('transliterates utf8 to ascii lowercase', function (): void {
|
||||
expect(IncoStringParser::slugify('Émincés & Rôti'))->toBe('eminces & roti');
|
||||
});
|
||||
});
|
||||
|
||||
describe('words', function (): void {
|
||||
it('truncates text to specified word count', function (): void {
|
||||
$text = 'Un deux trois quatre cinq six sept';
|
||||
|
||||
expect(IncoStringParser::words($text, 3, '...'))->toBe('Un deux trois...')
|
||||
->and(IncoStringParser::words($text, 10))->toBe($text);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue