Align lease finalization validation flows (#53)
This commit is contained in:
1 parent
8b37c96671
commit
d16239881a
17 files changed
+839
-163
No files matched your search
+60
@@ -15,11 +15,47 @@ import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
final class LeaseFinalizationApi {
|
||||
private LeaseFinalizationApi() {}
|
||||
|
||||
record ValidationErrorResponse(String code, List<FieldValidationError> errors) {}
|
||||
|
||||
record FieldValidationError(String field, String code) {}
|
||||
|
||||
record GuaranteeDeedRequest(
|
||||
Integer requirementIndex, String guarantorId, String tenantId, Boolean attestation) {
|
||||
List<FieldValidationError> validationErrors() {
|
||||
List<FieldValidationError> errors = new ArrayList<>();
|
||||
if (requirementIndex == null || requirementIndex < 0) {
|
||||
errors.add(new FieldValidationError("guaranteeRequirements", "invalid"));
|
||||
return errors;
|
||||
}
|
||||
String path = "guaranteeRequirements[" + requirementIndex + "].";
|
||||
if (guarantorId == null || guarantorId.isBlank()) {
|
||||
errors.add(new FieldValidationError(path + "guarantorId", "required"));
|
||||
}
|
||||
if (tenantId == null || tenantId.isBlank()) {
|
||||
errors.add(new FieldValidationError(path + "tenantId", "required"));
|
||||
}
|
||||
if (!Boolean.TRUE.equals(attestation)) {
|
||||
errors.add(new FieldValidationError(path + "attestation", "required"));
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
String filePath() {
|
||||
return "guaranteeRequirements[" + requirementIndex + "].file";
|
||||
}
|
||||
|
||||
String attestationPath() {
|
||||
return "guaranteeRequirements[" + requirementIndex + "].attestation";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
record DeedResponse(
|
||||
String guarantorId,
|
||||
String tenantId,
|
||||
@@ -178,6 +214,30 @@ record FinalizationRequest(
|
||||
@NotNull Boolean entryInventoryDiscrepancies,
|
||||
@NotNull List<@Valid InventoryCorrectionRequest> inventoryCorrections,
|
||||
@NotNull Boolean allInventoryCorrectionsInitialed) {
|
||||
List<FieldValidationError> validationErrors() {
|
||||
List<FieldValidationError> errors = new ArrayList<>();
|
||||
if (Boolean.FALSE.equals(allPartiesSigned)) {
|
||||
errors.add(new FieldValidationError("allPartiesSigned", "required"));
|
||||
}
|
||||
if (Boolean.FALSE.equals(revisionMatches)) {
|
||||
errors.add(new FieldValidationError("revisionMatches", "required"));
|
||||
}
|
||||
if (Boolean.FALSE.equals(entryInventoryDiscrepancies)
|
||||
&& (inventoryCorrections != null && !inventoryCorrections.isEmpty()
|
||||
|| Boolean.TRUE.equals(allInventoryCorrectionsInitialed))) {
|
||||
errors.add(new FieldValidationError("entryInventoryDiscrepancies", "invalid"));
|
||||
}
|
||||
if (Boolean.TRUE.equals(entryInventoryDiscrepancies)
|
||||
&& (inventoryCorrections == null || inventoryCorrections.isEmpty())) {
|
||||
errors.add(new FieldValidationError("inventoryCorrections", "required"));
|
||||
}
|
||||
if (Boolean.TRUE.equals(entryInventoryDiscrepancies)
|
||||
&& Boolean.FALSE.equals(allInventoryCorrectionsInitialed)) {
|
||||
errors.add(new FieldValidationError("allInventoryCorrectionsInitialed", "required"));
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
LeaseFinalizationService.FinalizationRequest toServiceRequest() {
|
||||
return new LeaseFinalizationService.FinalizationRequest(
|
||||
revisionNumber,
|
||||
|
||||
+89
-7
@@ -4,18 +4,23 @@ import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlor
|
||||
import fr.nessar.lmnporganizer.rental.adapter.in.web.rent.RentDueApi;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.LeaseFinalizationService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.LeaseFinalizationService.PdfUpload;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.LeaseFinalizationValidationFailure;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentStorageFailure;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseGuaranteeDeed;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
|
||||
import jakarta.validation.Valid;
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -45,11 +50,25 @@ public LeaseFinalizationController(
|
||||
ResponseEntity<LeaseFinalizationApi.DeedResponse> uploadGuaranteeDeed(
|
||||
@AuthenticationPrincipal OidcUser principal,
|
||||
@PathVariable String leaseId,
|
||||
@RequestParam("guarantorId") String guarantorId,
|
||||
@RequestParam("tenantId") String tenantId,
|
||||
@RequestParam("attestation") boolean attestation,
|
||||
@RequestPart("file") MultipartFile file) {
|
||||
var deed =
|
||||
@RequestParam(value = "requirementIndex", required = false) Integer requirementIndex,
|
||||
@RequestParam(value = "guarantorId", required = false) String guarantorId,
|
||||
@RequestParam(value = "tenantId", required = false) String tenantId,
|
||||
@RequestParam(value = "attestation", required = false) Boolean attestation,
|
||||
@RequestPart(value = "file", required = false) MultipartFile file) {
|
||||
LeaseFinalizationApi.GuaranteeDeedRequest request =
|
||||
new LeaseFinalizationApi.GuaranteeDeedRequest(
|
||||
requirementIndex, guarantorId, tenantId, attestation);
|
||||
List<LeaseFinalizationApi.FieldValidationError> errors =
|
||||
new ArrayList<>(request.validationErrors());
|
||||
if (file == null || file.isEmpty()) {
|
||||
errors.add(new LeaseFinalizationApi.FieldValidationError(request.filePath(), "required"));
|
||||
}
|
||||
if (!errors.isEmpty()) {
|
||||
throw new FinalizationValidationFailure(errors);
|
||||
}
|
||||
LeaseGuaranteeDeed deed;
|
||||
try {
|
||||
deed =
|
||||
finalizations.uploadGuaranteeDeed(
|
||||
authenticatedLandlord.id(principal),
|
||||
new LeaseId(leaseId),
|
||||
@@ -58,6 +77,9 @@ ResponseEntity<LeaseFinalizationApi.DeedResponse> uploadGuaranteeDeed(
|
||||
attestation,
|
||||
upload(file),
|
||||
principal.getSubject());
|
||||
} catch (LeaseFinalizationValidationFailure failure) {
|
||||
throw deedValidationFailure(request, failure);
|
||||
}
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(LeaseFinalizationApi.DeedResponse.from(deed));
|
||||
}
|
||||
@@ -73,16 +95,29 @@ LeaseFinalizationApi.StateResponse state(
|
||||
ResponseEntity<LeaseFinalizationApi.FinalizationResponse> finalizeLease(
|
||||
@AuthenticationPrincipal OidcUser principal,
|
||||
@PathVariable String leaseId,
|
||||
@RequestPart("signedLease") MultipartFile signedLease,
|
||||
@RequestPart(value = "signedLease", required = false) MultipartFile signedLease,
|
||||
@RequestPart("metadata") @Valid LeaseFinalizationApi.FinalizationRequest metadata) {
|
||||
List<LeaseFinalizationApi.FieldValidationError> errors =
|
||||
new ArrayList<>(metadata.validationErrors());
|
||||
if (signedLease == null || signedLease.isEmpty()) {
|
||||
errors.add(new LeaseFinalizationApi.FieldValidationError("signedLease", "required"));
|
||||
}
|
||||
if (!errors.isEmpty()) {
|
||||
throw new FinalizationValidationFailure(errors);
|
||||
}
|
||||
LocalDate today = LocalDate.now(clock);
|
||||
var result =
|
||||
LeaseFinalizationService.Result result;
|
||||
try {
|
||||
result =
|
||||
finalizations.finalizeLease(
|
||||
authenticatedLandlord.id(principal),
|
||||
new LeaseId(leaseId),
|
||||
metadata.toServiceRequest(),
|
||||
upload(signedLease),
|
||||
principal.getSubject());
|
||||
} catch (LeaseFinalizationValidationFailure failure) {
|
||||
throw finalizationValidationFailure(failure);
|
||||
}
|
||||
var response =
|
||||
new LeaseFinalizationApi.FinalizationResponse(
|
||||
LeaseApi.Response.from(result.lease(), today),
|
||||
@@ -91,6 +126,15 @@ ResponseEntity<LeaseFinalizationApi.FinalizationResponse> finalizeLease(
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
||||
}
|
||||
|
||||
@ExceptionHandler(FinalizationValidationFailure.class)
|
||||
ResponseEntity<LeaseFinalizationApi.ValidationErrorResponse> invalid(
|
||||
FinalizationValidationFailure failure) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(
|
||||
new LeaseFinalizationApi.ValidationErrorResponse(
|
||||
"validation_failed", failure.errors()));
|
||||
}
|
||||
|
||||
private static PdfUpload upload(MultipartFile file) {
|
||||
try {
|
||||
return new PdfUpload(file.getOriginalFilename(), file.getContentType(), file.getBytes());
|
||||
@@ -98,4 +142,42 @@ private static PdfUpload upload(MultipartFile file) {
|
||||
throw new AttachmentStorageFailure("PDF could not be opened.", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static FinalizationValidationFailure deedValidationFailure(
|
||||
LeaseFinalizationApi.GuaranteeDeedRequest request,
|
||||
LeaseFinalizationValidationFailure failure) {
|
||||
if (LeaseFinalizationValidationFailure.ATTESTATION.equals(failure.field())) {
|
||||
return new FinalizationValidationFailure(
|
||||
List.of(
|
||||
new LeaseFinalizationApi.FieldValidationError(
|
||||
request.attestationPath(), failure.code())));
|
||||
}
|
||||
if (LeaseFinalizationValidationFailure.FILE.equals(failure.field())) {
|
||||
return new FinalizationValidationFailure(
|
||||
List.of(new LeaseFinalizationApi.FieldValidationError(request.filePath(), failure.code())));
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
|
||||
private static FinalizationValidationFailure finalizationValidationFailure(
|
||||
LeaseFinalizationValidationFailure failure) {
|
||||
String field =
|
||||
LeaseFinalizationValidationFailure.FILE.equals(failure.field())
|
||||
? "signedLease"
|
||||
: failure.field();
|
||||
return new FinalizationValidationFailure(
|
||||
List.of(new LeaseFinalizationApi.FieldValidationError(field, failure.code())));
|
||||
}
|
||||
|
||||
private static final class FinalizationValidationFailure extends RuntimeException {
|
||||
private final List<LeaseFinalizationApi.FieldValidationError> errors;
|
||||
|
||||
private FinalizationValidationFailure(List<LeaseFinalizationApi.FieldValidationError> errors) {
|
||||
this.errors = List.copyOf(errors);
|
||||
}
|
||||
|
||||
private List<LeaseFinalizationApi.FieldValidationError> errors() {
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
}
|
||||
+91
-53
@@ -119,8 +119,8 @@ public LeaseGuaranteeDeed uploadGuaranteeDeed(
|
||||
Lease lease = draft(landlordId, leaseId);
|
||||
requireAuthor(authorId);
|
||||
if (!attestation) {
|
||||
throw new LeaseRuleViolation(
|
||||
"L'attestation de conformité de l'acte de cautionnement est obligatoire.");
|
||||
throw new LeaseFinalizationValidationFailure(
|
||||
LeaseFinalizationValidationFailure.ATTESTATION, "required");
|
||||
}
|
||||
requirePair(lease, guarantorId, tenantId);
|
||||
byte[] content = pdf(upload);
|
||||
@@ -240,41 +240,25 @@ public Result finalizeLease(
|
||||
if (finalizations.find(landlordId, leaseId).isPresent()) {
|
||||
throw new LeaseFinalizationConflict("Ce bail est déjà finalisé.");
|
||||
}
|
||||
requireDraft(lease);
|
||||
if (!request.allPartiesSigned() || !request.revisionMatches()) {
|
||||
throw new LeaseRuleViolation(
|
||||
"Les signatures et la correspondance de la révision doivent être attestées.");
|
||||
requireDraftForFinalization(lease);
|
||||
if (!request.allPartiesSigned()) {
|
||||
throw new LeaseFinalizationValidationFailure("allPartiesSigned", "required");
|
||||
}
|
||||
if (!request.revisionMatches()) {
|
||||
throw new LeaseFinalizationValidationFailure("revisionMatches", "required");
|
||||
}
|
||||
if (request.lastSignatureDate() == null) {
|
||||
throw new LeaseRuleViolation("La date de dernière signature est obligatoire.");
|
||||
throw new LeaseFinalizationValidationFailure("lastSignatureDate", "required");
|
||||
}
|
||||
requireInventoryCorrectionAttestation(request);
|
||||
byte[] content = pdf(signedLease);
|
||||
LeaseDocumentRevision revision =
|
||||
revisions
|
||||
.findCurrent(landlordId, leaseId)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new LeaseRuleViolation(
|
||||
"Une révision du bail doit être générée avant finalisation."));
|
||||
if (request.revisionNumber() != revision.number()) {
|
||||
throw new LeaseRuleViolation("La révision signée n'est plus la révision courante du bail.");
|
||||
}
|
||||
LeaseDocumentRevision revision = currentRevision(landlordId, leaseId);
|
||||
requireCurrentRevision(landlordId, leaseId, request, revision);
|
||||
validateInventoryCorrections(revision, request.inventoryCorrections());
|
||||
String expectedTemplate =
|
||||
FurnishedLeaseTemplate.effectiveOn(request.lastSignatureDate()).visibleId();
|
||||
if (!expectedTemplate.equals(revision.templateVersion())) {
|
||||
throw new LeaseRuleViolation(
|
||||
"Le modèle signé ne correspond pas à la date de dernière signature.");
|
||||
}
|
||||
if (!documents.matchesCurrentSource(landlordId, leaseId, revision)) {
|
||||
throw new LeaseRuleViolation("Le bail a changé depuis la génération de la révision signée.");
|
||||
}
|
||||
List<GuaranteeRequirement> requirements =
|
||||
requirements(landlordId, lease, guaranteeDeeds.findAll(landlordId, leaseId));
|
||||
if (requirements.stream().anyMatch(requirement -> requirement.deed() == null)) {
|
||||
throw new LeaseRuleViolation(
|
||||
"Tous les actes de cautionnement requis doivent être téléversés.");
|
||||
throw new LeaseFinalizationValidationFailure("guaranteeRequirements", "required");
|
||||
}
|
||||
List<LeaseFinalization.LegalOverride> overrides =
|
||||
overrides(landlordId, lease, revision, request.confirmedWarningCodes(), authorId);
|
||||
@@ -298,7 +282,8 @@ public Result finalizeLease(
|
||||
request.inventoryCorrections(),
|
||||
request.allInventoryCorrectionsInitialed());
|
||||
try {
|
||||
return transactions.run(() -> finalizeInTransaction(landlordId, leaseId, revision, proposed));
|
||||
return transactions.run(
|
||||
() -> finalizeInTransaction(landlordId, leaseId, request, revision, proposed));
|
||||
} catch (RuntimeException failure) {
|
||||
discard(storageKey, failure);
|
||||
throw failure;
|
||||
@@ -308,14 +293,16 @@ public Result finalizeLease(
|
||||
private Result finalizeInTransaction(
|
||||
LandlordId landlordId,
|
||||
LeaseId leaseId,
|
||||
FinalizationRequest request,
|
||||
LeaseDocumentRevision revision,
|
||||
LeaseFinalization proposed) {
|
||||
if (finalizations.find(landlordId, leaseId).isPresent()) {
|
||||
throw new LeaseFinalizationConflict("Ce bail est déjà finalisé.");
|
||||
}
|
||||
if (!documents.matchesCurrentSource(landlordId, leaseId, revision)) {
|
||||
throw new LeaseRuleViolation("Le bail a changé depuis la génération de la révision signée.");
|
||||
}
|
||||
requireDraftForFinalization(lease(landlordId, leaseId));
|
||||
LeaseDocumentRevision current = currentRevision(landlordId, leaseId);
|
||||
requireSameRevision(revision, current);
|
||||
requireCurrentRevision(landlordId, leaseId, request, current);
|
||||
freezeInventory(landlordId, leaseId);
|
||||
Lease activated = activation.activateInTransaction(landlordId, leaseId);
|
||||
LeaseFinalization saved = finalizations.save(proposed);
|
||||
@@ -371,7 +358,8 @@ private static void requirePair(Lease lease, String guarantorId, TenantId tenant
|
||||
boolean guarantorExists =
|
||||
lease.guarantors().stream().anyMatch(guarantor -> guarantor.id().equals(guarantorId));
|
||||
if (!guarantorExists || !lease.tenantIds().contains(tenantId)) {
|
||||
throw new LeaseRuleViolation("Le couple garant-locataire n'appartient pas à ce bail.");
|
||||
throw new LeaseFinalizationConflict(
|
||||
"L’occupation ou les garanties du bail ont changé ; rechargez avant de continuer.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,15 +373,14 @@ private List<LeaseFinalization.LegalOverride> overrides(
|
||||
List<LeaseDocumentWarning> warnings = warnings(landlordId, lease, revision);
|
||||
if (confirmed.stream()
|
||||
.anyMatch(code -> warnings.stream().noneMatch(warning -> warning.code().equals(code)))) {
|
||||
throw new LeaseRuleViolation("Le code d'alerte confirmé est inconnu.");
|
||||
throw new LeaseFinalizationValidationFailure("confirmedWarningCodes", "invalid");
|
||||
}
|
||||
Instant now = Instant.now(clock);
|
||||
return warnings.stream()
|
||||
.map(
|
||||
warning -> {
|
||||
if (!confirmed.contains(warning.code())) {
|
||||
throw new LeaseRuleViolation(
|
||||
"L'alerte " + warning.code() + " doit être confirmée.");
|
||||
throw new LeaseFinalizationValidationFailure("confirmedWarningCodes", "required");
|
||||
}
|
||||
return new LeaseFinalization.LegalOverride(
|
||||
warning.code(), warning.ruleVersion(), authorId, now);
|
||||
@@ -452,28 +439,76 @@ private static void validateInventoryCorrections(
|
||||
revision.furnitureInventory().lines().stream()
|
||||
.map(FurnitureInventorySnapshot.Line::id)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
for (LeaseFinalization.InventoryCorrection correction : corrections) {
|
||||
for (int index = 0; index < corrections.size(); index++) {
|
||||
LeaseFinalization.InventoryCorrection correction = corrections.get(index);
|
||||
if (!privateAreaIds.contains(correction.privateAreaId())) {
|
||||
throw new LeaseRuleViolation(
|
||||
"L'espace privatif corrigé n'appartient pas à la révision du bail.");
|
||||
throw new LeaseFinalizationValidationFailure(
|
||||
"inventoryCorrections[" + index + "].privateAreaId", "invalid");
|
||||
}
|
||||
if (correction.inventoryLineId() != null
|
||||
&& !inventoryLineIds.contains(correction.inventoryLineId())) {
|
||||
throw new LeaseRuleViolation(
|
||||
"La ligne d'inventaire corrigée n'appartient pas à la révision du bail.");
|
||||
throw new LeaseFinalizationValidationFailure(
|
||||
"inventoryCorrections[" + index + "].inventoryLineId", "invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Lease draft(LandlordId landlordId, LeaseId leaseId) {
|
||||
Lease lease = lease(landlordId, leaseId);
|
||||
requireDraft(lease);
|
||||
requireDraftForFinalization(lease);
|
||||
return lease;
|
||||
}
|
||||
|
||||
private static void requireDraft(Lease lease) {
|
||||
private static void requireDraftForFinalization(Lease lease) {
|
||||
if (lease.status() != LeaseStatus.DRAFT) {
|
||||
throw new LeaseRuleViolation("Seul un bail en brouillon peut être finalisé.");
|
||||
throw new LeaseFinalizationConflict(
|
||||
"Ce bail n'est plus un brouillon ; rechargez-le avant de finaliser.");
|
||||
}
|
||||
}
|
||||
|
||||
private LeaseDocumentRevision currentRevision(LandlordId landlordId, LeaseId leaseId) {
|
||||
return revisions
|
||||
.findCurrent(landlordId, leaseId)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new LeaseFinalizationConflict(
|
||||
"La révision signée n'est plus disponible ; générez-la à nouveau."));
|
||||
}
|
||||
|
||||
private void requireCurrentRevision(
|
||||
LandlordId landlordId,
|
||||
LeaseId leaseId,
|
||||
FinalizationRequest request,
|
||||
LeaseDocumentRevision revision) {
|
||||
if (request.revisionNumber() != revision.number()) {
|
||||
throw new LeaseFinalizationConflict(
|
||||
"La révision signée n'est plus la révision courante du bail.");
|
||||
}
|
||||
String expectedTemplate =
|
||||
FurnishedLeaseTemplate.effectiveOn(request.lastSignatureDate()).visibleId();
|
||||
if (!expectedTemplate.equals(revision.templateVersion())) {
|
||||
throw new LeaseFinalizationConflict(
|
||||
"Le modèle signé ne correspond pas à la date de dernière signature.");
|
||||
}
|
||||
if (!documents.matchesCurrentSource(landlordId, leaseId, revision)) {
|
||||
throw new LeaseFinalizationConflict(
|
||||
"Le bail a changé depuis la génération de la révision signée.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireSameRevision(
|
||||
LeaseDocumentRevision signedRevision, LeaseDocumentRevision currentRevision) {
|
||||
if (signedRevision.number() != currentRevision.number()) {
|
||||
throw new LeaseFinalizationConflict(
|
||||
"La révision signée n'est plus la révision courante du bail.");
|
||||
}
|
||||
if (!signedRevision.templateVersion().equals(currentRevision.templateVersion())) {
|
||||
throw new LeaseFinalizationConflict(
|
||||
"Le modèle signé ne correspond plus à la révision courante du bail.");
|
||||
}
|
||||
if (!signedRevision.sourceFingerprint().equals(currentRevision.sourceFingerprint())) {
|
||||
throw new LeaseFinalizationConflict(
|
||||
"Le bail a changé depuis la génération de la révision signée.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -483,11 +518,13 @@ private Lease lease(LandlordId landlordId, LeaseId leaseId) {
|
||||
|
||||
private byte[] pdf(PdfUpload upload) {
|
||||
if (upload == null) {
|
||||
throw new LeaseRuleViolation("Un fichier PDF est obligatoire.");
|
||||
throw new LeaseFinalizationValidationFailure(
|
||||
LeaseFinalizationValidationFailure.FILE, "required");
|
||||
}
|
||||
byte[] content = upload.content();
|
||||
if (content.length == 0) {
|
||||
throw new LeaseRuleViolation("Un fichier PDF est obligatoire.");
|
||||
throw new LeaseFinalizationValidationFailure(
|
||||
LeaseFinalizationValidationFailure.FILE, "required");
|
||||
}
|
||||
if (content.length > maximumPdfSize) {
|
||||
throw new AttachmentTooLarge(maximumPdfSize);
|
||||
@@ -495,7 +532,8 @@ private byte[] pdf(PdfUpload upload) {
|
||||
if (!"application/pdf".equalsIgnoreCase(upload.contentType())
|
||||
|| content.length < 5
|
||||
|| !Arrays.equals(Arrays.copyOf(content, 5), "%PDF-".getBytes(StandardCharsets.US_ASCII))) {
|
||||
throw new LeaseRuleViolation("Le fichier doit être un PDF lisible.");
|
||||
throw new LeaseFinalizationValidationFailure(
|
||||
LeaseFinalizationValidationFailure.FILE, "invalid");
|
||||
}
|
||||
return content;
|
||||
}
|
||||
@@ -519,13 +557,13 @@ private static void requireInventoryCorrectionAttestation(FinalizationRequest re
|
||||
if (!request.entryInventoryDiscrepancies()
|
||||
&& (!request.inventoryCorrections().isEmpty()
|
||||
|| request.allInventoryCorrectionsInitialed())) {
|
||||
throw new LeaseRuleViolation("Les corrections d’inventaire exigent une réponse positive.");
|
||||
throw new LeaseFinalizationValidationFailure("entryInventoryDiscrepancies", "invalid");
|
||||
}
|
||||
if (request.entryInventoryDiscrepancies()
|
||||
&& (request.inventoryCorrections().isEmpty()
|
||||
|| !request.allInventoryCorrectionsInitialed())) {
|
||||
throw new LeaseRuleViolation(
|
||||
"Chaque correction manuscrite d’inventaire doit être déclarée et paraphée.");
|
||||
if (request.entryInventoryDiscrepancies() && request.inventoryCorrections().isEmpty()) {
|
||||
throw new LeaseFinalizationValidationFailure("inventoryCorrections", "required");
|
||||
}
|
||||
if (request.entryInventoryDiscrepancies() && !request.allInventoryCorrectionsInitialed()) {
|
||||
throw new LeaseFinalizationValidationFailure("allInventoryCorrectionsInitialed", "required");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.lease;
|
||||
|
||||
public final class LeaseFinalizationValidationFailure extends RuntimeException {
|
||||
public static final String FILE = "file";
|
||||
public static final String ATTESTATION = "attestation";
|
||||
|
||||
private final String field;
|
||||
private final String code;
|
||||
|
||||
public LeaseFinalizationValidationFailure(String field, String code) {
|
||||
this.field = field;
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String field() {
|
||||
return field;
|
||||
}
|
||||
|
||||
public String code() {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
+39
@@ -16,6 +16,7 @@ import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlor
|
||||
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.RentalApiExceptionHandler;
|
||||
import fr.nessar.lmnporganizer.rental.application.landlord.LandlordAccountService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.LeaseFinalizationService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.LeaseFinalizationValidationFailure;
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.PrivateArea;
|
||||
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureEquipmentKind;
|
||||
@@ -92,6 +93,7 @@ void guaranteeUploadUsesAuthenticatedScopeAndMultipartAttestation() throws Excep
|
||||
.perform(
|
||||
multipart("/api/leases/lease-1/guarantee-deeds")
|
||||
.file(pdf("file"))
|
||||
.param("requirementIndex", "0")
|
||||
.param("guarantorId", "guarantor-1")
|
||||
.param("tenantId", "tenant-1")
|
||||
.param("attestation", "true")
|
||||
@@ -198,6 +200,7 @@ void differentGuaranteeAndDoubleFinalizationConflictsMapTo409() throws Exception
|
||||
.perform(
|
||||
multipart("/api/leases/lease-1/guarantee-deeds")
|
||||
.file(pdf("file"))
|
||||
.param("requirementIndex", "0")
|
||||
.param("guarantorId", "guarantor-1")
|
||||
.param("tenantId", "tenant-1")
|
||||
.param("attestation", "true")
|
||||
@@ -214,6 +217,42 @@ void differentGuaranteeAndDoubleFinalizationConflictsMapTo409() throws Exception
|
||||
.andExpect(status().isConflict());
|
||||
}
|
||||
|
||||
@Test
|
||||
void guaranteeAndFinalizationValidationUseVisibleStableFieldPaths() throws Exception {
|
||||
mockMvc
|
||||
.perform(
|
||||
multipart("/api/leases/lease-1/guarantee-deeds")
|
||||
.file(pdf("file"))
|
||||
.param("requirementIndex", "0")
|
||||
.param("guarantorId", "")
|
||||
.param("tenantId", "")
|
||||
.param("attestation", "false")
|
||||
.with(landlord())
|
||||
.with(csrf()))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("validation_failed"))
|
||||
.andExpect(jsonPath("$.errors[0].field").value("guaranteeRequirements[0].guarantorId"))
|
||||
.andExpect(jsonPath("$.errors[1].field").value("guaranteeRequirements[0].tenantId"))
|
||||
.andExpect(jsonPath("$.errors[2].field").value("guaranteeRequirements[0].attestation"));
|
||||
|
||||
when(finalizations.finalizeLease(any(), any(), any(), any(), any()))
|
||||
.thenThrow(
|
||||
new LeaseFinalizationValidationFailure(
|
||||
"inventoryCorrections[0].privateAreaId", "invalid"));
|
||||
mockMvc
|
||||
.perform(
|
||||
multipart("/api/leases/lease-1/finalization")
|
||||
.file(pdf("signedLease"))
|
||||
.file(metadata())
|
||||
.with(landlord())
|
||||
.with(csrf()))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("validation_failed"))
|
||||
.andExpect(
|
||||
jsonPath("$.errors[0].field").value("inventoryCorrections[0].privateAreaId"))
|
||||
.andExpect(jsonPath("$.errors[0].code").value("invalid"));
|
||||
}
|
||||
|
||||
private static MockMultipartFile pdf(String partName) {
|
||||
return new MockMultipartFile(
|
||||
partName, "signed.pdf", MediaType.APPLICATION_PDF_VALUE, "%PDF-signed".getBytes());
|
||||
|
||||
+47
-9
@@ -46,7 +46,6 @@ import fr.nessar.lmnporganizer.rental.domain.lease.LeaseGuaranteeDeed;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseGuaranteeDeedRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentTerms;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.RentalPeriod;
|
||||
@@ -210,7 +209,7 @@ void incompatibleTemplateAndStaleRevisionOrFactsAreBlockedBeforeStorage() {
|
||||
LeaseFinalizationService service = service(clock("2026-10-02T12:00:00Z"));
|
||||
|
||||
assertThrows(
|
||||
LeaseRuleViolation.class,
|
||||
LeaseFinalizationConflict.class,
|
||||
() ->
|
||||
service.finalizeLease(
|
||||
LANDLORD_ID,
|
||||
@@ -219,7 +218,7 @@ void incompatibleTemplateAndStaleRevisionOrFactsAreBlockedBeforeStorage() {
|
||||
upload(PDF),
|
||||
"subject-1"));
|
||||
assertThrows(
|
||||
LeaseRuleViolation.class,
|
||||
LeaseFinalizationConflict.class,
|
||||
() ->
|
||||
service.finalizeLease(
|
||||
LANDLORD_ID,
|
||||
@@ -231,7 +230,7 @@ void incompatibleTemplateAndStaleRevisionOrFactsAreBlockedBeforeStorage() {
|
||||
LeaseDocumentRevision current = revision(2, FurnishedLeaseTemplate.OCTOBER_2026.visibleId());
|
||||
when(documents.matchesCurrentSource(LANDLORD_ID, LEASE_ID, current)).thenReturn(false);
|
||||
assertThrows(
|
||||
LeaseRuleViolation.class,
|
||||
LeaseFinalizationConflict.class,
|
||||
() ->
|
||||
service.finalizeLease(
|
||||
LANDLORD_ID,
|
||||
@@ -242,6 +241,45 @@ void incompatibleTemplateAndStaleRevisionOrFactsAreBlockedBeforeStorage() {
|
||||
verify(storage, never()).store(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void finalizationRechecksRevisionAndOccupancyInsideTheTransaction() {
|
||||
Lease draft = draft(List.of(TENANT_1), List.of(), new BigDecimal("900"));
|
||||
LeaseDocumentRevision signedRevision = revision(1, FurnishedLeaseTemplate.CURRENT.visibleId());
|
||||
LeaseDocumentRevision newerRevision = revision(2, FurnishedLeaseTemplate.CURRENT.visibleId());
|
||||
when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(draft));
|
||||
when(documents.matchesCurrentSource(LANDLORD_ID, LEASE_ID, signedRevision)).thenReturn(true);
|
||||
when(storage.store(any())).thenReturn("signed-key");
|
||||
when(revisions.findCurrent(LANDLORD_ID, LEASE_ID))
|
||||
.thenReturn(Optional.of(signedRevision), Optional.of(newerRevision));
|
||||
|
||||
assertThrows(
|
||||
LeaseFinalizationConflict.class,
|
||||
() ->
|
||||
service(clock("2026-07-19T12:00:00Z"))
|
||||
.finalizeLease(
|
||||
LANDLORD_ID,
|
||||
LEASE_ID,
|
||||
request(1, LocalDate.of(2026, 7, 19), Set.of()),
|
||||
upload(PDF),
|
||||
"subject-1"));
|
||||
verify(storage).discard("signed-key");
|
||||
|
||||
when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(draft), Optional.of(active()));
|
||||
when(revisions.findCurrent(LANDLORD_ID, LEASE_ID))
|
||||
.thenReturn(Optional.of(signedRevision), Optional.of(signedRevision));
|
||||
assertThrows(
|
||||
LeaseFinalizationConflict.class,
|
||||
() ->
|
||||
service(clock("2026-07-19T12:00:00Z"))
|
||||
.finalizeLease(
|
||||
LANDLORD_ID,
|
||||
LEASE_ID,
|
||||
request(1, LocalDate.of(2026, 7, 19), Set.of()),
|
||||
upload(PDF),
|
||||
"subject-1"));
|
||||
verify(storage, org.mockito.Mockito.times(2)).discard("signed-key");
|
||||
}
|
||||
|
||||
@Test
|
||||
void excessiveDepositRequiresAndRecordsVersionedOverride() {
|
||||
Lease draft = draft(List.of(TENANT_1), List.of(), new BigDecimal("1900"));
|
||||
@@ -252,7 +290,7 @@ void excessiveDepositRequiresAndRecordsVersionedOverride() {
|
||||
LeaseFinalizationService service = service(clock("2026-07-19T12:00:00Z"));
|
||||
|
||||
assertThrows(
|
||||
LeaseRuleViolation.class,
|
||||
LeaseFinalizationValidationFailure.class,
|
||||
() ->
|
||||
service.finalizeLease(
|
||||
LANDLORD_ID,
|
||||
@@ -309,7 +347,7 @@ void finalizationRequiresAndAuditsWarningsFrozenOnTheRevisionWithoutReadingApart
|
||||
List.of(LeaseFinalizationWarning.DPE_EXPIRED.name()),
|
||||
service.state(LANDLORD_ID, LEASE_ID).warningCodes());
|
||||
assertThrows(
|
||||
LeaseRuleViolation.class,
|
||||
LeaseFinalizationValidationFailure.class,
|
||||
() ->
|
||||
service.finalizeLease(
|
||||
LANDLORD_ID,
|
||||
@@ -417,7 +455,7 @@ void finalizationRequiresInitialedStructuredInventoryCorrectionsWhenDiscrepancie
|
||||
"correction-1", null, "area-1", "Chaise", 0, "Absente à l'entrée");
|
||||
|
||||
assertThrows(
|
||||
LeaseRuleViolation.class,
|
||||
LeaseFinalizationValidationFailure.class,
|
||||
() ->
|
||||
service(clock("2026-07-19T12:00:00Z"))
|
||||
.finalizeLease(
|
||||
@@ -466,7 +504,7 @@ void
|
||||
|
||||
LeaseFinalizationService service = service(clock("2026-07-19T12:00:00Z"));
|
||||
assertThrows(
|
||||
LeaseRuleViolation.class,
|
||||
LeaseFinalizationValidationFailure.class,
|
||||
() ->
|
||||
service.finalizeLease(
|
||||
LANDLORD_ID,
|
||||
@@ -477,7 +515,7 @@ void
|
||||
upload(PDF),
|
||||
"subject-1"));
|
||||
assertThrows(
|
||||
LeaseRuleViolation.class,
|
||||
LeaseFinalizationValidationFailure.class,
|
||||
() ->
|
||||
service.finalizeLease(
|
||||
LANDLORD_ID,
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
<p class="uploaded-deed">
|
||||
<strong>{{ deed.fileName }}</strong>
|
||||
<span>Acte téléversé le {{ deed.uploadedAt }}</span>
|
||||
<span>Empreinte SHA-256 : {{ deed.sha256 }}</span>
|
||||
<span>Déposé par : {{ deed.authorId }}</span>
|
||||
</p>
|
||||
} @else {
|
||||
<label class="file-action">
|
||||
@@ -36,11 +38,17 @@
|
||||
requirement.tenantName
|
||||
"
|
||||
(change)="selectFile($event, requirement)"
|
||||
[attr.aria-invalid]="fieldError(requirement, 'file') ? 'true' : null"
|
||||
/>
|
||||
</label>
|
||||
@if (selectedFiles()[key(requirement)]; as file) {
|
||||
<p class="selected-file">{{ file.name }}</p>
|
||||
}
|
||||
@if (fieldError(requirement, 'file')) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ fieldError(requirement, 'file') }}
|
||||
</p>
|
||||
}
|
||||
<mat-checkbox
|
||||
[checked]="attestedKeys().has(key(requirement))"
|
||||
[disabled]="uploadingKeys().has(key(requirement))"
|
||||
@@ -48,14 +56,15 @@
|
||||
>
|
||||
J'atteste transmettre l'acte signé correspondant à ce garant et ce locataire.
|
||||
</mat-checkbox>
|
||||
@if (fieldError(requirement, 'attestation')) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ fieldError(requirement, 'attestation') }}
|
||||
</p>
|
||||
}
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="
|
||||
!selectedFiles()[key(requirement)] ||
|
||||
!attestedKeys().has(key(requirement)) ||
|
||||
uploadingKeys().has(key(requirement))
|
||||
"
|
||||
[disabled]="uploadingKeys().has(key(requirement))"
|
||||
(click)="upload(requirement)"
|
||||
>
|
||||
@if (uploadingKeys().has(key(requirement))) {
|
||||
|
||||
@@ -62,7 +62,12 @@ describe('GuaranteeDeedList', () => {
|
||||
const uploaded = vi.fn();
|
||||
component.deedUploaded.subscribe(uploaded);
|
||||
const button: HTMLButtonElement = fixture.nativeElement.querySelector('button');
|
||||
expect(button.disabled).toBe(true);
|
||||
expect(button.disabled).toBe(false);
|
||||
button.click();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain('Sélectionnez l’acte signé');
|
||||
expect(fixture.nativeElement.textContent).toContain('Attestez transmettre');
|
||||
expect(uploadGuaranteeDeed).not.toHaveBeenCalled();
|
||||
|
||||
component.selectedFiles.set({ 'guarantor-1:tenant-1': file });
|
||||
component.setAttestation(requirement, true);
|
||||
@@ -75,6 +80,7 @@ describe('GuaranteeDeedList', () => {
|
||||
file,
|
||||
'guarantor-1',
|
||||
'tenant-1',
|
||||
0,
|
||||
);
|
||||
expect(fixture.nativeElement.textContent).toContain('Téléversement…');
|
||||
|
||||
@@ -89,7 +95,7 @@ describe('GuaranteeDeedList', () => {
|
||||
|
||||
it('keeps an upload failure local to its pair', () => {
|
||||
uploadGuaranteeDeed.mockReturnValue(
|
||||
throwError(() => ({ error: { message: 'Acte refusé' } })),
|
||||
throwError(() => ({ status: 409, error: { message: 'Acte refusé' } })),
|
||||
);
|
||||
const fixture = createList();
|
||||
const component = fixture.componentInstance;
|
||||
@@ -107,10 +113,59 @@ describe('GuaranteeDeedList', () => {
|
||||
expect(fixture.nativeElement.querySelector('[role=alert]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('clears an invalid replacement and explains a missing file or attestation on submit', async () => {
|
||||
const fixture = createList();
|
||||
const component = fixture.componentInstance;
|
||||
component.selectedFiles.set({
|
||||
'guarantor-1:tenant-1': new File(['%PDF-valid'], 'previous.pdf', {
|
||||
type: 'application/pdf',
|
||||
}),
|
||||
});
|
||||
const input = document.createElement('input');
|
||||
const invalid = new File(['not a PDF'], 'notes.txt', { type: 'text/plain' });
|
||||
Object.defineProperty(input, 'files', {
|
||||
value: { item: () => invalid },
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
await component.selectFile({ target: input } as unknown as Event, requirement);
|
||||
expect(component.selectedFiles()['guarantor-1:tenant-1']).toBeUndefined();
|
||||
expect(component.fieldError(requirement, 'file')).toContain('PDF valide');
|
||||
|
||||
component.upload(requirement);
|
||||
expect(component.fieldError(requirement, 'attestation')).toContain('Attestez');
|
||||
expect(uploadGuaranteeDeed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not accept a deed response for another guarantee pair', () => {
|
||||
const response = new Subject<GuaranteeDeed>();
|
||||
uploadGuaranteeDeed.mockReturnValue(response);
|
||||
const fixture = createList();
|
||||
const component = fixture.componentInstance;
|
||||
const uploaded = vi.fn();
|
||||
component.deedUploaded.subscribe(uploaded);
|
||||
component.selectedFiles.set({
|
||||
'guarantor-1:tenant-1': new File(['%PDF-valid'], 'cautionnement.pdf', {
|
||||
type: 'application/pdf',
|
||||
}),
|
||||
});
|
||||
component.setAttestation(requirement, true);
|
||||
|
||||
component.upload(requirement);
|
||||
response.next({ ...deed, tenantId: 'tenant-2' });
|
||||
response.complete();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(uploaded).not.toHaveBeenCalled();
|
||||
expect(component.selectedFiles()['guarantor-1:tenant-1']?.name).toBe('cautionnement.pdf');
|
||||
expect(fixture.nativeElement.textContent).toContain('ne correspond pas au couple');
|
||||
});
|
||||
|
||||
it('renders an already uploaded deed without another upload form', () => {
|
||||
const fixture = createList([{ ...requirement, deed }]);
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('cautionnement.pdf');
|
||||
expect(fixture.nativeElement.textContent).toContain('Empreinte SHA-256');
|
||||
expect(fixture.nativeElement.querySelector('input[type=file]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
GuaranteeRequirement,
|
||||
LeaseFinalizationService,
|
||||
} from '../../services/lease-finalization.service';
|
||||
import { httpErrorMessage } from '../../services/http-error-message';
|
||||
import { FieldValidationError, saveFailure } from '../../services/http-error-message';
|
||||
import { withoutRecordKey, withoutSetValue } from '../../services/immutable-state';
|
||||
|
||||
export interface GuaranteeDeedUploaded {
|
||||
@@ -35,22 +35,35 @@ export class GuaranteeDeedList {
|
||||
readonly attestedKeys = signal<ReadonlySet<string>>(new Set());
|
||||
readonly uploadingKeys = signal<ReadonlySet<string>>(new Set());
|
||||
readonly errors = signal<Record<string, string>>({});
|
||||
readonly fieldErrors = signal<Record<string, FieldValidationError[]>>({});
|
||||
private readonly fileSelectionIds: Record<string, number> = {};
|
||||
|
||||
key(requirement: GuaranteeRequirement): string {
|
||||
return `${requirement.guarantorId}:${requirement.tenantId}`;
|
||||
}
|
||||
|
||||
selectFile(event: Event, requirement: GuaranteeRequirement): void {
|
||||
async selectFile(event: Event, requirement: GuaranteeRequirement): Promise<void> {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.item(0);
|
||||
const file = input.files?.item(0) ?? null;
|
||||
const key = this.key(requirement);
|
||||
if (file && file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
|
||||
this.errors.update((errors) => ({ ...errors, [key]: 'Sélectionnez un fichier PDF.' }));
|
||||
} else {
|
||||
this.errors.update((errors) => withoutRecordKey(errors, key));
|
||||
this.selectedFiles.update((files) => ({ ...files, [key]: file ?? undefined }));
|
||||
}
|
||||
const selectionId = (this.fileSelectionIds[key] ?? 0) + 1;
|
||||
this.fileSelectionIds[key] = selectionId;
|
||||
input.value = '';
|
||||
this.clearFieldError(requirement, 'file');
|
||||
if (!file) {
|
||||
this.selectedFiles.update((files) => ({ ...files, [key]: undefined }));
|
||||
return;
|
||||
}
|
||||
const isPdf =
|
||||
file.type.toLowerCase() === 'application/pdf' &&
|
||||
(await file.slice(0, 5).text()) === '%PDF-';
|
||||
if (selectionId !== this.fileSelectionIds[key]) return;
|
||||
this.selectedFiles.update((files) => ({ ...files, [key]: isPdf ? file : undefined }));
|
||||
if (!isPdf) {
|
||||
this.setFieldErrors(key, [
|
||||
{ field: this.fieldPath(requirement, 'file'), code: 'invalid' },
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
setAttestation(requirement: GuaranteeRequirement, checked: boolean): void {
|
||||
@@ -61,37 +74,122 @@ export class GuaranteeDeedList {
|
||||
else next.delete(key);
|
||||
return next;
|
||||
});
|
||||
this.clearFieldError(requirement, 'attestation');
|
||||
}
|
||||
|
||||
upload(requirement: GuaranteeRequirement): void {
|
||||
const key = this.key(requirement);
|
||||
const file = this.selectedFiles()[key];
|
||||
if (!file || !this.attestedKeys().has(key) || this.uploadingKeys().has(key)) return;
|
||||
if (this.uploadingKeys().has(key)) return;
|
||||
const fieldErrors = this.validationErrors(requirement, file);
|
||||
if (fieldErrors.length) {
|
||||
this.setFieldErrors(key, fieldErrors);
|
||||
return;
|
||||
}
|
||||
if (!file) return;
|
||||
this.uploadingKeys.update((keys) => new Set(keys).add(key));
|
||||
this.errors.update((errors) => withoutRecordKey(errors, key));
|
||||
this.fieldErrors.update((errors) => withoutRecordKey(errors, key));
|
||||
this.finalizationService
|
||||
.uploadGuaranteeDeed(
|
||||
this.leaseId(),
|
||||
file,
|
||||
requirement.guarantorId,
|
||||
requirement.tenantId,
|
||||
this.requirementIndex(requirement),
|
||||
)
|
||||
.pipe(finalize(() => this.uploadingKeys.update((keys) => withoutSetValue(keys, key))))
|
||||
.subscribe({
|
||||
next: (deed) =>
|
||||
next: (deed) => {
|
||||
if (
|
||||
deed.guarantorId !== requirement.guarantorId ||
|
||||
deed.tenantId !== requirement.tenantId
|
||||
) {
|
||||
this.errors.update((errors) => ({
|
||||
...errors,
|
||||
[key]: 'La réponse ne correspond pas au couple garant–locataire sélectionné.',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
this.deedUploaded.emit({
|
||||
guarantorId: requirement.guarantorId,
|
||||
tenantId: requirement.tenantId,
|
||||
deed,
|
||||
}),
|
||||
error: (error) =>
|
||||
this.errors.update((errors) => ({
|
||||
});
|
||||
},
|
||||
error: (error) => {
|
||||
const failure = saveFailure(
|
||||
error,
|
||||
'Impossible de téléverser cet acte de cautionnement.',
|
||||
);
|
||||
this.errors.update((errors) => ({ ...errors, [key]: failure.message }));
|
||||
this.fieldErrors.update((errors) => ({
|
||||
...errors,
|
||||
[key]: httpErrorMessage(
|
||||
error,
|
||||
'Impossible de téléverser cet acte de cautionnement.',
|
||||
),
|
||||
})),
|
||||
[key]: failure.fieldErrors,
|
||||
}));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
fieldError(requirement: GuaranteeRequirement, field: 'file' | 'attestation'): string {
|
||||
const code = this.fieldErrors()[this.key(requirement)]?.find(
|
||||
(error) => error.field === this.fieldPath(requirement, field),
|
||||
)?.code;
|
||||
if (!code) return '';
|
||||
if (field === 'attestation') {
|
||||
return 'Attestez transmettre l’acte signé correspondant à ce garant et ce locataire.';
|
||||
}
|
||||
return code === 'invalid'
|
||||
? 'Sélectionnez un fichier dont le contenu est un PDF valide.'
|
||||
: 'Sélectionnez l’acte signé au format PDF.';
|
||||
}
|
||||
|
||||
private validationErrors(
|
||||
requirement: GuaranteeRequirement,
|
||||
file: File | undefined,
|
||||
): FieldValidationError[] {
|
||||
const errors: FieldValidationError[] = [];
|
||||
if (!file) {
|
||||
const field = this.fieldPath(requirement, 'file');
|
||||
const code = this.fieldErrors()[this.key(requirement)]?.find(
|
||||
(error) => error.field === field,
|
||||
)?.code;
|
||||
errors.push({ field, code: code ?? 'required' });
|
||||
}
|
||||
if (!this.attestedKeys().has(this.key(requirement))) {
|
||||
errors.push({ field: this.fieldPath(requirement, 'attestation'), code: 'required' });
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
private setFieldErrors(key: string, fieldErrors: FieldValidationError[]): void {
|
||||
this.errors.update((errors) => withoutRecordKey(errors, key));
|
||||
this.fieldErrors.update((errors) => ({ ...errors, [key]: fieldErrors }));
|
||||
}
|
||||
|
||||
private clearFieldError(
|
||||
requirement: GuaranteeRequirement,
|
||||
field: 'file' | 'attestation',
|
||||
): void {
|
||||
const key = this.key(requirement);
|
||||
const remaining = (this.fieldErrors()[key] ?? []).filter(
|
||||
(error) => error.field !== this.fieldPath(requirement, field),
|
||||
);
|
||||
this.fieldErrors.update((errors) =>
|
||||
remaining.length ? { ...errors, [key]: remaining } : withoutRecordKey(errors, key),
|
||||
);
|
||||
if (!remaining.length) this.errors.update((errors) => withoutRecordKey(errors, key));
|
||||
}
|
||||
|
||||
private fieldPath(requirement: GuaranteeRequirement, field: 'file' | 'attestation'): string {
|
||||
return `guaranteeRequirements[${this.requirementIndex(requirement)}].${field}`;
|
||||
}
|
||||
|
||||
private requirementIndex(requirement: GuaranteeRequirement): number {
|
||||
return this.requirements().findIndex(
|
||||
(candidate) =>
|
||||
candidate.guarantorId === requirement.guarantorId &&
|
||||
candidate.tenantId === requirement.tenantId,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,11 +16,15 @@
|
||||
accept="application/pdf,.pdf"
|
||||
aria-label="Bail signé au format PDF"
|
||||
(change)="selectSignedLease($event)"
|
||||
[attr.aria-invalid]="fieldError('signedLease') ? 'true' : null"
|
||||
/>
|
||||
</label>
|
||||
@if (signedLease(); as file) {
|
||||
<p class="selected-file">{{ file.name }}</p>
|
||||
}
|
||||
@if (fieldError('signedLease')) {
|
||||
<p class="operation-error" role="alert">{{ fieldError('signedLease') }}</p>
|
||||
}
|
||||
|
||||
<label class="date-field" for="lastSignatureDate">
|
||||
<span>Date de la dernière signature</span>
|
||||
@@ -30,22 +34,34 @@
|
||||
required
|
||||
[value]="lastSignatureDate()"
|
||||
(input)="setLastSignatureDate($event)"
|
||||
[attr.aria-invalid]="fieldError('lastSignatureDate') ? 'true' : null"
|
||||
/>
|
||||
@if (fieldError('lastSignatureDate')) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ fieldError('lastSignatureDate') }}
|
||||
</p>
|
||||
}
|
||||
</label>
|
||||
|
||||
<mat-checkbox
|
||||
[checked]="allPartiesSigned()"
|
||||
(change)="allPartiesSigned.set($event.checked)"
|
||||
(change)="setAllPartiesSigned($event.checked)"
|
||||
>
|
||||
J'atteste que toutes les parties ont signé le PDF transmis.
|
||||
</mat-checkbox>
|
||||
@if (fieldError('allPartiesSigned')) {
|
||||
<p class="operation-error" role="alert">{{ fieldError('allPartiesSigned') }}</p>
|
||||
}
|
||||
<mat-checkbox
|
||||
[checked]="revisionMatches()"
|
||||
(change)="revisionMatches.set($event.checked)"
|
||||
(change)="setRevisionMatches($event.checked)"
|
||||
>
|
||||
J'ai vérifié que le PDF signé correspond à la révision {{ revision().number }} ·
|
||||
modèle {{ revision().templateVersion }}.
|
||||
</mat-checkbox>
|
||||
@if (fieldError('revisionMatches')) {
|
||||
<p class="operation-error" role="alert">{{ fieldError('revisionMatches') }}</p>
|
||||
}
|
||||
|
||||
@if (warnings().length) {
|
||||
<div class="warnings" aria-labelledby="warnings-heading">
|
||||
@@ -58,6 +74,11 @@
|
||||
{{ warningLabel(warning.code) }}
|
||||
<small>Version de règle : {{ warning.ruleVersion }}</small>
|
||||
</mat-checkbox>
|
||||
@if (fieldError('confirmedWarningCodes[' + $index + ']')) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ fieldError('confirmedWarningCodes[' + $index + ']') }}
|
||||
</p>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -66,19 +87,15 @@
|
||||
L'application conserve le document et vos attestations ; elle ne vérifie pas les
|
||||
signatures contenues dans le PDF.
|
||||
</p>
|
||||
@if (!guaranteesComplete()) {
|
||||
@if (guaranteesError()) {
|
||||
<p class="operation-error" role="alert">
|
||||
Téléversez tous les actes de cautionnement requis avant de finaliser.
|
||||
{{ guaranteesError() }}
|
||||
</p>
|
||||
}
|
||||
@if (validationError() || submitError()) {
|
||||
<p class="operation-error" role="alert">{{ validationError() || submitError() }}</p>
|
||||
}
|
||||
<button
|
||||
mat-flat-button
|
||||
type="submit"
|
||||
[disabled]="submitting() || !guaranteesComplete()"
|
||||
>
|
||||
<button mat-flat-button type="submit" [disabled]="submitting()">
|
||||
@if (submitting()) {
|
||||
<mat-spinner diameter="18" aria-hidden="true" /> Finalisation…
|
||||
} @else {
|
||||
@@ -125,11 +142,19 @@
|
||||
required
|
||||
[value]="correction.privateAreaId"
|
||||
(change)="setCorrection(index, 'privateAreaId', $event)"
|
||||
[attr.aria-invalid]="
|
||||
correctionError(index, 'privateAreaId') ? 'true' : null
|
||||
"
|
||||
>
|
||||
@for (area of revision().privateAreas; track area.id) {
|
||||
<option [value]="area.id">{{ area.label }}</option>
|
||||
}
|
||||
</select>
|
||||
@if (correctionError(index, 'privateAreaId')) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ correctionError(index, 'privateAreaId') }}
|
||||
</p>
|
||||
}
|
||||
</label>
|
||||
<label>
|
||||
Désignation
|
||||
@@ -138,7 +163,15 @@
|
||||
required
|
||||
[value]="correction.designation"
|
||||
(input)="setCorrection(index, 'designation', $event)"
|
||||
[attr.aria-invalid]="
|
||||
correctionError(index, 'designation') ? 'true' : null
|
||||
"
|
||||
/>
|
||||
@if (correctionError(index, 'designation')) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ correctionError(index, 'designation') }}
|
||||
</p>
|
||||
}
|
||||
</label>
|
||||
<label>
|
||||
Quantité
|
||||
@@ -149,7 +182,13 @@
|
||||
required
|
||||
[value]="correction.quantity"
|
||||
(input)="setCorrection(index, 'quantity', $event)"
|
||||
[attr.aria-invalid]="correctionError(index, 'quantity') ? 'true' : null"
|
||||
/>
|
||||
@if (correctionError(index, 'quantity')) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ correctionError(index, 'quantity') }}
|
||||
</p>
|
||||
}
|
||||
</label>
|
||||
<label>
|
||||
Description de la correction
|
||||
@@ -157,7 +196,15 @@
|
||||
required
|
||||
[value]="correction.description"
|
||||
(input)="setCorrection(index, 'description', $event)"
|
||||
[attr.aria-invalid]="
|
||||
correctionError(index, 'description') ? 'true' : null
|
||||
"
|
||||
></textarea>
|
||||
@if (correctionError(index, 'description')) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ correctionError(index, 'description') }}
|
||||
</p>
|
||||
}
|
||||
</label>
|
||||
<button type="button" (click)="removeCorrection(index)">
|
||||
Supprimer cette correction
|
||||
@@ -167,10 +214,15 @@
|
||||
<button type="button" (click)="addCorrection()">Ajouter une correction</button>
|
||||
<mat-checkbox
|
||||
[checked]="allInventoryCorrectionsInitialed()"
|
||||
(change)="allInventoryCorrectionsInitialed.set($event.checked)"
|
||||
(change)="setAllInventoryCorrectionsInitialed($event.checked)"
|
||||
>
|
||||
Toutes les parties ont paraphé chaque correction manuscrite déclarée.
|
||||
</mat-checkbox>
|
||||
@if (fieldError('allInventoryCorrectionsInitialed')) {
|
||||
<p class="operation-error" role="alert">
|
||||
{{ fieldError('allInventoryCorrectionsInitialed') }}
|
||||
</p>
|
||||
}
|
||||
@if (validationError()) {
|
||||
<p class="operation-error" role="alert">{{ validationError() }}</p>
|
||||
}
|
||||
|
||||
+21
-6
@@ -145,7 +145,9 @@ describe('LeaseFinalizationForm', () => {
|
||||
fixture.componentRef.setInput('guaranteesComplete', false);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('button[type=submit]').disabled).toBe(true);
|
||||
expect(fixture.nativeElement.querySelector('button[type=submit]').disabled).toBe(false);
|
||||
fixture.componentInstance.submit();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain('Téléversez tous les actes');
|
||||
});
|
||||
|
||||
@@ -209,7 +211,7 @@ describe('LeaseFinalizationForm', () => {
|
||||
expect(fixture.nativeElement.textContent).not.toContain('ID d’espace privatif');
|
||||
});
|
||||
|
||||
it('accepts only a PDF upload, captures the civil date, and toggles warning confirmations', () => {
|
||||
it('accepts only a PDF upload, captures the civil date, and toggles warning confirmations', async () => {
|
||||
const fixture = createForm(['DPE_MISSING']);
|
||||
const component = fixture.componentInstance;
|
||||
const input = document.createElement('input');
|
||||
@@ -219,13 +221,13 @@ describe('LeaseFinalizationForm', () => {
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
component.selectSignedLease({ target: input } as unknown as Event);
|
||||
await component.selectSignedLease({ target: input } as unknown as Event);
|
||||
expect(component.signedLease()).toBeNull();
|
||||
expect(component.validationError()).toContain('format PDF');
|
||||
expect(component.fieldError('signedLease')).toContain('PDF');
|
||||
|
||||
const pdf = new File(['pdf'], 'bail.pdf', { type: 'application/pdf' });
|
||||
const pdf = new File(['%PDF-valid'], 'bail.pdf', { type: 'application/pdf' });
|
||||
Object.defineProperty(input, 'files', { value: { item: () => pdf }, configurable: true });
|
||||
component.selectSignedLease({ target: input } as unknown as Event);
|
||||
await component.selectSignedLease({ target: input } as unknown as Event);
|
||||
const date = document.createElement('input');
|
||||
date.value = '2026-07-20';
|
||||
component.setLastSignatureDate(eventFor(date));
|
||||
@@ -238,6 +240,19 @@ describe('LeaseFinalizationForm', () => {
|
||||
expect(component.warningLabel('DPE_MISSING')).toContain('DPE');
|
||||
});
|
||||
|
||||
it('does not turn a blank correction quantity into zero', () => {
|
||||
const fixture = createForm();
|
||||
const component = fixture.componentInstance;
|
||||
component.answerYes();
|
||||
const quantity = document.createElement('input');
|
||||
quantity.value = '';
|
||||
component.setCorrection(0, 'quantity', eventFor(quantity));
|
||||
|
||||
expect(Number.isNaN(component.inventoryCorrections()[0].quantity)).toBe(true);
|
||||
component.continueWithCorrections();
|
||||
expect(component.correctionError(0, 'quantity')).toContain('quantité entière');
|
||||
});
|
||||
|
||||
it('edits a handwritten inventory correction from the revision snapshot', () => {
|
||||
const fixture = createForm();
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
FinalizeLeaseRequest,
|
||||
InventoryCorrection,
|
||||
} from '../../services/lease-finalization.service';
|
||||
import { FieldValidationError } from '../../services/http-error-message';
|
||||
import { LeaseRevisionWarning } from '../../services/lease.service';
|
||||
import { leaseWarningConfirmation } from '../../services/lease-warning-label';
|
||||
|
||||
@@ -24,7 +25,9 @@ export class LeaseFinalizationForm {
|
||||
readonly guaranteesComplete = input(true);
|
||||
readonly submitting = input(false);
|
||||
readonly submitError = input('');
|
||||
readonly fieldErrors = input<FieldValidationError[]>([]);
|
||||
readonly finalizationRequested = output<FinalizeLeaseRequest>();
|
||||
readonly fieldChanged = output<string>();
|
||||
|
||||
readonly signedLease = signal<File | null>(null);
|
||||
readonly lastSignatureDate = signal('');
|
||||
@@ -35,25 +38,55 @@ export class LeaseFinalizationForm {
|
||||
readonly discrepancyAnswer = signal<boolean | null>(null);
|
||||
readonly inventoryCorrections = signal<InventoryCorrection[]>([]);
|
||||
readonly allInventoryCorrectionsInitialed = signal(false);
|
||||
readonly clientFieldErrors = signal<FieldValidationError[]>([]);
|
||||
readonly discrepancyDialog = viewChild<ElementRef<HTMLDialogElement>>('discrepancyDialog');
|
||||
readonly firstCorrectionInput = viewChild<ElementRef<HTMLInputElement>>('firstCorrectionInput');
|
||||
private signedFileSelectionId = 0;
|
||||
|
||||
selectSignedLease(event: Event): void {
|
||||
async selectSignedLease(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.item(0) ?? null;
|
||||
if (file && file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
|
||||
this.signedLease.set(null);
|
||||
this.validationError.set('Sélectionnez le bail signé au format PDF.');
|
||||
} else {
|
||||
this.signedLease.set(file);
|
||||
this.validationError.set('');
|
||||
}
|
||||
const selectionId = ++this.signedFileSelectionId;
|
||||
input.value = '';
|
||||
this.clearFieldError('signedLease');
|
||||
if (!file) {
|
||||
this.signedLease.set(null);
|
||||
return;
|
||||
}
|
||||
const isPdf =
|
||||
file.type.toLowerCase() === 'application/pdf' &&
|
||||
(await file.slice(0, 5).text()) === '%PDF-';
|
||||
if (selectionId !== this.signedFileSelectionId) return;
|
||||
if (isPdf) {
|
||||
this.signedLease.set(file);
|
||||
} else {
|
||||
this.signedLease.set(null);
|
||||
this.clientFieldErrors.update((errors) => [
|
||||
...errors,
|
||||
{ field: 'signedLease', code: 'invalid' },
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
setLastSignatureDate(event: Event): void {
|
||||
const target = event.target;
|
||||
this.lastSignatureDate.set(target instanceof HTMLInputElement ? target.value : '');
|
||||
this.clearFieldError('lastSignatureDate');
|
||||
}
|
||||
|
||||
setAllPartiesSigned(checked: boolean): void {
|
||||
this.allPartiesSigned.set(checked);
|
||||
this.clearFieldError('allPartiesSigned');
|
||||
}
|
||||
|
||||
setRevisionMatches(checked: boolean): void {
|
||||
this.revisionMatches.set(checked);
|
||||
this.clearFieldError('revisionMatches');
|
||||
}
|
||||
|
||||
setAllInventoryCorrectionsInitialed(checked: boolean): void {
|
||||
this.allInventoryCorrectionsInitialed.set(checked);
|
||||
this.clearFieldError('allInventoryCorrectionsInitialed');
|
||||
}
|
||||
|
||||
setWarning(code: string, checked: boolean): void {
|
||||
@@ -63,6 +96,7 @@ export class LeaseFinalizationForm {
|
||||
else next.delete(code);
|
||||
return next;
|
||||
});
|
||||
this.clearFieldError(this.warningField(code));
|
||||
}
|
||||
|
||||
warningLabel(code: string): string {
|
||||
@@ -72,18 +106,18 @@ export class LeaseFinalizationForm {
|
||||
submit(): void {
|
||||
if (this.submitting()) return;
|
||||
if (this.discrepancyAnswer() === null) {
|
||||
const error = this.errorMessage();
|
||||
if (error) {
|
||||
this.validationError.set(error);
|
||||
const errors = this.validationErrors(false);
|
||||
if (errors.length) {
|
||||
this.showValidationErrors(errors);
|
||||
return;
|
||||
}
|
||||
this.discrepancyDialog()?.nativeElement.showModal();
|
||||
return;
|
||||
}
|
||||
const error = this.errorMessage();
|
||||
this.validationError.set(error);
|
||||
const errors = this.validationErrors(true);
|
||||
this.showValidationErrors(errors);
|
||||
const signedLease = this.signedLease();
|
||||
if (error || !signedLease) return;
|
||||
if (errors.length || !signedLease) return;
|
||||
this.finalizationRequested.emit({
|
||||
signedLease,
|
||||
revisionNumber: this.revision().number,
|
||||
@@ -126,7 +160,7 @@ export class LeaseFinalizationForm {
|
||||
inventoryLineId: null,
|
||||
privateAreaId: this.revision().privateAreas[0]?.id ?? '',
|
||||
designation: '',
|
||||
quantity: 0,
|
||||
quantity: Number.NaN,
|
||||
description: '',
|
||||
},
|
||||
]);
|
||||
@@ -136,6 +170,7 @@ export class LeaseFinalizationForm {
|
||||
this.inventoryCorrections.update((corrections) =>
|
||||
corrections.filter((_, item) => item !== index),
|
||||
);
|
||||
this.clearAllCorrectionErrors();
|
||||
}
|
||||
|
||||
setCorrection(index: number, field: keyof InventoryCorrection, event: Event): void {
|
||||
@@ -152,7 +187,9 @@ export class LeaseFinalizationForm {
|
||||
...correction,
|
||||
[field]:
|
||||
field === 'quantity'
|
||||
? Number(value)
|
||||
? value === ''
|
||||
? Number.NaN
|
||||
: Number(value)
|
||||
: field === 'inventoryLineId'
|
||||
? value || null
|
||||
: value,
|
||||
@@ -160,6 +197,7 @@ export class LeaseFinalizationForm {
|
||||
: correction,
|
||||
),
|
||||
);
|
||||
this.clearFieldError(this.correctionField(index, field));
|
||||
}
|
||||
|
||||
selectInventoryLine(index: number, event: Event): void {
|
||||
@@ -181,53 +219,161 @@ export class LeaseFinalizationForm {
|
||||
: correction,
|
||||
),
|
||||
);
|
||||
this.clearCorrectionErrors(index);
|
||||
}
|
||||
|
||||
continueWithCorrections(): void {
|
||||
const error = this.errorMessage();
|
||||
if (error) {
|
||||
this.validationError.set(error);
|
||||
const errors = this.validationErrors(true);
|
||||
if (errors.length) {
|
||||
this.showValidationErrors(errors);
|
||||
return;
|
||||
}
|
||||
this.discrepancyDialog()?.nativeElement.close();
|
||||
this.submit();
|
||||
}
|
||||
|
||||
private errorMessage(): string {
|
||||
if (!this.guaranteesComplete()) {
|
||||
return 'Téléversez tous les actes de cautionnement requis avant de finaliser.';
|
||||
fieldError(field: string): string {
|
||||
const code = this.errorCode(field);
|
||||
if (!code) return '';
|
||||
if (field === 'signedLease') {
|
||||
return code === 'invalid'
|
||||
? 'Sélectionnez un bail signé dont le contenu est un PDF valide.'
|
||||
: 'Sélectionnez le bail signé au format PDF.';
|
||||
}
|
||||
if (!this.signedLease()) return 'Sélectionnez le bail signé au format PDF.';
|
||||
if (!this.lastSignatureDate()) return 'Renseignez la date de la dernière signature.';
|
||||
if (!this.allPartiesSigned()) {
|
||||
if (field === 'lastSignatureDate') return 'Renseignez la date de la dernière signature.';
|
||||
if (field === 'allPartiesSigned') {
|
||||
return 'Attestez que toutes les parties ont signé le document transmis.';
|
||||
}
|
||||
if (!this.revisionMatches()) {
|
||||
if (field === 'revisionMatches') {
|
||||
return `Confirmez que le document signé correspond à la révision ${this.revision().number}.`;
|
||||
}
|
||||
if (this.discrepancyAnswer() && !this.inventoryCorrections().length) {
|
||||
return 'Déclarez au moins une correction d’inventaire.';
|
||||
if (field === 'guaranteeRequirements') {
|
||||
return 'Téléversez tous les actes de cautionnement requis avant de finaliser.';
|
||||
}
|
||||
if (
|
||||
this.discrepancyAnswer() &&
|
||||
this.inventoryCorrections().some(
|
||||
(correction) =>
|
||||
!correction.id ||
|
||||
!correction.privateAreaId.trim() ||
|
||||
!correction.designation.trim() ||
|
||||
!correction.description.trim() ||
|
||||
!Number.isInteger(correction.quantity) ||
|
||||
correction.quantity < 0,
|
||||
)
|
||||
) {
|
||||
return 'Complétez chaque correction d’inventaire avec son espace, sa désignation, sa quantité et sa description.';
|
||||
if (field === 'entryInventoryDiscrepancies') {
|
||||
return 'Répondez à la question sur les incohérences d’inventaire à l’entrée.';
|
||||
}
|
||||
if (this.discrepancyAnswer() && !this.allInventoryCorrectionsInitialed()) {
|
||||
if (field === 'allInventoryCorrectionsInitialed') {
|
||||
return 'Attestez que chaque correction manuscrite d’inventaire a été paraphée.';
|
||||
}
|
||||
const missingWarning = this.warningCodes().find(
|
||||
(code) => !this.confirmedWarningCodes().has(code),
|
||||
if (field.startsWith('confirmedWarningCodes[')) {
|
||||
return 'Confirmez cet avertissement légal avant de finaliser.';
|
||||
}
|
||||
if (field.endsWith('.privateAreaId')) {
|
||||
return 'Choisissez un espace privatif de la révision imprimée.';
|
||||
}
|
||||
if (field.endsWith('.designation')) return 'Renseignez la désignation de la correction.';
|
||||
if (field.endsWith('.quantity')) {
|
||||
return 'Saisissez une quantité entière positive ou nulle.';
|
||||
}
|
||||
if (field.endsWith('.description')) return 'Décrivez la correction manuscrite.';
|
||||
return 'Déclarez au moins une correction d’inventaire.';
|
||||
}
|
||||
|
||||
correctionError(index: number, field: keyof InventoryCorrection): string {
|
||||
return this.fieldError(this.correctionField(index, field));
|
||||
}
|
||||
|
||||
guaranteesError(): string {
|
||||
return this.fieldError('guaranteeRequirements');
|
||||
}
|
||||
|
||||
private validationErrors(includeDiscrepancy: boolean): FieldValidationError[] {
|
||||
const errors: FieldValidationError[] = [];
|
||||
if (!this.guaranteesComplete()) {
|
||||
errors.push({ field: 'guaranteeRequirements', code: 'incomplete' });
|
||||
}
|
||||
if (!this.signedLease()) {
|
||||
errors.push({
|
||||
field: 'signedLease',
|
||||
code: this.errorCode('signedLease') ?? 'required',
|
||||
});
|
||||
}
|
||||
if (!this.lastSignatureDate())
|
||||
errors.push({ field: 'lastSignatureDate', code: 'required' });
|
||||
if (!this.allPartiesSigned()) errors.push({ field: 'allPartiesSigned', code: 'required' });
|
||||
if (!this.revisionMatches()) errors.push({ field: 'revisionMatches', code: 'required' });
|
||||
for (const code of this.warningCodes()) {
|
||||
if (!this.confirmedWarningCodes().has(code)) {
|
||||
errors.push({ field: this.warningField(code), code: 'required' });
|
||||
}
|
||||
}
|
||||
if (!includeDiscrepancy) return errors;
|
||||
if (this.discrepancyAnswer() === null) {
|
||||
errors.push({ field: 'entryInventoryDiscrepancies', code: 'required' });
|
||||
return errors;
|
||||
}
|
||||
if (!this.discrepancyAnswer()) return errors;
|
||||
if (!this.inventoryCorrections().length) {
|
||||
errors.push({ field: 'inventoryCorrections', code: 'required' });
|
||||
}
|
||||
this.inventoryCorrections().forEach((correction, index) => {
|
||||
if (!correction.privateAreaId.trim()) {
|
||||
errors.push({
|
||||
field: this.correctionField(index, 'privateAreaId'),
|
||||
code: 'not_blank',
|
||||
});
|
||||
}
|
||||
if (!correction.designation.trim()) {
|
||||
errors.push({
|
||||
field: this.correctionField(index, 'designation'),
|
||||
code: 'not_blank',
|
||||
});
|
||||
}
|
||||
if (!Number.isInteger(correction.quantity) || correction.quantity < 0) {
|
||||
errors.push({ field: this.correctionField(index, 'quantity'), code: 'min' });
|
||||
}
|
||||
if (!correction.description.trim()) {
|
||||
errors.push({
|
||||
field: this.correctionField(index, 'description'),
|
||||
code: 'not_blank',
|
||||
});
|
||||
}
|
||||
});
|
||||
if (!this.allInventoryCorrectionsInitialed()) {
|
||||
errors.push({ field: 'allInventoryCorrectionsInitialed', code: 'required' });
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
private showValidationErrors(errors: FieldValidationError[]): void {
|
||||
this.clientFieldErrors.set(errors);
|
||||
this.validationError.set(errors.length ? this.fieldError(errors[0].field) : '');
|
||||
}
|
||||
|
||||
private errorCode(field: string): string | undefined {
|
||||
return (
|
||||
this.clientFieldErrors().find((error) => error.field === field)?.code ??
|
||||
this.fieldErrors().find((error) => error.field === field)?.code
|
||||
);
|
||||
return missingWarning ? 'Confirmez chaque avertissement légal avant de finaliser.' : '';
|
||||
}
|
||||
|
||||
private clearFieldError(field: string): void {
|
||||
this.clientFieldErrors.update((errors) => errors.filter((error) => error.field !== field));
|
||||
this.fieldChanged.emit(field);
|
||||
if (!this.clientFieldErrors().length) this.validationError.set('');
|
||||
}
|
||||
|
||||
private clearCorrectionErrors(index: number): void {
|
||||
const prefix = `inventoryCorrections[${index}].`;
|
||||
this.clientFieldErrors.update((errors) =>
|
||||
errors.filter((error) => !error.field.startsWith(prefix)),
|
||||
);
|
||||
this.fieldChanged.emit(prefix);
|
||||
}
|
||||
|
||||
private clearAllCorrectionErrors(): void {
|
||||
this.clientFieldErrors.update((errors) =>
|
||||
errors.filter((error) => !error.field.startsWith('inventoryCorrections[')),
|
||||
);
|
||||
this.fieldChanged.emit('inventoryCorrections[');
|
||||
}
|
||||
|
||||
private correctionField(index: number, field: keyof InventoryCorrection): string {
|
||||
return `inventoryCorrections[${index}].${field}`;
|
||||
}
|
||||
|
||||
private warningField(code: string): string {
|
||||
return `confirmedWarningCodes[${this.warningCodes().indexOf(code)}]`;
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,8 @@
|
||||
[guaranteesComplete]="guaranteesComplete()"
|
||||
[submitting]="submitting() || confirming()"
|
||||
[submitError]="submitError()"
|
||||
[fieldErrors]="submitFieldErrors()"
|
||||
(fieldChanged)="clearFinalizationFieldError($event)"
|
||||
(finalizationRequested)="requestFinalization($event)"
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -167,7 +167,9 @@ describe('LeaseFinalizationPage', () => {
|
||||
});
|
||||
|
||||
it('keeps a finalization failure actionable without showing success', () => {
|
||||
finalize.mockReturnValue(throwError(() => ({ error: { message: 'PDF signé refusé' } })));
|
||||
finalize.mockReturnValue(
|
||||
throwError(() => ({ status: 409, error: { message: 'PDF signé refusé' } })),
|
||||
);
|
||||
const fixture = TestBed.createComponent(LeaseFinalizationPage);
|
||||
fixture.detectChanges();
|
||||
const request: FinalizeLeaseRequest = {
|
||||
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
LeaseFinalizationState,
|
||||
} from '../../services/lease-finalization.service';
|
||||
import { WorkspaceService } from '../../services/workspace.service';
|
||||
import { httpErrorMessage } from '../../services/http-error-message';
|
||||
import {
|
||||
FieldValidationError,
|
||||
httpErrorMessage,
|
||||
saveFailure,
|
||||
} from '../../services/http-error-message';
|
||||
import {
|
||||
GuaranteeDeedList,
|
||||
GuaranteeDeedUploaded,
|
||||
@@ -39,6 +43,7 @@ export class LeaseFinalizationPage {
|
||||
readonly submitting = signal(false);
|
||||
readonly confirming = signal(false);
|
||||
readonly submitError = signal('');
|
||||
readonly submitFieldErrors = signal<FieldValidationError[]>([]);
|
||||
readonly guaranteesComplete = computed(
|
||||
() => this.state()?.guaranteeRequirements.every(({ deed }) => deed !== null) ?? false,
|
||||
);
|
||||
@@ -81,6 +86,14 @@ export class LeaseFinalizationPage {
|
||||
);
|
||||
}
|
||||
|
||||
clearFinalizationFieldError(field: string): void {
|
||||
const remaining = this.submitFieldErrors().filter((error) =>
|
||||
field.endsWith('[') ? !error.field.startsWith(field) : error.field !== field,
|
||||
);
|
||||
this.submitFieldErrors.set(remaining);
|
||||
if (!remaining.length) this.submitError.set('');
|
||||
}
|
||||
|
||||
requestFinalization(request: FinalizeLeaseRequest): void {
|
||||
if (this.submitting() || this.confirming()) return;
|
||||
this.confirming.set(true);
|
||||
@@ -108,6 +121,7 @@ export class LeaseFinalizationPage {
|
||||
if (this.submitting()) return;
|
||||
this.submitting.set(true);
|
||||
this.submitError.set('');
|
||||
this.submitFieldErrors.set([]);
|
||||
this.finalizationService
|
||||
.finalize(this.leaseId, request)
|
||||
.pipe(rxFinalize(() => this.submitting.set(false)))
|
||||
@@ -118,10 +132,11 @@ export class LeaseFinalizationPage {
|
||||
state ? { ...state, finalization: result.finalization } : state,
|
||||
);
|
||||
},
|
||||
error: (error) =>
|
||||
this.submitError.set(
|
||||
httpErrorMessage(error, 'Impossible de finaliser ce bail.'),
|
||||
),
|
||||
error: (error) => {
|
||||
const failure = saveFailure(error, 'Impossible de finaliser ce bail.');
|
||||
this.submitError.set(failure.message);
|
||||
this.submitFieldErrors.set(failure.fieldErrors);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ describe('LeaseFinalizationService', () => {
|
||||
it('uploads an attested guarantee deed for one guarantor and tenant pair', () => {
|
||||
const file = new File(['deed'], 'cautionnement.pdf', { type: 'application/pdf' });
|
||||
TestBed.inject(LeaseFinalizationService)
|
||||
.uploadGuaranteeDeed('lease-1', file, 'guarantor-1', 'tenant-1')
|
||||
.uploadGuaranteeDeed('lease-1', file, 'guarantor-1', 'tenant-1', 0)
|
||||
.subscribe();
|
||||
|
||||
const formData = http.post.mock.calls[0][1] as FormData;
|
||||
@@ -34,6 +34,7 @@ describe('LeaseFinalizationService', () => {
|
||||
expect(formData.get('file')).toBe(file);
|
||||
expect(formData.get('guarantorId')).toBe('guarantor-1');
|
||||
expect(formData.get('tenantId')).toBe('tenant-1');
|
||||
expect(formData.get('requirementIndex')).toBe('0');
|
||||
expect(formData.get('attestation')).toBe('true');
|
||||
});
|
||||
|
||||
|
||||
@@ -117,11 +117,13 @@ export class LeaseFinalizationService {
|
||||
file: File,
|
||||
guarantorId: string,
|
||||
tenantId: string,
|
||||
requirementIndex: number,
|
||||
): Observable<GuaranteeDeed> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('guarantorId', guarantorId);
|
||||
formData.append('tenantId', tenantId);
|
||||
formData.append('requirementIndex', String(requirementIndex));
|
||||
formData.append('attestation', 'true');
|
||||
return this.http.post<GuaranteeDeed>(`/api/leases/${leaseId}/guarantee-deeds`, formData);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user