fix: freeze condition-report visit order (#61)

This commit is contained in:
nessar committed 2026-08-17 14:18:17 +02:00
1 parent 053b76e74c
commit bb4db8238e
4 files changed
+332 -80

No files matched your search

@@ -710,24 +710,11 @@ private void validateReferences(
List<ReportParticipant> participants,
List<ForwardingAddress> forwardingAddresses) {
Set<String> tenantIds;
Set<String> 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<ReportParticipant> expectedParticipants =
report.type() == PropertyConditionReportType.EXIT
@@ -756,26 +743,21 @@ private void validateReferences(
"invalid",
"Une adresse de correspondance ne figure pas dans le bail.");
}
Set<String> 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<ConditionZone> required =
PropertyConditionDraftTemplate.requiredZones(
apartments
.find(landlord, lease.apartmentId())
.orElseThrow(() -> new ResourceNotFound("Apartment"))
.privateAreas(),
FurnitureInventorySnapshot.from(inventory));
report.zones().stream().filter(PropertyConditionReportService::isRequiredZone).toList();
Map<String, ConditionZone> supplied =
draft.zones().stream()
.collect(java.util.stream.Collectors.toMap(ConditionZone::id, value -> value));
@@ -791,22 +773,30 @@ private void validateReferences(
}
Map<String, InventoryCondition> 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<String> 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<String, InventoryCondition> expectedInventory(LandlordId landlord, Lease lease) {
Map<String, String> 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<String> visitOrder(List<ConditionZone> zones) {
return zones.stream()
.map(ConditionZone::privateAreaId)
.filter(id -> !id.isBlank())
.distinct()
.toList();
}
private static boolean sameParties(
@@ -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));
@@ -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<String, Object> reordered = new LinkedHashMap<>(read(apartment, "$"));
reordered.remove("id");
reordered.remove("mainRoomCount");
reordered.remove("referenced");
reordered.remove("version");
@SuppressWarnings("unchecked")
List<Map<String, Object>> areas = (List<Map<String, Object>>) reordered.get("privateAreas");
areas.get(1).put("label", "Séjour renommé");
reordered.put("privateAreas", List.of(areas.get(1), areas.getFirst()));
mockMvc
.perform(
put("/api/apartments/{apartmentId}", apartmentId)
.with(owner())
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(parse(reordered).jsonString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.privateAreas[0].id").value("area-living"))
.andExpect(jsonPath("$.privateAreas[1].id").value("area-kitchen"));
String savedEntry =
replaceEntryReport(
leaseId,
conditionReportRequest(read(entry, "$"), newStepConfirmations(false)),
"DRAFT");
assertEquals(List.of("area-kitchen", "area-living"), read(savedEntry, "$.privateAreas[*].id"));
assertEquals(List.of("Cuisine", "Séjour"), read(savedEntry, "$.privateAreas[*].label"));
Map<String, Object> entryWithoutInventory =
conditionReportRequest(read(savedEntry, "$"), newStepConfirmations(false));
entryWithoutInventory.put("inventoryConditions", List.of());
mockMvc
.perform(
put("/api/leases/{leaseId}/condition-reports/ENTRY", leaseId)
.with(owner())
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(parse(entryWithoutInventory).jsonString()))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors[0].field").value("inventoryConditions"))
.andExpect(jsonPath("$.errors[0].code").value("invalid"));
Map<String, Object> additionalZoneEntry =
conditionReportRequest(read(savedEntry, "$"), newStepConfirmations(false));
@SuppressWarnings("unchecked")
List<Map<String, Object>> zonesWithAdditional =
(List<Map<String, Object>>) additionalZoneEntry.get("zones");
zonesWithAdditional.add(
0,
Map.of(
"id", "living:additional",
"privateAreaId", "area-living",
"label", "Séjour — détail",
"equipment", "OTHER"));
mockMvc
.perform(
put("/api/leases/{leaseId}/condition-reports/ENTRY", leaseId)
.with(owner())
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(parse(additionalZoneEntry).jsonString()))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors[0].field").value("zones"))
.andExpect(jsonPath("$.errors[0].code").value("invalid"));
Map<String, Object> reorderedEntry =
conditionReportRequest(read(savedEntry, "$"), newStepConfirmations(false));
@SuppressWarnings("unchecked")
List<Map<String, Object>> entryZones = (List<Map<String, Object>>) reorderedEntry.get("zones");
java.util.Collections.swap(entryZones, 0, 3);
mockMvc
.perform(
put("/api/leases/{leaseId}/condition-reports/ENTRY", leaseId)
.with(owner())
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(parse(reorderedEntry).jsonString()))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors[0].field").value("zones"))
.andExpect(jsonPath("$.errors[0].code").value("invalid"));
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[0].label").value("Cuisine"))
.andExpect(jsonPath("$.privateAreas[1].id").value("area-living"))
.andExpect(jsonPath("$.privateAreas[1].label").value("Séjour"));
}
@Test
void completesAFullRentalAndPaymentJourneyWithPortfolioIsolation() throws Exception {
mockMvc.perform(get("/api/user").with(owner())).andExpect(status().isOk());
@@ -513,6 +713,51 @@ void completesAFullRentalAndPaymentJourneyWithPortfolioIsolation() throws Except
.andExpect(jsonPath("$.status").value("FINALIZED"))
.andExpect(jsonPath("$.finalization.revisionNumber").value(revisionNumber))
.andExpect(jsonPath("$.finalization.signedSha256").isNotEmpty());
String currentApartment =
mockMvc
.perform(get("/api/apartments/{apartmentId}", apartmentId).with(owner()))
.andExpect(status().isOk())
.andReturn()
.getResponse()
.getContentAsString();
Map<String, Object> changedApartment = new LinkedHashMap<>(read(currentApartment, "$"));
changedApartment.remove("id");
changedApartment.remove("mainRoomCount");
changedApartment.remove("referenced");
changedApartment.remove("version");
changedApartment.put(
"privateAreas",
List.of(
Map.of(
"id", "area-later",
"family", "PIECE_DE_SERVICE",
"subtype", "KITCHEN",
"label", "Cuisine ajoutée"),
Map.of(
"id", "area-main",
"family", "PIECE_PRINCIPALE",
"subtype", "LIVING_ROOM",
"label", "Pièce principale")));
mockMvc
.perform(
put("/api/apartments/{apartmentId}", apartmentId)
.with(owner())
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(parse(changedApartment).jsonString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.privateAreas[0].id").value("area-later"))
.andExpect(jsonPath("$.privateAreas[1].id").value("area-main"));
mockMvc
.perform(get(entryReport, leaseId).with(owner()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.privateAreas.length()").value(1))
.andExpect(jsonPath("$.privateAreas[0].id").value("area-main"));
mockMvc
.perform(get("/api/leases/{leaseId}/condition-reports/EXIT", leaseId).with(owner()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.privateAreas.length()").value(1))
.andExpect(jsonPath("$.privateAreas[0].id").value("area-main"));
byte[] entryBundle =
mockMvc
.perform(get(entryReport + "/bundle", leaseId).with(owner()))
+12 -5
View File
@@ -407,8 +407,10 @@ test('guides an entry condition report from an incomplete area to the combined p
const path = new URL(route.request().url()).pathname;
if (path.endsWith('/documents'))
return route.fulfill({ contentType: 'application/json', json: [] });
if (route.request().method() === 'PUT')
if (route.request().method() === 'PUT') {
Object.assign(apartment, route.request().postDataJSON());
return route.fulfill({ contentType: 'application/json', json: apartment });
}
await route.fulfill({ contentType: 'application/json', json: [apartment] });
});
await page.goto('/');
@@ -420,16 +422,21 @@ test('guides an entry condition report from an incomplete area to the combined p
const areas = page.locator('.private-area');
await expect(areas.nth(0)).toContainText('Séjour');
await expect(areas.nth(1)).toContainText('Cuisine');
await expect(areas.nth(0).getByRole('button', { name: 'Monter' })).toBeDisabled();
await expect(areas.nth(1).getByRole('button', { name: 'Descendre' })).toBeDisabled();
await areas.nth(1).getByRole('button', { name: 'Monter' }).focus();
await page.keyboard.press('Enter');
await expect(areas.nth(0)).toContainText('Cuisine');
await expect(areas.nth(1)).toContainText('Séjour');
await expect(areas.nth(0)).toContainText('Visite 1');
await expect(areas.nth(1)).toContainText('Visite 2');
await areas.nth(0).getByRole('button', { name: 'Descendre' }).focus();
await page.keyboard.press('Space');
await expect(areas.nth(0)).toContainText('Séjour');
await expect(areas.nth(1)).toContainText('Cuisine');
await page.getByRole('button', { name: 'Enregistrer' }).click();
await expect(page.getByRole('heading', { name: 'T2 République' })).toBeVisible();
expect(apartment.privateAreas.map((area) => area.id)).toEqual(['area-kitchen', 'area-living']);
await page.reload();
await page.getByRole('button', { name: 'Modifier' }).click();
await expect(areas.nth(0)).toContainText('Cuisine');
await expect(areas.nth(1)).toContainText('Séjour');
let uploads = 0;
let saves = 0;
let finalizations = 0;