diff --git a/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/config/DocumentIAConfig.java b/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/config/DocumentIAConfig.java
new file mode 100644
index 000000000..9245f47ac
--- /dev/null
+++ b/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/config/DocumentIAConfig.java
@@ -0,0 +1,16 @@
+package fr.dossierfacile.common.config;
+
+import fr.dossierfacile.common.entity.Document;
+import fr.dossierfacile.common.enums.DocumentSubCategory;
+import org.springframework.stereotype.Component;
+
+@Component
+public class DocumentIAConfig {
+
+ public boolean hasToSendFileForAnalysis(Document document) {
+ return
+ document.getDocumentSubCategory() == DocumentSubCategory.FRENCH_IDENTITY_CARD ||
+ document.getDocumentSubCategory() == DocumentSubCategory.SALARY;
+ }
+
+}
diff --git a/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/entity/DocumentAnalysisRule.java b/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/entity/DocumentAnalysisRule.java
index 78a9dc536..e570bfe13 100644
--- a/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/entity/DocumentAnalysisRule.java
+++ b/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/entity/DocumentAnalysisRule.java
@@ -1,5 +1,6 @@
package fr.dossierfacile.common.entity;
+import fr.dossierfacile.common.model.documentIA.GenericProperty;
import jakarta.persistence.Embeddable;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
@@ -8,6 +9,8 @@
import lombok.Data;
import lombok.NoArgsConstructor;
+import java.util.List;
+
@Embeddable
@Data
@Builder
@@ -24,19 +27,48 @@ public class DocumentAnalysisRule {
@Builder.Default
private DocumentRuleLevel level = DocumentRuleLevel.CRITICAL;
+ private List
expectedDatas;
+
+ private List extractedDatas;
+
public static DocumentAnalysisRule documentFailedRuleFrom(DocumentRule rule) {
return DocumentAnalysisRule.builder()
.rule(rule)
.message(rule.getFailedMessage())
+ .expectedDatas(List.of())
+ .extractedDatas(List.of())
.level(rule.getLevel())
.build();
}
+ public static DocumentAnalysisRule documentFailedRuleFromWithData(DocumentRule rule, List expectedDatas, List extractedDatas) {
+ return DocumentAnalysisRule.builder()
+ .rule(rule)
+ .message(rule.getPassedMessage())
+ .expectedDatas(expectedDatas)
+ .extractedDatas(extractedDatas)
+ .level(rule.getLevel())
+ .build();
+ }
+
+
public static DocumentAnalysisRule documentPassedRuleFrom(DocumentRule rule) {
return DocumentAnalysisRule.builder()
.rule(rule)
.message(rule.getPassedMessage())
+ .expectedDatas(List.of())
+ .extractedDatas(List.of())
+ .level(rule.getLevel())
+ .build();
+ }
+
+ public static DocumentAnalysisRule documentPassedRuleFromWithData(DocumentRule rule, List expectedDatas, List extractedDatas) {
+ return DocumentAnalysisRule.builder()
+ .rule(rule)
+ .message(rule.getPassedMessage())
+ .expectedDatas(expectedDatas)
+ .extractedDatas(extractedDatas)
.level(rule.getLevel())
.build();
}
@@ -45,6 +77,18 @@ public static DocumentAnalysisRule documentInconclusiveRuleFrom(DocumentRule rul
return DocumentAnalysisRule.builder()
.rule(rule)
.message(rule.getInconclusiveMessage())
+ .expectedDatas(List.of())
+ .extractedDatas(List.of())
+ .level(rule.getLevel())
+ .build();
+ }
+
+ public static DocumentAnalysisRule documentInconclusiveRuleFromWithData(DocumentRule rule, List expectedDatas) {
+ return DocumentAnalysisRule.builder()
+ .rule(rule)
+ .message(rule.getInconclusiveMessage())
+ .expectedDatas(expectedDatas)
+ .extractedDatas(List.of())
.level(rule.getLevel())
.build();
}
diff --git a/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/entity/DocumentIAFileAnalysis.java b/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/entity/DocumentIAFileAnalysis.java
new file mode 100644
index 000000000..f08718bf9
--- /dev/null
+++ b/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/entity/DocumentIAFileAnalysis.java
@@ -0,0 +1,67 @@
+package fr.dossierfacile.common.entity;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import fr.dossierfacile.common.enums.DocumentIAFileAnalysisStatus;
+import fr.dossierfacile.common.model.documentIA.ResultModel;
+import jakarta.persistence.*;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.hibernate.annotations.JdbcTypeCode;
+import org.hibernate.type.SqlTypes;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+@Data
+@Builder
+@Entity
+@Table(name = "document_ia_file_analysis")
+@AllArgsConstructor
+@NoArgsConstructor
+public class DocumentIAFileAnalysis implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 2405172041950251808L;
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @OneToOne(targetEntity = File.class, fetch = FetchType.LAZY)
+ @JoinColumn(name = "file_id")
+ private File file;
+
+ @Column(name = "document_ia_workflow_id", nullable = false)
+ private String documentIaWorkflowId;
+
+ @Column(name = "document_ia_execution_id", nullable = false)
+ private String documentIaExecutionId;
+
+ @Enumerated(EnumType.STRING)
+ private DocumentIAFileAnalysisStatus analysisStatus;
+
+ @JdbcTypeCode(SqlTypes.JSON)
+ @Column(name = "result", columnDefinition = "jsonb")
+ private ResultModel result;
+
+ @Column(name = "data_file_id")
+ private Long dataFileId;
+
+ @Column(name = "data_document_id")
+ private Long dataDocumentId;
+
+ @Override
+ public String toString() {
+ return "DocumentIAFileAnalysis{" +
+ "id=" + id +
+ ", file=" + (file != null ? file.getId() : null) +
+ ", documentIaWorkflowId='" + documentIaWorkflowId + '\'' +
+ ", documentIaExecutionId='" + documentIaExecutionId + '\'' +
+ ", analysisStatus=" + analysisStatus +
+ ", result=" + result +
+ '}';
+ }
+}
+
diff --git a/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/entity/DocumentRule.java b/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/entity/DocumentRule.java
index c19226c52..bb63b3ac3 100644
--- a/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/entity/DocumentRule.java
+++ b/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/entity/DocumentRule.java
@@ -183,6 +183,48 @@ public enum DocumentRule {
"Votre document semble flou",
"Votre document semble net et lisible",
""
+ ),
+ R_DOCUMENT_IA_ANALYSED(
+ DocumentRuleLevel.INFO,
+ "",
+ "Le document a été analysé par Document IA",
+ "Le document n'a pas pu être analysé par Document IA"
+ ),
+ R_DOCUMENT_IA_CLASSIFICATION(
+ DocumentRuleLevel.CRITICAL,
+ "Le document n'a pas été correctement classifié par Document IA",
+ "Le document a été correctement classifié par Document IA",
+ ""
+ ),
+ R_DOCUMENT_IA_OTHER_DOCUMENTS(
+ DocumentRuleLevel.WARN,
+ "Le document comprends des éléments inattendus",
+ "Tous les fichiers sont de la bonne catégorie",
+ ""
+ ),
+ R_FRENCH_IDENTITY_CARD_NAME_MATCH(
+ DocumentRuleLevel.CRITICAL,
+ "Le nom et le prénom sur la carte d'identité ne correspondent pas",
+ "Le nom et le prénom sur la carte d'identité correspondent",
+ "Impossible de vérifier le nom et le prénom sur la carte d'identité"
+ ),
+ R_FRENCH_IDENTITY_CARD_EXPIRATION(
+ DocumentRuleLevel.CRITICAL,
+ "La carte d'identité est expirée",
+ "La carte d'identité est valide",
+ "Impossible de vérifier la date de validité de la carte d'identité"
+ ),
+ R_PAYSLIP_CONTINUITY(
+ DocumentRuleLevel.CRITICAL,
+ "Les bulletins de salaire ne sont pas continus",
+ "Les bulletins de salaire sont continus",
+ "Impossible de vérifier la continuité des bulletins de salaire"
+ ),
+ R_PAYSLIP_NAME_MATCH(
+ DocumentRuleLevel.CRITICAL,
+ "Le nom et le prénom sur les bulletins de salaire ne correspondent pas",
+ "Le nom et le prénom sur les bulletins de salaire correspondent",
+ "Impossible de vérifier le nom et le prénom sur les bulletins de salaire"
);
private final DocumentRuleLevel level;
diff --git a/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/entity/File.java b/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/entity/File.java
index f78fcb17f..9b461f8d3 100644
--- a/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/entity/File.java
+++ b/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/entity/File.java
@@ -68,6 +68,10 @@ public class File implements Serializable {
@OneToOne(mappedBy = "file", fetch = FetchType.LAZY)
private BlurryFileAnalysis blurryFileAnalysis;
+ @Nullable
+ @OneToOne(mappedBy = "file", fetch = FetchType.LAZY)
+ private DocumentIAFileAnalysis documentIAFileAnalysis;
+
@Nullable
@OneToOne(cascade = {CascadeType.REMOVE}, mappedBy = "file", fetch = FetchType.LAZY)
private FileMetadata fileMetadata;
@@ -81,6 +85,9 @@ void deleteCascade() {
if (blurryFileAnalysis != null) {
blurryFileAnalysis.setFile(null);
}
+ if (documentIAFileAnalysis != null) {
+ documentIAFileAnalysis.setFile(null);
+ }
}
@Override
diff --git a/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/enums/DocumentIAFileAnalysisStatus.java b/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/enums/DocumentIAFileAnalysisStatus.java
new file mode 100644
index 000000000..caa49b92a
--- /dev/null
+++ b/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/enums/DocumentIAFileAnalysisStatus.java
@@ -0,0 +1,8 @@
+package fr.dossierfacile.common.enums;
+
+public enum DocumentIAFileAnalysisStatus {
+ STARTED,
+ SUCCESS,
+ FAILED
+}
+
diff --git a/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/model/documentIA/BarcodeModel.java b/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/model/documentIA/BarcodeModel.java
new file mode 100644
index 000000000..62122e0e4
--- /dev/null
+++ b/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/model/documentIA/BarcodeModel.java
@@ -0,0 +1,30 @@
+package fr.dossierfacile.common.model.documentIA;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import javax.annotation.Nullable;
+import java.util.List;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class BarcodeModel {
+ private BarcodePosition position;
+ private int page_number;
+ private String type;
+ @Nullable
+ private Boolean is_valid = null;
+ @JsonProperty("raw_data")
+ private Object rawData;
+ @JsonProperty("typed_data")
+ private List typedData;
+ @JsonProperty("ants_type")
+ private String antsType;
+}
diff --git a/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/model/documentIA/BarcodePosition.java b/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/model/documentIA/BarcodePosition.java
new file mode 100644
index 000000000..a633450e3
--- /dev/null
+++ b/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/model/documentIA/BarcodePosition.java
@@ -0,0 +1,24 @@
+package fr.dossierfacile.common.model.documentIA;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class BarcodePosition {
+ @JsonProperty("top_left")
+ private int[] topLeft;
+ @JsonProperty("top_right")
+ private int[] topRight;
+ @JsonProperty("bottom_left")
+ private int[] bottomLeft;
+ @JsonProperty("bottom_right")
+ private int[] bottomRight;
+}
diff --git a/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/model/documentIA/ClassificationModel.java b/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/model/documentIA/ClassificationModel.java
new file mode 100644
index 000000000..5c0cdd7b2
--- /dev/null
+++ b/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/model/documentIA/ClassificationModel.java
@@ -0,0 +1,19 @@
+package fr.dossierfacile.common.model.documentIA;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class ClassificationModel {
+ @JsonProperty("document_type")
+ private String documentType;
+ private String explanation;
+}
diff --git a/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/model/documentIA/ExtractionModel.java b/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/model/documentIA/ExtractionModel.java
new file mode 100644
index 000000000..8f16598ba
--- /dev/null
+++ b/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/model/documentIA/ExtractionModel.java
@@ -0,0 +1,19 @@
+package fr.dossierfacile.common.model.documentIA;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class ExtractionModel {
+ private String type;
+ private List properties;
+}
diff --git a/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/model/documentIA/GenericProperty.java b/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/model/documentIA/GenericProperty.java
new file mode 100644
index 000000000..48fe66139
--- /dev/null
+++ b/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/model/documentIA/GenericProperty.java
@@ -0,0 +1,62 @@
+package fr.dossierfacile.common.model.documentIA;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.Date;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class GenericProperty {
+ private String name;
+
+ private Object value;
+
+ private String type;
+
+ @JsonIgnore
+ public String getStringValue() {
+ if (!"string".equals(type)) {
+ throw new IllegalStateException("Property type is not string");
+ }
+ return (String) value;
+ }
+
+ @JsonIgnore
+ public LocalDate getDateValue() {
+ if (!"date".equals(type)) {
+ throw new IllegalStateException("Property type is not date");
+ }
+
+ if (value == null) {
+ return null;
+ }
+
+ if (value instanceof String || value instanceof CharSequence) {
+ String text = value.toString().trim();
+ if (text.isEmpty()) {
+ return null;
+ }
+ try {
+ return LocalDate.parse(text, DateTimeFormatter.ISO_LOCAL_DATE);
+ } catch (DateTimeParseException e) {
+ throw new IllegalArgumentException("Invalid date format for property '" + name + "': '" + text + "'. Expected format: YYYY-MM-DD", e);
+ }
+ } else if (value instanceof Date date) {
+ return date.toInstant().atZone(ZoneId.of("UTC")).toLocalDate();
+ } else {
+ throw new IllegalArgumentException("Unsupported value type for date property '" + name + "': " + value.getClass());
+ }
+ }
+}
\ No newline at end of file
diff --git a/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/model/documentIA/ResultModel.java b/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/model/documentIA/ResultModel.java
new file mode 100644
index 000000000..c273c7b6c
--- /dev/null
+++ b/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/model/documentIA/ResultModel.java
@@ -0,0 +1,20 @@
+package fr.dossierfacile.common.model.documentIA;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class ResultModel {
+ private ClassificationModel classification;
+ private ExtractionModel extraction;
+ private List barcodes;
+}
diff --git a/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/repository/DocumentIAFileAnalysisRepository.java b/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/repository/DocumentIAFileAnalysisRepository.java
new file mode 100644
index 000000000..a7ddd8e12
--- /dev/null
+++ b/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/repository/DocumentIAFileAnalysisRepository.java
@@ -0,0 +1,13 @@
+package fr.dossierfacile.common.repository;
+
+import fr.dossierfacile.common.entity.DocumentAnalysisReport;
+import fr.dossierfacile.common.entity.DocumentIAFileAnalysis;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+
+public interface DocumentIAFileAnalysisRepository extends JpaRepository {
+
+ Optional findByDocumentIaExecutionId(String id);
+
+}
\ No newline at end of file
diff --git a/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/utils/IDocumentIdentity.java b/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/utils/IDocumentIdentity.java
new file mode 100644
index 000000000..2e1794760
--- /dev/null
+++ b/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/utils/IDocumentIdentity.java
@@ -0,0 +1,11 @@
+package fr.dossierfacile.common.utils;
+
+import java.util.List;
+
+public interface IDocumentIdentity {
+ List getFirstNames();
+
+ String getLastName();
+
+ String getPreferredName();
+}
diff --git a/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/utils/NameUtil.java b/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/utils/NameUtil.java
new file mode 100644
index 000000000..9bd9eb734
--- /dev/null
+++ b/dossierfacile-common-library/src/main/java/fr/dossierfacile/common/utils/NameUtil.java
@@ -0,0 +1,119 @@
+package fr.dossierfacile.common.utils;
+
+import javax.annotation.Nullable;
+import java.text.Normalizer;
+import java.util.List;
+import java.util.Locale;
+import java.util.regex.Pattern;
+
+public class NameUtil {
+
+ // Pattern pour identifier les caractères diacritiques (les accents)
+ private static final Pattern DIACRITICS_AND_FRIENDS = Pattern.compile("\\p{M}");
+
+ // Pattern pour identifier tout ce qui N'EST PAS une lettre (A-Z)
+ private static final Pattern NON_ALPHABETIC = Pattern.compile("[^a-zA-Z]");
+
+ public static String normalizeName(String name) {
+ if (name == null)
+ return null;
+ String normalized = Normalizer.normalize(name, Normalizer.Form.NFD);
+ return normalized.replace('-', ' ')
+ .replaceAll("[\\p{InCombiningDiacriticalMarks}]", "").toUpperCase();
+ }
+
+
+ public static String sanitizeForComparison(@Nullable String input) {
+ if (input == null || input.isBlank()) {
+ return "";
+ }
+
+ // 1. Remplacements manuels pour les caractères qui ne se décomposent pas avec NFD
+ String preprocessed = input
+ // Caractères scandinaves
+ .replace("ø", "o").replace("Ø", "O")
+ .replace("å", "a").replace("Å", "A")
+ .replace("æ", "ae").replace("Æ", "AE")
+ // Caractères slaves
+ .replace("ł", "l").replace("Ł", "L")
+ // Caractères allemands
+ .replace("ß", "ss")
+ // Caractères islandais
+ .replace("ð", "d").replace("Ð", "D")
+ .replace("þ", "th").replace("Þ", "TH");
+
+ // 2. Décomposition canonique (NFD) : sépare 'é' en 'e' + 'accent aigu'
+ String normalized = Normalizer.normalize(preprocessed, Normalizer.Form.NFD);
+
+ // 3. Suppression des marques diacritiques (les accents flottants)
+ String withoutAccents = DIACRITICS_AND_FRIENDS.matcher(normalized).replaceAll("");
+
+ // 4. Suppression des caractères spéciaux et mise en majuscule
+ // On retire ici les espaces, tirets, etc. pour coller les noms
+ return NON_ALPHABETIC.matcher(withoutAccents).replaceAll("").toUpperCase(Locale.ROOT);
+ }
+
+ public static boolean isNameMatching(IDocumentIdentity document, IDocumentIdentity extractedIdentity) {
+ List documentSanitizedFirstNames = document.getFirstNames().stream()
+ .map(NameUtil::sanitizeForComparison)
+ .toList();
+ String documentSanitizedLastName = sanitizeForComparison(document.getLastName());
+ String documentSanitizedPreferredName = document.getPreferredName() != null ?
+ sanitizeForComparison(document.getPreferredName()) : null;
+
+ List extractedSanitizedFirstNames = extractedIdentity.getFirstNames().stream()
+ .map(NameUtil::sanitizeForComparison)
+ .toList();
+ String extractedSanitizedLastName = sanitizeForComparison(extractedIdentity.getLastName());
+ String extractedSanitizedPreferredName = extractedIdentity.getPreferredName() != null ?
+ sanitizeForComparison(extractedIdentity.getPreferredName()) : null;
+
+ boolean hasMatchingFirstName = hasMatchingFirstName(documentSanitizedFirstNames, extractedSanitizedFirstNames);
+ boolean hasMatchingLastName = hasMatchingLastName(
+ documentSanitizedLastName,
+ documentSanitizedPreferredName,
+ extractedSanitizedLastName,
+ extractedSanitizedPreferredName
+ );
+
+ return hasMatchingLastName && hasMatchingFirstName;
+ }
+
+ private static boolean hasMatchingFirstName(List documentFirstNames, List extractedFirstNames) {
+ // un prénom du document est contenu dans un prénom extrait ou l'inverse
+ return documentFirstNames.stream().anyMatch(
+ docFirst -> extractedFirstNames.stream()
+ .anyMatch(extFirst -> !docFirst.isEmpty() && !extFirst.isEmpty() &&
+ (docFirst.contains(extFirst) || extFirst.contains(docFirst))
+ )
+ ) || extractedFirstNames.stream().anyMatch(
+ extFirst -> documentFirstNames.stream()
+ .anyMatch(docFirst -> !docFirst.isEmpty() && !extFirst.isEmpty() &&
+ (docFirst.contains(extFirst) || extFirst.contains(docFirst))
+ )
+ );
+ }
+
+ private static boolean hasMatchingLastName(String documentLastName,
+ @Nullable String documentPreferredName,
+ String extractedLastName,
+ @Nullable String extractedPreferredName) {
+ boolean hasMatchingLastName = documentLastName.contains(extractedLastName)
+ || extractedLastName.contains(documentLastName);
+
+ if (documentPreferredName != null &&
+ (documentPreferredName.contains(extractedLastName)
+ || (extractedPreferredName != null && documentPreferredName.contains(extractedPreferredName)))) {
+ hasMatchingLastName = true;
+ }
+
+ if (extractedPreferredName != null &&
+ (extractedPreferredName.contains(documentLastName)
+ || (documentPreferredName != null && extractedPreferredName.contains(documentPreferredName)))) {
+ hasMatchingLastName = true;
+ }
+
+ return hasMatchingLastName;
+ }
+
+}
diff --git a/dossierfacile-common-library/src/main/resources/db/changelog/databaseChangeLog.xml b/dossierfacile-common-library/src/main/resources/db/changelog/databaseChangeLog.xml
index 625f74464..3085b6307 100644
--- a/dossierfacile-common-library/src/main/resources/db/changelog/databaseChangeLog.xml
+++ b/dossierfacile-common-library/src/main/resources/db/changelog/databaseChangeLog.xml
@@ -175,4 +175,5 @@
+
diff --git a/dossierfacile-common-library/src/main/resources/db/migration/202511250000-create-document-ia-file-analysis.xml b/dossierfacile-common-library/src/main/resources/db/migration/202511250000-create-document-ia-file-analysis.xml
new file mode 100644
index 000000000..fe66df55c
--- /dev/null
+++ b/dossierfacile-common-library/src/main/resources/db/migration/202511250000-create-document-ia-file-analysis.xml
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dossierfacile-common-library/src/test/java/fr/dossierfacile/common/utils/NameUtilTest.java b/dossierfacile-common-library/src/test/java/fr/dossierfacile/common/utils/NameUtilTest.java
new file mode 100644
index 000000000..45fe5a288
--- /dev/null
+++ b/dossierfacile-common-library/src/test/java/fr/dossierfacile/common/utils/NameUtilTest.java
@@ -0,0 +1,202 @@
+package fr.dossierfacile.common.utils;
+
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class NameUtilTest {
+
+ @ParameterizedTest(name = "[{index}] sanitizeForComparison(''{0}'') should return ''{1}''")
+ @CsvSource({
+ // Cas avec accents
+ "Dupont, DUPONT",
+ "Dupönt, DUPONT",
+ "Élise, ELISE",
+ "François, FRANCOIS",
+ "José, JOSE",
+ "Gérard, GERARD",
+ "Hélène, HELENE",
+ "Jérôme, JEROME",
+ "Zoë, ZOE",
+ "Noël, NOEL",
+
+ // Cas avec tirets et espaces
+ "Jean-Pierre, JEANPIERRE",
+ "Marie-Claire, MARIECLAIRE",
+ "De La Cruz, DELACRUZ",
+ "Van Der Berg, VANDERBERG",
+ "Le Goff, LEGOFF",
+
+ // Cas avec apostrophes
+ "O'Brien, OBRIEN",
+ "D'Angelo, DANGELO",
+
+ // Cas avec caractères spéciaux
+ "Smith-Jones, SMITHJONES",
+ "Müller, MULLER",
+ "Søren, SOREN",
+ "Łukasz, LUKASZ",
+
+ // Cas mixtes complexes
+ "Jean-François D'été, JEANFRANCOISDETE",
+ "Marie-Hélène De Saint-Exupéry, MARIEHELENEDESAINTEXUPERY",
+
+ // Cas avec espaces multiples
+ "John Doe, JOHNDOE",
+
+ // Cas edge
+ "'', ''",
+ " , ''",
+ "123, ''",
+ "a, A",
+
+ // Cas réels français
+ "François-Xavier, FRANCOISXAVIER",
+ "Anne-Sophie, ANNESOPHIE",
+ "Bérénice, BERENICE",
+ "Cécile, CECILE",
+ })
+ void testSanitizeForComparison(String input, String expected) {
+ String result = NameUtil.sanitizeForComparison(input);
+ assertThat(result).isEqualTo(expected);
+ }
+
+ private static java.util.stream.Stream provideNameMatchingTestCases() {
+ return java.util.stream.Stream.of(
+ Arguments.of(
+ new TestDocumentIdentity(List.of("Jean", "Pierre"), "Dupont"),
+ new TestDocumentIdentity(List.of("Jean", "Pierre"), "Dupont"),
+ true,
+ "Exact match - Nom et prénoms identiques"
+ ),
+ Arguments.of(
+ new TestDocumentIdentity(List.of("François"), "Dupont"),
+ new TestDocumentIdentity(List.of("Francois"), "Dupont"),
+ true,
+ "Match avec accent - François vs Francois"
+ ),
+ Arguments.of(
+ new TestDocumentIdentity(List.of("Jean-Pierre"), "Martin"),
+ new TestDocumentIdentity(List.of("JeanPierre"), "Martin"),
+ true,
+ "Match avec tiret - Jean-Pierre vs JeanPierre"
+ ),
+ Arguments.of(
+ new TestDocumentIdentity(List.of("Jean", "Pierre", "Paul"), "Dupont"),
+ new TestDocumentIdentity(List.of("Pierre"), "Dupont"),
+ true,
+ "Match avec un prénom commun parmi plusieurs"
+ ),
+ Arguments.of(
+ new TestDocumentIdentity(List.of("Marie"), "Dupont", "Martin"),
+ new TestDocumentIdentity(List.of("Marie"), "Martin"),
+ true,
+ "Match avec nom d'usage - preferredName Martin matche avec lastName Martin"
+ ),
+ Arguments.of(
+ new TestDocumentIdentity(List.of("Marie"), "Martin"),
+ new TestDocumentIdentity(List.of("Marie"), "Dupont", "Martin"),
+ true,
+ "Match avec nom d'usage extraction - lastName Martin matche avec preferredName Martin"
+ ),
+ Arguments.of(
+ new TestDocumentIdentity(List.of("Anne", "Sophie"), "De La Cruz"),
+ new TestDocumentIdentity(List.of("Anne"), "DeLaCruz"),
+ true,
+ "Match avec nom composé - De La Cruz vs DeLaCruz"
+ ),
+ Arguments.of(
+ new TestDocumentIdentity(List.of("Søren"), "Larsen"),
+ new TestDocumentIdentity(List.of("Soren"), "Larsen"),
+ true,
+ "Match avec caractère scandinave - Søren vs Soren"
+ ),
+ Arguments.of(
+ new TestDocumentIdentity(List.of("Łukasz"), "Kowalski"),
+ new TestDocumentIdentity(List.of("Lukasz"), "Kowalski"),
+ true,
+ "Match avec caractère slave - Łukasz vs Lukasz"
+ ),
+ Arguments.of(
+ new TestDocumentIdentity(List.of("Jean"), "Dupont"),
+ new TestDocumentIdentity(List.of("Pierre"), "Dupont"),
+ false,
+ "Pas de match - Prénoms différents"
+ ),
+ Arguments.of(
+ new TestDocumentIdentity(List.of("Jean"), "Dupont"),
+ new TestDocumentIdentity(List.of("Jean"), "Martin"),
+ false,
+ "Pas de match - Noms de famille différents"
+ ),
+ Arguments.of(
+ new TestDocumentIdentity(List.of("Jean"), "Dupont"),
+ new TestDocumentIdentity(List.of("Pierre"), "Martin"),
+ false,
+ "Pas de match - Prénom et nom différents"
+ ),
+ Arguments.of(
+ new TestDocumentIdentity(List.of("Marie"), "Dupont-Martin"),
+ new TestDocumentIdentity(List.of("Marie"), "Dupont"),
+ true,
+ "Match avec substring - Dupont-Martin contient Dupont"
+ ),
+ Arguments.of(
+ new TestDocumentIdentity(List.of("JEAN"), "DUPONT"),
+ new TestDocumentIdentity(List.of("jean"), "dupont"),
+ true,
+ "Match insensible à la casse"
+ ),
+ Arguments.of(
+ new TestDocumentIdentity(List.of("Jean Pierre"), "Du Pont"),
+ new TestDocumentIdentity(List.of("JeanPierre"), "DuPont"),
+ true,
+ "Match avec espaces multiples ignorés"
+ ),
+ Arguments.of(
+ new TestDocumentIdentity(List.of("Marie", "Hélène"), "Dupont", "De Saint-Exupéry"),
+ new TestDocumentIdentity(List.of("Marie"), "DeSaintExupery"),
+ true,
+ "Match avec nom d'usage composé et accents"
+ ),
+ Arguments.of(
+ new TestDocumentIdentity(List.of("Romaric"), "HALDENWANG"),
+ new TestDocumentIdentity(List.of("Romaric"), "HALDENWANG-TOURNESAC"),
+ true,
+ "Match Romaric 1"
+ ),
+ Arguments.of(
+ new TestDocumentIdentity(List.of("Romaric"), "HALDENWANG-TOURNESAC"),
+ new TestDocumentIdentity(List.of("Romaric"), "HALDENWANG"),
+ true,
+ "Match Romaric 2"
+ ),
+ Arguments.of(
+ new TestDocumentIdentity(List.of("Romaric"), "TEST", "HALDENWANG-TOURNESAC"),
+ new TestDocumentIdentity(List.of("Romaric"), "HALDENWANG"),
+ true,
+ "Match Romaric 3"
+ ),
+ Arguments.of(
+ new TestDocumentIdentity(List.of("Romaric"), "TEST"),
+ new TestDocumentIdentity(List.of("Romaric"), "HALDENWANG", "TEST"),
+ true,
+ "Match Romaric 4"
+ )
+ );
+ }
+
+ @ParameterizedTest(name = "[{index}] {3}")
+ @org.junit.jupiter.params.provider.MethodSource("provideNameMatchingTestCases")
+ void testIsNameMatching(TestDocumentIdentity document, TestDocumentIdentity extracted, boolean expectedMatch, String description) {
+ boolean result = NameUtil.isNameMatching(document, extracted);
+ assertThat(result)
+ .as(description)
+ .isEqualTo(expectedMatch);
+ }
+
+}
diff --git a/dossierfacile-common-library/src/test/java/fr/dossierfacile/common/utils/TestDocumentIdentity.java b/dossierfacile-common-library/src/test/java/fr/dossierfacile/common/utils/TestDocumentIdentity.java
new file mode 100644
index 000000000..258029937
--- /dev/null
+++ b/dossierfacile-common-library/src/test/java/fr/dossierfacile/common/utils/TestDocumentIdentity.java
@@ -0,0 +1,37 @@
+package fr.dossierfacile.common.utils;
+
+import java.util.List;
+
+public class TestDocumentIdentity implements IDocumentIdentity {
+
+ private final List firstNames;
+ private final String lastName;
+ private final String preferredName;
+
+ public TestDocumentIdentity(List firstNames, String lastName, String preferredName) {
+ this.firstNames = firstNames;
+ this.lastName = lastName;
+ this.preferredName = preferredName;
+ }
+
+ public TestDocumentIdentity(List firstNames, String lastName) {
+ this.firstNames = firstNames;
+ this.lastName = lastName;
+ this.preferredName = null;
+ }
+
+ @Override
+ public List getFirstNames() {
+ return firstNames;
+ }
+
+ @Override
+ public String getLastName() {
+ return lastName;
+ }
+
+ @Override
+ public String getPreferredName() {
+ return preferredName;
+ }
+}
diff --git a/dossierfacile-process-file/src/main/java/fr/dossierfacile/process/file/amqp/AnalyzeDocumentReceiver.java b/dossierfacile-process-file/src/main/java/fr/dossierfacile/process/file/amqp/AnalyzeDocumentReceiver.java
deleted file mode 100644
index a701ab622..000000000
--- a/dossierfacile-process-file/src/main/java/fr/dossierfacile/process/file/amqp/AnalyzeDocumentReceiver.java
+++ /dev/null
@@ -1,58 +0,0 @@
-package fr.dossierfacile.process.file.amqp;
-
-import fr.dossierfacile.common.entity.messaging.QueueName;
-import fr.dossierfacile.common.service.interfaces.QueueMessageService;
-import fr.dossierfacile.common.utils.JobContextUtil;
-import fr.dossierfacile.logging.job.LogAggregator;
-import fr.dossierfacile.process.file.service.AnalyzeDocumentService;
-import jakarta.annotation.PostConstruct;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.stereotype.Service;
-
-import java.util.concurrent.Executors;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.TimeUnit;
-
-@Service
-@Slf4j
-@RequiredArgsConstructor
-public class AnalyzeDocumentReceiver {
- private final AnalyzeDocumentService analyzeDocumentService;
- private final QueueMessageService queueMessageService;
- private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
- private final LogAggregator logAggregator;
- @Value("${document.analysis.delay.ms}")
- private Long documentAnalysisDelay;
- @Value("${document.analysis.timeout.ms}")
- private Long documentAnalysisTimeout;
-
- @PostConstruct
- public void startConsumer() {
- scheduler.scheduleAtFixedRate(this::receiveDocument, 0, 2, TimeUnit.SECONDS);
- }
-
- private void receiveDocument() {
- try {
- queueMessageService.consume(QueueName.QUEUE_DOCUMENT_ANALYSIS,
- documentAnalysisDelay,
- documentAnalysisTimeout,
- (message) -> {
- log.info("Received {} to process : {}", ActionType.ANALYZE_DOCUMENT.name(), message.getFileId());
- analyzeDocumentService.processDocument(message.getDocumentId());
- }, (jobContext) -> {
- log.info("Ending processing");
- logAggregator.sendWorkerLogs(
- jobContext.getProcessId(),
- ActionType.ANALYZE_DOCUMENT.name(),
- jobContext.getStartTime(),
- JobContextUtil.prepareJobAttributes(jobContext)
- );
- });
- } catch (Exception e) {
- log.error("Unable to consume the message queue");
- }
- }
-
-}
diff --git a/dossierfacile-process-file/src/main/java/fr/dossierfacile/process/file/service/AnalyzeDocumentService.java b/dossierfacile-process-file/src/main/java/fr/dossierfacile/process/file/service/AnalyzeDocumentService.java
deleted file mode 100644
index 98cefa6ef..000000000
--- a/dossierfacile-process-file/src/main/java/fr/dossierfacile/process/file/service/AnalyzeDocumentService.java
+++ /dev/null
@@ -1,127 +0,0 @@
-package fr.dossierfacile.process.file.service;
-
-import fr.dossierfacile.common.entity.*;
-import fr.dossierfacile.common.entity.messaging.QueueMessageStatus;
-import fr.dossierfacile.common.entity.messaging.QueueName;
-import fr.dossierfacile.common.exceptions.RetryableOperationException;
-import fr.dossierfacile.common.repository.DocumentAnalysisReportRepository;
-import fr.dossierfacile.common.repository.QueueMessageRepository;
-import fr.dossierfacile.process.file.repository.DocumentRepository;
-import fr.dossierfacile.process.file.service.document_rules.AbstractRulesValidationService;
-import fr.dossierfacile.process.file.service.document_rules.DocumentRulesValidationServiceFactory;
-import lombok.AllArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.apache.commons.collections4.CollectionUtils;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
-
-import java.time.LocalDateTime;
-import java.util.LinkedList;
-import java.util.List;
-import java.util.Objects;
-import java.util.Optional;
-
-@Slf4j
-@Service
-@AllArgsConstructor
-public class AnalyzeDocumentService {
- private final DocumentRepository documentRepository;
- private final DocumentAnalysisReportRepository documentAnalysisReportRepository;
- private final DocumentRulesValidationServiceFactory documentRulesValidationServiceFactory;
- private final QueueMessageRepository queueMessageRepository;
-
- @Transactional
- public void processDocument(Long documentId) throws RetryableOperationException {
- Document document = documentRepository.findById(documentId).orElse(null);
- if (document == null) {
- log.info("Document {} does not exist anymore", documentId);
- return;
- }
- if (hasBeenAnalysed(document)) {
- log.info("Ignoring document {} because it has already been analysed", documentId);
- return;
- }
- // before to analyze checks if a child file analysis is currently pending/processing
- if (!readyToBeAnalysed(document)) {
- throw new RetryableOperationException("Not yet ready to be analysed");
- }
-
- try {
- List rulesValidationServices = documentRulesValidationServiceFactory.getServices(document);
- if (!CollectionUtils.isEmpty(rulesValidationServices)) {
- Optional.ofNullable(document.getDocumentAnalysisReport()).ifPresent((report) -> {
- document.setDocumentAnalysisReport(null);
- documentAnalysisReportRepository.delete(report);
- });
-
- DocumentAnalysisReport report = DocumentAnalysisReport.builder()
- .document(document)
- .dataDocumentId(documentId)
- .failedRules(new LinkedList<>())
- .passedRules(new LinkedList<>())
- .inconclusiveRules(new LinkedList<>())
- .createdAt(LocalDateTime.now())
- .build();
-
- rulesValidationServices.forEach(rulesService -> rulesService.process(document, report));
-
- computeDocumentAnalysisReportStatus(report);
-
- document.setDocumentAnalysisReport(report);
- documentAnalysisReportRepository.save(report);
- documentRepository.save(document);// necessaire?
- }
-
- } catch (Exception e) {
- log.error("Unable to build report", e);
- throw e;
- }
- }
-
- private boolean readyToBeAnalysed(Document document) {
- // checks if a child file analysis is currently pending/processing
- List> messages = queueMessageRepository.findByQueueNameAndDocumentIdAndStatusIn(QueueName.QUEUE_FILE_ANALYSIS,
- document.getId(),
- List.of(QueueMessageStatus.PENDING, QueueMessageStatus.PROCESSING));
- List blurryAnalysis = document.getFiles().stream().map(File::getBlurryFileAnalysis).filter(Objects::nonNull).toList();
- blurryAnalysis.forEach(blurryAnalysis1 -> log.info("Found blurry file analysis : {}", blurryAnalysis1));
- if (blurryAnalysis.size() != document.getFiles().size()) {
- log.info("Document {} is not ready to be analysed because it has {} files with blurry analysis, but {} files in total",
- document.getId(), blurryAnalysis.size(), document.getFiles().size());
- return false;
- }
- return CollectionUtils.isEmpty(messages);
- }
-
- private void computeDocumentAnalysisReportStatus(DocumentAnalysisReport documentAnalysisReport) {
- // This will happen if there was an exception during the analysis
- if (documentAnalysisReport.getAnalysisStatus() == DocumentAnalysisStatus.UNDEFINED) {
- return;
- }
-
- // If there is at least one critical failed rule, the analysis statis will be DENIED (This is temporary until we are confident with the blurry algorithms)
- var criticalFailedRules = documentAnalysisReport.getFailedRules().stream()
- .filter(rule -> rule.getLevel() == DocumentRuleLevel.CRITICAL)
- .toList();
-
- if (CollectionUtils.isNotEmpty(criticalFailedRules)) {
- documentAnalysisReport.setAnalysisStatus(DocumentAnalysisStatus.DENIED);
- return;
- }
-
- // Other wise we check if the analysis is inclusive or not
- // If there is at least one inconclusive rule, the analysis status is UNDEFINED
- if (CollectionUtils.isNotEmpty(documentAnalysisReport.getInconclusiveRules())) {
- documentAnalysisReport.setAnalysisStatus(DocumentAnalysisStatus.UNDEFINED);
- return;
- }
-
- // Other wise it means that the analysis is Checked
- documentAnalysisReport.setAnalysisStatus(DocumentAnalysisStatus.CHECKED);
- }
-
- private boolean hasBeenAnalysed(Document document) {
- return document.getDocumentAnalysisReport() != null;
- }
-
-}
\ No newline at end of file
diff --git a/dossierfacile-process-file/src/main/java/fr/dossierfacile/process/file/util/IDocumentIdentity.java b/dossierfacile-process-file/src/main/java/fr/dossierfacile/process/file/util/IDocumentIdentity.java
new file mode 100644
index 000000000..6a07e7266
--- /dev/null
+++ b/dossierfacile-process-file/src/main/java/fr/dossierfacile/process/file/util/IDocumentIdentity.java
@@ -0,0 +1,11 @@
+package fr.dossierfacile.process.file.util;
+
+import java.util.List;
+
+public interface IDocumentIdentity {
+ List getFirstNames();
+
+ String getLastName();
+
+ String getPreferredName();
+}
diff --git a/dossierfacile-process-file/src/main/java/fr/dossierfacile/process/file/util/NameUtil.java b/dossierfacile-process-file/src/main/java/fr/dossierfacile/process/file/util/NameUtil.java
index 2d42f70f3..983c526d2 100644
--- a/dossierfacile-process-file/src/main/java/fr/dossierfacile/process/file/util/NameUtil.java
+++ b/dossierfacile-process-file/src/main/java/fr/dossierfacile/process/file/util/NameUtil.java
@@ -1,8 +1,19 @@
package fr.dossierfacile.process.file.util;
+import javax.annotation.Nullable;
import java.text.Normalizer;
+import java.util.List;
+import java.util.Locale;
+import java.util.regex.Pattern;
public class NameUtil {
+
+ // Pattern pour identifier les caractères diacritiques (les accents)
+ private static final Pattern DIACRITICS_AND_FRIENDS = Pattern.compile("\\p{M}");
+
+ // Pattern pour identifier tout ce qui N'EST PAS une lettre (A-Z)
+ private static final Pattern NON_ALPHABETIC = Pattern.compile("[^a-zA-Z]");
+
public static String normalizeName(String name) {
if (name == null)
return null;
@@ -11,4 +22,98 @@ public static String normalizeName(String name) {
.replaceAll("[\\p{InCombiningDiacriticalMarks}]", "").toUpperCase();
}
+
+ public static String sanitizeForComparison(@Nullable String input) {
+ if (input == null || input.isBlank()) {
+ return "";
+ }
+
+ // 1. Remplacements manuels pour les caractères qui ne se décomposent pas avec NFD
+ String preprocessed = input
+ // Caractères scandinaves
+ .replace("ø", "o").replace("Ø", "O")
+ .replace("å", "a").replace("Å", "A")
+ .replace("æ", "ae").replace("Æ", "AE")
+ // Caractères slaves
+ .replace("ł", "l").replace("Ł", "L")
+ // Caractères allemands
+ .replace("ß", "ss")
+ // Caractères islandais
+ .replace("ð", "d").replace("Ð", "D")
+ .replace("þ", "th").replace("Þ", "TH");
+
+ // 2. Décomposition canonique (NFD) : sépare 'é' en 'e' + 'accent aigu'
+ String normalized = Normalizer.normalize(preprocessed, Normalizer.Form.NFD);
+
+ // 3. Suppression des marques diacritiques (les accents flottants)
+ String withoutAccents = DIACRITICS_AND_FRIENDS.matcher(normalized).replaceAll("");
+
+ // 4. Suppression des caractères spéciaux et mise en majuscule
+ // On retire ici les espaces, tirets, etc. pour coller les noms
+ return NON_ALPHABETIC.matcher(withoutAccents).replaceAll("").toUpperCase(Locale.ROOT);
+ }
+
+ public static boolean isNameMatching(IDocumentIdentity document, IDocumentIdentity extractedIdentity) {
+ List documentSanitizedFirstNames = document.getFirstNames().stream()
+ .map(NameUtil::sanitizeForComparison)
+ .toList();
+ String documentSanitizedLastName = sanitizeForComparison(document.getLastName());
+ String documentSanitizedPreferredName = document.getPreferredName() != null ?
+ sanitizeForComparison(document.getPreferredName()) : null;
+
+ List extractedSanitizedFirstNames = extractedIdentity.getFirstNames().stream()
+ .map(NameUtil::sanitizeForComparison)
+ .toList();
+ String extractedSanitizedLastName = sanitizeForComparison(extractedIdentity.getLastName());
+ String extractedSanitizedPreferredName = extractedIdentity.getPreferredName() != null ?
+ sanitizeForComparison(extractedIdentity.getPreferredName()) : null;
+
+ boolean hasMatchingFirstName = hasMatchingFirstName(documentSanitizedFirstNames, extractedSanitizedFirstNames);
+ boolean hasMatchingLastName = hasMatchingLastName(
+ documentSanitizedLastName,
+ documentSanitizedPreferredName,
+ extractedSanitizedLastName,
+ extractedSanitizedPreferredName
+ );
+
+ return hasMatchingLastName && hasMatchingFirstName;
+ }
+
+ private static boolean hasMatchingFirstName(List documentFirstNames, List extractedFirstNames) {
+ // un prénom du document est contenu dans un prénom extrait ou l'inverse
+ return documentFirstNames.stream().anyMatch(
+ docFirst -> extractedFirstNames.stream()
+ .anyMatch(extFirst -> !docFirst.isEmpty() && !extFirst.isEmpty() &&
+ (docFirst.contains(extFirst) || extFirst.contains(docFirst))
+ )
+ ) || extractedFirstNames.stream().anyMatch(
+ extFirst -> documentFirstNames.stream()
+ .anyMatch(docFirst -> !docFirst.isEmpty() && !extFirst.isEmpty() &&
+ (docFirst.contains(extFirst) || extFirst.contains(docFirst))
+ )
+ );
+ }
+
+ private static boolean hasMatchingLastName(String documentLastName,
+ @Nullable String documentPreferredName,
+ String extractedLastName,
+ @Nullable String extractedPreferredName) {
+ boolean hasMatchingLastName = documentLastName.contains(extractedLastName)
+ || extractedLastName.contains(documentLastName);
+
+ if (documentPreferredName != null &&
+ (documentPreferredName.contains(extractedLastName)
+ || (extractedPreferredName != null && documentPreferredName.contains(extractedPreferredName)))) {
+ hasMatchingLastName = true;
+ }
+
+ if (extractedPreferredName != null &&
+ (extractedPreferredName.contains(documentLastName)
+ || (documentPreferredName != null && extractedPreferredName.contains(documentPreferredName)))) {
+ hasMatchingLastName = true;
+ }
+
+ return hasMatchingLastName;
+ }
+
}
diff --git a/dossierfacile-process-file/src/test/java/fr/dossierfacile/process/file/service/AnalyzeDocumentServiceTest.java b/dossierfacile-process-file/src/test/java/fr/dossierfacile/process/file/service/AnalyzeDocumentServiceTest.java
deleted file mode 100644
index 5ac95b5f0..000000000
--- a/dossierfacile-process-file/src/test/java/fr/dossierfacile/process/file/service/AnalyzeDocumentServiceTest.java
+++ /dev/null
@@ -1,97 +0,0 @@
-package fr.dossierfacile.process.file.service;
-
-import fr.dossierfacile.common.entity.DocumentAnalysisReport;
-import fr.dossierfacile.common.entity.DocumentAnalysisStatus;
-import fr.dossierfacile.common.entity.DocumentAnalysisRule;
-import fr.dossierfacile.common.entity.DocumentRule;
-import fr.dossierfacile.common.entity.DocumentRuleLevel;
-import org.assertj.core.api.Assertions;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-
-import java.lang.reflect.Method;
-import java.util.LinkedList;
-
-class AnalyzeDocumentServiceTest {
-
- private AnalyzeDocumentService service;
- private Method method;
-
- @BeforeEach
- void setUp() throws Exception {
- // Le constructeur prend 4 dépendances mais la méthode testée n'en a pas besoin
- service = new AnalyzeDocumentService(null, null, null, null);
- method = AnalyzeDocumentService.class.getDeclaredMethod("computeDocumentAnalysisReportStatus", DocumentAnalysisReport.class);
- method.setAccessible(true);
- }
-
- private DocumentAnalysisReport emptyReport() {
- return DocumentAnalysisReport.builder()
- .failedRules(new LinkedList<>())
- .passedRules(new LinkedList<>())
- .inconclusiveRules(new LinkedList<>())
- .build();
- }
-
- @Test
- void when_initial_status_is_undefined_then_do_nothing() throws Exception {
- var report = emptyReport();
- // Ajoute des règles critiques mais status=UNDEFINED doit court-circuiter
- report.getFailedRules().add(DocumentAnalysisRule.documentFailedRuleFrom(DocumentRule.R_TAX_BAD_CLASSIFICATION));
- report.setAnalysisStatus(DocumentAnalysisStatus.UNDEFINED);
-
- method.invoke(service, report);
-
- Assertions.assertThat(report.getAnalysisStatus()).isEqualTo(DocumentAnalysisStatus.UNDEFINED);
- }
-
- @Test
- void when_critical_failed_rule_then_status_denied() throws Exception {
- var report = emptyReport();
- report.setAnalysisStatus(DocumentAnalysisStatus.CHECKED);
- report.getFailedRules().add(DocumentAnalysisRule.documentFailedRuleFrom(DocumentRule.R_TAX_BAD_CLASSIFICATION)); // CRITICAL
-
- method.invoke(service, report);
-
- Assertions.assertThat(report.getAnalysisStatus()).isEqualTo(DocumentAnalysisStatus.DENIED);
- }
-
- @Test
- void when_inconclusive_and_no_critical_fail_then_status_undefined() throws Exception {
- var report = emptyReport();
- report.setAnalysisStatus(null);
- // échec non critique (WARN) pour vérifier que seul CRITICAL déclenche DENIED
- report.getFailedRules().add(DocumentAnalysisRule.documentFailedRuleFrom(DocumentRule.R_BLURRY_FILE)); // WARN
- report.getInconclusiveRules().add(DocumentAnalysisRule.documentInconclusiveRuleFrom(DocumentRule.R_BLURRY_FILE_BLANK));
-
- method.invoke(service, report);
-
- Assertions.assertThat(report.getAnalysisStatus()).isEqualTo(DocumentAnalysisStatus.UNDEFINED);
- }
-
- @Test
- void when_no_critical_fail_and_no_inconclusive_then_status_checked() throws Exception {
- var report = emptyReport();
- report.setAnalysisStatus(null);
- // seulement des règles PASSÉES ou rien dans failed/inconclusive
- report.getPassedRules().add(DocumentAnalysisRule.documentPassedRuleFrom(DocumentRule.R_TAX_PARSE));
-
- method.invoke(service, report);
-
- Assertions.assertThat(report.getAnalysisStatus()).isEqualTo(DocumentAnalysisStatus.CHECKED);
- }
-
- @Test
- void when_blurry_fail_and_other_pass_then_status_checked() throws Exception {
- var report = emptyReport();
- report.setAnalysisStatus(null);
- // échec non critique (WARN) pour vérifier que seul CRITICAL déclenche DENIED
- report.getFailedRules().add(DocumentAnalysisRule.documentFailedRuleFrom(DocumentRule.R_BLURRY_FILE)); // WARN
- report.getPassedRules().add(DocumentAnalysisRule.documentPassedRuleFrom(DocumentRule.R_TAX_NAMES));
-
- method.invoke(service, report);
-
- Assertions.assertThat(report.getAnalysisStatus()).isEqualTo(DocumentAnalysisStatus.CHECKED);
- }
-}
-
diff --git a/dossierfacile-process-file/src/test/java/fr/dossierfacile/process/file/util/NameUtilTest.java b/dossierfacile-process-file/src/test/java/fr/dossierfacile/process/file/util/NameUtilTest.java
new file mode 100644
index 000000000..fab7cf665
--- /dev/null
+++ b/dossierfacile-process-file/src/test/java/fr/dossierfacile/process/file/util/NameUtilTest.java
@@ -0,0 +1,203 @@
+package fr.dossierfacile.process.file.util;
+
+import fr.dossierfacile.process.file.service.document_rules.validator.french_identity_card.document_ia_model.DocumentIdentity;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class NameUtilTest {
+
+ @ParameterizedTest(name = "[{index}] sanitizeForComparison(''{0}'') should return ''{1}''")
+ @CsvSource({
+ // Cas avec accents
+ "Dupont, DUPONT",
+ "Dupönt, DUPONT",
+ "Élise, ELISE",
+ "François, FRANCOIS",
+ "José, JOSE",
+ "Gérard, GERARD",
+ "Hélène, HELENE",
+ "Jérôme, JEROME",
+ "Zoë, ZOE",
+ "Noël, NOEL",
+
+ // Cas avec tirets et espaces
+ "Jean-Pierre, JEANPIERRE",
+ "Marie-Claire, MARIECLAIRE",
+ "De La Cruz, DELACRUZ",
+ "Van Der Berg, VANDERBERG",
+ "Le Goff, LEGOFF",
+
+ // Cas avec apostrophes
+ "O'Brien, OBRIEN",
+ "D'Angelo, DANGELO",
+
+ // Cas avec caractères spéciaux
+ "Smith-Jones, SMITHJONES",
+ "Müller, MULLER",
+ "Søren, SOREN",
+ "Łukasz, LUKASZ",
+
+ // Cas mixtes complexes
+ "Jean-François D'été, JEANFRANCOISDETE",
+ "Marie-Hélène De Saint-Exupéry, MARIEHELENEDESAINTEXUPERY",
+
+ // Cas avec espaces multiples
+ "John Doe, JOHNDOE",
+
+ // Cas edge
+ "'', ''",
+ " , ''",
+ "123, ''",
+ "a, A",
+
+ // Cas réels français
+ "François-Xavier, FRANCOISXAVIER",
+ "Anne-Sophie, ANNESOPHIE",
+ "Bérénice, BERENICE",
+ "Cécile, CECILE",
+ })
+ void testSanitizeForComparison(String input, String expected) {
+ String result = NameUtil.sanitizeForComparison(input);
+ assertThat(result).isEqualTo(expected);
+ }
+
+ private static java.util.stream.Stream provideNameMatchingTestCases() {
+ return java.util.stream.Stream.of(
+ Arguments.of(
+ new DocumentIdentity(List.of("Jean", "Pierre"), "Dupont"),
+ new DocumentIdentity(List.of("Jean", "Pierre"), "Dupont"),
+ true,
+ "Exact match - Nom et prénoms identiques"
+ ),
+ Arguments.of(
+ new DocumentIdentity(List.of("François"), "Dupont"),
+ new DocumentIdentity(List.of("Francois"), "Dupont"),
+ true,
+ "Match avec accent - François vs Francois"
+ ),
+ Arguments.of(
+ new DocumentIdentity(List.of("Jean-Pierre"), "Martin"),
+ new DocumentIdentity(List.of("JeanPierre"), "Martin"),
+ true,
+ "Match avec tiret - Jean-Pierre vs JeanPierre"
+ ),
+ Arguments.of(
+ new DocumentIdentity(List.of("Jean", "Pierre", "Paul"), "Dupont"),
+ new DocumentIdentity(List.of("Pierre"), "Dupont"),
+ true,
+ "Match avec un prénom commun parmi plusieurs"
+ ),
+ Arguments.of(
+ new DocumentIdentity(List.of("Marie"), "Dupont", "Martin"),
+ new DocumentIdentity(List.of("Marie"), "Martin"),
+ true,
+ "Match avec nom d'usage - preferredName Martin matche avec lastName Martin"
+ ),
+ Arguments.of(
+ new DocumentIdentity(List.of("Marie"), "Martin"),
+ new DocumentIdentity(List.of("Marie"), "Dupont", "Martin"),
+ true,
+ "Match avec nom d'usage extraction - lastName Martin matche avec preferredName Martin"
+ ),
+ Arguments.of(
+ new DocumentIdentity(List.of("Anne", "Sophie"), "De La Cruz"),
+ new DocumentIdentity(List.of("Anne"), "DeLaCruz"),
+ true,
+ "Match avec nom composé - De La Cruz vs DeLaCruz"
+ ),
+ Arguments.of(
+ new DocumentIdentity(List.of("Søren"), "Larsen"),
+ new DocumentIdentity(List.of("Soren"), "Larsen"),
+ true,
+ "Match avec caractère scandinave - Søren vs Soren"
+ ),
+ Arguments.of(
+ new DocumentIdentity(List.of("Łukasz"), "Kowalski"),
+ new DocumentIdentity(List.of("Lukasz"), "Kowalski"),
+ true,
+ "Match avec caractère slave - Łukasz vs Lukasz"
+ ),
+ Arguments.of(
+ new DocumentIdentity(List.of("Jean"), "Dupont"),
+ new DocumentIdentity(List.of("Pierre"), "Dupont"),
+ false,
+ "Pas de match - Prénoms différents"
+ ),
+ Arguments.of(
+ new DocumentIdentity(List.of("Jean"), "Dupont"),
+ new DocumentIdentity(List.of("Jean"), "Martin"),
+ false,
+ "Pas de match - Noms de famille différents"
+ ),
+ Arguments.of(
+ new DocumentIdentity(List.of("Jean"), "Dupont"),
+ new DocumentIdentity(List.of("Pierre"), "Martin"),
+ false,
+ "Pas de match - Prénom et nom différents"
+ ),
+ Arguments.of(
+ new DocumentIdentity(List.of("Marie"), "Dupont-Martin"),
+ new DocumentIdentity(List.of("Marie"), "Dupont"),
+ true,
+ "Match avec substring - Dupont-Martin contient Dupont"
+ ),
+ Arguments.of(
+ new DocumentIdentity(List.of("JEAN"), "DUPONT"),
+ new DocumentIdentity(List.of("jean"), "dupont"),
+ true,
+ "Match insensible à la casse"
+ ),
+ Arguments.of(
+ new DocumentIdentity(List.of("Jean Pierre"), "Du Pont"),
+ new DocumentIdentity(List.of("JeanPierre"), "DuPont"),
+ true,
+ "Match avec espaces multiples ignorés"
+ ),
+ Arguments.of(
+ new DocumentIdentity(List.of("Marie", "Hélène"), "Dupont", "De Saint-Exupéry"),
+ new DocumentIdentity(List.of("Marie"), "DeSaintExupery"),
+ true,
+ "Match avec nom d'usage composé et accents"
+ ),
+ Arguments.of(
+ new DocumentIdentity(List.of("Romaric"), "HALDENWANG"),
+ new DocumentIdentity(List.of("Romaric"), "HALDENWANG-TOURNESAC"),
+ true,
+ "Match Romaric 1"
+ ),
+ Arguments.of(
+ new DocumentIdentity(List.of("Romaric"), "HALDENWANG-TOURNESAC"),
+ new DocumentIdentity(List.of("Romaric"), "HALDENWANG"),
+ true,
+ "Match Romaric 2"
+ ),
+ Arguments.of(
+ new DocumentIdentity(List.of("Romaric"), "TEST", "HALDENWANG-TOURNESAC"),
+ new DocumentIdentity(List.of("Romaric"), "HALDENWANG"),
+ true,
+ "Match Romaric 3"
+ ),
+ Arguments.of(
+ new DocumentIdentity(List.of("Romaric"), "TEST"),
+ new DocumentIdentity(List.of("Romaric"), "HALDENWANG", "TEST"),
+ true,
+ "Match Romaric 4"
+ )
+ );
+ }
+
+ @ParameterizedTest(name = "[{index}] {3}")
+ @org.junit.jupiter.params.provider.MethodSource("provideNameMatchingTestCases")
+ void testIsNameMatching(DocumentIdentity document, DocumentIdentity extracted, boolean expectedMatch, String description) {
+ boolean result = NameUtil.isNameMatching(document, extracted);
+ assertThat(result)
+ .as(description)
+ .isEqualTo(expectedMatch);
+ }
+
+}