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> areas = (List>) 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 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 additionalZoneEntry = + conditionReportRequest(read(savedEntry, "$"), newStepConfirmations(false)); + @SuppressWarnings("unchecked") + List> zonesWithAdditional = + (List>) 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 reorderedEntry = + conditionReportRequest(read(savedEntry, "$"), newStepConfirmations(false)); + @SuppressWarnings("unchecked") + List> entryZones = (List>) 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 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())) diff --git a/frontend/e2e/app.spec.ts b/frontend/e2e/app.spec.ts index dc81fa0..db9eda5 100644 --- a/frontend/e2e/app.spec.ts +++ b/frontend/e2e/app.spec.ts @@ -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;