diff --git a/bruno-api/DossierFacile/api-tenant/folder.bru b/bruno-api/DossierFacile/api-tenant/folder.bru new file mode 100644 index 000000000..9579dbc62 --- /dev/null +++ b/bruno-api/DossierFacile/api-tenant/folder.bru @@ -0,0 +1,8 @@ +meta { + name: api-tenant + seq: 1 +} + +auth { + mode: inherit +} diff --git a/bruno-api/DossierFacile/api-tenant/webhook/document-ia.bru b/bruno-api/DossierFacile/api-tenant/webhook/document-ia.bru new file mode 100644 index 000000000..9ee757f9c --- /dev/null +++ b/bruno-api/DossierFacile/api-tenant/webhook/document-ia.bru @@ -0,0 +1,153 @@ +meta { + name: document-ia + type: http + seq: 1 +} + +post { + url: {{server}}/api/webhook/v1/document-ia + body: json + auth: apikey +} + +auth:apikey { + key: X-Api-Key + value: Azerty123! + placement: header +} + +body:json { + { + "id": "33cae79f-09b0-4717-a9d5-e1d106d8c5ad", + "status": "SUCCESS", + "data": { + "total_processing_time_ms": 13861, + "result": { + "classification": { + "document_type": "cni", + "confidence": 0.99, + "explanation": "Présence des mentions 'République Française' et 'Carte Nationale d'Identité'. Contient nom, prénoms, date et lieu de naissance, numéro de document à 12 chiffres, et dates de validité." + }, + "extraction": { + "type": "cni", + "properties": [ + { + "name": "numero_document", + "value": "MCLND07Y6", + "type": "string" + }, + { + "name": "date_expiration", + "value": "10/08/2031", + "type": "string" + }, + { + "name": "nom", + "value": "ISumame", + "type": "string" + }, + { + "name": "prenom", + "value": "Nicolas", + "type": "string" + }, + { + "name": "date_naissance", + "value": "07/10/1993", + "type": "string" + }, + { + "name": "lieu_naissance", + "value": "ÉCULLY", + "type": "string" + }, + { + "name": "nationalite", + "value": "FRA", + "type": "string" + }, + { + "name": "bande_mrz", + "value": "MCLND07Y6\nNICOLAS PATRICK\nFRA9310073M380820<<<<<<<<<<<<<<<<<<\n07Y600000000000000000000000000", + "type": "string" + } + ] + }, + "barcodes": [ + { + "position": { + "top_left": [ + 245, + 176 + ], + "top_right": [ + 446, + 180 + ], + "bottom_right": [ + 443, + 385 + ], + "bottom_left": [ + 242, + 383 + ] + }, + "page_number": 2, + "type": "DATA_MATRIX", + "is_valid": true, + "data": { + "fields": { + "60": "NICOLAS/PATRICK", + "62": "SAGON", + "65": "ID", + "66": "MCLND07Y6", + "67": "FR", + "68": "M", + "6C": "FR" + }, + "country": "FR", + "doc_type": "07", + "perimeter": "01" + } + } + ], + "workflow_metadata": [ + { + "step_name": "DownloadFileStep", + "execution_time": 0.18137345800641924 + }, + { + "step_name": "PreprocessFileStep", + "execution_time": 0.15958420798415318 + }, + { + "step_name": "ExtractBarcodeData", + "execution_time": 0.0959704170236364 + }, + { + "step_name": "ExtractContentMarkerOcrStep", + "execution_time": 3.5028272090130486 + }, + { + "step_name": "LLMClassifyDocumentStep", + "execution_time": 2.7598340830008965, + "request_tokens": 1117, + "response_tokens": 79 + }, + { + "step_name": "LLMExtractDocumentStep", + "execution_time": 6.878677457978483, + "request_tokens": 1581, + "response_tokens": 170 + } + ] + } + } + } +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno-api/DossierFacile/api-tenant/webhook/folder.bru b/bruno-api/DossierFacile/api-tenant/webhook/folder.bru new file mode 100644 index 000000000..79c1c5436 --- /dev/null +++ b/bruno-api/DossierFacile/api-tenant/webhook/folder.bru @@ -0,0 +1,8 @@ +meta { + name: webhook + seq: 1 +} + +auth { + mode: inherit +} diff --git a/bruno-api/DossierFacile/bruno.json b/bruno-api/DossierFacile/bruno.json new file mode 100644 index 000000000..c93dae108 --- /dev/null +++ b/bruno-api/DossierFacile/bruno.json @@ -0,0 +1,9 @@ +{ + "version": "1", + "name": "DossierFacile", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ] +} \ No newline at end of file diff --git a/bruno-api/DossierFacile/environments/Local.bru b/bruno-api/DossierFacile/environments/Local.bru new file mode 100644 index 000000000..8e277e320 --- /dev/null +++ b/bruno-api/DossierFacile/environments/Local.bru @@ -0,0 +1,3 @@ +vars { + server: http://localhost:8090 +} diff --git a/dossierfacile-api-tenant/README.md b/dossierfacile-api-tenant/README.md index 11d6d4428..8fd26d171 100644 --- a/dossierfacile-api-tenant/README.md +++ b/dossierfacile-api-tenant/README.md @@ -110,6 +110,10 @@ brevo.template.id.tenant.validated.dossier.not.valid.w.partner= link.after.denied.default= link.after.validated.default= link.shared.property= + +# Document IA +document.ia.api.base.url= +document.ia.api.key= ``` ## LogStash : diff --git a/dossierfacile-api-tenant/pom.xml b/dossierfacile-api-tenant/pom.xml index 51b852820..fc7d96259 100644 --- a/dossierfacile-api-tenant/pom.xml +++ b/dossierfacile-api-tenant/pom.xml @@ -152,6 +152,12 @@ org.springframework.boot spring-boot-test + + org.apache.commons + commons-imaging + 1.0-alpha3 + compile + diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/config/ResourceServerConfig.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/config/ResourceServerConfig.java index 78fbbc2a5..99cb74b5d 100644 --- a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/config/ResourceServerConfig.java +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/config/ResourceServerConfig.java @@ -1,6 +1,7 @@ package fr.dossierfacile.api.front.config; import fr.dossierfacile.api.front.config.filter.ConnectionContextFilter; +import fr.dossierfacile.api.front.config.filter.DossierFacileWebhookApiKeyFilter; import fr.dossierfacile.api.front.security.PartnerAuthorizationManager; import fr.dossierfacile.logging.request.Oauth2LoggingHandler; import lombok.RequiredArgsConstructor; @@ -17,6 +18,7 @@ import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.intercept.AuthorizationFilter; import org.springframework.security.web.access.intercept.FilterSecurityInterceptor; import org.springframework.security.web.access.intercept.RequestAuthorizationContext; import org.springframework.security.web.header.writers.StaticHeadersWriter; @@ -36,6 +38,8 @@ public class ResourceServerConfig { private final AuthenticationEntryPoint authenticationEntryPoint = new Oauth2LoggingHandler(); + private final DossierFacileWebhookApiKeyFilter dossierFacileWebhookApiKeyFilter; + @Value("${resource.server.config.csp}") private String configCsp; @@ -43,6 +47,7 @@ public class ResourceServerConfig { SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .addFilterBefore(new ConnectionContextFilter(), FilterSecurityInterceptor.class) + .addFilterBefore(dossierFacileWebhookApiKeyFilter, AuthorizationFilter.class) .headers(headers -> headers .addHeaderWriter(new StaticHeadersWriter("X-Content-Type-Options", "nosniff")) .contentTypeOptions(withDefaults()) @@ -67,7 +72,8 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { "/api/stats/**", "/api/onetimesecret/**", "/error", - "/actuator/health").permitAll() + "/actuator/health", + "/api/webhook/**").permitAll() .requestMatchers("/api-partner/**").access(apiPartnerAuthorizationManager()) .requestMatchers("/dfc/api/**").access(dfcPartnerServiceAuthorizationManager()) .requestMatchers("/dfc/**").hasAuthority("SCOPE_dfc") diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/config/filter/DossierFacileWebhookApiKeyFilter.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/config/filter/DossierFacileWebhookApiKeyFilter.java new file mode 100644 index 000000000..cd631255e --- /dev/null +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/config/filter/DossierFacileWebhookApiKeyFilter.java @@ -0,0 +1,52 @@ +package fr.dossierfacile.api.front.config.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.jetbrains.annotations.NotNull; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +public class DossierFacileWebhookApiKeyFilter extends OncePerRequestFilter { + + private final AntPathMatcher pathMatcher = new AntPathMatcher(); + private final String expectedApiKey; + + public DossierFacileWebhookApiKeyFilter(@Value("${dossier.facile.webhook.api.key}") String expectedApiKey) { + this.expectedApiKey = expectedApiKey; + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + // Ne filtre que les webhooks Document IA + return !pathMatcher.match("/api/webhook/**", request.getRequestURI()); + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + @NotNull HttpServletResponse response, + @NotNull FilterChain filterChain + ) throws ServletException, IOException { + + String apiKey = request.getHeader("X-Api-Key"); + + if (expectedApiKey == null || expectedApiKey.isBlank() || !expectedApiKey.equals(apiKey)) { + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write("{\"error\":\"unauthorized\"}"); + return; + } + + filterChain.doFilter(request, response); + } +} + diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/controller/DocumentIAController.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/controller/DocumentIAController.java new file mode 100644 index 000000000..42fbd2ba4 --- /dev/null +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/controller/DocumentIAController.java @@ -0,0 +1,33 @@ +package fr.dossierfacile.api.front.controller; + +import fr.dossierfacile.api.front.model.documentIA.WebhookModel; +import fr.dossierfacile.api.front.service.interfaces.DocumentIAService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@AllArgsConstructor +@RequestMapping("/api/webhook") +public class DocumentIAController { + + private final DocumentIAService documentIAService; + + @PostMapping("/v1/document-ia") + public ResponseEntity receiveDocumentIAWebhook( + HttpServletRequest request, + @Valid @RequestBody WebhookModel body + ) { + + documentIAService.handleWebhookCallback(body); + + return ResponseEntity.accepted().build(); + } +} diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/external/documentIA/DocumentIAClient.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/external/documentIA/DocumentIAClient.java new file mode 100644 index 000000000..2c1793074 --- /dev/null +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/external/documentIA/DocumentIAClient.java @@ -0,0 +1,59 @@ +package fr.dossierfacile.api.front.external.documentIA; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +@Component +public class DocumentIAClient { + + private final RestTemplate restTemplate; + private final String baseUrl; + private final String apiKey; + private final String workflow_id; + + public DocumentIAClient( + RestTemplate restTemplate, + @Value("${document.ia.api.base.url}") String baseUrl, + @Value("${document.ia.api.key}") String apiKey, + @Value("${document.ia.api.workflow.id}") String workflow_id + ) { + + this.restTemplate = restTemplate; + this.baseUrl = baseUrl; + this.apiKey = apiKey; + this.workflow_id = workflow_id; + } + + public DocumentIAResponse sendForAnalysis(DocumentIARequest request) { + String url = String.format("%s/workflows/%s/execute", baseUrl, workflow_id); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + headers.set("X-Api-Key", apiKey); + + HttpEntity> requestEntity = new HttpEntity<>(createMultipartRequest(request), headers); + + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.POST, + requestEntity, + DocumentIAResponse.class + ); + + return response.getBody(); + } + + + private MultiValueMap createMultipartRequest(DocumentIARequest request) { + MultiValueMap multiValueMap = new LinkedMultiValueMap<>(); + + multiValueMap.add("metadata", request.getMetadata()); + multiValueMap.add("file", request.getFile().getResource()); + + return multiValueMap; + } + +} diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/external/documentIA/DocumentIARequest.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/external/documentIA/DocumentIARequest.java new file mode 100644 index 000000000..ce452131a --- /dev/null +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/external/documentIA/DocumentIARequest.java @@ -0,0 +1,18 @@ +package fr.dossierfacile.api.front.external.documentIA; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.web.multipart.MultipartFile; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class DocumentIARequest { + private String metadata; + private MultipartFile file; +} diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/external/documentIA/DocumentIAResponse.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/external/documentIA/DocumentIAResponse.java new file mode 100644 index 000000000..54125d664 --- /dev/null +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/external/documentIA/DocumentIAResponse.java @@ -0,0 +1,19 @@ +package fr.dossierfacile.api.front.external.documentIA; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class DocumentIAResponse { + + private String status; + private DocumentIAResponseData data; + +} diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/external/documentIA/DocumentIAResponseData.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/external/documentIA/DocumentIAResponseData.java new file mode 100644 index 000000000..4f47f41b8 --- /dev/null +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/external/documentIA/DocumentIAResponseData.java @@ -0,0 +1,20 @@ +package fr.dossierfacile.api.front.external.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 +@AllArgsConstructor +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class DocumentIAResponseData { + @JsonProperty("execution_id") + private String executionId; + @JsonProperty("workflow_id") + private String workflowId; +} diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/model/documentIA/WebhookModel.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/model/documentIA/WebhookModel.java new file mode 100644 index 000000000..f0346f41d --- /dev/null +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/model/documentIA/WebhookModel.java @@ -0,0 +1,21 @@ +package fr.dossierfacile.api.front.model.documentIA; + +import com.fasterxml.jackson.annotation.JsonInclude; +import fr.dossierfacile.common.enums.DocumentIAFileAnalysisStatus; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class WebhookModel { + private String id; + private DocumentIAFileAnalysisStatus status; + private WebhookModelData data; +} + + diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/model/documentIA/WebhookModelData.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/model/documentIA/WebhookModelData.java new file mode 100644 index 000000000..9804e878d --- /dev/null +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/model/documentIA/WebhookModelData.java @@ -0,0 +1,22 @@ +package fr.dossierfacile.api.front.model.documentIA; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import fr.dossierfacile.common.model.documentIA.ResultModel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class WebhookModelData { + @JsonProperty("total_processing_time_ms") + private long totalProcessingTimeMs; + + private ResultModel result; +} diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/DocumentIAServiceImpl.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/DocumentIAServiceImpl.java new file mode 100644 index 000000000..d5ddce8d5 --- /dev/null +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/DocumentIAServiceImpl.java @@ -0,0 +1,107 @@ +package fr.dossierfacile.api.front.service; + +import fr.dossierfacile.api.front.external.documentIA.DocumentIAClient; +import fr.dossierfacile.api.front.external.documentIA.DocumentIARequest; +import fr.dossierfacile.api.front.model.documentIA.WebhookModel; +import fr.dossierfacile.api.front.service.document.analysis.DocumentAnalysisServiceImpl; +import fr.dossierfacile.api.front.service.interfaces.DocumentIAService; +import fr.dossierfacile.common.config.DocumentIAConfig; +import fr.dossierfacile.common.entity.Document; +import fr.dossierfacile.common.entity.DocumentIAFileAnalysis; +import fr.dossierfacile.common.entity.File; +import fr.dossierfacile.common.enums.DocumentIAFileAnalysisStatus; +import fr.dossierfacile.common.enums.DocumentSubCategory; +import fr.dossierfacile.common.repository.DocumentIAFileAnalysisRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Objects; + +@Component +@Slf4j +public class DocumentIAServiceImpl implements DocumentIAService { + + private final DocumentIAClient documentIAClient; + private final DocumentIAFileAnalysisRepository documentIAFileAnalysisRepository; + private final DocumentIAConfig documentIAConfig; + private final DocumentAnalysisServiceImpl documentAnalysisService; + + + DocumentIAServiceImpl( + DocumentIAClient documentIAClient, + DocumentIAFileAnalysisRepository documentIAFileAnalysisRepository, + DocumentIAConfig documentIAConfig, + DocumentAnalysisServiceImpl documentAnalysisService + ) { + this.documentIAClient = documentIAClient; + this.documentIAFileAnalysisRepository = documentIAFileAnalysisRepository; + this.documentIAConfig = documentIAConfig; + this.documentAnalysisService = documentAnalysisService; + } + + @Override + public void handleWebhookCallback(WebhookModel payload) { + + var documentIAFileAnalysis = documentIAFileAnalysisRepository.findByDocumentIaExecutionId(payload.getId()); + + documentIAFileAnalysis.ifPresentOrElse(analysis -> { + if (payload.getStatus() == DocumentIAFileAnalysisStatus.STARTED) { + log.warn("Document IA callback with started status for id : {}", payload.getId()); + return; + } + + if (payload.getStatus() == DocumentIAFileAnalysisStatus.SUCCESS) { + analysis.setAnalysisStatus(DocumentIAFileAnalysisStatus.SUCCESS); + analysis.setResult(payload.getData().getResult()); + documentIAFileAnalysisRepository.save(analysis); + } else { + analysis.setAnalysisStatus(DocumentIAFileAnalysisStatus.FAILED); + documentIAFileAnalysisRepository.save(analysis); + } + + analyseDocument(analysis.getFile().getDocument()); + }, () -> log.warn("Document IA file analysis not found with id : {}", payload.getId())); + + } + + public void analyseDocument(Document document) { + // We try to retrieve all the pending analyses for the document. If there is none, we can start processing the document. Otherwise we wait the next webhook callback! + var notFinishedAnalyses = document.getFiles() + .stream() + .map(File::getDocumentIAFileAnalysis) + .filter(Objects::nonNull) + .filter(analysis -> analysis.getAnalysisStatus() == DocumentIAFileAnalysisStatus.STARTED) + .count(); + + if (notFinishedAnalyses == 0) { + log.warn("Start document analysis for document id: {}", document.getId()); + documentAnalysisService.analyseDocument(document); + } + } + + + @Override + public void sendForAnalysis(MultipartFile multipartFile, File file, Document document) { + if (!documentIAConfig.hasToSendFileForAnalysis(document)) { + return; + } + var request = DocumentIARequest.builder().metadata("{ \"document_id\": " + document.getId() + " }").file(multipartFile).build(); + try { + var response = documentIAClient.sendForAnalysis(request); + documentIAFileAnalysisRepository.save( + DocumentIAFileAnalysis.builder() + .file(file) + .documentIaWorkflowId(response.getData().getWorkflowId()) + .documentIaExecutionId(response.getData().getExecutionId()) + .analysisStatus(DocumentIAFileAnalysisStatus.STARTED) + .dataFileId(file.getId()) + .dataDocumentId(document.getId()) + .build() + ); + } catch (Exception e) { + // Log the exception or handle it as needed + System.err.println("Error sending document for analysis: " + e.getMessage()); + } + } +} diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/DocumentServiceImpl.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/DocumentServiceImpl.java index be963bbc4..0c44bcc35 100644 --- a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/DocumentServiceImpl.java +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/DocumentServiceImpl.java @@ -4,6 +4,7 @@ import fr.dossierfacile.api.front.exception.DocumentNotFoundException; import fr.dossierfacile.api.front.repository.DocumentRepository; import fr.dossierfacile.api.front.service.interfaces.ApartmentSharingService; +import fr.dossierfacile.api.front.service.interfaces.DocumentIAService; import fr.dossierfacile.api.front.service.interfaces.DocumentService; import fr.dossierfacile.api.front.service.interfaces.TenantStatusService; import fr.dossierfacile.common.entity.Document; @@ -43,6 +44,7 @@ public class DocumentServiceImpl implements DocumentService { private final DocumentHelperService documentHelperService; private final LogService logService; private final Producer producer; + private final DocumentIAService documentIAService; @Override @Transactional @@ -109,13 +111,8 @@ public void resetValidatedOrInProgressDocumentsAccordingCategories(List documentSubCategoryValidatorMap( + CarteNationalIdentiteRulesValidationService carteNationalIdentiteRulesValidationService, + BulletinSalaireRulesValidationService BulletinSalaireRulesValidationService + ) { + EnumMap validators = new EnumMap<>(DocumentSubCategory.class); + validators.put(DocumentSubCategory.FRENCH_IDENTITY_CARD, carteNationalIdentiteRulesValidationService); + validators.put(DocumentSubCategory.SALARY, BulletinSalaireRulesValidationService); // Placeholder for other validators + return validators; + } +} diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/DocumentAnalysisServiceImpl.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/DocumentAnalysisServiceImpl.java new file mode 100644 index 000000000..0a24ce723 --- /dev/null +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/DocumentAnalysisServiceImpl.java @@ -0,0 +1,83 @@ +package fr.dossierfacile.api.front.service.document.analysis; + +import fr.dossierfacile.api.front.repository.DocumentRepository; +import fr.dossierfacile.api.front.service.document.analysis.rule.AbstractRulesValidationService; +import fr.dossierfacile.common.entity.Document; +import fr.dossierfacile.common.entity.DocumentAnalysisReport; +import fr.dossierfacile.common.entity.DocumentAnalysisStatus; +import fr.dossierfacile.common.enums.DocumentSubCategory; +import fr.dossierfacile.common.repository.DocumentAnalysisReportRepository; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.LinkedList; +import java.util.Map; + +@Slf4j +@Service +@AllArgsConstructor +public class DocumentAnalysisServiceImpl { + + private final Map mapOfValidators; + private final DocumentRepository documentRepository; + private final DocumentAnalysisReportRepository documentAnalysisReportRepository; + + public void analyseDocument(Document document) { + var validator = mapOfValidators.get(document.getDocumentSubCategory()); + if (validator != null) { + log.info("Analyzing document of subcategory: {}", document.getDocumentSubCategory()); + var report = getDocumentReport(document); + report = validator.process(document, report); + + boolean hasFailed = !report.getFailedRules().isEmpty(); + boolean hasInconclusive = !report.getInconclusiveRules().isEmpty(); + boolean hasPassed = !report.getPassedRules().isEmpty(); + + // Empty set -> undefined + if (!hasFailed && !hasInconclusive && !hasPassed) { + report.setAnalysisStatus(DocumentAnalysisStatus.UNDEFINED); + // Any failed -> denied + } else if (hasFailed) { + report.setAnalysisStatus(DocumentAnalysisStatus.DENIED); + // No failed, but some inconclusive -> undefined + } else if (hasInconclusive) { + report.setAnalysisStatus(DocumentAnalysisStatus.UNDEFINED); + // Only passed -> checked + } else { + report.setAnalysisStatus(DocumentAnalysisStatus.CHECKED); + } + documentAnalysisReportRepository.save(report); + document.setDocumentAnalysisReport(report); + documentRepository.save(document); + log.info("Report of document of subcategory: {}", document.getDocumentSubCategory()); + } else { + log.warn("No validator found for document subcategory: {}", document.getDocumentSubCategory()); + } + } + + private DocumentAnalysisReport getDocumentReport(Document document) { + DocumentAnalysisReport previousReport = document.getDocumentAnalysisReport(); + + // If a previous report exists, reset its fields for a new analysis + if (previousReport != null) { + previousReport.setFailedRules(new LinkedList<>()); + previousReport.setPassedRules(new LinkedList<>()); + previousReport.setInconclusiveRules(new LinkedList<>()); + previousReport.setCreatedAt(LocalDateTime.now()); + previousReport.setAnalysisStatus(null); + return previousReport; + } + + return DocumentAnalysisReport.builder() + .document(document) + .dataDocumentId(document.getId()) + .failedRules(new LinkedList<>()) + .passedRules(new LinkedList<>()) + .inconclusiveRules(new LinkedList<>()) + .createdAt(LocalDateTime.now()) + .build(); + } + +} diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/AbstractRulesValidationService.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/AbstractRulesValidationService.java new file mode 100644 index 000000000..2a911a2b0 --- /dev/null +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/AbstractRulesValidationService.java @@ -0,0 +1,36 @@ +package fr.dossierfacile.api.front.service.document.analysis.rule; + +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.AbstractDocumentRuleValidator; +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.RuleValidatorOutput; +import fr.dossierfacile.common.entity.Document; +import fr.dossierfacile.common.entity.DocumentAnalysisReport; +import fr.dossierfacile.common.entity.DocumentAnalysisStatus; +import lombok.extern.slf4j.Slf4j; + +import java.util.List; + +@Slf4j +public abstract class AbstractRulesValidationService { + + abstract List getDocumentRuleValidators(Document document); + + public DocumentAnalysisReport process(Document document, DocumentAnalysisReport report) { + try { + for (var ruleValidator : getDocumentRuleValidators(document)) { + var output = ruleValidator.validate(document); + switch (output.ruleLevel()) { + case PASSED -> report.addDocumentPassedRule(output.rule()); + case FAILED -> report.addDocumentFailedRule(output.rule()); + case INCONCLUSIVE -> report.addDocumentInconclusiveRule(output.rule()); + } + if (output.isBlocking() && output.ruleLevel() != RuleValidatorOutput.RuleLevel.PASSED) { + return report; + } + } + } catch (Exception e) { + log.error("Error during the rules validation execution process", e); + report.setAnalysisStatus(DocumentAnalysisStatus.UNDEFINED); + } + return report; + } +} \ No newline at end of file diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/BulletinSalaireRulesValidationService.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/BulletinSalaireRulesValidationService.java new file mode 100644 index 000000000..878ec2ba1 --- /dev/null +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/BulletinSalaireRulesValidationService.java @@ -0,0 +1,36 @@ +package fr.dossierfacile.api.front.service.document.analysis.rule; + +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.AbstractDocumentRuleValidator; +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia.ClassificationValidatorB; +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia.HasBeenDocumentIAAnalysedBI; +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.payslip.PayslipContinuityRule; +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.payslip.PayslipNameMatch; +import fr.dossierfacile.common.entity.Document; +import fr.dossierfacile.common.enums.DocumentCategoryStep; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +public class BulletinSalaireRulesValidationService extends AbstractRulesValidationService { + + private static final String DOCUMENT_IA_DOCUMENT_TYPE = "bulletin_salaire"; + + @Override + List getDocumentRuleValidators(Document document) { + if (document.getDocumentCategoryStep() == DocumentCategoryStep.SALARY_EMPLOYED_NOT_YET) { + return List.of(); + } + + return List.of( + new HasBeenDocumentIAAnalysedBI(), + new ClassificationValidatorB(DOCUMENT_IA_DOCUMENT_TYPE), + new PayslipNameMatch(), + new PayslipContinuityRule() + ); + } +} \ No newline at end of file diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/CarteNationalIdentiteRulesValidationService.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/CarteNationalIdentiteRulesValidationService.java new file mode 100644 index 000000000..0d8875a5c --- /dev/null +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/CarteNationalIdentiteRulesValidationService.java @@ -0,0 +1,31 @@ +package fr.dossierfacile.api.front.service.document.analysis.rule; + +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.AbstractDocumentRuleValidator; +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia.ClassificationValidatorB; +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia.HasBeenDocumentIAAnalysedBI; +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.french_identity_card.FrenchIdentityCardExpirationRule; +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.french_identity_card.FrenchIdentityCardNameMatch; +import fr.dossierfacile.common.entity.Document; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +public class CarteNationalIdentiteRulesValidationService extends AbstractRulesValidationService { + + private static final String DOCUMENT_IA_DOCUMENT_TYPE = "cni"; + + @Override + List getDocumentRuleValidators(Document document) { + return List.of( + new HasBeenDocumentIAAnalysedBI(), + new ClassificationValidatorB(DOCUMENT_IA_DOCUMENT_TYPE), + new FrenchIdentityCardNameMatch(), + new FrenchIdentityCardExpirationRule() + ); + } +} \ No newline at end of file diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/AbstractDocumentRuleValidator.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/AbstractDocumentRuleValidator.java new file mode 100644 index 000000000..5ea468925 --- /dev/null +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/AbstractDocumentRuleValidator.java @@ -0,0 +1,33 @@ +package fr.dossierfacile.api.front.service.document.analysis.rule.validator; + +import fr.dossierfacile.common.entity.Document; +import fr.dossierfacile.common.entity.DocumentAnalysisRule; +import fr.dossierfacile.common.entity.DocumentRule; + +public abstract class AbstractDocumentRuleValidator { + + protected abstract boolean isValid(Document document); + + protected abstract boolean isBlocking(); + + protected abstract boolean isInconclusive(); + + protected abstract DocumentRule getRule(); + + public RuleValidatorOutput validate(Document document) { + boolean isValid = isValid(document); + boolean isBlocking = isBlocking(); + boolean isInconclusive = isInconclusive(); + DocumentRule rule = getRule(); + + if (!isValid) { + if (isInconclusive) { + return new RuleValidatorOutput(false, isBlocking, DocumentAnalysisRule.documentInconclusiveRuleFrom(rule), RuleValidatorOutput.RuleLevel.INCONCLUSIVE); + } else { + return new RuleValidatorOutput(false, isBlocking, DocumentAnalysisRule.documentFailedRuleFrom(rule), RuleValidatorOutput.RuleLevel.FAILED); + } + } else { + return new RuleValidatorOutput(true, isBlocking, DocumentAnalysisRule.documentPassedRuleFrom(rule), RuleValidatorOutput.RuleLevel.PASSED); + } + } +} diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/RuleValidatorOutput.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/RuleValidatorOutput.java new file mode 100644 index 000000000..afbf72292 --- /dev/null +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/RuleValidatorOutput.java @@ -0,0 +1,16 @@ +package fr.dossierfacile.api.front.service.document.analysis.rule.validator; + +import fr.dossierfacile.common.entity.DocumentAnalysisRule; + +public record RuleValidatorOutput( + boolean isValid, + boolean isBlocking, + DocumentAnalysisRule rule, + RuleLevel ruleLevel +) { + public enum RuleLevel { + FAILED, + PASSED, + INCONCLUSIVE + } +} diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/document_ia/BaseDocumentIAValidator.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/document_ia/BaseDocumentIAValidator.java new file mode 100644 index 000000000..3648ea7ce --- /dev/null +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/document_ia/BaseDocumentIAValidator.java @@ -0,0 +1,41 @@ +package fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia; + +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.AbstractDocumentRuleValidator; +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.french_identity_card.document_ia_model.DocumentIdentity; +import fr.dossierfacile.common.entity.Document; +import fr.dossierfacile.common.entity.DocumentIAFileAnalysis; +import fr.dossierfacile.common.entity.File; +import fr.dossierfacile.common.enums.DocumentIAFileAnalysisStatus; + +import java.util.List; +import java.util.Objects; + +public abstract class BaseDocumentIAValidator extends AbstractDocumentRuleValidator { + + protected List getSuccessfulDocumentIAAnalyses(Document document) { + return document.getFiles().stream() + .map(File::getDocumentIAFileAnalysis) + .filter(Objects::nonNull) + .filter(it -> it.getAnalysisStatus() == DocumentIAFileAnalysisStatus.SUCCESS) + .toList(); + } + + protected boolean hasAnyNonSuccessfulDocumentIAAnalyses(Document document) { + return !document.getFiles().stream() + .map(File::getDocumentIAFileAnalysis) + .filter(Objects::nonNull) + .allMatch(it -> it.getAnalysisStatus() == DocumentIAFileAnalysisStatus.SUCCESS); + } + + protected DocumentIdentity getNamesFromDocument(Document document) { + if (document.getGuarantor() != null) { + return new DocumentIdentity(List.of(document.getGuarantor().getFirstName()), document.getGuarantor().getLastName()); + } + + if (document.getTenant() != null) { + return new DocumentIdentity(List.of(document.getTenant().getFirstName()), document.getTenant().getLastName(), document.getTenant().getPreferredName()); + } + return null; + } + +} diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/document_ia/ClassificationValidatorB.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/document_ia/ClassificationValidatorB.java new file mode 100644 index 000000000..8f33f9b78 --- /dev/null +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/document_ia/ClassificationValidatorB.java @@ -0,0 +1,47 @@ +package fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia; + +import fr.dossierfacile.common.entity.Document; +import fr.dossierfacile.common.entity.DocumentRule; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class ClassificationValidatorB extends BaseDocumentIAValidator { + + private final String documentType; + + public ClassificationValidatorB(String documentType) { + this.documentType = documentType; + } + + @Override + protected boolean isBlocking() { + return true; + } + + @Override + protected boolean isInconclusive() { + return false; + } + + @Override + protected DocumentRule getRule() { + return DocumentRule.R_DOCUMENT_IA_CLASSIFICATION; + } + + @Override + protected boolean isValid(Document document) { + var documentIAAnalyses = this.getSuccessfulDocumentIAAnalyses(document); + if (documentIAAnalyses.isEmpty()) { + return false; + } + var allGoodClassification = true; + for (var analysis : documentIAAnalyses) { + var classification = analysis.getResult().getClassification(); + if (classification != null && !classification.getDocumentType().equals(documentType)) { + allGoodClassification = false; + break; + } + } + return allGoodClassification; + } +} diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/document_ia/DocumentIAPropertyType.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/document_ia/DocumentIAPropertyType.java new file mode 100644 index 000000000..7ba319ef8 --- /dev/null +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/document_ia/DocumentIAPropertyType.java @@ -0,0 +1,14 @@ +package fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia; + +public enum DocumentIAPropertyType { + STRING("string"), + DATE("date"); + + private final String label; + + DocumentIAPropertyType(String label) { + this.label = label; + } + + +} diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/document_ia/HasBeenDocumentIAAnalysedBI.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/document_ia/HasBeenDocumentIAAnalysedBI.java new file mode 100644 index 000000000..771755950 --- /dev/null +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/document_ia/HasBeenDocumentIAAnalysedBI.java @@ -0,0 +1,36 @@ +package fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia; + +import fr.dossierfacile.common.entity.Document; +import fr.dossierfacile.common.entity.DocumentRule; +import fr.dossierfacile.common.entity.File; +import fr.dossierfacile.common.enums.DocumentIAFileAnalysisStatus; +import lombok.extern.slf4j.Slf4j; + +import java.util.Objects; + +@Slf4j +public class HasBeenDocumentIAAnalysedBI extends BaseDocumentIAValidator { + + @Override + protected boolean isBlocking() { + return true; + } + + @Override + protected boolean isInconclusive() { + return true; + } + + @Override + protected DocumentRule getRule() { + return DocumentRule.R_DOCUMENT_IA_ANALYSED; + } + + @Override + protected boolean isValid(Document document) { + return document.getFiles().stream() + .map(File::getDocumentIAFileAnalysis) + .filter(Objects::nonNull) + .noneMatch(it -> it.getAnalysisStatus() != DocumentIAFileAnalysisStatus.SUCCESS); + } +} \ No newline at end of file diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/document_ia/mapper/BaseDocumentIAMapper.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/document_ia/mapper/BaseDocumentIAMapper.java new file mode 100644 index 000000000..3f3615b09 --- /dev/null +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/document_ia/mapper/BaseDocumentIAMapper.java @@ -0,0 +1,88 @@ +package fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia.mapper; + +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia.DocumentIAPropertyType; +import fr.dossierfacile.common.model.documentIA.GenericProperty; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.util.List; +import java.util.Optional; + +abstract public class BaseDocumentIAMapper { + + protected Optional instantiate(List twoDDocProperties, ListextractionProperties, Class targetClass) { + try { + T instance = targetClass.getDeclaredConstructor().newInstance(); + + boolean isEmpty = true; + for (Field field : targetClass.getDeclaredFields()) { + if (!field.isAnnotationPresent(DocumentIAField.class)) { + continue; + } + DocumentIAField annotation = field.getAnnotation(DocumentIAField.class); + GenericProperty genericProperty = findProperty(annotation, twoDDocProperties, extractionProperties); + + if (genericProperty != null) { + Object convertedValue = convertValue(genericProperty, annotation.type()); + convertedValue = applyTransformerIfNeeded(annotation, convertedValue); + + if (convertedValue != null) { + setFieldValue(field, instance, convertedValue); + isEmpty = false; + } + } + } + + return isEmpty ? Optional.empty() : Optional.of(instance); + } catch (Exception e) { + throw new IllegalStateException("Erreur lors du mapping DocumentIA vers " + targetClass.getName(), e); + } + } + + protected GenericProperty findProperty(DocumentIAField annotation, + List listOf2DDocItems, + List listOfExtractionItems) { + GenericProperty genericProperty = null; + + if (!annotation.twoDDocName().isBlank()) { + genericProperty = listOf2DDocItems.stream() + .filter(it -> it.getName().equals(annotation.twoDDocName())) + .filter(it -> it.getValue() != null) + .findFirst() + .orElse(null); + } + + if (genericProperty == null && !annotation.extractionName().isBlank()) { + genericProperty = listOfExtractionItems.stream() + .filter(it -> it.getName().equals(annotation.extractionName())) + .filter(it -> it.getValue() != null) + .findFirst() + .orElse(null); + } + + return genericProperty; + } + + private void setFieldValue(Field field, Object instance, Object value) throws IllegalAccessException { + field.setAccessible(true); + field.set(instance, value); + } + + private Object convertValue(GenericProperty property, DocumentIAPropertyType type) { + return switch (type) { + case STRING -> property.getStringValue(); + case DATE -> property.getDateValue(); + default -> property.getValue(); + }; + } + + private Object applyTransformerIfNeeded(DocumentIAField annotation, Object value) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException { + Class> transformerClass = annotation.transformer(); + if (transformerClass == DocumentIAField.NoOpTransformer.class) { + return value; + } + @SuppressWarnings("unchecked") PropertyTransformer transformer = (PropertyTransformer) transformerClass.getDeclaredConstructor().newInstance(); + return transformer.transform(value); + } + +} diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/document_ia/mapper/DocumentIAField.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/document_ia/mapper/DocumentIAField.java new file mode 100644 index 000000000..c649d71f6 --- /dev/null +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/document_ia/mapper/DocumentIAField.java @@ -0,0 +1,41 @@ +package fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia.mapper; + +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia.DocumentIAPropertyType; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface DocumentIAField { + /** + * Nom du champ dans le 2D-Doc. Optionnel, vide par défaut. + */ + String twoDDocName() default ""; + + /** + * Nom du champ dans l'extraction. Optionnel, vide par défaut. + */ + String extractionName() default ""; + + DocumentIAPropertyType type() default DocumentIAPropertyType.STRING; + + /** + * Classe de transformation optionnelle pour convertir la valeur extraite. + * La classe doit implémenter Function ou être un PropertyTransformer. + */ + Class> transformer() default NoOpTransformer.class; + + /** + * Transformer par défaut qui ne fait rien (identité) + */ + class NoOpTransformer implements PropertyTransformer { + + @Override + public Object transform(Object input) { + return input; + } + } +} diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/document_ia/mapper/DocumentIAMergerMapper.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/document_ia/mapper/DocumentIAMergerMapper.java new file mode 100644 index 000000000..a0d15d0e6 --- /dev/null +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/document_ia/mapper/DocumentIAMergerMapper.java @@ -0,0 +1,53 @@ +package fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia.mapper; + +import fr.dossierfacile.common.entity.DocumentIAFileAnalysis; +import fr.dossierfacile.common.model.documentIA.BarcodeModel; +import fr.dossierfacile.common.model.documentIA.GenericProperty; +import fr.dossierfacile.common.model.documentIA.ResultModel; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/* +This mapper is used to merge multiple analyses Front and Back on a document into a single object. +It uses the @DocumentIAField annotation to map fields from the analyses to the target object. +It prioritizes 2DDoc data over Extraction data and the last analysis over the first one + */ +public class DocumentIAMergerMapper extends BaseDocumentIAMapper { + + public Optional map(List documentIAAnalyses, Class targetClass) { + var listOf2DDocItems = extract2DDocItems(documentIAAnalyses); + var listOfExtractionItems = extractExtractionItems(documentIAAnalyses); + + return instantiate( + listOf2DDocItems, + listOfExtractionItems, + targetClass + ); + } + + private static List extract2DDocItems(List documentIAAnalyses) { + return documentIAAnalyses + .stream() + .map(DocumentIAFileAnalysis::getResult) + .map(ResultModel::getBarcodes) + .flatMap(Collection::stream) + .map(BarcodeModel::getTypedData) + .flatMap(Collection::stream) + .toList() + .reversed(); // We reverse the list to prioritize the last analysis results + } + + private static List extractExtractionItems(List documentIAAnalyses) { + return documentIAAnalyses + .stream() + .map(DocumentIAFileAnalysis::getResult) + .map(ResultModel::getExtraction) + .filter(Objects::nonNull) + .flatMap(it -> it.getProperties().stream()) + .toList() + .reversed(); // We reverse the list to prioritize the last analysis results + } +} \ No newline at end of file diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/document_ia/mapper/DocumentIAMultiMapper.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/document_ia/mapper/DocumentIAMultiMapper.java new file mode 100644 index 000000000..7526956a1 --- /dev/null +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/document_ia/mapper/DocumentIAMultiMapper.java @@ -0,0 +1,60 @@ +package fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia.mapper; + +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia.DocumentIAPropertyType; +import fr.dossierfacile.common.entity.DocumentIAFileAnalysis; +import fr.dossierfacile.common.model.documentIA.BarcodeModel; +import fr.dossierfacile.common.model.documentIA.GenericProperty; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.util.*; + +/* +This mapper is used to return a list of multiple analyses on a document. +It uses the @DocumentIAField annotation to map fields from the analyses to the target object. +It prioritizes 2DDoc data over Extraction data + */ +public class DocumentIAMultiMapper extends BaseDocumentIAMapper { + + public List map(List documentIAAnalyses, Class targetClass) { + var listOfResults = new ArrayList(); + + for (DocumentIAFileAnalysis documentIAFileAnalysis : documentIAAnalyses) { + + List doc2DProperties = extract2DDocItems(documentIAFileAnalysis); + List extractionProperties = extractExtractionItems(documentIAFileAnalysis); + + var instantiated = instantiate( + doc2DProperties, + extractionProperties, + targetClass + ); + instantiated.ifPresent(listOfResults::add); + } + + return listOfResults; + } + + private List extract2DDocItems(DocumentIAFileAnalysis documentIAAnalyse) { + return documentIAAnalyse + .getResult() + .getBarcodes() + .stream() + .filter(Objects::nonNull) + .map(BarcodeModel::getTypedData) + .filter(Objects::nonNull) + .flatMap(Collection::stream) + .toList(); + } + + private List extractExtractionItems(DocumentIAFileAnalysis documentIAAnalyse) { + var extraction = documentIAAnalyse.getResult().getExtraction(); + if (extraction == null) { + return Collections.emptyList(); + } + return documentIAAnalyse + .getResult() + .getExtraction() + .getProperties(); + } +} \ No newline at end of file diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/document_ia/mapper/PropertyTransformer.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/document_ia/mapper/PropertyTransformer.java new file mode 100644 index 000000000..3fd944f45 --- /dev/null +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/document_ia/mapper/PropertyTransformer.java @@ -0,0 +1,6 @@ +package fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia.mapper; + +@FunctionalInterface +public interface PropertyTransformer { + O transform(I input); +} diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/french_identity_card/FrenchIdentityCardExpirationRule.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/french_identity_card/FrenchIdentityCardExpirationRule.java new file mode 100644 index 000000000..51b311896 --- /dev/null +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/french_identity_card/FrenchIdentityCardExpirationRule.java @@ -0,0 +1,139 @@ +package fr.dossierfacile.api.front.service.document.analysis.rule.validator.french_identity_card; + +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.RuleValidatorOutput; +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia.BaseDocumentIAValidator; +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia.mapper.DocumentIAMergerMapper; +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.french_identity_card.document_ia_model.DocumentExpiration; +import fr.dossierfacile.common.entity.Document; +import fr.dossierfacile.common.entity.DocumentAnalysisRule; +import fr.dossierfacile.common.entity.DocumentRule; +import lombok.extern.slf4j.Slf4j; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.Optional; +import java.util.regex.Pattern; + +@Slf4j +public class FrenchIdentityCardExpirationRule extends BaseDocumentIAValidator { + + @Override + protected boolean isBlocking() { + return false; + } + + @Override + protected boolean isInconclusive() { + return true; + } + + @Override + protected DocumentRule getRule() { + return DocumentRule.R_FRENCH_IDENTITY_CARD_EXPIRATION; + } + + @Override + public RuleValidatorOutput validate(Document document) { + var documentIAAnalyses = this.getSuccessfulDocumentIAAnalyses(document); + + if (documentIAAnalyses.isEmpty()) { + return new RuleValidatorOutput(false, isBlocking(), DocumentAnalysisRule.documentInconclusiveRuleFrom(getRule()), RuleValidatorOutput.RuleLevel.INCONCLUSIVE); + } + + var isCardValid = Optional.of(false); + + var extractedDates = new DocumentIAMergerMapper().map(documentIAAnalyses, DocumentExpiration.class); + + if (extractedDates.isPresent()) { + isCardValid = isIdentityCardValid(extractedDates.get()); + } else { + return new RuleValidatorOutput(false, isBlocking(), DocumentAnalysisRule.documentInconclusiveRuleFrom(getRule()), RuleValidatorOutput.RuleLevel.INCONCLUSIVE); + } + + if (isCardValid.isEmpty()) { + return new RuleValidatorOutput(false, isBlocking(), DocumentAnalysisRule.documentInconclusiveRuleFrom(getRule()), RuleValidatorOutput.RuleLevel.INCONCLUSIVE); + } + + boolean cardValid = isCardValid.orElse(false); + + if (cardValid) { + return new RuleValidatorOutput( + true, + isBlocking(), + DocumentAnalysisRule.documentPassedRuleFrom(getRule()), + RuleValidatorOutput.RuleLevel.PASSED + ); + } else { + return new RuleValidatorOutput( + false, + isBlocking(), + DocumentAnalysisRule.documentFailedRuleFrom(getRule()), + RuleValidatorOutput.RuleLevel.FAILED + ); + } + } + + // We return Optional to be able to return empty when we don't have enough data to decide + private Optional isIdentityCardValid(DocumentExpiration dates) { + if (dates.expirationDate == null) { + return Optional.empty(); + } + + // The card is valid; + if (dates.expirationDate.isAfter(LocalDate.now())) { + return Optional.of(true); + } + + if (dates.cardNumber == null) { + // We don't have the card number but we decided the card is expired ! + return Optional.of(false); + } + + // Old card we need complex algorithm + // Old card has 12 numeric characters without letters + // New card has alphanumeric characters with at least one letter + boolean isNewCard = Pattern.compile("[A-Z]").matcher(dates.cardNumber).find(); + + if (isNewCard) { + // New card and expired + // The card is expired + return Optional.of(false); + } + + // Old card : we need the complex algorithm + // We have all the information to decide ! + if (dates.birthDate != null && dates.deliveryDate != null) { + boolean valid = isIdentityCardValidComplexAlgorithm( + dates.deliveryDate, + dates.expirationDate, + dates.birthDate + ); + return Optional.of(valid); + } + + // Not enough data to decide + return Optional.empty(); + } + + // Only called when identity card is an old one and the extraction returned all dates + private boolean isIdentityCardValidComplexAlgorithm(LocalDate deliveryDate, LocalDate expirationDate, LocalDate birthDate) { + var today = LocalDate.now(); + + long ageAtDelivery = ChronoUnit.YEARS.between(birthDate, deliveryDate); + LocalDate realExpirationDate = expirationDate; + + // Règle : Si majeur (>= 18 ans) au moment de la délivrance, on ajoute 5 ans + if (ageAtDelivery >= 18) { + realExpirationDate = expirationDate.plusYears(5); + } + + // Étape 4 : Vérification finale + // La carte est valide si la date du jour n'est PAS après la date d'expiration réelle + return !today.isAfter(realExpirationDate); + } + + @Override + protected boolean isValid(Document document) { + return false; + } +} diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/french_identity_card/FrenchIdentityCardNameMatch.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/french_identity_card/FrenchIdentityCardNameMatch.java new file mode 100644 index 000000000..60ae19c42 --- /dev/null +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/french_identity_card/FrenchIdentityCardNameMatch.java @@ -0,0 +1,80 @@ +package fr.dossierfacile.api.front.service.document.analysis.rule.validator.french_identity_card; + +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.RuleValidatorOutput; +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia.BaseDocumentIAValidator; +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia.mapper.DocumentIAMergerMapper; +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.french_identity_card.document_ia_model.DocumentIdentity; +import fr.dossierfacile.common.entity.Document; +import fr.dossierfacile.common.entity.DocumentAnalysisRule; +import fr.dossierfacile.common.entity.DocumentRule; +import fr.dossierfacile.common.model.documentIA.GenericProperty; +import fr.dossierfacile.common.utils.NameUtil; +import lombok.extern.slf4j.Slf4j; + +import java.util.ArrayList; + +@Slf4j +public class FrenchIdentityCardNameMatch extends BaseDocumentIAValidator { + + @Override + protected boolean isBlocking() { + return false; + } + + @Override + protected boolean isInconclusive() { + return true; + } + + @Override + protected DocumentRule getRule() { + return DocumentRule.R_FRENCH_IDENTITY_CARD_NAME_MATCH; + } + + @Override + public RuleValidatorOutput validate(Document document) { + var documentIAAnalyses = this.getSuccessfulDocumentIAAnalyses(document); + + var nameToMatch = getNamesFromDocument(document); + var expectedDatas = new ArrayList(); + if (nameToMatch != null) { + expectedDatas.add(new GenericProperty("firstNames", nameToMatch.getFirstNamesAsString(), "String")); + expectedDatas.add(new GenericProperty("lastName", nameToMatch.getLastName(), "String")); + expectedDatas.add(new GenericProperty("preferredName", nameToMatch.getPreferredName() != null ? nameToMatch.getPreferredName() : "N/A", "String")); + } + + if (documentIAAnalyses.isEmpty()) { + return new RuleValidatorOutput(false, isBlocking(), DocumentAnalysisRule.documentInconclusiveRuleFromWithData(getRule(), expectedDatas), RuleValidatorOutput.RuleLevel.INCONCLUSIVE); + } + + if (nameToMatch == null) { + return new RuleValidatorOutput(false, isBlocking(), DocumentAnalysisRule.documentInconclusiveRuleFromWithData(getRule(), expectedDatas), RuleValidatorOutput.RuleLevel.INCONCLUSIVE); + } + + var isNameMatch = false; + + var extractedIdentity = new DocumentIAMergerMapper().map(documentIAAnalyses, DocumentIdentity.class); + + if (extractedIdentity.isPresent() && extractedIdentity.get().isValid()) { + isNameMatch = NameUtil.isNameMatching(nameToMatch, extractedIdentity.get()); + } else { + return new RuleValidatorOutput(false, isBlocking(), DocumentAnalysisRule.documentInconclusiveRuleFromWithData(getRule(), expectedDatas), RuleValidatorOutput.RuleLevel.INCONCLUSIVE); + } + + var extractedDatas = new ArrayList(); + extractedDatas.add(new GenericProperty("firstNames", extractedIdentity.get().getFirstNamesAsString(), "String")); + extractedDatas.add(new GenericProperty("lastName", extractedIdentity.get().getLastName(), "String")); + extractedDatas.add(new GenericProperty("preferredName", extractedIdentity.get().getPreferredName() != null ? extractedIdentity.get().getPreferredName() : "N/A", "String")); + + if (isNameMatch) { + return new RuleValidatorOutput(true, isBlocking(), DocumentAnalysisRule.documentPassedRuleFromWithData(getRule(), expectedDatas, extractedDatas), RuleValidatorOutput.RuleLevel.PASSED); + } else { + return new RuleValidatorOutput(false, isBlocking(), DocumentAnalysisRule.documentFailedRuleFromWithData(getRule(), expectedDatas, extractedDatas), RuleValidatorOutput.RuleLevel.FAILED); + } + } + + @Override + protected boolean isValid(Document document) { + return false; + } +} diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/french_identity_card/document_ia_model/DocumentExpiration.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/french_identity_card/document_ia_model/DocumentExpiration.java new file mode 100644 index 000000000..cdb95e026 --- /dev/null +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/french_identity_card/document_ia_model/DocumentExpiration.java @@ -0,0 +1,45 @@ +package fr.dossierfacile.api.front.service.document.analysis.rule.validator.french_identity_card.document_ia_model; + +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia.DocumentIAPropertyType; +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia.mapper.DocumentIAField; +import lombok.Setter; + +import java.time.LocalDate; + +@Setter +public class DocumentExpiration { + + @DocumentIAField( + twoDDocName = "numero_document", + extractionName = "numero_document", + type = DocumentIAPropertyType.STRING + ) + public String cardNumber; + + @DocumentIAField( + twoDDocName = "date_debut_validite", + extractionName = "date_delivrance", + type = DocumentIAPropertyType.DATE + ) + public LocalDate deliveryDate; + + @DocumentIAField( + twoDDocName = "date_fin_validite", + extractionName = "date_expiration", + type = DocumentIAPropertyType.DATE + ) + public LocalDate expirationDate; + + @DocumentIAField( + twoDDocName = "date_naissance", + extractionName = "date_naissance", + type = DocumentIAPropertyType.DATE + ) + public LocalDate birthDate; + + // Empty constructor needed by DocumentIA Mapper + public DocumentExpiration() { + // Intentionally empty: required for reflection-based instantiation by DocumentIA mapper + } + +} diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/french_identity_card/document_ia_model/DocumentIdentity.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/french_identity_card/document_ia_model/DocumentIdentity.java new file mode 100644 index 000000000..3a5734e4f --- /dev/null +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/french_identity_card/document_ia_model/DocumentIdentity.java @@ -0,0 +1,90 @@ +package fr.dossierfacile.api.front.service.document.analysis.rule.validator.french_identity_card.document_ia_model; + +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia.DocumentIAPropertyType; +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia.mapper.DocumentIAField; +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia.mapper.PropertyTransformer; +import fr.dossierfacile.common.utils.IDocumentIdentity; +import lombok.Setter; + +import java.util.List; + +@Setter +public class DocumentIdentity implements IDocumentIdentity { + + public static class NameNormalizerTransformer implements PropertyTransformer> { + @Override + public List transform(String input) { + if (input == null) { + return List.of(); + } + return List.of(input.split("/")); + } + } + + @DocumentIAField(twoDDocName = "nom_patronymique", extractionName = "nom") + public String lastName; + + @DocumentIAField(twoDDocName = "nom_usage") + public String preferredName; + + @DocumentIAField( + twoDDocName = "liste_prenoms", + type = DocumentIAPropertyType.STRING, + transformer = NameNormalizerTransformer.class + ) + public List firstNames; + + @DocumentIAField(twoDDocName = "prenom", extractionName = "prenom") + public String firstName; + + public DocumentIdentity() { + this.lastName = null; + this.preferredName = null; + this.firstNames = List.of(); + } + + public DocumentIdentity(List firstNames, String lastName, String preferredName) { + this.firstNames = firstNames; + this.lastName = lastName; + this.preferredName = preferredName; + } + + public DocumentIdentity(List firstNames, String lastName) { + this.firstNames = firstNames; + this.lastName = lastName; + } + + @Override + public List getFirstNames() { + if (firstNames == null || firstNames.isEmpty()) { + if (firstName == null) { + return List.of(); + } + return List.of(firstName); + } + return firstNames; + } + + public String getFirstNamesAsString() { + return String.join("/", getFirstNames()); + } + + @Override + public String getLastName() { + return lastName; + } + + @Override + public String getPreferredName() { + return preferredName; + } + + public boolean isValid() { + + if (lastName == null && preferredName == null) { + return false; + } + + return !getFirstNames().isEmpty(); + } +} diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/payslip/PayslipContinuityRule.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/payslip/PayslipContinuityRule.java new file mode 100644 index 000000000..d8450f494 --- /dev/null +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/payslip/PayslipContinuityRule.java @@ -0,0 +1,142 @@ +package fr.dossierfacile.api.front.service.document.analysis.rule.validator.payslip; + +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.RuleValidatorOutput; +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia.BaseDocumentIAValidator; +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia.mapper.DocumentIAMultiMapper; +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.payslip.document_ia_model.PayslipDate; +import fr.dossierfacile.common.entity.Document; +import fr.dossierfacile.common.entity.DocumentAnalysisRule; +import fr.dossierfacile.common.entity.DocumentRule; +import fr.dossierfacile.common.model.documentIA.GenericProperty; + +import java.time.Clock; +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.List; + +public class PayslipContinuityRule extends BaseDocumentIAValidator { + + private final Clock clock; + + public PayslipContinuityRule() { + this(Clock.systemDefaultZone()); + } + + public PayslipContinuityRule(Clock clock) { + this.clock = clock; + } + + @Override + protected boolean isBlocking() { + return false; + } + + @Override + protected boolean isInconclusive() { + return true; + } + + @Override + protected DocumentRule getRule() { + return DocumentRule.R_PAYSLIP_CONTINUITY; + } + + @Override + public RuleValidatorOutput validate(Document document) { + var documentIAAnalyses = this.getSuccessfulDocumentIAAnalyses(document); + + if (documentIAAnalyses.isEmpty() || hasAnyNonSuccessfulDocumentIAAnalyses(document)) { + return new RuleValidatorOutput(false, isBlocking(), DocumentAnalysisRule.documentInconclusiveRuleFrom(getRule()), RuleValidatorOutput.RuleLevel.INCONCLUSIVE); + } + + var extractedDates = new DocumentIAMultiMapper().map(documentIAAnalyses, PayslipDate.class); + + if (extractedDates.stream().anyMatch(it -> it.startDate == null)) { + return new RuleValidatorOutput(false, isBlocking(), DocumentAnalysisRule.documentInconclusiveRuleFrom(getRule()), RuleValidatorOutput.RuleLevel.INCONCLUSIVE); + } + + var expectedMonths = getExpectedMonthsLists(); + var extractedMonths = getExtractedMonths(extractedDates); + + List validMonths = extractedMonths.stream() + .filter(expectedMonths::contains) + .sorted() + .toList(); + + var isValid = hasThreeConsecutiveMonths(validMonths); + + if (isValid) { + return new RuleValidatorOutput( + true, + isBlocking(), + DocumentAnalysisRule.documentPassedRuleFromWithData( + getRule(), + convertYearMonthsToGenericProperties(expectedMonths), + convertYearMonthsToGenericProperties(extractedMonths) + ), + RuleValidatorOutput.RuleLevel.PASSED + ); + } else { + return new RuleValidatorOutput( + false, + isBlocking(), + DocumentAnalysisRule.documentFailedRuleFromWithData( + getRule(), + convertYearMonthsToGenericProperties(expectedMonths), + convertYearMonthsToGenericProperties(extractedMonths) + ), + RuleValidatorOutput.RuleLevel.FAILED + ); + } + } + + private boolean hasThreeConsecutiveMonths(List months) { + if (months.size() < 3) { + return false; + } + int consecutiveCount = 1; + for (int i = 0; i < months.size() - 1; i++) { + if (months.get(i).plusMonths(1).equals(months.get(i + 1))) { + consecutiveCount++; + if (consecutiveCount >= 3) { + return true; + } + } else { + consecutiveCount = 1; + } + } + return false; + } + + @Override + protected boolean isValid(Document document) { + return false; + } + + private List getExpectedMonthsLists() { + LocalDate localDate = LocalDate.now(clock); + YearMonth yearMonth = YearMonth.now(clock); + // If before the 15th, we expect M - 2, M - 3, M - 4 + // Else we expect M - 1, M - 2, M -3 + return (localDate.getDayOfMonth() <= 15) ? + List.of(yearMonth.minusMonths(1), yearMonth.minusMonths(2), yearMonth.minusMonths(3), yearMonth.minusMonths(4)) : + List.of(yearMonth, yearMonth.minusMonths(1), yearMonth.minusMonths(2), yearMonth.minusMonths(3)); + } + + // We need to extract YearMonth from DocumentDate based on startDate and endDate + private List getExtractedMonths(List extractedDates) { + return extractedDates.stream() + .map(date -> { + LocalDate startDate = date.startDate; + return YearMonth.from(startDate); + }) + .distinct() + .toList(); + } + + private List convertYearMonthsToGenericProperties(List months) { + return months.stream() + .map(month -> new GenericProperty("month", month.toString(), "YearMonth")) + .toList(); + } +} diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/payslip/PayslipNameMatch.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/payslip/PayslipNameMatch.java new file mode 100644 index 000000000..a25111b8d --- /dev/null +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/payslip/PayslipNameMatch.java @@ -0,0 +1,79 @@ +package fr.dossierfacile.api.front.service.document.analysis.rule.validator.payslip; + +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.RuleValidatorOutput; +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia.BaseDocumentIAValidator; +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia.mapper.DocumentIAMultiMapper; +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.payslip.document_ia_model.PayslipNames; +import fr.dossierfacile.common.entity.Document; +import fr.dossierfacile.common.entity.DocumentAnalysisRule; +import fr.dossierfacile.common.entity.DocumentRule; +import fr.dossierfacile.common.model.documentIA.GenericProperty; +import fr.dossierfacile.common.utils.NameUtil; +import lombok.extern.slf4j.Slf4j; + +import java.util.ArrayList; + +@Slf4j +public class PayslipNameMatch extends BaseDocumentIAValidator { + + @Override + protected boolean isBlocking() { + return false; + } + + @Override + protected boolean isInconclusive() { + return true; + } + + @Override + protected DocumentRule getRule() { + return DocumentRule.R_PAYSLIP_NAME_MATCH; + } + + @Override + public RuleValidatorOutput validate(Document document) { + var documentIAAnalyses = this.getSuccessfulDocumentIAAnalyses(document); + + var nameToMatch = getNamesFromDocument(document); + var expectedDatas = new ArrayList(); + if (nameToMatch != null) { + expectedDatas.add(new GenericProperty("firstNames", nameToMatch.getFirstNamesAsString(), "String")); + expectedDatas.add(new GenericProperty("lastName", nameToMatch.getLastName(), "String")); + expectedDatas.add(new GenericProperty("preferredName", nameToMatch.getPreferredName() != null ? nameToMatch.getPreferredName() : "N/A", "String")); + } + + if (documentIAAnalyses.isEmpty() || hasAnyNonSuccessfulDocumentIAAnalyses(document)) { + return new RuleValidatorOutput(false, isBlocking(), DocumentAnalysisRule.documentInconclusiveRuleFromWithData(getRule(), expectedDatas), RuleValidatorOutput.RuleLevel.INCONCLUSIVE); + } + + if (nameToMatch == null) { + return new RuleValidatorOutput(false, isBlocking(), DocumentAnalysisRule.documentInconclusiveRuleFromWithData(getRule(), expectedDatas), RuleValidatorOutput.RuleLevel.INCONCLUSIVE); + } + + var isNameMatch = true; + + var extractedIdentities = new DocumentIAMultiMapper().map(documentIAAnalyses, PayslipNames.class); + + var extractedDatas = new ArrayList(); + var i = 1; + for (PayslipNames name : extractedIdentities) { + if (!NameUtil.isNameMatching(nameToMatch, name)) { + isNameMatch = false; + } + extractedDatas.add(new GenericProperty("document" + i, name.getFirstNames() + " " + name.getLastName(), "String")); + i++; + } + + if (isNameMatch) { + return new RuleValidatorOutput(true, isBlocking(), DocumentAnalysisRule.documentPassedRuleFromWithData(getRule(), expectedDatas, extractedDatas), RuleValidatorOutput.RuleLevel.PASSED); + } else { + return new RuleValidatorOutput(false, isBlocking(), DocumentAnalysisRule.documentFailedRuleFromWithData(getRule(), expectedDatas, extractedDatas), RuleValidatorOutput.RuleLevel.FAILED); + } + } + + @Override + protected boolean isValid(Document document) { + return false; + } +} diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/payslip/document_ia_model/PayslipDate.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/payslip/document_ia_model/PayslipDate.java new file mode 100644 index 000000000..3d032e5e0 --- /dev/null +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/payslip/document_ia_model/PayslipDate.java @@ -0,0 +1,30 @@ +package fr.dossierfacile.api.front.service.document.analysis.rule.validator.payslip.document_ia_model; + +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia.DocumentIAPropertyType; +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia.mapper.DocumentIAField; +import lombok.Setter; + +import java.time.LocalDate; + +@Setter +public class PayslipDate { + + @DocumentIAField( + extractionName = "periode_debut", + type = DocumentIAPropertyType.DATE + ) + public LocalDate startDate; + + @DocumentIAField( + extractionName = "periode_fin", + type = DocumentIAPropertyType.DATE + ) + public LocalDate endDate; + + @DocumentIAField( + extractionName = "date_paiement", + type = DocumentIAPropertyType.DATE + ) + public LocalDate paymentDate; + +} diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/payslip/document_ia_model/PayslipNames.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/payslip/document_ia_model/PayslipNames.java new file mode 100644 index 000000000..3f0b8d9df --- /dev/null +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/payslip/document_ia_model/PayslipNames.java @@ -0,0 +1,42 @@ +package fr.dossierfacile.api.front.service.document.analysis.rule.validator.payslip.document_ia_model; + +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia.DocumentIAPropertyType; +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia.mapper.DocumentIAField; +import fr.dossierfacile.common.utils.IDocumentIdentity; +import lombok.Setter; + +import java.util.List; + +@Setter +public class PayslipNames implements IDocumentIdentity { + + @DocumentIAField( + extractionName = "nom_salarie", + type = DocumentIAPropertyType.STRING + ) + public String lastName; + + @DocumentIAField( + extractionName = "prenom_salarie", + type = DocumentIAPropertyType.STRING + ) + public String firstName; + + @Override + public List getFirstNames() { + if (firstName != null) { + return List.of(firstName); + } + return List.of(); + } + + @Override + public String getLastName() { + return lastName; + } + + @Override + public String getPreferredName() { + return null; + } +} diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/interfaces/DocumentIAService.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/interfaces/DocumentIAService.java new file mode 100644 index 000000000..54508ddbf --- /dev/null +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/service/interfaces/DocumentIAService.java @@ -0,0 +1,12 @@ +package fr.dossierfacile.api.front.service.interfaces; + +import fr.dossierfacile.api.front.model.documentIA.WebhookModel; +import fr.dossierfacile.common.entity.Document; +import fr.dossierfacile.common.entity.File; +import org.springframework.web.multipart.MultipartFile; + +public interface DocumentIAService { + void handleWebhookCallback(WebhookModel payload); + void sendForAnalysis(MultipartFile multipartFile, File file, Document document); + void analyseDocument(Document document); +} diff --git a/dossierfacile-api-tenant/src/main/resources/application.properties b/dossierfacile-api-tenant/src/main/resources/application.properties index a7c020ed0..29612d44e 100644 --- a/dossierfacile-api-tenant/src/main/resources/application.properties +++ b/dossierfacile-api-tenant/src/main/resources/application.properties @@ -94,7 +94,14 @@ dossierfacile.common.global.exception.handler=true api.failed.rules.min.level=critical +# Document IA +document.ia.api.base.url= +document.ia.api.key= +document.ia.api.workflow.id= # Brute-Force Protection Configuration brute-force.max-attempts=5 brute-force.time-window-hours=1 + +# Webhook security +dossier.facile.webhook.api-key= \ No newline at end of file diff --git a/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/french_identity_card/FrenchIdentityCardExpirationRuleTest.java b/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/french_identity_card/FrenchIdentityCardExpirationRuleTest.java new file mode 100644 index 000000000..44688de1a --- /dev/null +++ b/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/french_identity_card/FrenchIdentityCardExpirationRuleTest.java @@ -0,0 +1,295 @@ +package fr.dossierfacile.api.front.service.document.analysis.rule.validator.french_identity_card; + +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.RuleValidatorOutput; +import fr.dossierfacile.common.entity.Document; +import fr.dossierfacile.common.entity.DocumentIAFileAnalysis; +import fr.dossierfacile.common.entity.DocumentRule; +import fr.dossierfacile.common.entity.File; +import fr.dossierfacile.common.enums.DocumentIAFileAnalysisStatus; +import fr.dossierfacile.common.model.documentIA.BarcodeModel; +import fr.dossierfacile.common.model.documentIA.ExtractionModel; +import fr.dossierfacile.common.model.documentIA.GenericProperty; +import fr.dossierfacile.common.model.documentIA.ResultModel; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.List; +import java.util.Objects; +import java.util.stream.Stream; + +class FrenchIdentityCardExpirationRuleTest { + + private final FrenchIdentityCardExpirationRule rule = new FrenchIdentityCardExpirationRule(); + + // ========================== + // Helpers / Fixtures + // ========================== + + private DocumentIAFileAnalysis iaAnalysisWithExtraction( + String cardNumber, + String birthDate, + String deliveryDate, + String expirationDate + ) { + ExtractionModel extraction = ExtractionModel.builder() + .type("cni") + .properties(Stream.of( + birthDate != null ? GenericProperty.builder() + .name("date_naissance") + .type("date") + .value(birthDate) + .build() : null, + deliveryDate != null ? GenericProperty.builder() + .name("date_delivrance") + .type("date") + .value(deliveryDate) + .build() : null, + expirationDate != null ? GenericProperty.builder() + .name("date_expiration") + .type("date") + .value(expirationDate) + .build() : null, + cardNumber != null ? GenericProperty.builder() + .name("numero_document") + .type("string") + .value(cardNumber) + .build() : null + ).filter(Objects::nonNull).toList()) + .build(); + + ResultModel result = ResultModel.builder() + .extraction(extraction) + .barcodes(List.of()) + .build(); + + return DocumentIAFileAnalysis.builder() + .analysisStatus(DocumentIAFileAnalysisStatus.SUCCESS) + .result(result) + .build(); + } + + private DocumentIAFileAnalysis iaAnalysisWith2DDoc( + String cardNumber, + String birthDate, + String deliveryDate, + String expirationDate + ) { + // On construit un seul barcode avec des typedData (GenericProperty) + BarcodeModel barcode = BarcodeModel.builder() + .type("DATA_MATRIX") + .typedData(Stream.of( + birthDate != null ? GenericProperty.builder() + .name("date_naissance") + .type("date") + .value(birthDate) + .build() : null, + deliveryDate != null ? GenericProperty.builder() + .name("date_debut_validite") + .type("date") + .value(deliveryDate) + .build() : null, + expirationDate != null ? GenericProperty.builder() + .name("date_fin_validite") + .type("date") + .value(expirationDate) + .build() : null, + cardNumber != null ? GenericProperty.builder() + .name("numero_document") + .type("string") + .value(cardNumber) + .build() : null + ).filter(Objects::nonNull).toList()) + .build(); + + ResultModel result = ResultModel.builder() + .barcodes(List.of(barcode)) + .extraction(null) + .build(); + + return DocumentIAFileAnalysis.builder() + .analysisStatus(DocumentIAFileAnalysisStatus.SUCCESS) + .result(result) + .build(); + } + + private Document documentWithIaAnalyses(DocumentIAFileAnalysis... analyses) { + // Créer un File pour chaque analyse (relation OneToOne) + List files = Stream.of(analyses) + .map(analysis -> { + File file = File.builder() + .documentIAFileAnalysis(analysis) + .build(); + analysis.setFile(file); + return file; + }) + .toList(); + + return Document.builder() + .files(files) + .build(); + } + + private RuleValidatorOutput validate(Document document) { + return rule.validate(document); + } + + // ========================== + // Tests + // ========================== + + @Test + @DisplayName("INCONCLUSIVE si aucune analyse DocumentIA disponible") + void inconclusive_when_no_analysis() { + Document doc = Document.builder().files(List.of()).build(); + RuleValidatorOutput out = validate(doc); + + Assertions.assertThat(out.isValid()).isFalse(); + Assertions.assertThat(out.ruleLevel()).isEqualTo(RuleValidatorOutput.RuleLevel.INCONCLUSIVE); + Assertions.assertThat(out.rule().getRule()).isEqualTo(DocumentRule.R_FRENCH_IDENTITY_CARD_EXPIRATION); + } + + @Test + @DisplayName("PASSED quand la date d'expiration (2D-Doc) est dans le futur") + void passed_when_expiration_in_future_from_2d_doc() { + LocalDate future = LocalDate.now().plusYears(1); + DocumentIAFileAnalysis analysis = iaAnalysisWith2DDoc( + "123456789", + "1990-01-01", + future.minusYears(10).toString(), + future.toString() + ); + + Document doc = documentWithIaAnalyses(analysis); + RuleValidatorOutput out = validate(doc); + + Assertions.assertThat(out.isValid()).isTrue(); + Assertions.assertThat(out.ruleLevel()).isEqualTo(RuleValidatorOutput.RuleLevel.PASSED); + } + + @Test + @DisplayName("FAILED quand la carte est expirée (nouvelle carte, numéro différent de 111)") + void failed_when_expired_new_card() { + LocalDate past = LocalDate.now().minusYears(1); + DocumentIAFileAnalysis analysis = iaAnalysisWith2DDoc( + "MCD123", // nouvelle carte + "1990-01-01", + past.minusYears(10).toString(), + past.toString() + ); + + Document doc = documentWithIaAnalyses(analysis); + RuleValidatorOutput out = validate(doc); + + Assertions.assertThat(out.isValid()).isFalse(); + Assertions.assertThat(out.ruleLevel()).isEqualTo(RuleValidatorOutput.RuleLevel.FAILED); + } + + @Test + @DisplayName("INCONCLUSIVE quand la carte est expirée mais numéro 111 et données incomplètes") + void inconclusive_when_old_card_111_but_missing_dates() { + LocalDate past = LocalDate.now().minusYears(10); + // On ne met que la date d'expiration, sans date de délivrance ni de naissance + DocumentIAFileAnalysis analysis = iaAnalysisWith2DDoc( + "111", + null, + null, + past.toString() + ); + + Document doc = documentWithIaAnalyses(analysis); + RuleValidatorOutput out = validate(doc); + + Assertions.assertThat(out.isValid()).isFalse(); + Assertions.assertThat(out.ruleLevel()).isEqualTo(RuleValidatorOutput.RuleLevel.INCONCLUSIVE); + } + + @Test + @DisplayName("PASSED quand vieille CNI (numéro 111) reste valide après application de l'algorithme complexe") + void passed_when_old_card_111_and_still_valid_after_complex_algorithm() { + // Cas : + // - Né il y a 40 ans + // - Carte délivrée il y a 5 ans (majeur au moment de la délivrance) + // - Expiration initiale il y a 1 an + // => réelle expiration = +5 ans => encore 4 ans de validité + LocalDate birthDate = LocalDate.now().minusYears(40); + LocalDate deliveryDate = LocalDate.now().minusYears(5); + LocalDate expirationDate = LocalDate.now().minusYears(1); + + DocumentIAFileAnalysis analysis = iaAnalysisWith2DDoc( + "111", + birthDate.toString(), + deliveryDate.toString(), + expirationDate.toString() + ); + + Document doc = documentWithIaAnalyses(analysis); + RuleValidatorOutput out = validate(doc); + + Assertions.assertThat(out.isValid()).isTrue(); + Assertions.assertThat(out.ruleLevel()).isEqualTo(RuleValidatorOutput.RuleLevel.PASSED); + } + + @Test + @DisplayName("FAILED quand vieille CNI (numéro 111) est expirée après algorithme complexe") + void failed_when_old_card_111_and_expired_after_complex_algorithm() { + // Cas : + // - Né il y a 40 ans + // - Carte délivrée il y a 15 ans (majeur au moment de la délivrance) + // - Expiration initiale il y a 11 ans + // => réelle expiration = +5 ans => il y a 6 ans (donc expirée) + LocalDate birthDate = LocalDate.now().minusYears(40); + LocalDate deliveryDate = LocalDate.now().minusYears(15); + LocalDate expirationDate = LocalDate.now().minusYears(11); + + DocumentIAFileAnalysis analysis = iaAnalysisWith2DDoc( + "111", + birthDate.toString(), + deliveryDate.toString(), + expirationDate.toString() + ); + + Document doc = documentWithIaAnalyses(analysis); + RuleValidatorOutput out = validate(doc); + + Assertions.assertThat(out.isValid()).isFalse(); + Assertions.assertThat(out.ruleLevel()).isEqualTo(RuleValidatorOutput.RuleLevel.FAILED); + } + + @Test + @DisplayName("Utilise l'extraction si le 2D-Doc ne contient pas de dates") + void use_extraction_when_2d_doc_missing() { + LocalDate future = LocalDate.now().plusYears(2); + // Analyse sans barcodes (ou barcodes vides) mais avec extraction complète + DocumentIAFileAnalysis analysis = iaAnalysisWithExtraction( + "123456789", + "1990-01-01", + future.minusYears(10).toString(), + future.toString() + ); + + Document doc = documentWithIaAnalyses(analysis); + RuleValidatorOutput out = validate(doc); + + Assertions.assertThat(out.isValid()).isTrue(); + Assertions.assertThat(out.ruleLevel()).isEqualTo(RuleValidatorOutput.RuleLevel.PASSED); + } + + @Test + @DisplayName("INCONCLUSIVE quand aucune date d'expiration trouvée ni en 2D-Doc ni en extraction") + void inconclusive_when_no_expiration_anywhere() { + DocumentIAFileAnalysis analysis = iaAnalysisWithExtraction( + "123456789", + "1990-01-01", + null, + null + ); + + Document doc = documentWithIaAnalyses(analysis); + RuleValidatorOutput out = validate(doc); + + Assertions.assertThat(out.isValid()).isFalse(); + Assertions.assertThat(out.ruleLevel()).isEqualTo(RuleValidatorOutput.RuleLevel.INCONCLUSIVE); + } +} diff --git a/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/french_identity_card/FrenchIdentityCardNameMatchTest.java b/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/french_identity_card/FrenchIdentityCardNameMatchTest.java new file mode 100644 index 000000000..4fb18b429 --- /dev/null +++ b/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/french_identity_card/FrenchIdentityCardNameMatchTest.java @@ -0,0 +1,466 @@ +package fr.dossierfacile.api.front.service.document.analysis.rule.validator.french_identity_card; + +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.RuleValidatorOutput; +import fr.dossierfacile.common.entity.*; +import fr.dossierfacile.common.enums.DocumentIAFileAnalysisStatus; +import fr.dossierfacile.common.model.documentIA.BarcodeModel; +import fr.dossierfacile.common.model.documentIA.ExtractionModel; +import fr.dossierfacile.common.model.documentIA.GenericProperty; +import fr.dossierfacile.common.model.documentIA.ResultModel; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.List; +import java.util.Objects; +import java.util.stream.Stream; + +class FrenchIdentityCardNameMatchTest { + + private final FrenchIdentityCardNameMatch rule = new FrenchIdentityCardNameMatch(); + + // ========================== + // Helpers / Fixtures + // ========================== + + private DocumentIAFileAnalysis iaAnalysisWithExtraction( + String lastName, + String firstName + ) { + ExtractionModel extraction = ExtractionModel.builder() + .type("cni") + .properties(Stream.of( + lastName != null ? GenericProperty.builder() + .name("nom") + .type("string") + .value(lastName) + .build() : null, + firstName != null ? GenericProperty.builder() + .name("prenom") + .type("string") + .value(firstName) + .build() : null + ).filter(Objects::nonNull).toList()) + .build(); + + ResultModel result = ResultModel.builder() + .extraction(extraction) + .barcodes(List.of()) + .build(); + + return DocumentIAFileAnalysis.builder() + .analysisStatus(DocumentIAFileAnalysisStatus.SUCCESS) + .result(result) + .build(); + } + + private DocumentIAFileAnalysis iaAnalysisWith2DDoc( + String lastName, + String usageName, + String firstName, + String listFirstNames + ) { + // On construit un seul barcode avec des typedData (GenericProperty) + BarcodeModel barcode = BarcodeModel.builder() + .type("DATA_MATRIX") + .typedData(Stream.of( + lastName != null ? GenericProperty.builder() + .name("nom_patronymique") + .type("string") + .value(lastName) + .build() : null, + usageName != null ? GenericProperty.builder() + .name("nom_usage") + .type("string") + .value(usageName) + .build() : null, + firstName != null ? GenericProperty.builder() + .name("prenom") + .type("string") + .value(firstName) + .build() : null, + listFirstNames != null ? GenericProperty.builder() + .name("liste_prenoms") + .type("string") + .value(listFirstNames) + .build() : null + ).filter(Objects::nonNull).toList()) + .build(); + + ResultModel result = ResultModel.builder() + .barcodes(List.of(barcode)) + .extraction(null) + .build(); + + return DocumentIAFileAnalysis.builder() + .analysisStatus(DocumentIAFileAnalysisStatus.SUCCESS) + .result(result) + .build(); + } + + private Document documentWithIaAnalysesAndTenant(String tenantFirstName, String tenantLastName, String preferredName, DocumentIAFileAnalysis... analyses) { + Tenant tenant = Tenant.builder() + .firstName(tenantFirstName) + .lastName(tenantLastName) + .preferredName(preferredName) + .build(); + + List files = Stream.of(analyses) + .map(analysis -> { + File file = File.builder() + .documentIAFileAnalysis(analysis) + .build(); + analysis.setFile(file); + return file; + }) + .toList(); + + Document doc = Document.builder() + .files(files) + .tenant(tenant) + .build(); + + files.forEach(file -> file.setDocument(doc)); + + return doc; + } + + private Document documentWithIaAnalysesAndGuarantor(String guarantorFirstName, String guarantorLastName, DocumentIAFileAnalysis... analyses) { + Guarantor guarantor = Guarantor.builder() + .firstName(guarantorFirstName) + .lastName(guarantorLastName) + .build(); + + List files = Stream.of(analyses) + .map(analysis -> { + File file = File.builder() + .documentIAFileAnalysis(analysis) + .build(); + analysis.setFile(file); + return file; + }) + .toList(); + + Document doc = Document.builder() + .files(files) + .guarantor(guarantor) + .build(); + + files.forEach(file -> file.setDocument(doc)); + + return doc; + } + + private RuleValidatorOutput validate(Document document) { + return rule.validate(document); + } + + // ========================== + // Tests + // ========================== + + @Test + @DisplayName("INCONCLUSIVE si aucune analyse DocumentIA disponible") + void inconclusive_when_no_analysis() { + Tenant tenant = Tenant.builder() + .firstName("Jean") + .lastName("Dupont") + .build(); + + Document doc = Document.builder() + .files(List.of()) + .tenant(tenant) + .build(); + + RuleValidatorOutput out = validate(doc); + + Assertions.assertThat(out.isValid()).isFalse(); + Assertions.assertThat(out.ruleLevel()).isEqualTo(RuleValidatorOutput.RuleLevel.INCONCLUSIVE); + Assertions.assertThat(out.rule().getRule()).isEqualTo(DocumentRule.R_FRENCH_IDENTITY_CARD_NAME_MATCH); + } + + @Test + @DisplayName("INCONCLUSIVE si le document n'a ni tenant ni guarantor") + void inconclusive_when_no_tenant_no_guarantor() { + DocumentIAFileAnalysis analysis = iaAnalysisWithExtraction("Dupont", "Jean"); + + List files = List.of(File.builder() + .documentIAFileAnalysis(analysis) + .build()); + + Document doc = Document.builder() + .files(files) + .tenant(null) + .guarantor(null) + .build(); + + RuleValidatorOutput out = validate(doc); + + Assertions.assertThat(out.isValid()).isFalse(); + Assertions.assertThat(out.ruleLevel()).isEqualTo(RuleValidatorOutput.RuleLevel.INCONCLUSIVE); + } + + @Test + @DisplayName("PASSED quand les noms correspondent exactement (2D-Doc, nom patronymique)") + void passed_when_exact_match_2d_doc_patronymic() { + DocumentIAFileAnalysis analysis = iaAnalysisWith2DDoc( + "Dupont", // nom_patronymique + null, // nom_usage + "Jean", // prenoms + null // liste_prenoms + ); + + Document doc = documentWithIaAnalysesAndTenant("Jean", "Dupont", null, analysis); + RuleValidatorOutput out = validate(doc); + + Assertions.assertThat(out.isValid()).isTrue(); + Assertions.assertThat(out.ruleLevel()).isEqualTo(RuleValidatorOutput.RuleLevel.PASSED); + } + + @Test + @DisplayName("PASSED quand les noms correspondent avec accents normalisés (2D-Doc)") + void passed_when_match_with_accents_2d_doc() { + DocumentIAFileAnalysis analysis = iaAnalysisWith2DDoc( + "Dupont", + null, + "François", + null + ); + + Document doc = documentWithIaAnalysesAndTenant("Francois", "Dupont", null, analysis); + RuleValidatorOutput out = validate(doc); + + Assertions.assertThat(out.isValid()).isTrue(); + Assertions.assertThat(out.ruleLevel()).isEqualTo(RuleValidatorOutput.RuleLevel.PASSED); + } + + @Test + @DisplayName("PASSED avec nom d'usage qui matche (tenant a preferredName)") + void passed_when_usage_name_matches_preferred_name() { + DocumentIAFileAnalysis analysis = iaAnalysisWith2DDoc( + "Dupont", // nom_patronymique + "Martin", // nom_usage + "Marie", + null + ); + + Document doc = documentWithIaAnalysesAndTenant("Marie", "Dupont", "Martin", analysis); + RuleValidatorOutput out = validate(doc); + + Assertions.assertThat(out.isValid()).isTrue(); + Assertions.assertThat(out.ruleLevel()).isEqualTo(RuleValidatorOutput.RuleLevel.PASSED); + } + + @Test + @DisplayName("PASSED avec liste de prénoms séparés par slash") + void passed_when_multiple_first_names_slash_separated() { + DocumentIAFileAnalysis analysis = iaAnalysisWith2DDoc( + "Dupont", + null, + null, + "Jean/Pierre/Paul" // liste_prenoms + ); + + Document doc = documentWithIaAnalysesAndTenant("Pierre", "Dupont", null, analysis); + RuleValidatorOutput out = validate(doc); + + Assertions.assertThat(out.isValid()).isTrue(); + Assertions.assertThat(out.ruleLevel()).isEqualTo(RuleValidatorOutput.RuleLevel.PASSED); + } + + @Test + @DisplayName("PASSED avec prénoms séparés par espace dans extraction") + void passed_when_first_names_space_separated_extraction() { + DocumentIAFileAnalysis analysis = iaAnalysisWithExtraction( + "Sagon", + "Nicolas Patrick" // prenom avec plusieurs prénoms + ); + + Document doc = documentWithIaAnalysesAndTenant("Nicolas", "Sagon", null, analysis); + RuleValidatorOutput out = validate(doc); + + Assertions.assertThat(out.isValid()).isTrue(); + Assertions.assertThat(out.ruleLevel()).isEqualTo(RuleValidatorOutput.RuleLevel.PASSED); + } + + @Test + @DisplayName("PASSED pour un guarantor avec correspondance exacte") + void passed_when_guarantor_exact_match() { + DocumentIAFileAnalysis analysis = iaAnalysisWith2DDoc( + "Martin", + null, + "Sophie", + null + ); + + Document doc = documentWithIaAnalysesAndGuarantor("Sophie", "Martin", analysis); + RuleValidatorOutput out = validate(doc); + + Assertions.assertThat(out.isValid()).isTrue(); + Assertions.assertThat(out.ruleLevel()).isEqualTo(RuleValidatorOutput.RuleLevel.PASSED); + } + + @ParameterizedTest(name = "FAILED case {index}: {2}") + @MethodSource("failedNameMatchCases") + @DisplayName("FAILED quand le prénom et/ou le nom ne correspondent pas") + void failed_when_name_or_first_name_do_not_match( + String tenantFirstName, + String tenantLastName, + String description + ) { + DocumentIAFileAnalysis analysis = iaAnalysisWith2DDoc( + "Dupont", + null, + "Jean", + null + ); + + Document doc = documentWithIaAnalysesAndTenant(tenantFirstName, tenantLastName, null, analysis); + RuleValidatorOutput out = validate(doc); + + Assertions.assertThat(out.isValid()).isFalse(); + Assertions.assertThat(out.ruleLevel()).isEqualTo(RuleValidatorOutput.RuleLevel.FAILED); + } + + private static Stream failedNameMatchCases() { + return Stream.of( + Arguments.of("Pierre", "Dupont", "Prénom différent"), + Arguments.of("Jean", "Martin", "Nom de famille différent"), + Arguments.of("Pierre", "Martin", "Prénom et nom différents") + ); + } + + @Test + @DisplayName("INCONCLUSIVE quand aucun nom trouvé dans le 2D-Doc") + void inconclusive_when_no_name_in_2d_doc() { + DocumentIAFileAnalysis analysis = iaAnalysisWith2DDoc( + null, // pas de nom_patronymique + null, // pas de nom_usage + "Jean", + null + ); + + Document doc = documentWithIaAnalysesAndTenant("Jean", "Dupont", null, analysis); + RuleValidatorOutput out = validate(doc); + + Assertions.assertThat(out.isValid()).isFalse(); + Assertions.assertThat(out.ruleLevel()).isEqualTo(RuleValidatorOutput.RuleLevel.INCONCLUSIVE); + } + + @Test + @DisplayName("INCONCLUSIVE quand aucun prénom trouvé dans le 2D-Doc") + void inconclusive_when_no_first_name_in_2d_doc() { + DocumentIAFileAnalysis analysis = iaAnalysisWith2DDoc( + "Dupont", + null, + null, // pas de prenoms + null // pas de liste_prenoms + ); + + Document doc = documentWithIaAnalysesAndTenant("Jean", "Dupont", null, analysis); + RuleValidatorOutput out = validate(doc); + + Assertions.assertThat(out.isValid()).isFalse(); + Assertions.assertThat(out.ruleLevel()).isEqualTo(RuleValidatorOutput.RuleLevel.INCONCLUSIVE); + } + + @Test + @DisplayName("Utilise l'extraction si le 2D-Doc est incomplet") + void use_extraction_when_2d_doc_incomplete() { + DocumentIAFileAnalysis analysis = iaAnalysisWithExtraction( + "Dupont", + "Jean Pierre" + ); + + Document doc = documentWithIaAnalysesAndTenant("Jean", "Dupont", null, analysis); + RuleValidatorOutput out = validate(doc); + + Assertions.assertThat(out.isValid()).isTrue(); + Assertions.assertThat(out.ruleLevel()).isEqualTo(RuleValidatorOutput.RuleLevel.PASSED); + } + + @Test + @DisplayName("INCONCLUSIVE quand l'extraction ne contient pas de nom") + void inconclusive_when_extraction_missing_last_name() { + DocumentIAFileAnalysis analysis = iaAnalysisWithExtraction( + null, // pas de nom + "Jean" + ); + + Document doc = documentWithIaAnalysesAndTenant("Jean", "Dupont", null, analysis); + RuleValidatorOutput out = validate(doc); + + Assertions.assertThat(out.isValid()).isFalse(); + Assertions.assertThat(out.ruleLevel()).isEqualTo(RuleValidatorOutput.RuleLevel.INCONCLUSIVE); + } + + @Test + @DisplayName("INCONCLUSIVE quand l'extraction ne contient pas de prénom") + void inconclusive_when_extraction_missing_first_name() { + DocumentIAFileAnalysis analysis = iaAnalysisWithExtraction( + "Dupont", + null // pas de prenom + ); + + Document doc = documentWithIaAnalysesAndTenant("Jean", "Dupont", null, analysis); + RuleValidatorOutput out = validate(doc); + + Assertions.assertThat(out.isValid()).isFalse(); + Assertions.assertThat(out.ruleLevel()).isEqualTo(RuleValidatorOutput.RuleLevel.INCONCLUSIVE); + } + + @Test + @DisplayName("PASSED avec noms composés et tirets") + void passed_with_composite_names_and_hyphens() { + DocumentIAFileAnalysis analysis = iaAnalysisWith2DDoc( + "De La Cruz", + null, + "Jean-Pierre", + null + ); + + Document doc = documentWithIaAnalysesAndTenant("JeanPierre", "DeLaCruz", null, analysis); + RuleValidatorOutput out = validate(doc); + + Assertions.assertThat(out.isValid()).isTrue(); + Assertions.assertThat(out.ruleLevel()).isEqualTo(RuleValidatorOutput.RuleLevel.PASSED); + } + + @Test + @DisplayName("PASSED avec casse différente") + void passed_with_different_case() { + DocumentIAFileAnalysis analysis = iaAnalysisWith2DDoc( + "DUPONT", + null, + "JEAN", + null + ); + + Document doc = documentWithIaAnalysesAndTenant("jean", "dupont", null, analysis); + RuleValidatorOutput out = validate(doc); + + Assertions.assertThat(out.isValid()).isTrue(); + Assertions.assertThat(out.ruleLevel()).isEqualTo(RuleValidatorOutput.RuleLevel.PASSED); + } + + @Test + @DisplayName("PASSED avec caractères spéciaux normalisés") + void passed_with_special_characters_normalized() { + DocumentIAFileAnalysis analysis = iaAnalysisWith2DDoc( + "Müller", + null, + "François", + null + ); + + Document doc = documentWithIaAnalysesAndTenant("Francois", "Muller", null, analysis); + RuleValidatorOutput out = validate(doc); + + Assertions.assertThat(out.isValid()).isTrue(); + Assertions.assertThat(out.ruleLevel()).isEqualTo(RuleValidatorOutput.RuleLevel.PASSED); + } +} + diff --git a/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/mapper/DocumentIAMergerMapperTest.java b/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/mapper/DocumentIAMergerMapperTest.java new file mode 100644 index 000000000..de0c9b02e --- /dev/null +++ b/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/mapper/DocumentIAMergerMapperTest.java @@ -0,0 +1,319 @@ +package fr.dossierfacile.api.front.service.document.analysis.rule.validator.mapper; + +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia.DocumentIAPropertyType; +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia.mapper.DocumentIAField; +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia.mapper.DocumentIAMergerMapper; +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia.mapper.PropertyTransformer; +import fr.dossierfacile.common.entity.DocumentIAFileAnalysis; +import fr.dossierfacile.common.enums.DocumentIAFileAnalysisStatus; +import fr.dossierfacile.common.model.documentIA.BarcodeModel; +import fr.dossierfacile.common.model.documentIA.ExtractionModel; +import fr.dossierfacile.common.model.documentIA.GenericProperty; +import fr.dossierfacile.common.model.documentIA.ResultModel; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +class DocumentIAMergerMapperTest { + + // ========================== + // Modèle cible pour les tests + // ========================== + + public static class TestTargetModel { + + public static class TestTransformer implements PropertyTransformer { + @Override + public String transform(String input) { + return "TEST_TRANSFORMED_" + input; + } + } + + @DocumentIAField(twoDDocName = "nom_patronymique", extractionName = "nom") + String lastName; + + @DocumentIAField(twoDDocName = "prenoms", extractionName = "prenom") + String firstNames; + + @DocumentIAField(twoDDocName = "date_naissance", extractionName = "date_naissance", type = DocumentIAPropertyType.DATE) + LocalDate birthDate; + + @DocumentIAField(twoDDocName = "numero_document", extractionName = "numero_document") + String documentNumber; + + @DocumentIAField(twoDDocName = "test_transformer", extractionName = "test_transformer", type = DocumentIAPropertyType.STRING, transformer = TestTransformer.class) + String transformedField; + } + + public static class TestTargetModelOnlyExtraction { + @DocumentIAField(extractionName = "nom") + String lastName; + + @DocumentIAField(extractionName = "prenom") + String firstNames; + } + + // ========================== + // Fixtures helpers + // ========================== + + private BarcodeModel generateBarcodeModel(GenericProperty... properties) { + return BarcodeModel.builder() + .type("DATA_MATRIX") + .typedData(Stream.of(properties).filter(Objects::nonNull).toList()) + .build(); + } + + private DocumentIAFileAnalysis analysisWith2DDoc(BarcodeModel... barcodes) { + + ResultModel result = ResultModel.builder() + .barcodes(Stream.of(barcodes).filter(Objects::nonNull).toList()) + .extraction(null) + .build(); + + return DocumentIAFileAnalysis.builder() + .analysisStatus(DocumentIAFileAnalysisStatus.SUCCESS) + .result(result) + .build(); + } + + private DocumentIAFileAnalysis analysisWithExtraction(GenericProperty... properties) { + ExtractionModel extraction = ExtractionModel.builder() + .type("cni") + .properties(Stream.of(properties).filter(Objects::nonNull).toList()) + .build(); + + ResultModel result = ResultModel.builder() + .extraction(extraction) + .barcodes(List.of()) + .build(); + + return DocumentIAFileAnalysis.builder() + .analysisStatus(DocumentIAFileAnalysisStatus.SUCCESS) + .result(result) + .build(); + } + + private DocumentIAFileAnalysis buildFullAnalysis(BarcodeModel[] barcodes, GenericProperty[] extractionProperties) { + ExtractionModel extraction = ExtractionModel.builder() + .type("cni") + .properties(Stream.of(extractionProperties).filter(Objects::nonNull).toList()) + .build(); + + ResultModel result = ResultModel.builder() + .extraction(extraction) + .barcodes(Stream.of(barcodes).filter(Objects::nonNull).toList()) + .build(); + + return DocumentIAFileAnalysis.builder() + .analysisStatus(DocumentIAFileAnalysisStatus.SUCCESS) + .result(result) + .build(); + } + + private GenericProperty stringProp(String name, String value) { + return GenericProperty.builder() + .name(name) + .type("string") + .value(value) + .build(); + } + + private GenericProperty dateProp(String name, LocalDate date) { + return GenericProperty.builder() + .name(name) + .type("date") + .value(date != null ? date.toString() : null) + .build(); + } + + // ========================== + // Tests + // ========================== + + @Test + @DisplayName("Retourne empty quand aucune propriété n'est mappable") + void map_returns_empty_when_no_properties() { + List analyses = List.of( + analysisWith2DDoc(), + analysisWithExtraction() + ); + + Optional result = new DocumentIAMergerMapper().map(analyses, TestTargetModel.class); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("Mappe correctement les propriétés depuis un 2DDoc complet") + void map_complet_object_when_2D_doc_analysis() { + List analyses = List.of( + analysisWith2DDoc( + generateBarcodeModel( + stringProp("nom_patronymique", "Dupont"), + stringProp("prenoms", "Jean Pierre"), + dateProp("date_naissance", LocalDate.of(1990, 5, 20)), + stringProp("numero_document", "AB123456"), + stringProp("test_transformer", "original_value") + ) + ) + ); + + Optional result = new DocumentIAMergerMapper().map(analyses, TestTargetModel.class); + + assertThat(result).isPresent(); + assertThat(result.get().lastName).isEqualTo("Dupont"); + assertThat(result.get().firstNames).isEqualTo("Jean Pierre"); + assertThat(result.get().birthDate).isEqualTo(LocalDate.of(1990, 5, 20)); + assertThat(result.get().documentNumber).isEqualTo("AB123456"); + assertThat(result.get().transformedField).isEqualTo("TEST_TRANSFORMED_original_value"); + + } + + @Test + @DisplayName("Merge les données entre plusieurs 2DDoc de la meme analyse, le dernier 2D DOC a la priorité") + void map_complet_object_from_multiple_barcodes() { + List analyses = List.of( + analysisWith2DDoc( + generateBarcodeModel( + stringProp("nom_patronymique", "Dupont"), + stringProp("prenoms", "Jean Pierre"), + stringProp("numero_document", "11111111") + ), + generateBarcodeModel( + dateProp("date_naissance", LocalDate.of(1990, 5, 20)), + stringProp("numero_document", "AB123456"), + stringProp("test_transformer", "original_value") + ) + ) + ); + + Optional result = new DocumentIAMergerMapper().map(analyses, TestTargetModel.class); + + assertThat(result).isPresent(); + assertThat(result.get().lastName).isEqualTo("Dupont"); + assertThat(result.get().firstNames).isEqualTo("Jean Pierre"); + assertThat(result.get().birthDate).isEqualTo(LocalDate.of(1990, 5, 20)); + assertThat(result.get().documentNumber).isEqualTo("AB123456"); + assertThat(result.get().transformedField).isEqualTo("TEST_TRANSFORMED_original_value"); + + } + + @Test + @DisplayName("Merge les données entre plusieurs 2DDoc, la dernière analyse a la priorité") + void map_complet_object_from_multiple_barcodes_multi_analysis() { + List analyses = List.of( + analysisWith2DDoc( + generateBarcodeModel( + stringProp("nom_patronymique", "Dupont"), + stringProp("prenoms", "Jean Pierre"), + stringProp("numero_document", "11111111") + ) + ), + analysisWith2DDoc( + generateBarcodeModel( + dateProp("date_naissance", LocalDate.of(1990, 5, 20)), + stringProp("numero_document", "AB123456"), + stringProp("test_transformer", "original_value") + ) + ) + ); + + Optional result = new DocumentIAMergerMapper().map(analyses, TestTargetModel.class); + + assertThat(result).isPresent(); + assertThat(result.get().lastName).isEqualTo("Dupont"); + assertThat(result.get().firstNames).isEqualTo("Jean Pierre"); + assertThat(result.get().birthDate).isEqualTo(LocalDate.of(1990, 5, 20)); + assertThat(result.get().documentNumber).isEqualTo("AB123456"); + assertThat(result.get().transformedField).isEqualTo("TEST_TRANSFORMED_original_value"); + + } + + @Test + @DisplayName("Mappe correctement les propriétés depuis un 2DDoc et une extraction, le 2DDoc a la priorité") + void map_complet_object_from_2D_DOC_and_extraction() { + List analyses = List.of( + buildFullAnalysis( + new BarcodeModel[]{ + generateBarcodeModel( + stringProp("nom_patronymique", "Dupont"), + stringProp("prenoms", "Jean Pierre") + ) + }, + new GenericProperty[]{ + stringProp("nom", "ShouldNotBeUsed"), + stringProp("prenom", "ShouldNotBeUsed"), + dateProp("date_naissance", LocalDate.of(1990, 5, 20)), + stringProp("numero_document", "AB123456"), + stringProp("test_transformer", "original_value") + } + ) + ); + + Optional result = new DocumentIAMergerMapper().map(analyses, TestTargetModel.class); + + assertThat(result).isPresent(); + assertThat(result.get().lastName).isEqualTo("Dupont"); + assertThat(result.get().firstNames).isEqualTo("Jean Pierre"); + assertThat(result.get().birthDate).isEqualTo(LocalDate.of(1990, 5, 20)); + assertThat(result.get().documentNumber).isEqualTo("AB123456"); + assertThat(result.get().transformedField).isEqualTo("TEST_TRANSFORMED_original_value"); + + } + + @Test + @DisplayName("Mappe correctement les propriétés depuis un 2DDoc et une extraction, avec plusieurs analyses") + void map_complet_object_from_2D_DOC_and_extraction_multiple_analysis() { + List analyses = List.of( + analysisWith2DDoc( + generateBarcodeModel( + stringProp("nom_patronymique", "Dupont"), + stringProp("prenoms", "Jean Pierre") + ) + ), + analysisWithExtraction( + stringProp("nom", "ShouldNotBeUsed"), + stringProp("prenom", "ShouldNotBeUsed"), + dateProp("date_naissance", LocalDate.of(1990, 5, 20)), + stringProp("numero_document", "AB123456"), + stringProp("test_transformer", "original_value") + ) + ); + + Optional result = new DocumentIAMergerMapper().map(analyses, TestTargetModel.class); + + assertThat(result).isPresent(); + assertThat(result.get().lastName).isEqualTo("Dupont"); + assertThat(result.get().firstNames).isEqualTo("Jean Pierre"); + assertThat(result.get().birthDate).isEqualTo(LocalDate.of(1990, 5, 20)); + assertThat(result.get().documentNumber).isEqualTo("AB123456"); + assertThat(result.get().transformedField).isEqualTo("TEST_TRANSFORMED_original_value"); + + } + + @Test + @DisplayName("Mappe correctement les propriétés quand seul extractionName est défini") + void map_object_with_only_extraction_name() { + List analyses = List.of( + analysisWithExtraction( + stringProp("nom", "Martin"), + stringProp("prenom", "Paul") + ) + ); + + Optional result = new DocumentIAMergerMapper().map(analyses, TestTargetModelOnlyExtraction.class); + + assertThat(result).isPresent(); + assertThat(result.get().lastName).isEqualTo("Martin"); + assertThat(result.get().firstNames).isEqualTo("Paul"); + } + +} diff --git a/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/mapper/DocumentIAMultiMapperTest.java b/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/mapper/DocumentIAMultiMapperTest.java new file mode 100644 index 000000000..f234ec681 --- /dev/null +++ b/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/mapper/DocumentIAMultiMapperTest.java @@ -0,0 +1,215 @@ +package fr.dossierfacile.api.front.service.document.analysis.rule.validator.mapper; + +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia.DocumentIAPropertyType; +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia.mapper.DocumentIAField; +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia.mapper.DocumentIAMultiMapper; +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.document_ia.mapper.PropertyTransformer; +import fr.dossierfacile.common.entity.DocumentIAFileAnalysis; +import fr.dossierfacile.common.enums.DocumentIAFileAnalysisStatus; +import fr.dossierfacile.common.model.documentIA.BarcodeModel; +import fr.dossierfacile.common.model.documentIA.ExtractionModel; +import fr.dossierfacile.common.model.documentIA.GenericProperty; +import fr.dossierfacile.common.model.documentIA.ResultModel; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.List; +import java.util.Objects; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +class DocumentIAMultiMapperTest { + + public static class TestTargetModel { + + public static class TestTransformer implements PropertyTransformer { + @Override + public String transform(String input) { + return "TEST_TRANSFORMED_" + input; + } + } + + @DocumentIAField(twoDDocName = "nom_patronymique", extractionName = "nom") + String lastName; + + @DocumentIAField(twoDDocName = "prenoms", extractionName = "prenom") + String firstNames; + + @DocumentIAField(twoDDocName = "date_naissance", extractionName = "date_naissance", type = DocumentIAPropertyType.DATE) + LocalDate birthDate; + + @DocumentIAField(twoDDocName = "numero_document", extractionName = "numero_document") + String documentNumber; + + @DocumentIAField(twoDDocName = "test_transformer", extractionName = "test_transformer", type = DocumentIAPropertyType.STRING, transformer = TestTransformer.class) + String transformedField; + } + + // ========================== + // Fixtures helpers + // ========================== + + private BarcodeModel generateBarcodeModel(GenericProperty... properties) { + return BarcodeModel.builder() + .type("DATA_MATRIX") + .typedData(Stream.of(properties).filter(Objects::nonNull).toList()) + .build(); + } + + private DocumentIAFileAnalysis analysisWith2DDoc(BarcodeModel... barcodes) { + ResultModel result = ResultModel.builder() + .barcodes(Stream.of(barcodes).filter(Objects::nonNull).toList()) + .extraction(null) + .build(); + return DocumentIAFileAnalysis.builder() + .analysisStatus(DocumentIAFileAnalysisStatus.SUCCESS) + .result(result) + .build(); + } + + private DocumentIAFileAnalysis analysisWithExtraction(GenericProperty... properties) { + ExtractionModel extraction = ExtractionModel.builder() + .type("cni") + .properties(Stream.of(properties).filter(Objects::nonNull).toList()) + .build(); + ResultModel result = ResultModel.builder() + .extraction(extraction) + .barcodes(List.of()) + .build(); + return DocumentIAFileAnalysis.builder() + .analysisStatus(DocumentIAFileAnalysisStatus.SUCCESS) + .result(result) + .build(); + } + + private DocumentIAFileAnalysis analysisWithBoth(BarcodeModel[] barcodes, GenericProperty[] extractionProperties) { + ExtractionModel extraction = ExtractionModel.builder() + .type("cni") + .properties(Stream.of(extractionProperties).filter(Objects::nonNull).toList()) + .build(); + ResultModel result = ResultModel.builder() + .extraction(extraction) + .barcodes(Stream.of(barcodes).filter(Objects::nonNull).toList()) + .build(); + return DocumentIAFileAnalysis.builder() + .analysisStatus(DocumentIAFileAnalysisStatus.SUCCESS) + .result(result) + .build(); + } + + private GenericProperty stringProp(String name, String value) { + return GenericProperty.builder().name(name).type("string").value(value).build(); + } + + private GenericProperty dateProp(String name, LocalDate date) { + return GenericProperty.builder().name(name).type("date").value(date != null ? date.toString() : null).build(); + } + + // ========================== + // Tests + // ========================== + + @Test + @DisplayName("Retourne une liste vide quand aucune analyse n'est fournie") + void should_return_empty_list_when_no_analysis() { + List results = new DocumentIAMultiMapper().map(List.of(), TestTargetModel.class); + assertThat(results).isEmpty(); + } + + @Test + @DisplayName("Retourne un objet mappé depuis une seule analyse avec 2DDoc") + void should_map_single_analysis_with_2ddoc() { + var analysis = analysisWith2DDoc( + generateBarcodeModel( + stringProp("nom_patronymique", "Dupont"), + stringProp("prenoms", "Jean Pierre"), + dateProp("date_naissance", LocalDate.of(1990, 5, 20)), + stringProp("numero_document", "AB123456"), + stringProp("test_transformer", "original_value") + ) + ); + + List results = new DocumentIAMultiMapper().map(List.of(analysis), TestTargetModel.class); + + assertThat(results).hasSize(1); + TestTargetModel result = results.getFirst(); + assertThat(result.lastName).isEqualTo("Dupont"); + assertThat(result.firstNames).isEqualTo("Jean Pierre"); + assertThat(result.birthDate).isEqualTo(LocalDate.of(1990, 5, 20)); + assertThat(result.documentNumber).isEqualTo("AB123456"); + assertThat(result.transformedField).isEqualTo("TEST_TRANSFORMED_original_value"); + } + + @Test + @DisplayName("Retourne un objet mappé depuis une seule analyse avec Extraction") + void should_map_single_analysis_with_extraction() { + var analysis = analysisWithExtraction( + stringProp("nom", "Martin"), + stringProp("prenom", "Paul"), + dateProp("date_naissance", LocalDate.of(1985, 3, 10)) + ); + + List results = new DocumentIAMultiMapper().map(List.of(analysis), TestTargetModel.class); + + assertThat(results).hasSize(1); + TestTargetModel result = results.getFirst(); + assertThat(result.lastName).isEqualTo("Martin"); + assertThat(result.firstNames).isEqualTo("Paul"); + assertThat(result.birthDate).isEqualTo(LocalDate.of(1985, 3, 10)); + } + + @Test + @DisplayName("Priorise les données 2DDoc sur l'extraction au sein d'une même analyse") + void should_prioritize_2ddoc_over_extraction() { + var analysis = analysisWithBoth( + new BarcodeModel[]{generateBarcodeModel(stringProp("nom_patronymique", "Dupont"))}, + new GenericProperty[]{stringProp("nom", "Martin")} + ); + + List results = new DocumentIAMultiMapper().map(List.of(analysis), TestTargetModel.class); + + assertThat(results).hasSize(1); + assertThat(results.getFirst().lastName).isEqualTo("Dupont"); + } + + @Test + @DisplayName("Mappe plusieurs analyses en une liste de plusieurs résultats") + void should_map_multiple_analyses() { + var analysis1 = analysisWith2DDoc(generateBarcodeModel( + stringProp("nom_patronymique", "Dupont"), + stringProp("prenoms", "Jean") + )); + var analysis2 = analysisWithExtraction( + stringProp("nom", "Martin"), + stringProp("prenom", "Paul") + ); + + List results = new DocumentIAMultiMapper().map(List.of(analysis1, analysis2), TestTargetModel.class); + + assertThat(results).hasSize(2); + + TestTargetModel res1 = results.getFirst(); // L'ordre de la liste d'entrée doit être préservé + assertThat(res1.lastName).isEqualTo("Dupont"); + assertThat(res1.firstNames).isEqualTo("Jean"); + + TestTargetModel res2 = results.get(1); + assertThat(res2.lastName).isEqualTo("Martin"); + assertThat(res2.firstNames).isEqualTo("Paul"); + } + + @Test + @DisplayName("Ignore les analyses qui ne produisent aucun résultat mappé") + void should_ignore_analysis_without_mappable_data() { + var analysis1 = analysisWith2DDoc(generateBarcodeModel( + stringProp("nom_patronymique", "Dupont") + )); + var emptyAnalysis = analysisWithExtraction(); // Pas de propriétés + + List results = new DocumentIAMultiMapper().map(List.of(analysis1, emptyAnalysis), TestTargetModel.class); + + assertThat(results).hasSize(1); + assertThat(results.getFirst().lastName).isEqualTo("Dupont"); + } +} \ No newline at end of file diff --git a/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/payslip/PayslipContinuityRuleTest.java b/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/payslip/PayslipContinuityRuleTest.java new file mode 100644 index 000000000..f43bf1ef0 --- /dev/null +++ b/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/service/document/analysis/rule/validator/payslip/PayslipContinuityRuleTest.java @@ -0,0 +1,208 @@ +package fr.dossierfacile.api.front.service.document.analysis.rule.validator.payslip; + +import fr.dossierfacile.api.front.service.document.analysis.rule.validator.RuleValidatorOutput; +import fr.dossierfacile.common.entity.Document; +import fr.dossierfacile.common.entity.DocumentIAFileAnalysis; +import fr.dossierfacile.common.entity.File; +import fr.dossierfacile.common.enums.DocumentIAFileAnalysisStatus; +import fr.dossierfacile.common.model.documentIA.ExtractionModel; +import fr.dossierfacile.common.model.documentIA.GenericProperty; +import fr.dossierfacile.common.model.documentIA.ResultModel; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.time.Clock; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +class PayslipContinuityRuleTest { + + // ========================================================================= + // FIXTURES + // ========================================================================= + + // Helper to create a validator fixed in time + private PayslipContinuityRule createValidator(LocalDate currentFixedDate) { + Clock fixedClock = Clock.fixed( + currentFixedDate.atStartOfDay(ZoneId.systemDefault()).toInstant(), + ZoneId.systemDefault() + ); + return new PayslipContinuityRule(fixedClock); + } + + private Document createDocumentWithAnalyses(DocumentIAFileAnalysis... analyses) { + List files = new ArrayList<>(); + for (DocumentIAFileAnalysis analysis : analyses) { + File file = File.builder().build(); + file.setDocumentIAFileAnalysis(analysis); + if (analysis != null) { + analysis.setFile(file); + } + files.add(file); + } + return Document.builder().files(files).build(); + } + + // Note: On utilise "date_delivrance" car c'est ce qui est défini dans DocumentDate pour le moment. + // Idéalement il faudrait mettre à jour DocumentDate pour utiliser un champ plus cohérent pour les fiches de paie. + private DocumentIAFileAnalysis analysisWithDate(LocalDate startDate, LocalDate endDate) { + ExtractionModel extraction = ExtractionModel.builder() + .properties(List.of( + GenericProperty.builder() + .name("periode_debut") + .type("date") + .value(startDate.toString()) + .build(), + GenericProperty.builder() + .name("periode_fin") + .type("date") + .value(endDate.toString()) + .build() + )) + .build(); + + ResultModel result = ResultModel.builder() + .extraction(extraction) + .barcodes(Collections.emptyList()) + .build(); + + return DocumentIAFileAnalysis.builder() + .analysisStatus(DocumentIAFileAnalysisStatus.SUCCESS) + .result(result) + .build(); + } + + private DocumentIAFileAnalysis failedAnalysis() { + return DocumentIAFileAnalysis.builder() + .analysisStatus(DocumentIAFileAnalysisStatus.FAILED) + .build(); + } + + private static Stream provideContinuityScenarios() { + return Stream.of( + // Cas 1 : 10 Avril (<=15). Attend M-1 (Mars) .. M-4 (Dec). + // Scénario : Fev, Jan, Dec -> OK (3 consécutifs dans la fenêtre) + Arguments.of( + LocalDate.of(2023, 4, 10), + List.of(LocalDate.of(2023, 2, 1), LocalDate.of(2023, 1, 1), LocalDate.of(2022, 12, 1)), + true + ), + // Cas 2 : 10 Avril (<=15). + // Scénario : Mars, Fev, Jan -> OK (3 consécutifs dans la fenêtre) + Arguments.of( + LocalDate.of(2023, 4, 10), + List.of(LocalDate.of(2023, 3, 1), LocalDate.of(2023, 2, 1), LocalDate.of(2023, 1, 1)), + true + ), + // Cas 3 : 10 Avril (<=15). + // Scénario : Mars, Fev, Jan, Dec -> OK (4 présents, donc 3 consécutifs ok) + Arguments.of( + LocalDate.of(2023, 4, 10), + List.of(LocalDate.of(2023, 3, 1), LocalDate.of(2023, 2, 1), LocalDate.of(2023, 1, 1), LocalDate.of(2022, 12, 1)), + true + ), + // Cas 4 : 10 Février 2024 (<=15). Changement d'année. + // Fenêtre : Jan 24, Dec 23, Nov 23, Oct 23. + // Scénario : Jan 24, Dec 23, Nov 23 -> OK + Arguments.of( + LocalDate.of(2024, 2, 10), + List.of(LocalDate.of(2024, 1, 1), LocalDate.of(2023, 12, 1), LocalDate.of(2023, 11, 1)), + true + ), + // Cas 5 : 10 Mai (<=15). + // Fenêtre : Avril, Mars, Fev, Jan. + // Scénario : Avril, Fev, Jan -> KO (Manque Mars pour faire suite) + Arguments.of( + LocalDate.of(2023, 5, 10), + List.of(LocalDate.of(2023, 4, 1), LocalDate.of(2023, 2, 1), LocalDate.of(2023, 1, 1)), + false + ), + // Cas 6 : 10 Avril (<=15). + // Scénario : Mars, Mars, Jan -> KO (Doublon ne compte qu'une fois, donc Mars + Jan = pas de suite de 3) + Arguments.of( + LocalDate.of(2023, 4, 10), + List.of(LocalDate.of(2023, 3, 1), LocalDate.of(2023, 3, 1), LocalDate.of(2023, 1, 1)), + false + ), + // Cas 7 : 20 Avril (>15). Attend M (Avril) .. M-3 (Jan). + // Scénario : Avril, Mars, Fev -> OK + Arguments.of( + LocalDate.of(2023, 4, 20), + List.of(LocalDate.of(2023, 4, 1), LocalDate.of(2023, 3, 1), LocalDate.of(2023, 2, 1)), + true + ), + // Cas 8 : 20 Avril (>15). + // Scénario : Mars, Fev, Jan -> OK + Arguments.of( + LocalDate.of(2023, 4, 20), + List.of(LocalDate.of(2023, 3, 1), LocalDate.of(2023, 2, 1), LocalDate.of(2023, 1, 1)), + true + ), + // Cas 9 : 20 Avril (>15). + // Scénario : Fev, Jan, Dec -> KO (Dec n'est pas dans la fenêtre attendue [Avril..Jan]) + Arguments.of( + LocalDate.of(2023, 4, 20), + List.of(LocalDate.of(2023, 2, 1), LocalDate.of(2023, 1, 1), LocalDate.of(2022, 12, 1)), + false + ) + ); + } + + @ParameterizedTest(name = "Date: {0}, Mois fournis: {1} -> Valide: {2}") + @MethodSource("provideContinuityScenarios") + void checkContinuityRule(LocalDate currentDate, List payslipDates, boolean expectedValidity) { + PayslipContinuityRule validator = createValidator(currentDate); + + // Transforme la liste de dates en liste d'objets analysés + DocumentIAFileAnalysis[] analyses = payslipDates.stream() + .map(date -> analysisWithDate(date, date.plusMonths(1).minusDays(1))) + .toArray(DocumentIAFileAnalysis[]::new); + + Document document = createDocumentWithAnalyses(analyses); + + RuleValidatorOutput result = validator.validate(document); + + assertThat(result.isValid()).isEqualTo(expectedValidity); + } + + @Test + @DisplayName("Devrait être INCONCLUSIVE si aucune analyse n'est disponible") + void should_be_inconclusive_when_no_analysis() { + PayslipContinuityRule validator = createValidator(LocalDate.of(2023, 4, 10)); + Document document = Document.builder().files(new ArrayList<>()).build(); + + RuleValidatorOutput result = validator.validate(document); + + assertThat(result.ruleLevel()).isEqualTo(RuleValidatorOutput.RuleLevel.INCONCLUSIVE); + } + + @Test + @DisplayName("Si une analyse est failed on statut que c'est inconclusive") + void should_pass_when_mixed_with_failed_analyses() { + LocalDate currentDate = LocalDate.of(2023, 4, 10); + PayslipContinuityRule validator = createValidator(currentDate); + + // Analyse avec 3 mois consécutifs (valide) + 1 analyse échouée + DocumentIAFileAnalysis[] analyses = new DocumentIAFileAnalysis[]{ + analysisWithDate(LocalDate.of(2023, 3, 1), LocalDate.of(2023, 3, 31)), + analysisWithDate(LocalDate.of(2023, 2, 1), LocalDate.of(2023, 2, 28)), + analysisWithDate(LocalDate.of(2023, 1, 1), LocalDate.of(2023, 1, 31)), + failedAnalysis() + }; + + Document document = createDocumentWithAnalyses(analyses); + + RuleValidatorOutput result = validator.validate(document); + + assertThat(result.ruleLevel()).isEqualTo(RuleValidatorOutput.RuleLevel.INCONCLUSIVE); + } +} diff --git a/dossierfacile-bo/src/main/java/fr/gouv/bo/controller/BOTenantController.java b/dossierfacile-bo/src/main/java/fr/gouv/bo/controller/BOTenantController.java index fa1ab2225..7ab7553df 100644 --- a/dossierfacile-bo/src/main/java/fr/gouv/bo/controller/BOTenantController.java +++ b/dossierfacile-bo/src/main/java/fr/gouv/bo/controller/BOTenantController.java @@ -264,16 +264,13 @@ private List getItemDetailForSubcategoryOfDocument(DocumentCategory return itemDetails; } - private CustomMessage getCustomMessage(Tenant tenant) { - - CustomMessage customMessage = new CustomMessage(); + private List getMessageItemsForDocuments(List documents, Tenant tenant) { + List messageItems = new ArrayList<>(); - List documents = tenant.getDocuments(); - documents.sort(Comparator.comparing(Document::getDocumentCategory)); for (Document document : documents) { if (document.getDocumentStatus().equals(DocumentStatus.TO_PROCESS)) { - - customMessage.getMessageItems().add(MessageItem.builder() + var messageItemBuilder = MessageItem.builder(); + messageItemBuilder .monthlySum(document.getMonthlySum()) .newMonthlySum(document.getMonthlySum()) .customTex(document.getCustomText()) @@ -288,10 +285,29 @@ private CustomMessage getCustomMessage(Tenant tenant) { .analyzedFiles(DisplayableFile.onlyAnalyzedFilesOf(document)) .previousDeniedReasons(documentDeniedReasonsService.getLastDeniedReason(document, tenant).orElse(null)) .documentAnalysisReport(document.getDocumentAnalysisReport()) - .analysisReportComment(document.getDocumentAnalysisReport() != null && (DocumentAnalysisStatus.DENIED == document.getDocumentAnalysisReport().getAnalysisStatus()) ? document.getDocumentAnalysisReport().getComment() : null) - .build()); + .analysisReportComment(document.getDocumentAnalysisReport() != null && (DocumentAnalysisStatus.DENIED == document.getDocumentAnalysisReport().getAnalysisStatus()) ? document.getDocumentAnalysisReport().getComment() : null); + + var resultList = document.getFiles().stream().filter( + it -> it.getDocumentIAFileAnalysis() != null && it.getDocumentIAFileAnalysis().getAnalysisStatus() == DocumentIAFileAnalysisStatus.SUCCESS + ).map( + file -> file.getDocumentIAFileAnalysis().getResult() + ).toList(); + + messageItemBuilder.documentIAResults(resultList); + messageItems.add(messageItemBuilder.build()); } } + return messageItems; + } + + private CustomMessage getCustomMessage(Tenant tenant) { + + CustomMessage customMessage = new CustomMessage(); + + List documents = tenant.getDocuments(); + documents.sort(Comparator.comparing(Document::getDocumentCategory)); + + customMessage.setMessageItems(getMessageItemsForDocuments(documents, tenant)); for (Guarantor guarantor : tenant.getGuarantors()) { GuarantorItem guarantorItem = GuarantorItem.builder() @@ -304,30 +320,10 @@ private CustomMessage getCustomMessage(Tenant tenant) { documents = guarantor.getDocuments(); documents.sort(Comparator.comparing(Document::getDocumentCategory)); - for (Document document : documents) { - if (document.getDocumentStatus().equals(DocumentStatus.TO_PROCESS)) { - - guarantorItem.getMessageItems().add(MessageItem.builder() - .monthlySum(document.getMonthlySum()) - .newMonthlySum(document.getMonthlySum()) - .avisDetected(document.getAvisDetected()) - .customTex(document.getCustomText()) - .documentCategory(document.getDocumentCategory()) - .documentSubCategory(document.getDocumentSubCategory()) - .documentCategoryStep(document.getDocumentCategoryStep()) - .itemDetailList(getItemDetailForSubcategoryOfDocument(document.getDocumentCategory(), document.getDocumentSubCategory(), GUARANTOR)) - .documentId(document.getId()) - .documentName(document.getName()) - .metadataOfFiles(getFilesMetadata(document)) - .analyzedFiles(DisplayableFile.onlyAnalyzedFilesOf(document)) - .documentAnalysisReport(document.getDocumentAnalysisReport()) - .analysisReportComment(document.getDocumentAnalysisReport() != null && (DocumentAnalysisStatus.DENIED == document.getDocumentAnalysisReport().getAnalysisStatus()) ? document.getDocumentAnalysisReport().getComment() : null) - .previousDeniedReasons(documentDeniedReasonsService.getLastDeniedReason(document, tenant).orElse(null)) - .build()); - } - } + guarantorItem.setMessageItems(getMessageItemsForDocuments(documents, tenant)); customMessage.getGuarantorItems().add(guarantorItem); } + return customMessage; } diff --git a/dossierfacile-bo/src/main/java/fr/gouv/bo/dto/MessageItem.java b/dossierfacile-bo/src/main/java/fr/gouv/bo/dto/MessageItem.java index b9184a415..771c6b228 100644 --- a/dossierfacile-bo/src/main/java/fr/gouv/bo/dto/MessageItem.java +++ b/dossierfacile-bo/src/main/java/fr/gouv/bo/dto/MessageItem.java @@ -5,6 +5,7 @@ import fr.dossierfacile.common.enums.DocumentCategory; import fr.dossierfacile.common.enums.DocumentCategoryStep; import fr.dossierfacile.common.enums.DocumentSubCategory; +import fr.dossierfacile.common.model.documentIA.ResultModel; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -38,5 +39,6 @@ public class MessageItem { private DocumentDeniedReasons previousDeniedReasons; private DocumentAnalysisReport documentAnalysisReport; private String analysisReportComment; + private List documentIAResults = new ArrayList<>(); } diff --git a/dossierfacile-bo/src/main/resources/static/css/process-file.css b/dossierfacile-bo/src/main/resources/static/css/process-file.css index feee80ddd..85e317e07 100644 --- a/dossierfacile-bo/src/main/resources/static/css/process-file.css +++ b/dossierfacile-bo/src/main/resources/static/css/process-file.css @@ -248,4 +248,159 @@ div.scrollmenu a:hover { } .card-header.category-NULL{ background-color: rgb(179, 179, 179); +} + +.document-ia-block{ + padding-left: 15px; + padding-bottom: 5px; +} + +.ia-analysis-card { + background: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 12px; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05); + padding: 1rem; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + max-width: 100%; +} + +/* Header styling */ +.ia-icon-box { + background-color: #eff6ff; + color: #3b82f6; + width: 36px; + height: 36px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +/* Pagination styling */ +.ia-pagination { + height: 24px; + border: 1px solid #e2e8f0; +} + +.ia-pagination .btn:hover { + color: #3b82f6 !important; + background-color: rgba(59, 130, 246, 0.1); +} + +.ia-toggle-btn { + border-radius: 20px; + padding-left: 12px; + padding-right: 12px; + font-size: 0.8rem; + font-weight: 500; + transition: all 0.2s; +} + +.ia-toggle-btn .chevron { + transition: transform 0.3s ease; +} + +.ia-toggle-btn[aria-expanded="true"] .chevron { + transform: rotate(180deg); +} + +.ia-toggle-btn[aria-expanded="true"] .label-collapsed { display: none; } +.ia-toggle-btn[aria-expanded="false"] .label-expanded { display: none; } + +.ia-divider { + margin: 1rem 0; + border-color: #f1f5f9; + opacity: 1; +} + +.ia-section-title { + text-transform: uppercase; + letter-spacing: 0.05em; + font-size: 0.7rem; + font-weight: 700; + color: #94a3b8; + margin-bottom: 0.5rem; +} + +.ia-info-box { + background-color: #f8fafc; + border-radius: 8px; + padding: 10px; + border-left: 3px solid #3b82f6; +} + +.ia-data-grid { + display: flex; + flex-direction: column; + gap: 8px; +} + +.ia-data-row { + display: flex; + justify-content: space-between; + align-items: baseline; + padding-bottom: 6px; + border-bottom: 1px dashed #e2e8f0; + font-size: 0.9rem; +} + +.ia-data-row:last-child { + border-bottom: none; +} + +.ia-data-label { + color: #64748b; + font-weight: 500; + flex-shrink: 0; + margin-right: 1rem; + font-size: 0.85rem; +} + +.ia-data-label span::first-letter { + text-transform: uppercase; +} + +.ia-data-value { + text-align: right; + word-break: break-word; + line-height: 1.4; +} + +.ia-data-type { + font-size: 0.65rem; + color: #cbd5e1; + font-family: monospace; + margin-left: 4px; + vertical-align: middle; +} + +/* Style spécifique pour les codes barres */ +.ia-barcode-box { + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 8px; + background-color: #fff; +} + +.ia-code-block { + background-color: #f1f5f9; + padding: 8px; + border-radius: 6px; + font-size: 0.75rem; + color: #334155; + word-break: break-all; + max-height: 100px; + overflow-y: auto; + border: 1px solid #e2e8f0; +} + +/* Scrollbar fine pour le bloc de code */ +.ia-code-block::-webkit-scrollbar { + width: 4px; +} +.ia-code-block::-webkit-scrollbar-thumb { + background-color: #cbd5e1; + border-radius: 4px; } \ No newline at end of file diff --git a/dossierfacile-bo/src/main/resources/templates/bo/fragments/document-ia-data.html b/dossierfacile-bo/src/main/resources/templates/bo/fragments/document-ia-data.html new file mode 100644 index 000000000..0503f4369 --- /dev/null +++ b/dossierfacile-bo/src/main/resources/templates/bo/fragments/document-ia-data.html @@ -0,0 +1,214 @@ + +
+ + +
+ + +
+
+ +
+ +
+
+
Analyse IA
+ + + +
+
+ +
+ +
+ + + + + 1/ + + + +
+ + + +
+
+ + +
+ +
+ + +
+ + +
+

Classification

+
+
+ Type détecté + +
+
+ +
+
+
+ + +
+
+

Données extraites

+ +
+ +
+ +
+
+ +
+
+ + +
+
+
+
+ +
+

Codes détectés

+ +
+
+
+
+ + + + + + + + + + + + +
+ +
+ + +
+
+ + + + +
+
+
+
+ + + +
+
+
+
+
+
+
+
+
+
+
+
+ + +
+ +
+
+
+
+
+
+
+ + +
\ No newline at end of file diff --git a/dossierfacile-bo/src/main/resources/templates/bo/process-file.html b/dossierfacile-bo/src/main/resources/templates/bo/process-file.html index ad521cf92..2e7682752 100644 --- a/dossierfacile-bo/src/main/resources/templates/bo/process-file.html +++ b/dossierfacile-bo/src/main/resources/templates/bo/process-file.html @@ -227,6 +227,13 @@

Texte du document :

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