From bb4db8238ee5110cc52088151966f82234862536 Mon Sep 17 00:00:00 2001
From: Nessar
Date: Mon, 27 Jul 2026 16:25:48 +0200
Subject: [PATCH] fix: freeze condition-report visit order (#61)
---
.../PropertyConditionReportService.java | 115 +++-----
.../PropertyConditionReportTest.java | 35 +++
.../ReplacementRentalApiJourneyIT.java | 245 ++++++++++++++++++
frontend/e2e/app.spec.ts | 17 +-
4 files changed, 332 insertions(+), 80 deletions(-)
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionReportService.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionReportService.java
index 5096156..abeb28c 100644
--- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionReportService.java
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionReportService.java
@@ -710,24 +710,11 @@ private void validateReferences(
List participants,
List forwardingAddresses) {
Set tenantIds;
- Set areas;
if (report.type() == PropertyConditionReportType.EXIT) {
var version = lease.contractVersionOn(draft.occurredOn());
tenantIds = Set.copyOf(version.tenants().stream().map(value -> value.id().value()).toList());
- areas =
- Set.copyOf(
- report.entrySnapshot().zones().stream().map(ConditionZone::privateAreaId).toList());
} else {
tenantIds = Set.copyOf(lease.tenantIds().stream().map(value -> value.value()).toList());
- areas =
- Set.copyOf(
- apartments
- .find(landlord, lease.apartmentId())
- .orElseThrow(() -> new ResourceNotFound("Apartment"))
- .privateAreas()
- .stream()
- .map(value -> value.id())
- .toList());
}
List expectedParticipants =
report.type() == PropertyConditionReportType.EXIT
@@ -756,26 +743,21 @@ private void validateReferences(
"invalid",
"Une adresse de correspondance ne figure pas dans le bail.");
}
+ Set areas =
+ Set.copyOf(report.zones().stream().map(ConditionZone::privateAreaId).toList());
if (draft.zones().stream()
.anyMatch(
value -> !value.privateAreaId().isEmpty() && !areas.contains(value.privateAreaId()))) {
throw new PropertyConditionReportRuleViolation(
"zones", "invalid", "Une zone ne figure pas dans l'instantané du logement.");
}
+ if (!visitOrder(report.zones()).equals(visitOrder(draft.zones()))) {
+ throw new PropertyConditionReportRuleViolation(
+ "zones", "invalid", "L’ordre de visite des espaces privatifs est figé.");
+ }
if (report.type() == PropertyConditionReportType.ENTRY) {
- var inventory =
- inventories
- .find(landlord, lease.id())
- .orElseThrow(
- () ->
- new PropertyConditionReportConflict("L’inventaire du bail est introuvable."));
List required =
- PropertyConditionDraftTemplate.requiredZones(
- apartments
- .find(landlord, lease.apartmentId())
- .orElseThrow(() -> new ResourceNotFound("Apartment"))
- .privateAreas(),
- FurnitureInventorySnapshot.from(inventory));
+ report.zones().stream().filter(PropertyConditionReportService::isRequiredZone).toList();
Map supplied =
draft.zones().stream()
.collect(java.util.stream.Collectors.toMap(ConditionZone::id, value -> value));
@@ -791,22 +773,30 @@ private void validateReferences(
}
Map expectedInventory =
report.type() == PropertyConditionReportType.ENTRY
- ? expectedInventory(landlord, lease)
+ ? report.inventoryConditions().stream()
+ .collect(
+ java.util.stream.Collectors.toMap(
+ InventoryCondition::inventoryLineId, value -> value))
: report.entrySnapshot().inventoryConditions().stream()
.collect(
java.util.stream.Collectors.toMap(
InventoryCondition::inventoryLineId, value -> value));
- if (draft.inventoryConditions().stream()
- .anyMatch(
- value -> {
- InventoryCondition expected = expectedInventory.get(value.inventoryLineId());
- return expected == null
- || !expected.privateAreaId().equals(value.privateAreaId())
- || !expected.privateAreaLabel().equals(value.privateAreaLabel())
- || !expected.designation().equals(value.designation())
- || expected.contractualQuantity() != value.contractualQuantity()
- || !expected.contractualDescription().equals(value.contractualDescription());
- })) {
+ Set suppliedInventoryLineIds =
+ draft.inventoryConditions().stream()
+ .map(InventoryCondition::inventoryLineId)
+ .collect(java.util.stream.Collectors.toSet());
+ if (!expectedInventory.keySet().equals(suppliedInventoryLineIds)
+ || draft.inventoryConditions().stream()
+ .anyMatch(
+ value -> {
+ InventoryCondition expected = expectedInventory.get(value.inventoryLineId());
+ return expected == null
+ || !expected.privateAreaId().equals(value.privateAreaId())
+ || !expected.privateAreaLabel().equals(value.privateAreaLabel())
+ || !expected.designation().equals(value.designation())
+ || expected.contractualQuantity() != value.contractualQuantity()
+ || !expected.contractualDescription().equals(value.contractualDescription());
+ })) {
throw new PropertyConditionReportRuleViolation(
"inventoryConditions",
"invalid",
@@ -814,44 +804,19 @@ private void validateReferences(
}
}
-private Map expectedInventory(LandlordId landlord, Lease lease) {
- Map labels =
- apartments
- .find(landlord, lease.apartmentId())
- .orElseThrow(() -> new ResourceNotFound("Apartment"))
- .privateAreas()
- .stream()
- .collect(
- java.util.stream.Collectors.toMap(value -> value.id(), value -> value.label()));
- return inventories
- .find(landlord, lease.id())
- .orElseThrow(
- () -> new PropertyConditionReportConflict("L’inventaire du bail est introuvable."))
- .lines()
- .stream()
- .collect(
- java.util.stream.Collectors.toMap(
- value -> value.id(),
- value ->
- new InventoryCondition(
- value.id(),
- value.privateAreaId(),
- labels.get(value.privateAreaId()),
- value.designation(),
- value.quantity(),
- value.description(),
- value.quantity(),
- value.description(),
- List.of(
- new InventoryQuantitySplit(
- value.id(),
- value.quantity() == 0
- ? ConditionState.ABSENT
- : ConditionState.BON_ETAT,
- value.quantity(),
- value.quantity() == 0
- ? "Absent de l’inventaire contractuel."
- : "")))));
+private static boolean isRequiredZone(ConditionZone zone) {
+ return zone.id().startsWith("inventory:")
+ || zone.id().endsWith(":floor")
+ || zone.id().endsWith(":walls")
+ || zone.id().endsWith(":ceiling");
+}
+
+private static List visitOrder(List zones) {
+ return zones.stream()
+ .map(ConditionZone::privateAreaId)
+ .filter(id -> !id.isBlank())
+ .distinct()
+ .toList();
}
private static boolean sameParties(
diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/domain/condition/PropertyConditionReportTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/domain/condition/PropertyConditionReportTest.java
index 20da298..aaee927 100644
--- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/domain/condition/PropertyConditionReportTest.java
+++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/domain/condition/PropertyConditionReportTest.java
@@ -417,6 +417,41 @@ void requiresEntryStepConfirmationsAndInventoryReconciliationBeforeLeasePackageF
assertEquals(PropertyConditionReportStatus.FINALIZED, report.status());
}
+@Test
+void exitDraftKeepsTheEntryPrivateAreaOrder() {
+ PropertyConditionReport entry = entryDraft();
+ entry.replaceParticipants(participants(ParticipantAttendance.PRESENT));
+ entry.replaceZones(
+ List.of(
+ new ConditionZone("kitchen:floor", "kitchen", "Cuisine — Sol", ZoneEquipment.NONE),
+ new ConditionZone("living:floor", "living", "Séjour — Sol", ZoneEquipment.NONE)));
+ entry.replaceObservations(
+ List.of(
+ new ConditionObservation("kitchen:floor", ConditionState.BON_ETAT, ""),
+ new ConditionObservation("living:floor", ConditionState.BON_ETAT, "")));
+ entry.replacePhotos(
+ List.of(
+ overviewPhoto("entry-photo-kitchen", "kitchen", "kitchen"),
+ overviewPhoto("entry-photo-living", "living", "living")));
+ entry.replaceTechnicalDeclarations(true, true);
+ entry.confirmGeneral();
+ entry.confirmPrivateArea("kitchen");
+ entry.confirmPrivateArea("living");
+ entry.confirmTechnical();
+ entry.finalizeWithLeasePackage(finalization(1));
+
+ PropertyConditionReport exit =
+ PropertyConditionReport.draftExit(
+ new PropertyConditionReportId("report-2"),
+ entry,
+ LocalDate.of(2026, 7, 21),
+ "Europe/Paris");
+
+ assertEquals(
+ List.of("kitchen", "living"),
+ exit.zones().stream().map(ConditionZone::privateAreaId).toList());
+}
+
private static PropertyConditionReport finalizedEntry() {
PropertyConditionReport entry = entryDraft();
entry.replaceParticipants(participants(ParticipantAttendance.PRESENT));
diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/integration/ReplacementRentalApiJourneyIT.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/integration/ReplacementRentalApiJourneyIT.java
index de1911c..1f5a799 100644
--- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/integration/ReplacementRentalApiJourneyIT.java
+++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/integration/ReplacementRentalApiJourneyIT.java
@@ -278,6 +278,206 @@ void entryReportRejectsAnIncompleteApartmentLegalProfile() throws Exception {
.andExpect(jsonPath("$.code").value("apartment_legal_profile_incomplete"));
}
+@Test
+void apartmentWithoutPrivateAreasRoundTripsButCannotStartAnEntryReport() throws Exception {
+ mockMvc
+ .perform(
+ put("/api/landlord-profile")
+ .with(owner())
+ .with(csrf())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(
+ """
+ {"firstName":"Camille","lastName":"Martin","email":"camille@example.com",
+ "phone":"06 12 34 56 78","address":"1 rue de Paris"}
+ """))
+ .andExpect(status().isOk());
+ String apartmentId =
+ create(
+ "/api/apartments",
+ """
+ {"name":"Studio sans espace","address":"9 rue du Test","purchasePrice":120000,
+ "description":"Meublé","privateAreas":[],"legalProfile":{
+ "taxIdentifier":"tax-sans-espace","buildingType":"APARTMENT_BUILDING",
+ "ownershipRegime":"Copropriété","constructionPeriod":"1949_1974",
+ "habitableSurface":31.5,"heating":"Individuel","hotWater":"Individuelle",
+ "energyRating":"F","estimatedAnnualEnergyCost":1200,
+ "estimatedAnnualEnergyCostReferenceYear":2024,"destination":"Habitation",
+ "departmentCode":"75"}}
+ """);
+ String tenantId =
+ create(
+ "/api/tenants",
+ """
+ {"firstName":"Chloé","lastName":"Test","email":"chloe.no-area@example.com",
+ "phone":"0633333333","address":"3 rue C"}
+ """);
+ String leaseId =
+ create(
+ "/api/leases",
+ """
+ {"apartmentId":"%s","tenantIds":["%s"],"guarantors":[],
+ "startDate":"2026-08-01","monthlyRent":900,"monthlyCharges":50,"securityDeposit":900}
+ """
+ .formatted(apartmentId, tenantId));
+
+ mockMvc
+ .perform(get("/api/apartments/{apartmentId}", apartmentId).with(owner()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.privateAreas").isEmpty());
+ mockMvc
+ .perform(get("/api/leases/{leaseId}/condition-reports/ENTRY", leaseId).with(owner()))
+ .andExpect(status().isConflict())
+ .andExpect(jsonPath("$.code").value("condition_report_conflict"));
+}
+
+@Test
+void entryReportKeepsItsPrivateAreaVisitOrderAfterTheApartmentIsReordered() throws Exception {
+ mockMvc
+ .perform(
+ put("/api/landlord-profile")
+ .with(owner())
+ .with(csrf())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(
+ """
+ {"firstName":"Camille","lastName":"Martin","email":"camille@example.com",
+ "phone":"06 12 34 56 78","address":"1 rue de Paris"}
+ """))
+ .andExpect(status().isOk());
+ String apartmentId =
+ create(
+ "/api/apartments",
+ """
+ {"name":"T2 ordonné","address":"10 rue du Test","purchasePrice":120000,
+ "description":"Meublé","privateAreas":[
+ {"id":"area-kitchen","family":"PIECE_DE_SERVICE","subtype":"KITCHEN","label":"Cuisine"},
+ {"id":"area-living","family":"PIECE_PRINCIPALE","subtype":"LIVING_ROOM","label":"Séjour"}],
+ "legalProfile":{"taxIdentifier":"tax-ordre","buildingType":"APARTMENT_BUILDING",
+ "ownershipRegime":"Copropriété","constructionPeriod":"1949_1974",
+ "habitableSurface":31.5,"heating":"Individuel","hotWater":"Individuelle",
+ "energyRating":"F","estimatedAnnualEnergyCost":1200,
+ "estimatedAnnualEnergyCostReferenceYear":2024,"destination":"Habitation",
+ "departmentCode":"75"}}
+ """);
+ String tenantId =
+ create(
+ "/api/tenants",
+ """
+ {"firstName":"Chloé","lastName":"Test","email":"chloe.visit-order@example.com",
+ "phone":"0633333333","address":"3 rue C"}
+ """);
+ String leaseId =
+ create(
+ "/api/leases",
+ """
+ {"apartmentId":"%s","tenantIds":["%s"],"guarantors":[],
+ "startDate":"2026-08-01","monthlyRent":900,"monthlyCharges":50,"securityDeposit":900}
+ """
+ .formatted(apartmentId, tenantId));
+ completeInventory(leaseId);
+
+ String entry =
+ mockMvc
+ .perform(get("/api/leases/{leaseId}/condition-reports/ENTRY", leaseId).with(owner()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.privateAreas[0].id").value("area-kitchen"))
+ .andExpect(jsonPath("$.privateAreas[1].id").value("area-living"))
+ .andReturn()
+ .getResponse()
+ .getContentAsString();
+ String apartment =
+ mockMvc
+ .perform(get("/api/apartments/{apartmentId}", apartmentId).with(owner()))
+ .andExpect(status().isOk())
+ .andReturn()
+ .getResponse()
+ .getContentAsString();
+ Map reordered = new LinkedHashMap<>(read(apartment, "$"));
+ reordered.remove("id");
+ reordered.remove("mainRoomCount");
+ reordered.remove("referenced");
+ reordered.remove("version");
+ @SuppressWarnings("unchecked")
+ List