From cf918ccd30949563f1a30d6406ecbe0101fa85c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Munsch?= Date: Wed, 26 Aug 2026 17:29:05 +0200 Subject: [PATCH] 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 --- .../Adapters/Mappers/ArcadiaBrandMapper.php | 34 ++ src/Infra/Adapters/Mappers/EmployeeMapper.php | 83 ++++ .../Mappers/IncoCompositionMapper.php | 49 +++ src/Infra/Adapters/Mappers/IncoMapper.php | 180 +++++++++ .../Adapters/Mappers/IncoShortMapper.php | 77 ++++ .../Parsers/IncoCompositionParser.php | 348 ++++++++++++++++ .../Adapters/Parsers/IncoIngredientParser.php | 359 +++++++++++++++++ src/Infra/Adapters/Parsers/IncoParser.php | 374 ++++++++++++++++++ .../Adapters/Parsers/IncoStringParser.php | 256 ++++++++++++ .../Mappers/ArcadiaBrandMapperTest.php | 64 +++ .../Adapters/Mappers/EmployeeMapperTest.php | 74 ++++ .../Mappers/IncoCompositionMapperTest.php | 64 +++ .../Infra/Adapters/Mappers/IncoMapperTest.php | 78 ++++ .../Adapters/Mappers/IncoShortMapperTest.php | 47 +++ .../Parsers/IncoCompositionParserTest.php | 172 ++++++++ .../Parsers/IncoIngredientParserTest.php | 70 ++++ .../Infra/Adapters/Parsers/IncoParserTest.php | 158 ++++++++ .../Adapters/Parsers/IncoStringParserTest.php | 97 +++++ 18 files changed, 2584 insertions(+) create mode 100644 src/Infra/Adapters/Mappers/ArcadiaBrandMapper.php create mode 100644 src/Infra/Adapters/Mappers/EmployeeMapper.php create mode 100644 src/Infra/Adapters/Mappers/IncoCompositionMapper.php create mode 100644 src/Infra/Adapters/Mappers/IncoMapper.php create mode 100644 src/Infra/Adapters/Mappers/IncoShortMapper.php create mode 100644 src/Infra/Adapters/Parsers/IncoCompositionParser.php create mode 100644 src/Infra/Adapters/Parsers/IncoIngredientParser.php create mode 100644 src/Infra/Adapters/Parsers/IncoParser.php create mode 100644 src/Infra/Adapters/Parsers/IncoStringParser.php create mode 100644 tests/Unit/Infra/Adapters/Mappers/ArcadiaBrandMapperTest.php create mode 100644 tests/Unit/Infra/Adapters/Mappers/EmployeeMapperTest.php create mode 100644 tests/Unit/Infra/Adapters/Mappers/IncoCompositionMapperTest.php create mode 100644 tests/Unit/Infra/Adapters/Mappers/IncoMapperTest.php create mode 100644 tests/Unit/Infra/Adapters/Mappers/IncoShortMapperTest.php create mode 100644 tests/Unit/Infra/Adapters/Parsers/IncoCompositionParserTest.php create mode 100644 tests/Unit/Infra/Adapters/Parsers/IncoIngredientParserTest.php create mode 100644 tests/Unit/Infra/Adapters/Parsers/IncoParserTest.php create mode 100644 tests/Unit/Infra/Adapters/Parsers/IncoStringParserTest.php diff --git a/src/Infra/Adapters/Mappers/ArcadiaBrandMapper.php b/src/Infra/Adapters/Mappers/ArcadiaBrandMapper.php new file mode 100644 index 0000000..6f070db --- /dev/null +++ b/src/Infra/Adapters/Mappers/ArcadiaBrandMapper.php @@ -0,0 +1,34 @@ +|object $row + */ + public static function toDomain(array | object $row): ArcadiaBrand + { + /** @var array $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; + } +} diff --git a/src/Infra/Adapters/Mappers/EmployeeMapper.php b/src/Infra/Adapters/Mappers/EmployeeMapper.php new file mode 100644 index 0000000..fc7faaa --- /dev/null +++ b/src/Infra/Adapters/Mappers/EmployeeMapper.php @@ -0,0 +1,83 @@ +|object $row + */ + // phpcs:disable Generic.Metrics.CyclomaticComplexity + public static function toDomain(array | object $row): Employee + { + // phpcs:enable Generic.Metrics.CyclomaticComplexity + /** @var array $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; + } +} diff --git a/src/Infra/Adapters/Mappers/IncoCompositionMapper.php b/src/Infra/Adapters/Mappers/IncoCompositionMapper.php new file mode 100644 index 0000000..9abd3d5 --- /dev/null +++ b/src/Infra/Adapters/Mappers/IncoCompositionMapper.php @@ -0,0 +1,49 @@ +|object $row + */ + public static function toDomain(array | object $row): IncoComposition + { + /** @var array $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; + } +} diff --git a/src/Infra/Adapters/Mappers/IncoMapper.php b/src/Infra/Adapters/Mappers/IncoMapper.php new file mode 100644 index 0000000..875b247 --- /dev/null +++ b/src/Infra/Adapters/Mappers/IncoMapper.php @@ -0,0 +1,180 @@ +|object $row + */ + // phpcs:disable Generic.Metrics.CyclomaticComplexity + public function toDomain(array | object $row): IncoSheet + { + // phpcs:enable Generic.Metrics.CyclomaticComplexity + /** @var array $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 $raw + */ + private static function parseArrayIngredients(array $raw): Ingredients + { + /** @var array> $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; + } +} diff --git a/src/Infra/Adapters/Mappers/IncoShortMapper.php b/src/Infra/Adapters/Mappers/IncoShortMapper.php new file mode 100644 index 0000000..d9c3deb --- /dev/null +++ b/src/Infra/Adapters/Mappers/IncoShortMapper.php @@ -0,0 +1,77 @@ + */ + public const array SELECT_COLUMNS = [ + 'NO_ART', + 'DENOM_LEG', + 'DESCRIPTIF', + 'DAT_CRE', + 'DAT_MAJ', + ]; + + /** + * @param array|object $row + */ + // phpcs:disable Generic.Metrics.CyclomaticComplexity + public static function toDomain(array | object $row): IncoShortSheet + { + // phpcs:enable Generic.Metrics.CyclomaticComplexity + /** @var array $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; + } +} diff --git a/src/Infra/Adapters/Parsers/IncoCompositionParser.php b/src/Infra/Adapters/Parsers/IncoCompositionParser.php new file mode 100644 index 0000000..3d98b83 --- /dev/null +++ b/src/Infra/Adapters/Parsers/IncoCompositionParser.php @@ -0,0 +1,348 @@ + $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 $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 $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 $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 $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 $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 $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 $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 $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 $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 $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 $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 $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 $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 $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; + } +} diff --git a/src/Infra/Adapters/Parsers/IncoIngredientParser.php b/src/Infra/Adapters/Parsers/IncoIngredientParser.php new file mode 100644 index 0000000..492117a --- /dev/null +++ b/src/Infra/Adapters/Parsers/IncoIngredientParser.php @@ -0,0 +1,359 @@ + self::removeNonOpenedParentheses(self::fixParentheses($part . ')')), + $parts + ); + $ingredientString = implode('.', $fixedParts); + } + + // Tokenisation par '.' + $tokenizedString = self::buildTokenizedString($ingredientString); + + $actualKey = null; + /** @var array> $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> $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 $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 $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> + */ + 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 + */ + 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 $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 $tokens + * @return list + */ + 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; + } +} diff --git a/src/Infra/Adapters/Parsers/IncoParser.php b/src/Infra/Adapters/Parsers/IncoParser.php new file mode 100644 index 0000000..3a2c0d1 --- /dev/null +++ b/src/Infra/Adapters/Parsers/IncoParser.php @@ -0,0 +1,374 @@ + 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 + */ + // 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 + */ + // 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 + */ + 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; + } +} diff --git a/src/Infra/Adapters/Parsers/IncoStringParser.php b/src/Infra/Adapters/Parsers/IncoStringParser.php new file mode 100644 index 0000000..9a95319 --- /dev/null +++ b/src/Infra/Adapters/Parsers/IncoStringParser.php @@ -0,0 +1,256 @@ + to preserve paragraph breaks + $line = str_replace(["\r\n", "\r"], "\n", $line); + $line = str_replace("\n", '
', $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
back to newlines and final strip_tags as safety + $line = str_replace('
', "\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|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; + } +} diff --git a/tests/Unit/Infra/Adapters/Mappers/ArcadiaBrandMapperTest.php b/tests/Unit/Infra/Adapters/Mappers/ArcadiaBrandMapperTest.php new file mode 100644 index 0000000..ed82965 --- /dev/null +++ b/tests/Unit/Infra/Adapters/Mappers/ArcadiaBrandMapperTest.php @@ -0,0 +1,64 @@ + 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'); + }); +}); diff --git a/tests/Unit/Infra/Adapters/Mappers/EmployeeMapperTest.php b/tests/Unit/Infra/Adapters/Mappers/EmployeeMapperTest.php new file mode 100644 index 0000000..a30469d --- /dev/null +++ b/tests/Unit/Infra/Adapters/Mappers/EmployeeMapperTest.php @@ -0,0 +1,74 @@ + '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'); + }); +}); diff --git a/tests/Unit/Infra/Adapters/Mappers/IncoCompositionMapperTest.php b/tests/Unit/Infra/Adapters/Mappers/IncoCompositionMapperTest.php new file mode 100644 index 0000000..f33ccc9 --- /dev/null +++ b/tests/Unit/Infra/Adapters/Mappers/IncoCompositionMapperTest.php @@ -0,0 +1,64 @@ + 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'); + }); +}); diff --git a/tests/Unit/Infra/Adapters/Mappers/IncoMapperTest.php b/tests/Unit/Infra/Adapters/Mappers/IncoMapperTest.php new file mode 100644 index 0000000..5ea1486 --- /dev/null +++ b/tests/Unit/Infra/Adapters/Mappers/IncoMapperTest.php @@ -0,0 +1,78 @@ +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(); + }); +}); diff --git a/tests/Unit/Infra/Adapters/Mappers/IncoShortMapperTest.php b/tests/Unit/Infra/Adapters/Mappers/IncoShortMapperTest.php new file mode 100644 index 0000000..b9bb160 --- /dev/null +++ b/tests/Unit/Infra/Adapters/Mappers/IncoShortMapperTest.php @@ -0,0 +1,47 @@ + 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'); + }); +}); diff --git a/tests/Unit/Infra/Adapters/Parsers/IncoCompositionParserTest.php b/tests/Unit/Infra/Adapters/Parsers/IncoCompositionParserTest.php new file mode 100644 index 0000000..97f1ef5 --- /dev/null +++ b/tests/Unit/Infra/Adapters/Parsers/IncoCompositionParserTest.php @@ -0,0 +1,172 @@ + '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); + }); +}); diff --git a/tests/Unit/Infra/Adapters/Parsers/IncoIngredientParserTest.php b/tests/Unit/Infra/Adapters/Parsers/IncoIngredientParserTest.php new file mode 100644 index 0000000..a0169ef --- /dev/null +++ b/tests/Unit/Infra/Adapters/Parsers/IncoIngredientParserTest.php @@ -0,0 +1,70 @@ +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(); + }); +}); diff --git a/tests/Unit/Infra/Adapters/Parsers/IncoParserTest.php b/tests/Unit/Infra/Adapters/Parsers/IncoParserTest.php new file mode 100644 index 0000000..dae016c --- /dev/null +++ b/tests/Unit/Infra/Adapters/Parsers/IncoParserTest.php @@ -0,0 +1,158 @@ +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('

A conserver entre 0°C et +4°C

'); + + 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'); + }); + }); +}); diff --git a/tests/Unit/Infra/Adapters/Parsers/IncoStringParserTest.php b/tests/Unit/Infra/Adapters/Parsers/IncoStringParserTest.php new file mode 100644 index 0000000..46c52ec --- /dev/null +++ b/tests/Unit/Infra/Adapters/Parsers/IncoStringParserTest.php @@ -0,0 +1,97 @@ +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 = '

Poulet & Dinde

'; + 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); + }); + }); +});