diff --git a/CONTEXT.md b/CONTEXT.md index cf8b964..55cce10 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -209,7 +209,7 @@ Dépense de valeur significative (seuil fiscal de 500 € HT en France) pour des _Avoid_: Investissement, charge à amortir **Inventaire du mobilier** (Code: `FurnitureInventory`): -Annexe contractuelle du bail recensant de façon exhaustive et détaillée les meubles, appareils et équipements mis à la disposition du locataire, dont les mêmes éléments sont ensuite décrits dans les états des lieux d'entrée et de sortie. Chaque ligne regroupe un type d'objet homogène dans un espace privatif avec sa désignation, sa quantité et une description facultative, sans décrire son état ; l'inventaire est prérempli depuis le catalogue de la version de modèle de bail applicable, complété librement par le bailleur, exigé avant la génération du bail et figé avec le snapshot des espaces privatifs dans chaque révision générée, chaque élément réglementaire devant être explicitement renseigné sans être présumé présent et son absence produisant une alerte juridique contournable et auditée plutôt qu'un blocage permanent. +Annexe contractuelle du bail recensant de façon exhaustive et détaillée les meubles, appareils et équipements mis à la disposition du locataire, dont les mêmes éléments sont ensuite décrits dans les états des lieux d'entrée et de sortie. Chaque ligne regroupe un type d'objet homogène dans un espace privatif avec sa désignation, sa quantité et une description facultative, sans décrire son état ; une classification structurée, jamais déduite du libellé libre, indique si elle doit aussi créer une zone dédiée d'équipement dans l'état des lieux. L'inventaire est prérempli depuis le catalogue de la version de modèle de bail applicable, complété librement par le bailleur, exigé avant la génération du bail et figé avec le snapshot des espaces privatifs dans chaque révision générée, chaque élément réglementaire devant être explicitement renseigné sans être présumé présent et son absence produisant une alerte juridique contournable et auditée plutôt qu'un blocage permanent. _Avoid_: Inventaire des lieux, liste des meubles **Écart d'inventaire à l'entrée** (Code: `EntryInventoryDiscrepancy`): @@ -217,7 +217,7 @@ Différence entre l'inventaire du mobilier inclus dans la révision générée d _Avoid_: Incohérence, modification libre du bail signé **État des lieux** (Code: `PropertyConditionReport`): -Constat écrit et contradictoire rattaché au bail, décrivant l'état de chaque espace privatif du logement et de ses équipements lors de l'entrée (remise des clés) ou de la sortie (restitution des clés), et illustré de photos de preuve ; il fige les parties au bail effectives à sa date, présentes ou représentées, les relevés de compteurs, les clés et autres moyens d'accès ainsi que, à la sortie, une adresse de correspondance propre à chaque locataire effectif sans modifier son profil ni son adresse contractuelle, tandis que chaque sol, mur, plafond, équipement et ligne d'inventaire reçoit un état parmi neuf, bon état, état d'usage, dégradé, absent ou non applicable, assorti d'une observation libre obligatoire pour les trois derniers, et que la quantité constatée d'une ligne d'inventaire peut être répartie entre plusieurs états sans modifier sa quantité contractuelle. Sa date civile réelle n'est jamais future dans le fuseau IANA du navigateur, la sortie ne précède jamais l'entrée sans devoir coïncider avec une borne contractuelle ; son brouillon peut commencer dès la génération d'une révision du bail mais devient obsolète si celle-ci est remplacée ; le bail est finalisé en premier, puis la finalisation de ce seul parcours amiable exige le téléversement de sa version papier signée et numérisée, l'attestation de sa correspondance par le bailleur et la signature de chaque partie ou de son mandataire identifié, conserve l'auteur du téléversement, l'horodatage UTC et l'empreinte SHA-256, et rend le constat immuable ; le départ isolé d'un colocataire n'en crée pas un nouveau, toute correction ultérieure est ajoutée sans remplacer le constat finalisé et l'établissement par commissaire de justice reste hors du périmètre. +Constat écrit et contradictoire rattaché au bail, décrivant l'état de chaque espace privatif du logement et de ses équipements lors de l'entrée (remise des clés) ou de la sortie (restitution des clés), et illustré de photos de preuve ; il fige les parties au bail effectives à sa date, présentes ou représentées, les relevés de compteurs, les clés et autres moyens d'accès ainsi que, à la sortie, une adresse de correspondance propre à chaque locataire effectif sans modifier son profil ni son adresse contractuelle, tandis que chaque sol, mur, plafond, équipement et ligne d'inventaire reçoit un état parmi six états : neuf, bon état, état d'usage, dégradé, absent ou non applicable, assorti d'une observation libre obligatoire pour les trois derniers, et que la quantité constatée d'une ligne d'inventaire peut être répartie entre plusieurs états sans modifier sa quantité contractuelle. Sa date civile réelle n'est jamais future dans le fuseau IANA du navigateur, la sortie ne précède jamais l'entrée sans devoir coïncider avec une borne contractuelle ; son brouillon peut commencer dès la génération d'une révision du bail mais devient obsolète si celle-ci est remplacée ; le bail est finalisé en premier, puis la finalisation de ce seul parcours amiable exige le téléversement de sa version papier signée et numérisée, l'attestation de sa correspondance par le bailleur et la signature de chaque partie ou de son mandataire identifié, conserve l'auteur du téléversement, l'horodatage UTC et l'empreinte SHA-256, et rend le constat immuable ; le départ isolé d'un colocataire n'en crée pas un nouveau, toute correction ultérieure est ajoutée sans remplacer le constat finalisé et l'établissement par commissaire de justice reste hors du périmètre. _Avoid_: Constat, check-list d'entrée **Révision de l'état des lieux** (Code: `PropertyConditionReportRevision`): @@ -229,7 +229,7 @@ Archive ZIP immuable produite après finalisation, contenant le PDF signé, les _Avoid_: E-mail certifié, accès locataire, dossier photo mutable **Relevé de compteur** (Code: `MeterReading`): -Index décimal non négatif constaté dans un état des lieux pour un compteur d'électricité, de gaz, d'eau froide, d'eau chaude ou d'un autre type explicitement libellé, avec son unité et, lorsqu'ils existent, son libellé ou numéro et une photo. Les compteurs identifiés à l'entrée sont repris à la sortie par la même référence ; un remplacement ou une impossibilité d'accès exige une observation. +Index décimal non négatif constaté dans un état des lieux pour un compteur d'électricité, de gaz, d'eau froide, d'eau chaude ou d'un autre type explicitement libellé, avec son unité et, lorsqu'ils existent, son libellé ou numéro et une photo. Un compteur inaccessible est enregistré sans index inventé et avec une observation obligatoire. Les compteurs identifiés à l'entrée sont repris à la sortie par la même référence ; un remplacement ou une impossibilité d'accès exige une observation. _Avoid_: Consommation calculée, facture d'énergie **Moyen d'accès** (Code: `AccessMeans`): diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/config/RentalApplicationConfiguration.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/config/RentalApplicationConfiguration.java index e886b5d..71d64bf 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/config/RentalApplicationConfiguration.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/config/RentalApplicationConfiguration.java @@ -87,8 +87,8 @@ public class RentalApplicationConfiguration { @Bean FurnitureInventoryService furnitureInventoryService(FurnitureInventoryRepository inventories, LeaseRepository leases, ApartmentRepository apartments, - IdentifierGenerator identifiers) { - return new FurnitureInventoryService(inventories, leases, apartments, identifiers); + IdentifierGenerator identifiers, Clock rentalClock) { + return new FurnitureInventoryService(inventories, leases, apartments, identifiers, rentalClock); } @Bean diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/FurnitureInventoryController.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/FurnitureInventoryController.java index ace4a26..091a07f 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/FurnitureInventoryController.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/FurnitureInventoryController.java @@ -4,6 +4,7 @@ import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlor import fr.nessar.lmnporganizer.rental.application.furniture.FurnitureInventoryService; import fr.nessar.lmnporganizer.rental.domain.apartment.PrivateArea; import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureCategory; +import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureEquipmentKind; import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventory; import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureLine; import fr.nessar.lmnporganizer.rental.domain.furniture.FurniturePresence; @@ -47,18 +48,20 @@ public class FurnitureInventoryController { } record Request(@NotNull @Min(0) Long version, @NotNull @Valid List lines) { } record LineRequest(@NotBlank String id, FurnitureCategory category, FurniturePresence presence, + @NotNull FurnitureEquipmentKind equipmentKind, @NotBlank String privateAreaId, @NotBlank String designation, @Min(0) int quantity, String description) { FurnitureLine toDomain() { - return new FurnitureLine(id, category, presence, privateAreaId, designation, quantity, description); + return new FurnitureLine(id, category, presence, equipmentKind, privateAreaId, designation, quantity, + description); } } - record LineResponse(String id, FurnitureCategory category, FurniturePresence presence, String privateAreaId, - String designation, int quantity, - String description) { + record LineResponse(String id, FurnitureCategory category, FurniturePresence presence, + FurnitureEquipmentKind equipmentKind, String privateAreaId, String designation, + int quantity, String description) { static LineResponse from(FurnitureLine line) { - return new LineResponse(line.id(), line.category(), line.presence(), line.privateAreaId(), - line.designation(), line.quantity(), line.description()); + return new LineResponse(line.id(), line.category(), line.presence(), line.equipmentKind(), + line.privateAreaId(), line.designation(), line.quantity(), line.description()); } } record PrivateAreaResponse(String id, String label) { diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseApi.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseApi.java index 5fb307c..d2a6062 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseApi.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseApi.java @@ -310,12 +310,12 @@ final class LeaseApi { } } - record RevisionInventoryLineResponse(String id, String category, String presence, String privateAreaId, - String designation, int quantity, String description) { + record RevisionInventoryLineResponse(String id, String category, String presence, String equipmentKind, + String privateAreaId, String designation, int quantity, String description) { static RevisionInventoryLineResponse from(FurnitureInventorySnapshot.Line line) { return new RevisionInventoryLineResponse(line.id(), line.category() == null ? null : line.category().name(), - line.presence() == null ? null : line.presence().name(), line.privateAreaId(), line.designation(), - line.quantity(), line.description()); + line.presence() == null ? null : line.presence().name(), line.equipmentKind().name(), + line.privateAreaId(), line.designation(), line.quantity(), line.description()); } } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/PropertyConditionComparisonController.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/PropertyConditionComparisonController.java index e45c0dc..5d14a8c 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/PropertyConditionComparisonController.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/PropertyConditionComparisonController.java @@ -3,11 +3,18 @@ package fr.nessar.lmnporganizer.rental.adapter.in.web.lease; import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord; import fr.nessar.lmnporganizer.rental.application.condition.PropertyConditionDocumentService; import fr.nessar.lmnporganizer.rental.domain.condition.AccessMeans; +import fr.nessar.lmnporganizer.rental.domain.condition.AuditedPhotoAbsence; +import fr.nessar.lmnporganizer.rental.domain.condition.ConditionPhoto; import fr.nessar.lmnporganizer.rental.domain.condition.ConditionObservation; import fr.nessar.lmnporganizer.rental.domain.condition.ConditionZone; import fr.nessar.lmnporganizer.rental.domain.condition.InventoryCondition; +import fr.nessar.lmnporganizer.rental.domain.condition.InventoryQuantitySplit; +import fr.nessar.lmnporganizer.rental.domain.condition.LateSupplementAddition; import fr.nessar.lmnporganizer.rental.domain.condition.MeterReading; +import fr.nessar.lmnporganizer.rental.domain.condition.OnTimeSupplement; import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportSupplement; +import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportSupplementType; +import fr.nessar.lmnporganizer.rental.domain.condition.SupplementAudit; import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.oauth2.core.oidc.user.OidcUser; @@ -16,7 +23,11 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; import java.util.List; +import java.util.function.Function; @RestController @RequestMapping("/api/leases/{leaseId}/condition-report-comparison") @@ -32,25 +43,112 @@ public final class PropertyConditionComparisonController { @GetMapping ComparisonResponse get(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId) { var comparison = documents.comparison(landlord.id(principal), new LeaseId(leaseId)); - return new ComparisonResponse(comparison.observations(), comparison.zones(), comparison.inventory(), - comparison.meters(), comparison.accessMeans(), comparison.supplements(), + return new ComparisonResponse(lines(comparison.observations(), ObservationResponse::from), + lines(comparison.zones(), ZoneResponse::from), lines(comparison.inventory(), InventoryResponse::from), + lines(comparison.meters(), MeterResponse::from), lines(comparison.accessMeans(), AccessResponse::from), + lines(comparison.supplements(), SupplementResponse::from), comparison.photos().stream().map(PhotoLineResponse::from).toList()); } - record ComparisonResponse(List> observations, - List> zones, - List> inventory, - List> meters, - List> accessMeans, - List> supplements, + private static List> lines( + List> values, + Function mapper) { + return values.stream().map(value -> new LineResponse<>(value.reference(), + value.entry() == null ? null : mapper.apply(value.entry()), + value.exit() == null ? null : mapper.apply(value.exit()), value.changed())).toList(); + } + + record ComparisonResponse(List> observations, + List> zones, + List> inventory, + List> meters, + List> accessMeans, + List> supplements, List photos) { } - record PhotoLineResponse(String reference, PropertyConditionReportController.PhotoResponse entry, - PropertyConditionReportController.PhotoResponse exit, - fr.nessar.lmnporganizer.rental.domain.condition.AuditedPhotoAbsence absence, + + record LineResponse(String reference, T entry, T exit, boolean changed) { } + record ObservationResponse(String subjectId, String state, String observation) { + static ObservationResponse from(ConditionObservation value) { + return new ObservationResponse(value.subjectId(), value.state().name(), value.observation()); + } + } + record ZoneResponse(String id, String privateAreaId, String label, String equipment) { + static ZoneResponse from(ConditionZone value) { + return new ZoneResponse(value.id(), value.privateAreaId(), value.label(), value.equipment().name()); + } + } + record SplitResponse(String inventoryLineId, String state, int quantity, String observation) { + static SplitResponse from(InventoryQuantitySplit value) { + return new SplitResponse(value.inventoryLineId(), value.state().name(), value.quantity(), + value.observation()); + } + } + record InventoryResponse(String inventoryLineId, String privateAreaId, String privateAreaLabel, + String designation, int contractualQuantity, List splits) { + static InventoryResponse from(InventoryCondition value) { + return new InventoryResponse(value.inventoryLineId(), value.privateAreaId(), value.privateAreaLabel(), + value.designation(), value.contractualQuantity(), value.splits().stream() + .map(SplitResponse::from).toList()); + } + } + record MeterResponse(String id, String type, String state, BigDecimal index, String unit, String label, String photoId, + String observation) { + static MeterResponse from(MeterReading value) { + return new MeterResponse(value.id(), value.type().name(), value.state().name(), value.index(), value.unit(), + value.label(), value.photoId(), value.observation()); + } + } + record AccessResponse(String id, String type, String destination, int quantity, String observation) { + static AccessResponse from(AccessMeans value) { + return new AccessResponse(value.id(), value.type().name(), value.destination(), value.quantity(), + value.observation()); + } + } + record SupplementFileResponse(String reference, String sha256) { + static SupplementFileResponse from(PropertyConditionReportSupplement.FileEvidence value) { + return new SupplementFileResponse(value.reference(), value.sha256()); + } + } + record SupplementAuditResponse(String status, LocalDate deadlineOn, String ruleVersion, String reason, + String auditedBy, Instant auditedAt) { + static SupplementAuditResponse from(SupplementAudit value) { + return value instanceof LateSupplementAddition late + ? new SupplementAuditResponse("LATE", late.deadlineOn(), late.ruleVersion(), late.reason(), + late.auditedBy(), late.auditedAt()) + : new SupplementAuditResponse("ON_TIME", ((OnTimeSupplement) value).deadlineOn(), null, null, + null, null); + } + } + record SupplementResponse(String id, PropertyConditionReportSupplementType type, + SupplementFileResponse signedPdf, List photos, + String uploadedBy, Instant uploadedAt, LocalDate requestedOn, + LocalDate heatingPeriodStartedOn, String acceptedAdditions, SupplementAuditResponse audit) { + static SupplementResponse from(PropertyConditionReportSupplement value) { + return new SupplementResponse(value.id(), value.type(), SupplementFileResponse.from(value.signedPdf()), + value.photos().stream().map(PhotoResponse::from).toList(), value.uploadedBy(), + value.uploadedAt(), value.requestedOn(), value.heatingPeriodStartedOn(), value.acceptedAdditions(), + SupplementAuditResponse.from(value.audit())); + } + } + record PhotoResponse(String reference, String zoneId, String sha256, int width, int height, String uploadedBy, + Instant uploadedAt) { + static PhotoResponse from(ConditionPhoto value) { + return new PhotoResponse(value.reference(), value.zoneId(), value.sha256(), value.width(), value.height(), + value.uploadedBy(), value.uploadedAt()); + } + } + record AbsenceResponse(String reason, String auditedBy, Instant auditedAt) { + static AbsenceResponse from(AuditedPhotoAbsence value) { + return value == null ? null : new AbsenceResponse(value.reason(), value.auditedBy(), value.auditedAt()); + } + } + record PhotoLineResponse(String reference, PhotoResponse entry, PhotoResponse exit, + AbsenceResponse absence, boolean changed) { static PhotoLineResponse from(PropertyConditionDocumentService.PropertyConditionComparison.PhotoLine value) { - return new PhotoLineResponse(value.reference(), value.entry() == null ? null : PropertyConditionReportController.PhotoResponse.from(value.entry()), - value.exit() == null ? null : PropertyConditionReportController.PhotoResponse.from(value.exit()), value.absence(), value.changed()); + return new PhotoLineResponse(value.reference(), value.entry() == null ? null : PhotoResponse.from(value.entry()), + value.exit() == null ? null : PhotoResponse.from(value.exit()), + AbsenceResponse.from(value.absence()), value.changed()); } } } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/PropertyConditionReportController.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/PropertyConditionReportController.java index c9dfa19..e0fb633 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/PropertyConditionReportController.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/PropertyConditionReportController.java @@ -79,23 +79,35 @@ public final class PropertyConditionReportController { @PostMapping(path = "/supplements", consumes = "multipart/form-data") Response supplement(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId, @PathVariable PropertyConditionReportType type, @RequestPart MultipartFile signedPdf, @RequestPart(required = false) List photos, + @RequestParam(required = false) List photoZoneIds, @RequestParam LocalDate requestedOn, @RequestParam String acceptedAdditions, - @RequestParam(required = false) LocalDate heatingPeriodStartedOn, @RequestParam(required = false) String reason) throws java.io.IOException { + @RequestParam PropertyConditionReportSupplementType supplementType, + @RequestParam(required = false) LocalDate heatingPeriodStartedOn, + @RequestParam(defaultValue = "false") boolean lateWarningConfirmed, + @RequestParam(required = false) String reason) throws java.io.IOException { if (type != PropertyConditionReportType.ENTRY) throw new IllegalArgumentException("Les compléments appartiennent à l'état des lieux d'entrée."); if (signedPdf.getSize() > attachmentProperties.maxSize().toBytes()) { throw new IllegalArgumentException("Le complément PDF est trop volumineux."); } byte[] pdf = signedPdf.getBytes(); if (!startsWithPdf(pdf)) throw new IllegalArgumentException("Le complément PDF signé est invalide."); - List photoContents = new java.util.ArrayList<>(); - for (MultipartFile photo : photos == null ? List.of() : photos) { + List photoFiles = photos == null ? List.of() : photos; + List zones = photoZoneIds == null ? List.of() : photoZoneIds; + if (photoFiles.size() != zones.size()) { + throw new IllegalArgumentException("Chaque photo du complément doit préciser sa zone."); + } + List photoContents = new java.util.ArrayList<>(); + for (int index = 0; index < photoFiles.size(); index++) { + MultipartFile photo = photoFiles.get(index); if (photo.getSize() > photoProperties.maxUploadSize().toBytes()) { throw new IllegalArgumentException("Une photo du complément est trop volumineuse."); } - photoContents.add(photo.getBytes()); + photoContents.add(new PropertyConditionReportService.SupplementPhotoSource(zones.get(index), + photo.getBytes())); } return Response.from(reports.appendSupplement(landlord.id(principal), new LeaseId(leaseId), pdf, - photoContents, principal.getSubject(), requestedOn, acceptedAdditions, heatingPeriodStartedOn, reason)); + photoContents, principal.getSubject(), requestedOn, acceptedAdditions, supplementType, + heatingPeriodStartedOn, lateWarningConfirmed, reason)); } record Request(@NotNull Long version, @NotNull LocalDate occurredOn, @NotBlank String timeZone, @Valid List participants, @@ -111,7 +123,10 @@ public final class PropertyConditionReportController { record AddressRequest(@NotBlank String tenantPartyId, @NotBlank String address) { ForwardingAddress toDomain() { return new ForwardingAddress(tenantPartyId, address); } } record ZoneRequest(@NotBlank String id, String privateAreaId, @NotBlank String label, @NotNull ZoneEquipment equipment) { ConditionZone toDomain() { return new ConditionZone(id, privateAreaId, label, equipment); } } record ObservationRequest(@NotBlank String subjectId, @NotNull ConditionState state, String observation) { ConditionObservation toDomain() { return new ConditionObservation(subjectId, state, observation); } } - record MeterRequest(@NotBlank String id, @NotNull MeterType type, @NotNull BigDecimal index, @NotBlank String unit, String label, String photoId, String observation) { MeterReading toDomain() { return new MeterReading(id, type, index, unit, label, photoId, observation); } } + record MeterRequest(@NotBlank String id, @NotNull MeterType type, @NotNull MeterReadingState state, + BigDecimal index, @NotBlank String unit, String label, String photoId, String observation) { + MeterReading toDomain() { return new MeterReading(id, type, state, index, unit, label, photoId, observation); } + } record AccessRequest(@NotBlank String id, @NotNull AccessMeansType type, @NotBlank String destination, int quantity, String observation) { AccessMeans toDomain() { return new AccessMeans(id, type, destination, quantity, observation); } } record InventoryRequest(@NotBlank String inventoryLineId, @NotBlank String privateAreaId, @NotBlank String privateAreaLabel, @NotBlank String designation, @@ -155,13 +170,26 @@ public final class PropertyConditionReportController { return new SupplementFileResponse(value.reference(), value.sha256()); } } - record SupplementResponse(String id, SupplementFileResponse signedPdf, List photos, + record SupplementResponse(String id, PropertyConditionReportSupplementType type, SupplementFileResponse signedPdf, + List photos, String uploadedBy, java.time.Instant uploadedAt, LocalDate requestedOn, - String acceptedAdditions, SupplementAudit audit) { + LocalDate heatingPeriodStartedOn, String acceptedAdditions, + SupplementAuditResponse audit) { static SupplementResponse from(PropertyConditionReportSupplement value) { return new SupplementResponse( - value.id(), SupplementFileResponse.from(value.signedPdf()), value.photos().stream() - .map(SupplementFileResponse::from).toList(), value.uploadedBy(), value.uploadedAt(), - value.requestedOn(), value.acceptedAdditions(), value.audit()); } + value.id(), value.type(), SupplementFileResponse.from(value.signedPdf()), value.photos().stream() + .map(PhotoResponse::from).toList(), value.uploadedBy(), value.uploadedAt(), + value.requestedOn(), value.heatingPeriodStartedOn(), value.acceptedAdditions(), + SupplementAuditResponse.from(value.audit())); } + } + record SupplementAuditResponse(String status, LocalDate deadlineOn, String ruleVersion, String reason, + String auditedBy, java.time.Instant auditedAt) { + static SupplementAuditResponse from(SupplementAudit value) { + return value instanceof LateSupplementAddition late + ? new SupplementAuditResponse("LATE", late.deadlineOn(), late.ruleVersion(), late.reason(), + late.auditedBy(), late.auditedAt()) + : new SupplementAuditResponse("ON_TIME", ((OnTimeSupplement) value).deadlineOn(), null, null, + null, null); + } } record EntrySnapshotResponse(String reportId, List participants, List zones, List observations, List meterReadings, diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/condition/MongoPropertyConditionReportRepository.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/condition/MongoPropertyConditionReportRepository.java index 537723f..9a01825 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/condition/MongoPropertyConditionReportRepository.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/condition/MongoPropertyConditionReportRepository.java @@ -39,7 +39,7 @@ public class MongoPropertyConditionReportRepository implements PropertyCondition r.forwardingAddresses().stream().map(x -> new AddressDocument(x.tenantPartyId(), x.address())).toList(), r.zones().stream().map(x -> new ZoneDocument(x.id(), x.privateAreaId(), x.label(), x.equipment().name())).toList(), r.observations().stream().map(x -> new ObservationDocument(x.subjectId(), x.state().name(), x.observation())).toList(), - r.meterReadings().stream().map(x -> new MeterDocument(x.id(), x.type().name(), x.index(), x.unit(), x.label(), x.photoId(), x.observation())).toList(), + r.meterReadings().stream().map(x -> new MeterDocument(x.id(), x.type().name(), x.state().name(), x.index(), x.unit(), x.label(), x.photoId(), x.observation())).toList(), r.accessMeans().stream().map(x -> new AccessDocument(x.id(), x.type().name(), x.destination(), x.quantity(), x.observation())).toList(), r.inventoryConditions().stream().map(x -> new InventoryDocument(x.inventoryLineId(), x.privateAreaId(), x.privateAreaLabel(), x.designation(), x.contractualQuantity(), x.splits().stream().map(s -> @@ -55,7 +55,7 @@ public class MongoPropertyConditionReportRepository implements PropertyCondition return PropertyConditionReport.reconstitute(new PropertyConditionReportId(d.id()), new LandlordId(d.landlordId()), new LeaseId(d.leaseId()), PropertyConditionReportType.valueOf(d.type()), d.occurredOn(), d.timeZone(), d.participants().stream().map(x -> new ReportParticipant(x.partyId(), ReportPartyRole.valueOf(x.role()), x.name(), ParticipantAttendance.valueOf(x.attendance()), x.representativeName())).toList(), d.forwardingAddresses().stream().map(x -> new ForwardingAddress(x.tenantPartyId(), x.address())).toList(), d.zones().stream().map(x -> new ConditionZone(x.id(), x.privateAreaId(), x.label(), ZoneEquipment.valueOf(x.equipment()))).toList(), - d.observations().stream().map(x -> new ConditionObservation(x.subjectId(), ConditionState.valueOf(x.state()), x.observation())).toList(), d.meterReadings().stream().map(x -> new MeterReading(x.id(), MeterType.valueOf(x.type()), x.index(), x.unit(), x.label(), x.photoId(), x.observation())).toList(), + d.observations().stream().map(x -> new ConditionObservation(x.subjectId(), ConditionState.valueOf(x.state()), x.observation())).toList(), d.meterReadings().stream().map(x -> new MeterReading(x.id(), MeterType.valueOf(x.type()), MeterReadingState.valueOf(x.state()), x.index(), x.unit(), x.label(), x.photoId(), x.observation())).toList(), d.accessMeans().stream().map(x -> new AccessMeans(x.id(), AccessMeansType.valueOf(x.type()), x.destination(), x.quantity(), x.observation())).toList(), d.inventoryConditions().stream().map(x -> new InventoryCondition(x.inventoryLineId(), x.privateAreaId(), x.privateAreaLabel(), x.designation(), x.contractualQuantity(), x.splits().stream().map(s -> new InventoryQuantitySplit(s.inventoryLineId(), ConditionState.valueOf(s.state()), s.quantity(), s.observation())).toList())).toList(), d.photos().stream().map(x -> new ConditionPhoto(x.reference(), x.zoneId(), x.storageKey(), x.sha256(), x.width(), x.height(), x.uploadedBy(), x.uploadedAt())).toList(), d.zonePhotoAbsences().stream().map(x -> new ZonePhotoAbsence(x.zoneId(), absence(x.absence()))).toList(), d.entryPhotoDispositions().stream().map(this::disposition).toList(), d.supplements().stream().map(this::supplement).toList(), @@ -67,20 +67,22 @@ public class MongoPropertyConditionReportRepository implements PropertyCondition private static AuditedPhotoAbsence absence(AbsenceDocument x) { return new AuditedPhotoAbsence(x.reason(), x.auditedBy(), x.auditedAt()); } private DispositionDocument disposition(EntryPhotoDisposition x) { return x instanceof MatchedEntryPhoto m ? new DispositionDocument(m.entryPhotoId(), m.exitPhotoId(), null) : new DispositionDocument(x.entryPhotoId(), null, absence(((MissingEntryPhoto) x).absence())); } private EntryPhotoDisposition disposition(DispositionDocument x) { return x.exitPhotoId() == null ? new MissingEntryPhoto(x.entryPhotoId(), absence(x.absence())) : new MatchedEntryPhoto(x.entryPhotoId(), x.exitPhotoId()); } - private SupplementDocument supplement(PropertyConditionReportSupplement x) { return new SupplementDocument(x.id(), file(x.signedPdf()), x.photos().stream().map(this::file).toList(), x.uploadedBy(), x.uploadedAt(), x.requestedOn(), x.acceptedAdditions(), x.audit() instanceof OnTimeSupplement onTime ? onTime.deadlineOn() : ((LateSupplementAddition) x.audit()).deadlineOn(), x.audit() instanceof LateSupplementAddition late ? late.ruleVersion() : null, x.audit() instanceof LateSupplementAddition late ? late.reason() : null, x.audit() instanceof LateSupplementAddition late ? late.auditedBy() : null, x.audit() instanceof LateSupplementAddition late ? late.auditedAt() : null); } - private PropertyConditionReportSupplement supplement(SupplementDocument x) { return new PropertyConditionReportSupplement(x.id(), file(x.signedPdf()), x.photos().stream().map(this::file).toList(), x.uploadedBy(), x.uploadedAt(), x.requestedOn(), x.acceptedAdditions(), x.lateReason() == null ? new OnTimeSupplement(x.deadlineOn()) : new LateSupplementAddition(x.lateRuleVersion(), x.deadlineOn(), x.lateReason(), x.lateAuditedBy(), x.lateAuditedAt())); } + private SupplementDocument supplement(PropertyConditionReportSupplement x) { return new SupplementDocument(x.id(), x.type().name(), file(x.signedPdf()), x.photos().stream().map(this::photo).toList(), x.uploadedBy(), x.uploadedAt(), x.requestedOn(), x.heatingPeriodStartedOn(), x.acceptedAdditions(), x.audit() instanceof OnTimeSupplement onTime ? onTime.deadlineOn() : ((LateSupplementAddition) x.audit()).deadlineOn(), x.audit() instanceof LateSupplementAddition late ? late.ruleVersion() : null, x.audit() instanceof LateSupplementAddition late ? late.reason() : null, x.audit() instanceof LateSupplementAddition late ? late.auditedBy() : null, x.audit() instanceof LateSupplementAddition late ? late.auditedAt() : null); } + private PropertyConditionReportSupplement supplement(SupplementDocument x) { return new PropertyConditionReportSupplement(x.id(), PropertyConditionReportSupplementType.valueOf(x.type()), file(x.signedPdf()), x.photos().stream().map(this::photo).toList(), x.uploadedBy(), x.uploadedAt(), x.requestedOn(), x.heatingPeriodStartedOn(), x.acceptedAdditions(), x.lateReason() == null ? new OnTimeSupplement(x.deadlineOn()) : new LateSupplementAddition(x.lateRuleVersion(), x.deadlineOn(), x.lateReason(), x.lateAuditedBy(), x.lateAuditedAt())); } private FileDocument file(PropertyConditionReportSupplement.FileEvidence x) { return new FileDocument(x.reference(), x.storageKey(), x.sha256()); } private PropertyConditionReportSupplement.FileEvidence file(FileDocument x) { return new PropertyConditionReportSupplement.FileEvidence(x.reference(), x.storageKey(), x.sha256()); } + private PhotoDocument photo(ConditionPhoto x) { return new PhotoDocument(x.reference(), x.zoneId(), x.storageKey(), x.sha256(), x.width(), x.height(), x.uploadedBy(), x.uploadedAt()); } + private ConditionPhoto photo(PhotoDocument x) { return new ConditionPhoto(x.reference(), x.zoneId(), x.storageKey(), x.sha256(), x.width(), x.height(), x.uploadedBy(), x.uploadedAt()); } private RevisionDocument revision(PropertyConditionReportRevision x) { return new RevisionDocument(x.number(), x.storageKey(), x.sha256(), x.sourceFingerprint(), x.authorId(), x.generatedAt(), x.obsolete()); } private PropertyConditionReportRevision revision(RevisionDocument x) { return new PropertyConditionReportRevision(x.number(), x.storageKey(), x.sha256(), x.sourceFingerprint(), x.authorId(), x.generatedAt(), x.obsolete()); } private FinalizationDocument finalization(PropertyConditionReportFinalization x) { return x == null ? null : new FinalizationDocument(x.revisionNumber(), x.signedStorageKey(), x.signedSha256(), x.authorId(), x.finalizedAt()); } private PropertyConditionReportFinalization finalization(FinalizationDocument x) { return x == null ? null : new PropertyConditionReportFinalization(x.revisionNumber(), x.signedStorageKey(), x.signedSha256(), x.authorId(), x.finalizedAt()); } private DeliveryDocument delivery(PropertyConditionReportDeliveryAttestation x) { return new DeliveryDocument(x.partyId(), x.attestedBy(), x.attestedAt()); } private PropertyConditionReportDeliveryAttestation delivery(DeliveryDocument x) { return new PropertyConditionReportDeliveryAttestation(x.partyId(), x.attestedBy(), x.attestedAt()); } - private EntrySnapshotDocument snapshot(EntryReportSnapshot x) { return new EntrySnapshotDocument(x.reportId().value(), documentSnapshotParticipants(x.participants()), x.zones().stream().map(z -> new ZoneDocument(z.id(), z.privateAreaId(), z.label(), z.equipment().name())).toList(), x.observations().stream().map(o -> new ObservationDocument(o.subjectId(), o.state().name(), o.observation())).toList(), x.meterReadings().stream().map(m -> new MeterDocument(m.id(), m.type().name(), m.index(), m.unit(), m.label(), m.photoId(), m.observation())).toList(), x.accessMeans().stream().map(a -> new AccessDocument(a.id(), a.type().name(), a.destination(), a.quantity(), a.observation())).toList(), x.inventoryConditions().stream().map(i -> new InventoryDocument(i.inventoryLineId(), i.privateAreaId(), i.privateAreaLabel(), i.designation(), i.contractualQuantity(), i.splits().stream().map(s -> new SplitDocument(s.inventoryLineId(), s.state().name(), s.quantity(), s.observation())).toList())).toList(), x.photos().stream().map(p -> new PhotoDocument(p.reference(), p.zoneId(), p.storageKey(), p.sha256(), p.width(), p.height(), p.uploadedBy(), p.uploadedAt())).toList(), x.supplements().stream().map(this::supplement).toList()); } - private EntryReportSnapshot snapshot(EntrySnapshotDocument x) { return new EntryReportSnapshot(new PropertyConditionReportId(x.reportId()), x.participants().stream().map(p -> new ReportParticipant(p.partyId(), ReportPartyRole.valueOf(p.role()), p.name(), ParticipantAttendance.valueOf(p.attendance()), p.representativeName())).toList(), x.zones().stream().map(z -> new ConditionZone(z.id(), z.privateAreaId(), z.label(), ZoneEquipment.valueOf(z.equipment()))).toList(), x.observations().stream().map(o -> new ConditionObservation(o.subjectId(), ConditionState.valueOf(o.state()), o.observation())).toList(), x.meterReadings().stream().map(m -> new MeterReading(m.id(), MeterType.valueOf(m.type()), m.index(), m.unit(), m.label(), m.photoId(), m.observation())).toList(), x.accessMeans().stream().map(a -> new AccessMeans(a.id(), AccessMeansType.valueOf(a.type()), a.destination(), a.quantity(), a.observation())).toList(), x.inventoryConditions().stream().map(i -> new InventoryCondition(i.inventoryLineId(), i.privateAreaId(), i.privateAreaLabel(), i.designation(), i.contractualQuantity(), i.splits().stream().map(s -> new InventoryQuantitySplit(s.inventoryLineId(), ConditionState.valueOf(s.state()), s.quantity(), s.observation())).toList())).toList(), x.photos().stream().map(p -> new ConditionPhoto(p.reference(), p.zoneId(), p.storageKey(), p.sha256(), p.width(), p.height(), p.uploadedBy(), p.uploadedAt())).toList(), x.supplements().stream().map(this::supplement).toList()); } + private EntrySnapshotDocument snapshot(EntryReportSnapshot x) { return new EntrySnapshotDocument(x.reportId().value(), documentSnapshotParticipants(x.participants()), x.zones().stream().map(z -> new ZoneDocument(z.id(), z.privateAreaId(), z.label(), z.equipment().name())).toList(), x.observations().stream().map(o -> new ObservationDocument(o.subjectId(), o.state().name(), o.observation())).toList(), x.meterReadings().stream().map(m -> new MeterDocument(m.id(), m.type().name(), m.state().name(), m.index(), m.unit(), m.label(), m.photoId(), m.observation())).toList(), x.accessMeans().stream().map(a -> new AccessDocument(a.id(), a.type().name(), a.destination(), a.quantity(), a.observation())).toList(), x.inventoryConditions().stream().map(i -> new InventoryDocument(i.inventoryLineId(), i.privateAreaId(), i.privateAreaLabel(), i.designation(), i.contractualQuantity(), i.splits().stream().map(s -> new SplitDocument(s.inventoryLineId(), s.state().name(), s.quantity(), s.observation())).toList())).toList(), x.photos().stream().map(p -> new PhotoDocument(p.reference(), p.zoneId(), p.storageKey(), p.sha256(), p.width(), p.height(), p.uploadedBy(), p.uploadedAt())).toList(), x.supplements().stream().map(this::supplement).toList()); } + private EntryReportSnapshot snapshot(EntrySnapshotDocument x) { return new EntryReportSnapshot(new PropertyConditionReportId(x.reportId()), x.participants().stream().map(p -> new ReportParticipant(p.partyId(), ReportPartyRole.valueOf(p.role()), p.name(), ParticipantAttendance.valueOf(p.attendance()), p.representativeName())).toList(), x.zones().stream().map(z -> new ConditionZone(z.id(), z.privateAreaId(), z.label(), ZoneEquipment.valueOf(z.equipment()))).toList(), x.observations().stream().map(o -> new ConditionObservation(o.subjectId(), ConditionState.valueOf(o.state()), o.observation())).toList(), x.meterReadings().stream().map(m -> new MeterReading(m.id(), MeterType.valueOf(m.type()), MeterReadingState.valueOf(m.state()), m.index(), m.unit(), m.label(), m.photoId(), m.observation())).toList(), x.accessMeans().stream().map(a -> new AccessMeans(a.id(), AccessMeansType.valueOf(a.type()), a.destination(), a.quantity(), a.observation())).toList(), x.inventoryConditions().stream().map(i -> new InventoryCondition(i.inventoryLineId(), i.privateAreaId(), i.privateAreaLabel(), i.designation(), i.contractualQuantity(), i.splits().stream().map(s -> new InventoryQuantitySplit(s.inventoryLineId(), ConditionState.valueOf(s.state()), s.quantity(), s.observation())).toList())).toList(), x.photos().stream().map(p -> new ConditionPhoto(p.reference(), p.zoneId(), p.storageKey(), p.sha256(), p.width(), p.height(), p.uploadedBy(), p.uploadedAt())).toList(), x.supplements().stream().map(this::supplement).toList()); } private static List documentSnapshotParticipants(List values) { return values.stream().map(x -> new ParticipantDocument(x.partyId(), x.role().name(), x.name(), x.attendance().name(), x.representativeName())).toList(); } @Document(collection = "propertyConditionReports") @CompoundIndex(name = "condition_report_lease_type_unique", def = "{'landlordId': 1, 'leaseId': 1, 'type': 1}", unique = true) record ReportDocument(@Id String id, String landlordId, String leaseId, String type, LocalDate occurredOn, String timeZone, List participants, List forwardingAddresses, List zones, List observations, List meterReadings, List accessMeans, List inventoryConditions, List photos, List zonePhotoAbsences, List entryPhotoDispositions, List supplements, List revisions, FinalizationDocument finalization, List deliveryAttestations, EntrySnapshotDocument entrySnapshot, Integer leaseRevisionNumber, String leaseRevisionFingerprint, String status, @Version Long version) { ReportDocument withVersion(Long value) { return new ReportDocument(id, landlordId, leaseId, type, occurredOn, timeZone, participants, forwardingAddresses, zones, observations, meterReadings, accessMeans, inventoryConditions, photos, zonePhotoAbsences, entryPhotoDispositions, supplements, revisions, finalization, deliveryAttestations, entrySnapshot, leaseRevisionNumber, leaseRevisionFingerprint, status, value); } } - record ParticipantDocument(String partyId, String role, String name, String attendance, String representativeName) { } record AddressDocument(String tenantPartyId, String address) { } record ZoneDocument(String id, String privateAreaId, String label, String equipment) { } record ObservationDocument(String subjectId, String state, String observation) { } record MeterDocument(String id, String type, BigDecimal index, String unit, String label, String photoId, String observation) { } record AccessDocument(String id, String type, String destination, int quantity, String observation) { } record SplitDocument(String inventoryLineId, String state, int quantity, String observation) { } record InventoryDocument(String inventoryLineId, String privateAreaId, String privateAreaLabel, String designation, int contractualQuantity, List splits) { } record PhotoDocument(String reference, String zoneId, String storageKey, String sha256, int width, int height, String uploadedBy, Instant uploadedAt) { } record AbsenceDocument(String reason, String auditedBy, Instant auditedAt) { } record ZoneAbsenceDocument(String zoneId, AbsenceDocument absence) { } record DispositionDocument(String entryPhotoId, String exitPhotoId, AbsenceDocument absence) { } record FileDocument(String reference, String storageKey, String sha256) { } record SupplementDocument(String id, FileDocument signedPdf, List photos, String uploadedBy, Instant uploadedAt, LocalDate requestedOn, String acceptedAdditions, LocalDate deadlineOn, String lateRuleVersion, String lateReason, String lateAuditedBy, Instant lateAuditedAt) { } record RevisionDocument(int number, String storageKey, String sha256, String sourceFingerprint, String authorId, Instant generatedAt, boolean obsolete) { } record FinalizationDocument(int revisionNumber, String signedStorageKey, String signedSha256, String authorId, Instant finalizedAt) { } record DeliveryDocument(String partyId, String attestedBy, Instant attestedAt) { } record EntrySnapshotDocument(String reportId, List participants, List zones, List observations, List meterReadings, List accessMeans, List inventoryConditions, List photos, List supplements) { } + record ParticipantDocument(String partyId, String role, String name, String attendance, String representativeName) { } record AddressDocument(String tenantPartyId, String address) { } record ZoneDocument(String id, String privateAreaId, String label, String equipment) { } record ObservationDocument(String subjectId, String state, String observation) { } record MeterDocument(String id, String type, String state, BigDecimal index, String unit, String label, String photoId, String observation) { } record AccessDocument(String id, String type, String destination, int quantity, String observation) { } record SplitDocument(String inventoryLineId, String state, int quantity, String observation) { } record InventoryDocument(String inventoryLineId, String privateAreaId, String privateAreaLabel, String designation, int contractualQuantity, List splits) { } record PhotoDocument(String reference, String zoneId, String storageKey, String sha256, int width, int height, String uploadedBy, Instant uploadedAt) { } record AbsenceDocument(String reason, String auditedBy, Instant auditedAt) { } record ZoneAbsenceDocument(String zoneId, AbsenceDocument absence) { } record DispositionDocument(String entryPhotoId, String exitPhotoId, AbsenceDocument absence) { } record FileDocument(String reference, String storageKey, String sha256) { } record SupplementDocument(String id, String type, FileDocument signedPdf, List photos, String uploadedBy, Instant uploadedAt, LocalDate requestedOn, LocalDate heatingPeriodStartedOn, String acceptedAdditions, LocalDate deadlineOn, String lateRuleVersion, String lateReason, String lateAuditedBy, Instant lateAuditedAt) { } record RevisionDocument(int number, String storageKey, String sha256, String sourceFingerprint, String authorId, Instant generatedAt, boolean obsolete) { } record FinalizationDocument(int revisionNumber, String signedStorageKey, String signedSha256, String authorId, Instant finalizedAt) { } record DeliveryDocument(String partyId, String attestedBy, Instant attestedAt) { } record EntrySnapshotDocument(String reportId, List participants, List zones, List observations, List meterReadings, List accessMeans, List inventoryConditions, List photos, List supplements) { } } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/furniture/MongoFurnitureInventoryRepository.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/furniture/MongoFurnitureInventoryRepository.java index ab27148..a673479 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/furniture/MongoFurnitureInventoryRepository.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/furniture/MongoFurnitureInventoryRepository.java @@ -2,6 +2,7 @@ package fr.nessar.lmnporganizer.rental.adapter.out.mongodb.furniture; import fr.nessar.lmnporganizer.rental.adapter.out.mongodb.LandlordScopedMongoWriter; import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureCategory; +import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureEquipmentKind; import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventory; import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryId; import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryRepository; @@ -47,8 +48,8 @@ public class MongoFurnitureInventoryRepository implements FurnitureInventoryRepo return new InventoryDocument(inventory.id().value(), inventory.landlordId().value(), inventory.leaseId().value(), inventory.templateVersion(), inventory.lines().stream().map(line -> new LineDocument(line.id(), line.category() == null ? null : line.category().name(), - line.presence() == null ? null : line.presence().name(), line.privateAreaId(), line.designation(), - line.quantity(), line.description())).toList(), inventory.frozen(), + line.presence() == null ? null : line.presence().name(), line.equipmentKind().name(), + line.privateAreaId(), line.designation(), line.quantity(), line.description())).toList(), inventory.frozen(), inventory.version() == -1 ? null : inventory.version()); } @@ -56,8 +57,9 @@ public class MongoFurnitureInventoryRepository implements FurnitureInventoryRepo return FurnitureInventory.reconstitute(new FurnitureInventoryId(document.id()), new LandlordId(document.landlordId()), new LeaseId(document.leaseId()), document.templateVersion(), document.lines().stream().map(line -> new FurnitureLine(line.id(), line.category() == null ? null : FurnitureCategory.valueOf(line.category()), - line.presence() == null ? null : FurniturePresence.valueOf(line.presence()), line.privateAreaId(), - line.designation(), line.quantity(), line.description())).toList(), + line.presence() == null ? null : FurniturePresence.valueOf(line.presence()), + FurnitureEquipmentKind.valueOf(line.equipmentKind()), line.privateAreaId(), line.designation(), + line.quantity(), line.description())).toList(), document.frozen(), document.version() == null ? -1 : document.version()); } @@ -70,6 +72,6 @@ public class MongoFurnitureInventoryRepository implements FurnitureInventoryRepo } } - record LineDocument(String id, String category, String presence, String privateAreaId, String designation, int quantity, - String description) { } + record LineDocument(String id, String category, String presence, String equipmentKind, String privateAreaId, + String designation, int quantity, String description) { } } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/lease/MongoLeaseDocumentRevisionRepository.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/lease/MongoLeaseDocumentRevisionRepository.java index c79b55b..76e6563 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/lease/MongoLeaseDocumentRevisionRepository.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/lease/MongoLeaseDocumentRevisionRepository.java @@ -4,6 +4,7 @@ import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId; import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentDocumentType; import fr.nessar.lmnporganizer.rental.domain.apartment.PrivateArea; import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureCategory; +import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureEquipmentKind; import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventorySnapshot; import fr.nessar.lmnporganizer.rental.domain.furniture.FurniturePresence; import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentAnnex; @@ -90,15 +91,16 @@ public class MongoLeaseDocumentRevisionRepository implements LeaseDocumentRevisi return new FurnitureInventoryDocument(inventory.templateVersion(), inventory.lines().stream() .map(line -> new FurnitureInventoryLineDocument(line.id(), line.category() == null ? null : line.category().name(), line.presence() == null ? null : line.presence().name(), - line.privateAreaId(), line.designation(), line.quantity(), line.description())).toList()); + line.equipmentKind().name(), line.privateAreaId(), line.designation(), line.quantity(), + line.description())).toList()); } private FurnitureInventorySnapshot toDomain(FurnitureInventoryDocument inventory) { return new FurnitureInventorySnapshot(inventory.templateVersion(), inventory.lines().stream() .map(line -> new FurnitureInventorySnapshot.Line(line.id(), line.category() == null ? null : FurnitureCategory.valueOf(line.category()), line.presence() == null ? null - : FurniturePresence.valueOf(line.presence()), line.privateAreaId(), line.designation(), - line.quantity(), line.description())).toList()); + : FurniturePresence.valueOf(line.presence()), FurnitureEquipmentKind.valueOf(line.equipmentKind()), + line.privateAreaId(), line.designation(), line.quantity(), line.description())).toList()); } private PrivateAreaDocument toDocument(PrivateArea privateArea) { @@ -151,8 +153,8 @@ public class MongoLeaseDocumentRevisionRepository implements LeaseDocumentRevisi record FurnitureInventoryDocument(String templateVersion, List lines) { } - record FurnitureInventoryLineDocument(String id, String category, String presence, String privateAreaId, - String designation, int quantity, String description) { + record FurnitureInventoryLineDocument(String id, String category, String presence, String equipmentKind, + String privateAreaId, String designation, int quantity, String description) { } record PrivateAreaDocument(String id, String family, String subtype, String label) { diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionDocumentService.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionDocumentService.java index 3570668..e9e92af 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionDocumentService.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionDocumentService.java @@ -108,7 +108,9 @@ public final class PropertyConditionDocumentService { public GeneratedDocument downloadPhoto(LandlordId landlord, LeaseId lease, PropertyConditionReportType type, String reference) { - ConditionPhoto photo = report(landlord, lease, type).photos().stream() + PropertyConditionReport report = report(landlord, lease, type); + ConditionPhoto photo = java.util.stream.Stream.concat(report.photos().stream(), report.supplements().stream() + .flatMap(supplement -> supplement.photos().stream())) .filter(value -> value.reference().equals(reference)).findFirst() .orElseThrow(() -> new ResourceNotFound("Property condition report photo")); byte[] content = read(photo.storageKey()); @@ -193,7 +195,7 @@ public final class PropertyConditionDocumentService { aligned(snapshot.meterReadings(), exit.meterReadings(), value -> value.id()), aligned(snapshot.accessMeans(), exit.accessMeans(), value -> value.id()), aligned(snapshot.supplements(), List.of(), PropertyConditionReportSupplement::id), - photoComparison(snapshot.photos(), exit)); + photoComparison(snapshot.effectivePhotos(), exit)); } private void addPhotos(Map files, List photos) { diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionDraftTemplate.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionDraftTemplate.java index cbe1026..da7ec40 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionDraftTemplate.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionDraftTemplate.java @@ -2,10 +2,10 @@ package fr.nessar.lmnporganizer.rental.application.condition; import fr.nessar.lmnporganizer.rental.domain.condition.ConditionZone; import fr.nessar.lmnporganizer.rental.domain.condition.ZoneEquipment; +import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureEquipmentKind; import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentRevision; import java.util.List; -import java.util.Locale; import java.util.stream.Stream; final class PropertyConditionDraftTemplate { @@ -18,15 +18,17 @@ final class PropertyConditionDraftTemplate { new ConditionZone(area.id() + ":walls", area.id(), area.label() + " — Murs", ZoneEquipment.NONE), new ConditionZone(area.id() + ":ceiling", area.id(), area.label() + " — Plafond", ZoneEquipment.NONE))); Stream equipment = revision.furnitureInventory().lines().stream() - .filter(line -> line.category() == null) + .filter(line -> line.equipmentKind() != FurnitureEquipmentKind.NONE && line.quantity() > 0) .map(line -> new ConditionZone("inventory:" + line.id(), line.privateAreaId(), line.designation(), - equipment(line.designation()))); + equipment(line.equipmentKind()))); return Stream.concat(surfaces, equipment).toList(); } - private static ZoneEquipment equipment(String designation) { - // ponytail: designation-based classification; add an explicit inventory equipment type when more kinds matter. - return designation.toLowerCase(Locale.FRENCH).contains("climatisation") - ? ZoneEquipment.CLIMATISATION : ZoneEquipment.OTHER; + private static ZoneEquipment equipment(FurnitureEquipmentKind kind) { + return switch (kind) { + case CLIMATISATION -> ZoneEquipment.CLIMATISATION; + case OTHER -> ZoneEquipment.OTHER; + case NONE -> throw new IllegalArgumentException("Furniture is not condition-report equipment."); + }; } } 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 632fad1..32fc2ab 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 @@ -101,22 +101,39 @@ public final class PropertyConditionReportService { } public PropertyConditionReport appendSupplement(LandlordId landlord, LeaseId leaseId, byte[] signedPdf, - List photoSources, String author, LocalDate requestedOn, - String acceptedAdditions, LocalDate heatingPeriodStartedOn, String reason) { + List photoSources, String author, LocalDate requestedOn, + String acceptedAdditions, PropertyConditionReportSupplementType type, + LocalDate heatingPeriodStartedOn, boolean lateWarningConfirmed, String reason) { PropertyConditionReport report = get(landlord, leaseId, PropertyConditionReportType.ENTRY, "Europe/Paris"); if (!report.finalized()) throw new IllegalStateException("L'état des lieux d'entrée doit être finalisé."); + if (reports.find(landlord, leaseId, PropertyConditionReportType.EXIT).isPresent()) { + throw new IllegalStateException("Un complément ne peut plus être ajouté après la création de la sortie."); + } LocalDate today = LocalDate.now(clock.withZone(ZoneId.of(report.timeZone()))); - if (requestedOn == null || requestedOn.isAfter(today)) { - throw new IllegalArgumentException("La date de demande du complément ne peut pas être future."); + if (requestedOn == null || requestedOn.isBefore(report.occurredOn()) || requestedOn.isAfter(today)) { + throw new IllegalArgumentException("La date de demande du complément doit suivre le constat sans être future."); } - if (heatingPeriodStartedOn != null && heatingPeriodStartedOn.isAfter(today)) { - throw new IllegalArgumentException("Le début de période de chauffe ne peut pas être futur."); + if (type == null || (type == PropertyConditionReportSupplementType.GENERAL + && heatingPeriodStartedOn != null) + || (type == PropertyConditionReportSupplementType.HEATING + && (heatingPeriodStartedOn == null || heatingPeriodStartedOn.isAfter(requestedOn)))) { + throw new IllegalArgumentException("La période de chauffe ne s'applique qu'aux compléments chauffage."); + } + LocalDate deadline = type == PropertyConditionReportSupplementType.HEATING + ? heatingPeriodStartedOn.plusMonths(1) : report.occurredOn().plusDays(10); + boolean late = requestedOn.isAfter(deadline); + if (late && (!lateWarningConfirmed || reason == null || reason.isBlank())) { + throw new IllegalArgumentException("Le complément tardif exige la confirmation de l'avertissement et un motif."); + } + SupplementAudit audit = late ? new LateSupplementAddition( + "PROPERTY_CONDITION_SUPPLEMENT_DEADLINE_2026-07", deadline, reason, author, Instant.now(clock)) + : new OnTimeSupplement(deadline); + java.util.Set zoneIds = report.zones().stream().map(ConditionZone::id) + .collect(java.util.stream.Collectors.toSet()); + if ((photoSources == null ? List.of() : photoSources).stream() + .anyMatch(source -> source == null || !zoneIds.contains(source.zoneId()))) { + throw new IllegalArgumentException("Chaque photo du complément doit référencer une zone du constat."); } - LocalDate deadline = heatingPeriodStartedOn == null ? report.occurredOn().plusDays(10) - : heatingPeriodStartedOn.plusMonths(1); - SupplementAudit audit = requestedOn.isAfter(deadline) - ? new LateSupplementAddition("PROPERTY_CONDITION_SUPPLEMENT_DEADLINE_2026-07", deadline, reason, - author, Instant.now(clock)) : new OnTimeSupplement(deadline); List storedKeys = new ArrayList<>(); try { String pdfReference = identifiers.next(); @@ -124,17 +141,18 @@ public final class PropertyConditionReportService { storedKeys.add(pdfStorageKey); var pdf = new PropertyConditionReportSupplement.FileEvidence(pdfReference, pdfStorageKey, sha256(signedPdf)); - List photos = new ArrayList<>(); - for (byte[] source : photoSources == null ? List.of() : photoSources) { - byte[] content = normalizer.normalize(source).content(); + List photos = new ArrayList<>(); + for (SupplementPhotoSource source : photoSources == null ? List.of() : photoSources) { + PropertyConditionPhotoNormalizer.NormalizedPhoto normalized = normalizer.normalize(source.content()); + byte[] content = normalized.content(); String reference = identifiers.next(); String storageKey = storage.store(leaseId, reference, "webp", content); storedKeys.add(storageKey); - photos.add(new PropertyConditionReportSupplement.FileEvidence(reference, storageKey, - sha256(content))); + photos.add(new ConditionPhoto(reference, source.zoneId(), storageKey, sha256(content), + normalized.width(), normalized.height(), author, Instant.now(clock))); } - report.appendSupplement(new PropertyConditionReportSupplement(identifiers.next(), pdf, photos, author, - Instant.now(clock), requestedOn, acceptedAdditions, audit)); + report.appendSupplement(new PropertyConditionReportSupplement(identifiers.next(), type, pdf, photos, + author, Instant.now(clock), requestedOn, heatingPeriodStartedOn, acceptedAdditions, audit)); return reports.save(report); } catch (RuntimeException failure) { storedKeys.forEach(key -> discard(key, failure)); @@ -296,4 +314,13 @@ public final class PropertyConditionReportService { public record Draft(LocalDate occurredOn, String timeZone, String author, List participants, List forwardingAddresses, List zones, List observations, List meterReadings, List accessMeans, List inventoryConditions, List zonePhotoAbsences, List entryPhotoDispositions) { } public record PhotoAbsence(String zoneId, String reason) { } public record EntryPhotoDispositionInput(String entryPhotoId, String exitPhotoId, String absenceReason) { } + public record SupplementPhotoSource(String zoneId, byte[] content) { + public SupplementPhotoSource { + if (zoneId == null || zoneId.isBlank() || content == null) { + throw new IllegalArgumentException("La photo du complément et sa zone sont obligatoires."); + } + zoneId = zoneId.trim(); + content = content.clone(); + } + } } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/furniture/FurnitureInventoryService.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/furniture/FurnitureInventoryService.java index 351ffb7..497d34b 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/furniture/FurnitureInventoryService.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/furniture/FurnitureInventoryService.java @@ -16,6 +16,8 @@ import fr.nessar.lmnporganizer.rental.domain.lease.Lease; import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId; import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository; +import java.time.Clock; +import java.time.LocalDate; import java.util.List; import java.util.Set; @@ -24,17 +26,26 @@ public final class FurnitureInventoryService { private final LeaseRepository leases; private final ApartmentRepository apartments; private final IdentifierGenerator identifiers; + private final Clock clock; public FurnitureInventoryService(FurnitureInventoryRepository inventories, LeaseRepository leases, - ApartmentRepository apartments, IdentifierGenerator identifiers) { + ApartmentRepository apartments, IdentifierGenerator identifiers, Clock clock) { this.inventories = inventories; this.leases = leases; this.apartments = apartments; this.identifiers = identifiers; + this.clock = clock; } public FurnitureInventory get(LandlordId landlordId, LeaseId leaseId) { Lease lease = lease(landlordId, leaseId); - return inventories.find(landlordId, leaseId).orElseGet(() -> initialize(landlordId, lease)); + FurnitureInventory inventory = inventories.find(landlordId, leaseId) + .orElseGet(() -> initialize(landlordId, lease)); + String templateVersion = currentTemplateVersion(); + if (!inventory.templateVersion().equals(templateVersion) && !inventory.frozen()) { + inventory.alignWithTemplate(templateVersion); + return inventories.save(inventory); + } + return inventory; } public FurnitureInventory replace(LandlordId landlordId, LeaseId leaseId, long expectedVersion, @@ -62,9 +73,12 @@ public final class FurnitureInventoryService { Apartment apartment = apartment(landlordId, lease); String areaId = apartment.privateAreas().stream().findFirst().map(area -> area.id()) .orElseThrow(() -> new IllegalArgumentException("Le logement doit définir un espace privatif.")); - String templateVersion = FurnishedLeaseTemplate.effectiveOn(lease.period().startDate()).visibleId(); return inventories.save(FurnitureInventory.prefilled(new FurnitureInventoryId(identifiers.next()), landlordId, - lease.id(), templateVersion, ignored -> identifiers.next(), areaId)); + lease.id(), currentTemplateVersion(), ignored -> identifiers.next(), areaId)); + } + + private String currentTemplateVersion() { + return FurnishedLeaseTemplate.effectiveOn(LocalDate.now(clock)).visibleId(); } private void validatePrivateAreas(LandlordId landlordId, Lease lease, List lines) { diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/lease/GenerateLeaseDocumentService.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/lease/GenerateLeaseDocumentService.java index 2ee26cd..15fa9fd 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/lease/GenerateLeaseDocumentService.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/lease/GenerateLeaseDocumentService.java @@ -96,8 +96,8 @@ public final class GenerateLeaseDocumentService { throw new IllegalArgumentException("Author ID is required."); } Lease lease = lease(landlordId, leaseId); - FurnitureInventory inventory = inventory(landlordId, lease); FurnishedLeaseTemplate template = FurnishedLeaseTemplate.effectiveOn(java.time.LocalDate.now(clock)); + FurnitureInventory inventory = inventory(landlordId, lease, template); Source source = source(landlordId, lease, template, inventory); var current = revisions.findCurrent(landlordId, leaseId); if (current.filter(revision -> revision.sourceFingerprint().equals(source.fingerprint())).isPresent()) { @@ -206,14 +206,31 @@ public final class GenerateLeaseDocumentService { } private FurnitureInventory inventory(LandlordId landlordId, Lease lease) { + return inventory(landlordId, lease, null); + } + + private FurnitureInventory inventory(LandlordId landlordId, Lease lease, FurnishedLeaseTemplate template) { FurnitureInventory inventory = inventories.find(landlordId, lease.id()) .orElseThrow(() -> new LeaseRuleViolation("L'inventaire du mobilier doit être renseigné.")); + if (template != null && !inventory.templateVersion().equals(template.visibleId()) && !inventory.frozen()) { + inventory.alignWithTemplate(template.visibleId()); + inventories.save(inventory); + } if (!inventory.isReadyForLeaseDocument()) { throw new LeaseRuleViolation("Chaque catégorie réglementaire de mobilier doit être renseignée."); } + if (template != null) { + requireInventoryTemplate(inventory, template); + } return inventory; } + private static void requireInventoryTemplate(FurnitureInventory inventory, FurnishedLeaseTemplate template) { + if (!inventory.templateVersion().equals(template.visibleId())) { + throw new LeaseRuleViolation("L'inventaire doit utiliser la même version de modèle que le bail."); + } + } + private static void validateInventoryPrivateAreas(FurnitureInventorySnapshot inventory, List privateAreas) { Set ids = privateAreas.stream().map(area -> area.id()).collect(java.util.stream.Collectors.toSet()); @@ -345,6 +362,7 @@ public final class GenerateLeaseDocumentService { append(facts, "furniture-id", line.id()); append(facts, "furniture-category", line.category() == null ? "" : line.category().name()); append(facts, "furniture-presence", line.presence() == null ? "" : line.presence().name()); + append(facts, "furniture-equipment-kind", line.equipmentKind().name()); append(facts, "furniture-private-area", line.privateAreaId()); append(facts, "furniture-designation", line.designation()); append(facts, "furniture-quantity", Integer.toString(line.quantity())); diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/condition/EntryReportSnapshot.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/condition/EntryReportSnapshot.java index 0cca3f6..e840004 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/condition/EntryReportSnapshot.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/condition/EntryReportSnapshot.java @@ -28,4 +28,9 @@ public record EntryReportSnapshot( private static List copy(List values) { return values == null ? List.of() : List.copyOf(values); } + + public List effectivePhotos() { + return java.util.stream.Stream.concat(photos.stream(), supplements.stream() + .flatMap(supplement -> supplement.photos().stream())).toList(); + } } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/condition/MeterReading.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/condition/MeterReading.java index 492c613..ea83e6e 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/condition/MeterReading.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/condition/MeterReading.java @@ -3,17 +3,19 @@ package fr.nessar.lmnporganizer.rental.domain.condition; import java.math.BigDecimal; import java.util.Objects; -public record MeterReading(String id, MeterType type, BigDecimal index, String unit, String label, String photoId, - String observation) { +public record MeterReading(String id, MeterType type, MeterReadingState state, BigDecimal index, String unit, + String label, String photoId, String observation) { public MeterReading { id = required(id, "Meter ID"); type = Objects.requireNonNull(type, "Meter type is required."); - index = Objects.requireNonNull(index, "Meter index is required."); + state = Objects.requireNonNull(state, "Meter reading state is required."); unit = required(unit, "Meter unit"); label = label == null ? "" : label.trim(); photoId = photoId == null ? "" : photoId.trim(); observation = observation == null ? "" : observation.trim(); - if (index.signum() < 0 || (type == MeterType.OTHER && label.isEmpty())) { + if ((state == MeterReadingState.RECORDED && (index == null || index.signum() < 0)) + || (state == MeterReadingState.INACCESSIBLE && (index != null || observation.isEmpty())) + || (type == MeterType.OTHER && label.isEmpty())) { throw new IllegalArgumentException("Meter reading is invalid."); } } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/condition/MeterReadingState.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/condition/MeterReadingState.java new file mode 100644 index 0000000..a04b5aa --- /dev/null +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/condition/MeterReadingState.java @@ -0,0 +1,3 @@ +package fr.nessar.lmnporganizer.rental.domain.condition; + +public enum MeterReadingState { RECORDED, INACCESSIBLE } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/condition/PropertyConditionReport.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/condition/PropertyConditionReport.java index e8e9e56..1d3699a 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/condition/PropertyConditionReport.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/condition/PropertyConditionReport.java @@ -76,6 +76,7 @@ public final class PropertyConditionReport { this.entryPhotoDispositions = unique(entryPhotoDispositions, EntryPhotoDisposition::entryPhotoId, "Entry photo dispositions"); this.supplements = unique(supplements, PropertyConditionReportSupplement::id, "Report supplements"); + validateSupplementPhotos(this.zones, this.photos, this.supplements); this.revisions = unique(revisions, revision -> Integer.toString(revision.number()), "Report revisions"); this.finalization = finalization; this.deliveryAttestations = unique(deliveryAttestations, PropertyConditionReportDeliveryAttestation::partyId, @@ -238,9 +239,14 @@ public final class PropertyConditionReport { if (type != PropertyConditionReportType.ENTRY || !finalized()) { throw new IllegalStateException("Supplements can only be appended to a finalized entry report."); } + PropertyConditionReportSupplement value = Objects.requireNonNull(supplement, + "Report supplement is required."); List appended = new ArrayList<>(supplements); - appended.add(Objects.requireNonNull(supplement, "Report supplement is required.")); - supplements = unique(appended, PropertyConditionReportSupplement::id, "Report supplements"); + appended.add(value); + List next = unique(appended, PropertyConditionReportSupplement::id, + "Report supplements"); + validateSupplementPhotos(zones, photos, next); + supplements = next; } public void addRevision(PropertyConditionReportRevision revision) { @@ -263,6 +269,9 @@ public final class PropertyConditionReport { if (type != PropertyConditionReportType.ENTRY || status != PropertyConditionReportStatus.OBSOLETE) { throw new IllegalStateException("Only an obsolete entry report can be restarted."); } + if (leaseRevisionNumber < 1 || leaseRevisionFingerprint == null || leaseRevisionFingerprint.isBlank()) { + throw new IllegalArgumentException("An entry report must reference its lease document revision."); + } this.occurredOn = Objects.requireNonNull(occurredOn, "Property condition report date is required."); this.timeZone = timeZone(timeZone); this.participants = List.of(); @@ -277,7 +286,7 @@ public final class PropertyConditionReport { this.entryPhotoDispositions = List.of(); this.deliveryAttestations = List.of(); this.leaseRevisionNumber = leaseRevisionNumber; - this.leaseRevisionFingerprint = leaseRevisionFingerprint; + this.leaseRevisionFingerprint = leaseRevisionFingerprint.trim(); this.status = PropertyConditionReportStatus.DRAFT; } @@ -373,7 +382,8 @@ public final class PropertyConditionReport { InventoryCondition::inventoryLineId, "Exit inventory conditions"); for (MeterReading entryMeter : entrySnapshot.meterReadings()) { MeterReading exitMeter = byId(meterReadings, MeterReading::id).get(entryMeter.id()); - if (!entryMeter.hasSameIdentityAs(exitMeter) && exitMeter.observation().isEmpty()) { + if ((!entryMeter.hasSameIdentityAs(exitMeter) || exitMeter.state() == MeterReadingState.INACCESSIBLE) + && exitMeter.observation().isEmpty()) { throw new IllegalStateException("A replaced or inaccessible meter requires an observation."); } } @@ -395,9 +405,9 @@ public final class PropertyConditionReport { } private void validatePhotoDispositions() { - requireSameIds(entrySnapshot.photos(), entryPhotoDispositions, ConditionPhoto::reference, + requireSameIds(entrySnapshot.effectivePhotos(), entryPhotoDispositions, ConditionPhoto::reference, EntryPhotoDisposition::entryPhotoId, "Entry photo dispositions"); - Map entryPhotos = byId(entrySnapshot.photos(), ConditionPhoto::reference); + Map entryPhotos = byId(entrySnapshot.effectivePhotos(), ConditionPhoto::reference); Map exitPhotos = byId(photos, ConditionPhoto::reference); Set matchedExitPhotos = new HashSet<>(); for (EntryPhotoDisposition disposition : entryPhotoDispositions) { @@ -439,8 +449,20 @@ public final class PropertyConditionReport { unique(snapshot.meterReadings(), MeterReading::id, "Entry snapshot meter readings"); unique(snapshot.accessMeans(), AccessMeans::id, "Entry snapshot access means"); unique(snapshot.inventoryConditions(), InventoryCondition::inventoryLineId, "Entry snapshot inventory conditions"); - unique(snapshot.photos(), ConditionPhoto::reference, "Entry snapshot photos"); unique(snapshot.supplements(), PropertyConditionReportSupplement::id, "Entry snapshot supplements"); + validateSupplementPhotos(snapshot.zones(), snapshot.photos(), snapshot.supplements()); + } + + private static void validateSupplementPhotos(List zones, List photos, + List supplements) { + Set zoneIds = byId(zones, ConditionZone::id).keySet(); + if (supplements.stream().flatMap(supplement -> supplement.photos().stream()) + .anyMatch(photo -> !zoneIds.contains(photo.zoneId()))) { + throw new IllegalArgumentException("Supplement photos must reference an entry report zone."); + } + unique(java.util.stream.Stream.concat(photos.stream(), supplements.stream() + .flatMap(supplement -> supplement.photos().stream())).toList(), ConditionPhoto::reference, + "Entry report photos"); } private static String timeZone(String value) { diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/condition/PropertyConditionReportSupplement.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/condition/PropertyConditionReportSupplement.java index bf5e933..e8b269a 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/condition/PropertyConditionReportSupplement.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/condition/PropertyConditionReportSupplement.java @@ -5,17 +5,27 @@ import java.time.LocalDate; import java.util.List; import java.util.Objects; -public record PropertyConditionReportSupplement(String id, FileEvidence signedPdf, List photos, - String uploadedBy, - Instant uploadedAt, LocalDate requestedOn, String acceptedAdditions, +public record PropertyConditionReportSupplement(String id, PropertyConditionReportSupplementType type, + FileEvidence signedPdf, List photos, String uploadedBy, + Instant uploadedAt, LocalDate requestedOn, + LocalDate heatingPeriodStartedOn, String acceptedAdditions, SupplementAudit audit) { public PropertyConditionReportSupplement { id = required(id, "Supplement ID"); + type = Objects.requireNonNull(type, "Supplement type is required."); signedPdf = Objects.requireNonNull(signedPdf, "Signed supplement PDF is required."); photos = List.copyOf(photos == null ? List.of() : photos); + if (photos.stream().map(ConditionPhoto::reference).distinct().count() != photos.size()) { + throw new IllegalArgumentException("Supplement photos must use unique references."); + } uploadedBy = required(uploadedBy, "Supplement uploader"); uploadedAt = Objects.requireNonNull(uploadedAt, "Supplement upload time is required."); requestedOn = Objects.requireNonNull(requestedOn, "Supplement request date is required."); + if ((type == PropertyConditionReportSupplementType.GENERAL && heatingPeriodStartedOn != null) + || (type == PropertyConditionReportSupplementType.HEATING + && (heatingPeriodStartedOn == null || heatingPeriodStartedOn.isAfter(requestedOn)))) { + throw new IllegalArgumentException("Heating period start is invalid for this supplement."); + } acceptedAdditions = required(acceptedAdditions, "Accepted supplement additions"); audit = Objects.requireNonNull(audit, "Supplement audit is required."); } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/condition/PropertyConditionReportSupplementType.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/condition/PropertyConditionReportSupplementType.java new file mode 100644 index 0000000..ca05836 --- /dev/null +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/condition/PropertyConditionReportSupplementType.java @@ -0,0 +1,3 @@ +package fr.nessar.lmnporganizer.rental.domain.condition; + +public enum PropertyConditionReportSupplementType { GENERAL, HEATING } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/furniture/FurnitureCategory.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/furniture/FurnitureCategory.java index 8548a22..68450dd 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/furniture/FurnitureCategory.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/furniture/FurnitureCategory.java @@ -22,4 +22,12 @@ public enum FurnitureCategory { public String label() { return label; } + + public FurnitureEquipmentKind equipmentKind() { + return switch (this) { + case SHUTTERS, HOTPLATES, OVEN_OR_MICROWAVE, REFRIGERATOR_WITH_FREEZER, LIGHTING -> + FurnitureEquipmentKind.OTHER; + default -> FurnitureEquipmentKind.NONE; + }; + } } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/furniture/FurnitureEquipmentKind.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/furniture/FurnitureEquipmentKind.java new file mode 100644 index 0000000..d79aefc --- /dev/null +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/furniture/FurnitureEquipmentKind.java @@ -0,0 +1,3 @@ +package fr.nessar.lmnporganizer.rental.domain.furniture; + +public enum FurnitureEquipmentKind { NONE, CLIMATISATION, OTHER } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/furniture/FurnitureInventory.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/furniture/FurnitureInventory.java index 5d0ad5b..d35300a 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/furniture/FurnitureInventory.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/furniture/FurnitureInventory.java @@ -15,7 +15,7 @@ public final class FurnitureInventory { private final FurnitureInventoryId id; private final LandlordId landlordId; private final LeaseId leaseId; - private final String templateVersion; + private String templateVersion; private final long version; private List lines; private boolean frozen; @@ -42,7 +42,8 @@ public final class FurnitureInventory { throw new IllegalArgumentException("A private area is required for the legal furniture catalog."); } List lines = legalCatalog(templateVersion).stream().map(category -> new FurnitureLine( - lineIds.apply(category.ordinal()), category, null, privateAreaId, category.label(), 0, "")).toList(); + lineIds.apply(category.ordinal()), category, null, category.equipmentKind(), privateAreaId, + category.label(), 0, "")).toList(); return new FurnitureInventory(id, landlordId, leaseId, templateVersion, lines, false, -1); } @@ -68,6 +69,13 @@ public final class FurnitureInventory { this.lines = validated(lines); } + public void alignWithTemplate(String templateVersion) { + if (frozen) { + throw new IllegalStateException("Furniture inventory is frozen."); + } + this.templateVersion = FurnishedLeaseTemplate.fromVisibleId(templateVersion).visibleId(); + } + public boolean isReadyForLeaseDocument() { return missingCategories().isEmpty(); } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/furniture/FurnitureInventorySnapshot.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/furniture/FurnitureInventorySnapshot.java index d66cd8c..ea7a00b 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/furniture/FurnitureInventorySnapshot.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/furniture/FurnitureInventorySnapshot.java @@ -1,6 +1,7 @@ package fr.nessar.lmnporganizer.rental.domain.furniture; import java.util.List; +import java.util.Objects; /** Immutable inventory facts printed in a lease document revision. */ public record FurnitureInventorySnapshot(String templateVersion, List lines) { @@ -17,11 +18,19 @@ public record FurnitureInventorySnapshot(String templateVersion, List line .map(Line::from).toList()); } - public record Line(String id, FurnitureCategory category, FurniturePresence presence, String privateAreaId, - String designation, int quantity, String description) { + public record Line(String id, FurnitureCategory category, FurniturePresence presence, + FurnitureEquipmentKind equipmentKind, String privateAreaId, String designation, + int quantity, String description) { + public Line { + Objects.requireNonNull(equipmentKind, "Furniture equipment kind is required."); + if (category != null && equipmentKind != category.equipmentKind()) { + throw new IllegalArgumentException("Legal furniture equipment classification cannot be changed."); + } + } + private static Line from(FurnitureLine line) { - return new Line(line.id(), line.category(), line.presence(), line.privateAreaId(), line.designation(), - line.quantity(), line.description()); + return new Line(line.id(), line.category(), line.presence(), line.equipmentKind(), line.privateAreaId(), + line.designation(), line.quantity(), line.description()); } } } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/furniture/FurnitureLine.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/furniture/FurnitureLine.java index 92b4f18..af480dd 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/furniture/FurnitureLine.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/furniture/FurnitureLine.java @@ -1,9 +1,10 @@ package fr.nessar.lmnporganizer.rental.domain.furniture; -public record FurnitureLine(String id, FurnitureCategory category, FurniturePresence presence, String privateAreaId, - String designation, int quantity, String description) { +public record FurnitureLine(String id, FurnitureCategory category, FurniturePresence presence, + FurnitureEquipmentKind equipmentKind, String privateAreaId, String designation, + int quantity, String description) { public FurnitureLine { - if (id == null || id.isBlank() || privateAreaId == null || privateAreaId.isBlank() + if (id == null || id.isBlank() || equipmentKind == null || privateAreaId == null || privateAreaId.isBlank() || designation == null || designation.isBlank() || quantity < 0) { throw new IllegalArgumentException("Furniture line is invalid."); } @@ -14,6 +15,9 @@ public record FurnitureLine(String id, FurnitureCategory category, FurniturePres if (category == null && presence != null) { throw new IllegalArgumentException("Only legal furniture categories may declare a presence."); } + if (category != null && equipmentKind != category.equipmentKind()) { + throw new IllegalArgumentException("Legal furniture equipment classification cannot be changed."); + } if (category != null && presence == null && quantity != 0) { throw new IllegalArgumentException("An undeclared legal furniture category must have a zero quantity."); } diff --git a/backend/src/main/resources/templates/rental/property-condition-report.html b/backend/src/main/resources/templates/rental/property-condition-report.html index ee8e365..5439889 100644 --- a/backend/src/main/resources/templates/rental/property-condition-report.html +++ b/backend/src/main/resources/templates/rental/property-condition-report.html @@ -11,7 +11,7 @@ body { font-family: sans-serif; font-size: 11pt; } table { border-collapse: coll

Observations

ÉlémentÉtatObservation
ÉlémentÉtatObservation

Photos

Résumé des photos

Absences et correspondances photo

Zone/référenceMotif ou correspondance
ZoneMotif
Photo d’entréeCorrespondance
-

Relevés de compteurs

CompteurTypeIndexUnitéObservation
CompteurTypeIndexUnitéObservation
+

Relevés de compteurs

CompteurTypeIndexUnitéObservation
CompteurTypeIndexUnitéObservation

Moyens d’accès

TypeDestinationQuantitéObservation
TypeDestinationQuantitéObservation

Inventaire constaté

ÉlémentQuantité contractuelleRépartition constatée
ÉlémentQuantité
  • Répartition

Adresses de correspondance

LocataireAdresse
LocataireAdresse
diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/FurnitureInventoryWebTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/FurnitureInventoryWebTest.java index a7b8694..3839cd1 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/FurnitureInventoryWebTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/FurnitureInventoryWebTest.java @@ -75,7 +75,7 @@ class FurnitureInventoryWebTest { when(inventories.replace(eq(LANDLORD_ID), eq(LEASE_ID), eq(0L), any())).thenReturn(inventory()); mockMvc.perform(put("/api/leases/lease-1/inventory").contentType(MediaType.APPLICATION_JSON) - .content("{\"version\":0,\"lines\":[{\"id\":\"line-1\",\"privateAreaId\":\"area-1\",\"designation\":\"Lampe\",\"quantity\":1}]}") + .content("{\"version\":0,\"lines\":[{\"id\":\"line-1\",\"equipmentKind\":\"NONE\",\"privateAreaId\":\"area-1\",\"designation\":\"Lampe\",\"quantity\":1}]}") .with(landlord()).with(csrf())) .andExpect(status().isOk()); verify(inventories).replace(eq(LANDLORD_ID), eq(LEASE_ID), eq(0L), any()); diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseFinalizationWebTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseFinalizationWebTest.java index 47a26ba..d289e1f 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseFinalizationWebTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseFinalizationWebTest.java @@ -9,6 +9,7 @@ import fr.nessar.lmnporganizer.rental.application.lease.LeaseFinalizationService import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId; import fr.nessar.lmnporganizer.rental.domain.apartment.PrivateArea; import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventorySnapshot; +import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureEquipmentKind; import fr.nessar.lmnporganizer.rental.domain.landlord.ExternalIdentity; import fr.nessar.lmnporganizer.rental.domain.landlord.Landlord; import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId; @@ -191,7 +192,8 @@ class LeaseFinalizationWebTest { private static LeaseDocumentRevision revision() { return new LeaseDocumentRevision("revision-1", LANDLORD_ID, LEASE_ID, 1, "FR-MEUBLE-2025-01", "source", "storage", "revision-hash", NOW, "subject-1", new FurnitureInventorySnapshot("FR-MEUBLE-2025-01", - List.of(new FurnitureInventorySnapshot.Line("line-1", null, null, "area-1", "Chaise", 1, ""))), + List.of(new FurnitureInventorySnapshot.Line("line-1", null, null, FurnitureEquipmentKind.NONE, + "area-1", "Chaise", 1, ""))), List.of(new PrivateArea("area-1", PrivateArea.Family.PIECE_PRINCIPALE, PrivateArea.Subtype.LIVING_ROOM, "Séjour")), List.of(), List.of()); } diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/PropertyConditionReportWebTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/PropertyConditionReportWebTest.java new file mode 100644 index 0000000..82cc045 --- /dev/null +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/PropertyConditionReportWebTest.java @@ -0,0 +1,116 @@ +package fr.nessar.lmnporganizer.rental.adapter.in.web.lease; + +import fr.nessar.lmnporganizer.LmnpOrganizerApplication; +import fr.nessar.lmnporganizer.identity.adapter.in.security.SecurityConfiguration; +import fr.nessar.lmnporganizer.rental.adapter.in.config.PropertyConditionPhotoProperties; +import fr.nessar.lmnporganizer.rental.adapter.in.config.RentalAttachmentProperties; +import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord; +import fr.nessar.lmnporganizer.rental.adapter.in.web.common.RentalApiExceptionHandler; +import fr.nessar.lmnporganizer.rental.application.condition.PropertyConditionDocumentService; +import fr.nessar.lmnporganizer.rental.application.condition.PropertyConditionReportService; +import fr.nessar.lmnporganizer.rental.application.landlord.LandlordAccountService; +import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReport; +import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportId; +import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportSupplementType; +import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportType; +import fr.nessar.lmnporganizer.rental.domain.landlord.ExternalIdentity; +import fr.nessar.lmnporganizer.rental.domain.landlord.Landlord; +import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId; +import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.RequestPostProcessor; +import org.springframework.util.unit.DataSize; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = PropertyConditionReportController.class) +@ContextConfiguration(classes = LmnpOrganizerApplication.class) +@Import({AuthenticatedLandlord.class, RentalApiExceptionHandler.class, SecurityConfiguration.class}) +class PropertyConditionReportWebTest { + private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1"); + private static final LeaseId LEASE_ID = new LeaseId("lease-1"); + + @Autowired MockMvc mockMvc; + @MockitoBean PropertyConditionReportService reports; + @MockitoBean PropertyConditionDocumentService documents; + @MockitoBean LandlordAccountService accounts; + @MockitoBean PropertyConditionPhotoProperties photoProperties; + @MockitoBean RentalAttachmentProperties attachmentProperties; + + @BeforeEach + void authenticateAndConfigureUploadLimits() { + when(accounts.identify(any())).thenReturn(Landlord.register(LANDLORD_ID, + new ExternalIdentity("subject-1"), "Camille", "Martin", "camille@example.com")); + when(photoProperties.maxUploadSize()).thenReturn(DataSize.ofMegabytes(1)); + when(attachmentProperties.maxSize()).thenReturn(DataSize.ofMegabytes(1)); + } + + @Test + void supplementMultipartMapsTheTypePhotoZonesAndLateConfirmation() throws Exception { + when(reports.appendSupplement(eq(LANDLORD_ID), eq(LEASE_ID), any(byte[].class), anyList(), + eq("subject-1"), eq(LocalDate.of(2026, 7, 22)), eq("Radiateur à compléter."), + eq(PropertyConditionReportSupplementType.HEATING), eq(LocalDate.of(2026, 7, 1)), eq(true), + eq("Demande tardive confirmée."))).thenReturn(report()); + + mockMvc.perform(multipart("/api/leases/lease-1/condition-reports/ENTRY/supplements") + .file(pdf()).file(photo()).param("photoZoneIds", "living-room:floor") + .param("requestedOn", "2026-07-22").param("acceptedAdditions", "Radiateur à compléter.") + .param("supplementType", "HEATING").param("heatingPeriodStartedOn", "2026-07-01") + .param("lateWarningConfirmed", "true").param("reason", "Demande tardive confirmée.") + .with(landlord()).with(csrf())) + .andExpect(status().isOk()); + + ArgumentCaptor signedPdf = ArgumentCaptor.forClass(byte[].class); + ArgumentCaptor> photos = ArgumentCaptor.captor(); + verify(reports).appendSupplement(eq(LANDLORD_ID), eq(LEASE_ID), signedPdf.capture(), photos.capture(), + eq("subject-1"), eq(LocalDate.of(2026, 7, 22)), eq("Radiateur à compléter."), + eq(PropertyConditionReportSupplementType.HEATING), eq(LocalDate.of(2026, 7, 1)), eq(true), + eq("Demande tardive confirmée.")); + assertThat(signedPdf.getValue()).isEqualTo("%PDF-supplement".getBytes()); + assertThat(photos.getValue()).singleElement().satisfies(source -> { + assertThat(source.zoneId()).isEqualTo("living-room:floor"); + assertThat(source.content()).isEqualTo("photo-content".getBytes()); + }); + } + + private static MockMultipartFile pdf() { + return new MockMultipartFile("signedPdf", "supplement.pdf", MediaType.APPLICATION_PDF_VALUE, + "%PDF-supplement".getBytes()); + } + + private static MockMultipartFile photo() { + return new MockMultipartFile("photos", "floor.webp", "image/webp", "photo-content".getBytes()); + } + + private static PropertyConditionReport report() { + return PropertyConditionReport.draft(new PropertyConditionReportId("report-1"), LANDLORD_ID, LEASE_ID, + PropertyConditionReportType.ENTRY, LocalDate.of(2026, 7, 20), "Europe/Paris", 1, "fingerprint"); + } + + private static RequestPostProcessor landlord() { + return oidcLogin().idToken(token -> token.claim("sub", "subject-1").claim("given_name", "Camille") + .claim("family_name", "Martin").claim("email", "camille@example.com")); + } +} diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/condition/MongoPropertyConditionReportRepositoryTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/condition/MongoPropertyConditionReportRepositoryTest.java new file mode 100644 index 0000000..baa1f18 --- /dev/null +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/condition/MongoPropertyConditionReportRepositoryTest.java @@ -0,0 +1,96 @@ +package fr.nessar.lmnporganizer.rental.adapter.out.mongodb.condition; + +import fr.nessar.lmnporganizer.rental.domain.condition.ConditionPhoto; +import fr.nessar.lmnporganizer.rental.domain.condition.ConditionZone; +import fr.nessar.lmnporganizer.rental.domain.condition.LateSupplementAddition; +import fr.nessar.lmnporganizer.rental.domain.condition.MeterReading; +import fr.nessar.lmnporganizer.rental.domain.condition.MeterReadingState; +import fr.nessar.lmnporganizer.rental.domain.condition.MeterType; +import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReport; +import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportFinalization; +import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportId; +import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportStatus; +import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportSupplement; +import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportSupplementType; +import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportType; +import fr.nessar.lmnporganizer.rental.domain.condition.ZoneEquipment; +import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId; +import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Query; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MongoPropertyConditionReportRepositoryTest { + private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1"); + private static final LeaseId LEASE_ID = new LeaseId("lease-1"); + private static final Instant NOW = Instant.parse("2026-07-23T10:00:00Z"); + + @Mock MongoTemplate mongo; + + @Test + void roundTripsMeterStateAndZonedHeatingSupplementPhotos() { + PropertyConditionReport source = report(); + when(mongo.insert(any(MongoPropertyConditionReportRepository.ReportDocument.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + MongoPropertyConditionReportRepository repository = new MongoPropertyConditionReportRepository(mongo); + + repository.save(source); + + ArgumentCaptor document = ArgumentCaptor.forClass( + MongoPropertyConditionReportRepository.ReportDocument.class); + verify(mongo).insert(document.capture()); + when(mongo.findOne(any(Query.class), eq(MongoPropertyConditionReportRepository.ReportDocument.class))) + .thenReturn(document.getValue()); + + PropertyConditionReport restored = repository.find(LANDLORD_ID, LEASE_ID, + PropertyConditionReportType.ENTRY).orElseThrow(); + + assertThat(restored.meterReadings()).singleElement().satisfies(meter -> { + assertThat(meter.state()).isEqualTo(MeterReadingState.INACCESSIBLE); + assertThat(meter.index()).isNull(); + }); + assertThat(restored.supplements()).singleElement().satisfies(supplement -> { + assertThat(supplement.type()).isEqualTo(PropertyConditionReportSupplementType.HEATING); + assertThat(supplement.heatingPeriodStartedOn()).isEqualTo(LocalDate.of(2026, 7, 1)); + assertThat(supplement.photos()).singleElement().satisfies(photo -> + assertThat(photo.zoneId()).isEqualTo("living-room:floor")); + }); + } + + private static PropertyConditionReport report() { + return PropertyConditionReport.reconstitute(new PropertyConditionReportId("report-1"), LANDLORD_ID, + LEASE_ID, PropertyConditionReportType.ENTRY, LocalDate.of(2026, 7, 20), "Europe/Paris", + List.of(), List.of(), List.of(new ConditionZone("living-room:floor", "living-room", "Sol", + ZoneEquipment.NONE)), List.of(), List.of(new MeterReading("water-1", + MeterType.EAU_FROIDE, MeterReadingState.INACCESSIBLE, null, "m3", "", "", + "Compteur inaccessible.")), List.of(), List.of(), List.of(), List.of(), List.of(), + List.of(supplement()), List.of(), new PropertyConditionReportFinalization(1, "signed.pdf", + "signed-sha", "subject-1", NOW), List.of(), null, 1, "lease-fingerprint", + PropertyConditionReportStatus.FINALIZED, -1); + } + + private static PropertyConditionReportSupplement supplement() { + return new PropertyConditionReportSupplement("supplement-1", PropertyConditionReportSupplementType.HEATING, + new PropertyConditionReportSupplement.FileEvidence("supplement.pdf", "supplement.pdf", "pdf-sha"), + List.of(new ConditionPhoto("supplement-photo-1", "living-room:floor", "photo.webp", "photo-sha", + 800, 600, "subject-1", NOW)), "subject-1", NOW, LocalDate.of(2026, 7, 22), + LocalDate.of(2026, 7, 1), "Radiateur à compléter.", + new LateSupplementAddition("SUPPLEMENT_RULE", LocalDate.of(2026, 7, 21), + "Demande tardive confirmée.", "subject-1", NOW)); + } +} diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/furniture/MongoFurnitureInventoryRepositoryTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/furniture/MongoFurnitureInventoryRepositoryTest.java index e708555..d017222 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/furniture/MongoFurnitureInventoryRepositoryTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/furniture/MongoFurnitureInventoryRepositoryTest.java @@ -33,7 +33,7 @@ class MongoFurnitureInventoryRepositoryTest { void readsPresenceAndScopesTheInventoryByLandlordAndLease() { var document = new MongoFurnitureInventoryRepository.InventoryDocument("inventory-1", "landlord-1", "lease-1", "FR-MEUBLE-2025-01", List.of(new MongoFurnitureInventoryRepository.LineDocument( - "line-1", "BEDDING", "ABSENT", "area-1", "Literie", 0, "")), false, 3L); + "line-1", "BEDDING", "ABSENT", "NONE", "area-1", "Literie", 0, "")), false, 3L); when(mongo.findOne(any(Query.class), eq(MongoFurnitureInventoryRepository.InventoryDocument.class))) .thenReturn(document); diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/lease/MongoLeaseDocumentRevisionRepositoryTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/lease/MongoLeaseDocumentRevisionRepositoryTest.java index 6936793..6974997 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/lease/MongoLeaseDocumentRevisionRepositoryTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/lease/MongoLeaseDocumentRevisionRepositoryTest.java @@ -4,6 +4,7 @@ import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId; import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentDocumentType; import fr.nessar.lmnporganizer.rental.domain.apartment.PrivateArea; import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventorySnapshot; +import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureEquipmentKind; import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentAnnex; import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentRevision; import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentRevisionConflict; @@ -86,13 +87,14 @@ class MongoLeaseDocumentRevisionRepositoryTest { private static FurnitureInventorySnapshot inventorySnapshot() { return new FurnitureInventorySnapshot("FR-MEUBLE-2025-01", List.of( - new FurnitureInventorySnapshot.Line("line-1", null, null, "area-1", "Chaise", 1, ""))); + new FurnitureInventorySnapshot.Line("line-1", null, null, FurnitureEquipmentKind.NONE, + "area-1", "Chaise", 1, ""))); } private static MongoLeaseDocumentRevisionRepository.FurnitureInventoryDocument inventoryDocument() { return new MongoLeaseDocumentRevisionRepository.FurnitureInventoryDocument("FR-MEUBLE-2025-01", List.of( new MongoLeaseDocumentRevisionRepository.FurnitureInventoryLineDocument("line-1", null, null, - "area-1", "Chaise", 1, ""))); + "NONE", "area-1", "Chaise", 1, ""))); } private static PrivateArea privateArea() { diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/pdf/ThymeleafPropertyConditionReportRendererTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/pdf/ThymeleafPropertyConditionReportRendererTest.java index 9bb5c7a..71ad048 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/pdf/ThymeleafPropertyConditionReportRendererTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/pdf/ThymeleafPropertyConditionReportRendererTest.java @@ -9,6 +9,7 @@ import fr.nessar.lmnporganizer.rental.domain.condition.ConditionZone; import fr.nessar.lmnporganizer.rental.domain.condition.InventoryCondition; import fr.nessar.lmnporganizer.rental.domain.condition.InventoryQuantitySplit; import fr.nessar.lmnporganizer.rental.domain.condition.MeterReading; +import fr.nessar.lmnporganizer.rental.domain.condition.MeterReadingState; import fr.nessar.lmnporganizer.rental.domain.condition.MeterType; import fr.nessar.lmnporganizer.rental.domain.condition.ParticipantAttendance; import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReport; @@ -78,7 +79,7 @@ class ThymeleafPropertyConditionReportRendererTest { report.replaceObservations(List.of(new ConditionObservation("kitchen:walls", ConditionState.DEGRADE, "Rayure visible"))); report.replaceMeterReadings(List.of(new MeterReading("meter-1", MeterType.ELECTRICITE, - new BigDecimal("12.5"), "kWh", "Compteur cuisine", "", ""))); + MeterReadingState.RECORDED, new BigDecimal("12.5"), "kWh", "Compteur cuisine", "", ""))); report.replaceAccessMeans(List.of(new AccessMeans("key-1", AccessMeansType.CLE, "Porte d’entrée", 2, ""))); report.replaceInventoryConditions(List.of(new InventoryCondition("chair-1", "kitchen", "Cuisine", "Chaise de cuisine", 2, List.of(new InventoryQuantitySplit("chair-1", ConditionState.DEGRADE, 2, "Assise usée"))))); diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/pdf/ThymeleafRentalDocumentRendererTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/pdf/ThymeleafRentalDocumentRendererTest.java index 87e7727..bca436d 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/pdf/ThymeleafRentalDocumentRendererTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/pdf/ThymeleafRentalDocumentRendererTest.java @@ -13,6 +13,7 @@ import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile.Bui import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile.ConstructionPeriod; import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId; import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureCategory; +import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureEquipmentKind; import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventorySnapshot; import fr.nessar.lmnporganizer.rental.domain.furniture.FurniturePresence; import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId; @@ -126,7 +127,8 @@ class ThymeleafRentalDocumentRendererTest { contract.leaseType(), contract.specialClauses(), contract.legalTerms(), "FR-MEUBLE-2025-01", 1, new FurnitureInventorySnapshot("FR-MEUBLE-2025-01", List.of(new FurnitureInventorySnapshot.Line("line-1", FurnitureCategory.TABLE_AND_SEATING, - FurniturePresence.PRESENT, "area-dining", "Table et quatre chaises", 4, + FurniturePresence.PRESENT, FurnitureEquipmentKind.NONE, "area-dining", + "Table et quatre chaises", 4, "Bois clair")))); String text = normalized(pdfText(renderer.renderLease(document))); diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionDraftTemplateTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionDraftTemplateTest.java index 91839fd..19faaf3 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionDraftTemplateTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionDraftTemplateTest.java @@ -3,6 +3,7 @@ package fr.nessar.lmnporganizer.rental.application.condition; import fr.nessar.lmnporganizer.rental.domain.apartment.PrivateArea; import fr.nessar.lmnporganizer.rental.domain.condition.ZoneEquipment; import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventorySnapshot; +import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureEquipmentKind; import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId; import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentRevision; import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId; @@ -20,8 +21,8 @@ class PropertyConditionDraftTemplateTest { new LeaseId("lease-1"), 1, "FR-MEUBLE-2025-01", "source", "storage", "sha", Instant.parse("2026-07-20T10:00:00Z"), "author", new FurnitureInventorySnapshot("FR-MEUBLE-2025-01", List.of( - new FurnitureInventorySnapshot.Line("air-conditioner", null, null, "living-room", - "Climatisation", 1, ""))), + new FurnitureInventorySnapshot.Line("air-conditioner", null, null, + FurnitureEquipmentKind.CLIMATISATION, "living-room", "Pompe à chaleur", 1, ""))), List.of(new PrivateArea("living-room", PrivateArea.Family.PIECE_PRINCIPALE, PrivateArea.Subtype.LIVING_ROOM, "Séjour")), List.of(), List.of()); diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionReportServiceTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionReportServiceTest.java index 32b2588..784ba0e 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionReportServiceTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionReportServiceTest.java @@ -7,6 +7,7 @@ import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId; import fr.nessar.lmnporganizer.rental.domain.apartment.PrivateArea; import fr.nessar.lmnporganizer.rental.domain.condition.*; import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventorySnapshot; +import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureEquipmentKind; import fr.nessar.lmnporganizer.rental.domain.furniture.FurniturePresence; import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId; import fr.nessar.lmnporganizer.rental.domain.landlord.Landlord; @@ -85,23 +86,49 @@ class PropertyConditionReportServiceTest { fixture.reports.entry = finalizedEntry(); PropertyConditionReport heatingOnTime = fixture.service.appendSupplement(LANDLORD, LEASE, "%PDF-one".getBytes(), - List.of(), "author", LocalDate.of(2026, 7, 31), "Ajout accepté", - LocalDate.of(2026, 7, 1), null); + List.of(new PropertyConditionReportService.SupplementPhotoSource("kitchen:floor", new byte[]{1})), + "author", LocalDate.of(2026, 7, 31), "Ajout accepté", + PropertyConditionReportSupplementType.HEATING, LocalDate.of(2026, 7, 1), false, null); assertEquals(OnTimeSupplement.class, heatingOnTime.supplements().getFirst().audit().getClass()); + assertEquals(LocalDate.of(2026, 7, 1), + heatingOnTime.supplements().getFirst().heatingPeriodStartedOn()); + assertEquals("kitchen:floor", heatingOnTime.supplements().getFirst().photos().getFirst().zoneId()); PropertyConditionReport late = fixture.service.appendSupplement(LANDLORD, LEASE, "%PDF-two".getBytes(), List.of(), "author", LocalDate.of(2026, 7, 31), "Ajout accepté", - null, "Accepté malgré le retard"); + PropertyConditionReportSupplementType.GENERAL, null, true, "Accepté malgré le retard"); assertEquals("Accepté malgré le retard", ((LateSupplementAddition) late.supplements().getLast().audit()).reason()); assertThrows(IllegalArgumentException.class, () -> fixture.service.appendSupplement(LANDLORD, LEASE, "%PDF-three".getBytes(), List.of(), "author", - LocalDate.of(2026, 8, 1), "Ajout accepté", null, "")); + LocalDate.of(2026, 8, 1), "Ajout accepté", PropertyConditionReportSupplementType.GENERAL, + null, true, "Motif")); + assertThrows(IllegalArgumentException.class, () -> fixture.service.appendSupplement(LANDLORD, LEASE, + "%PDF-four".getBytes(), List.of(), "author", + LocalDate.of(2026, 7, 19), "Ajout accepté", PropertyConditionReportSupplementType.GENERAL, + null, false, null)); + assertThrows(IllegalArgumentException.class, () -> fixture.service.appendSupplement(LANDLORD, LEASE, + "%PDF-five".getBytes(), List.of(), "author", + LocalDate.of(2026, 7, 31), "Ajout accepté", PropertyConditionReportSupplementType.GENERAL, + null, false, "Motif non confirmé")); + } + + @Test + void refusesASupplementAfterTheExitSnapshotExists() { + Fixture fixture = fixture(revision(1, "source-1")); + fixture.reports.entry = finalizedEntry(); + fixture.reports.exit = PropertyConditionReport.draftExit(new PropertyConditionReportId("report-exit"), + fixture.reports.entry, LocalDate.of(2026, 7, 31), "Europe/Paris"); + + assertThrows(IllegalStateException.class, () -> fixture.service.appendSupplement(LANDLORD, LEASE, + "%PDF".getBytes(), List.of(), "author", LocalDate.of(2026, 7, 25), "Ajout accepté", + PropertyConditionReportSupplementType.GENERAL, null, false, null)); } private static PropertyConditionReport finalizedEntry() { return PropertyConditionReport.reconstitute(new PropertyConditionReportId("report-1"), LANDLORD, LEASE, PropertyConditionReportType.ENTRY, LocalDate.of(2026, 7, 20), "Europe/Paris", List.of(), List.of(), - List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), + List.of(new ConditionZone("kitchen:floor", "kitchen", "Cuisine — Sol", ZoneEquipment.NONE)), + List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), new PropertyConditionReportFinalization(1, "signed", "sha", "author", Instant.parse("2026-07-20T10:00:00Z")), List.of(), null, 1, "source-1", PropertyConditionReportStatus.FINALIZED, 0); @@ -111,8 +138,8 @@ class PropertyConditionReportServiceTest { return new LeaseDocumentRevision("revision-" + number, LANDLORD, LEASE, number, "FR-MEUBLE-2025-01", fingerprint, "storage", "sha", Instant.parse("2026-07-20T10:00:00Z"), "author", new FurnitureInventorySnapshot("FR-MEUBLE-2025-01", List.of( - new FurnitureInventorySnapshot.Line("chair", fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureCategory.TABLE_AND_SEATING, FurniturePresence.PRESENT, "kitchen", - "Chaise", 2, ""))), + new FurnitureInventorySnapshot.Line("chair", fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureCategory.TABLE_AND_SEATING, + FurniturePresence.PRESENT, FurnitureEquipmentKind.NONE, "kitchen", "Chaise", 2, ""))), List.of(new PrivateArea("kitchen", PrivateArea.Family.PIECE_DE_SERVICE, PrivateArea.Subtype.KITCHEN, "Cuisine")), List.of(), List.of()); } @@ -152,10 +179,14 @@ class PropertyConditionReportServiceTest { private record Fixture(PropertyConditionReportService service, MemoryReports reports, MemoryRevisions revisions) { } private static final class MemoryReports implements PropertyConditionReportRepository { private PropertyConditionReport entry; + private PropertyConditionReport exit; @Override public Optional find(LandlordId landlord, LeaseId lease, PropertyConditionReportType type) { - return type == PropertyConditionReportType.ENTRY ? Optional.ofNullable(entry) : Optional.empty(); + return Optional.ofNullable(type == PropertyConditionReportType.ENTRY ? entry : exit); + } + @Override public PropertyConditionReport save(PropertyConditionReport value) { + if (value.type() == PropertyConditionReportType.ENTRY) entry = value; else exit = value; + return value; } - @Override public PropertyConditionReport save(PropertyConditionReport value) { entry = value; return value; } } private static final class MemoryRevisions implements LeaseDocumentRevisionRepository { private LeaseDocumentRevision current; diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/furniture/FurnitureInventoryServiceTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/furniture/FurnitureInventoryServiceTest.java index f237df9..ac890ad 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/furniture/FurnitureInventoryServiceTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/furniture/FurnitureInventoryServiceTest.java @@ -8,6 +8,7 @@ import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile; import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentRepository; import fr.nessar.lmnporganizer.rental.domain.apartment.PrivateArea; import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureCategory; +import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureEquipmentKind; import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventory; import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryRepository; import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureLine; @@ -26,7 +27,10 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.math.BigDecimal; +import java.time.Clock; +import java.time.Instant; import java.time.LocalDate; +import java.time.ZoneOffset; import java.util.List; import java.util.Optional; import java.util.concurrent.atomic.AtomicInteger; @@ -75,7 +79,8 @@ class FurnitureInventoryServiceTest { when(apartments.find(LANDLORD_ID, APARTMENT_ID)).thenReturn(Optional.of(apartment())); when(inventories.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(inventory)); - FurnitureLine outsideArea = new FurnitureLine("line-1", null, null, "area-other", "Lampe", 1, ""); + FurnitureLine outsideArea = new FurnitureLine("line-1", null, null, FurnitureEquipmentKind.OTHER, + "area-other", "Lampe", 1, ""); assertThrows(IllegalArgumentException.class, () -> service(() -> "unused").replace(LANDLORD_ID, LEASE_ID, 2, List.of(outsideArea))); @@ -83,8 +88,26 @@ class FurnitureInventoryServiceTest { () -> service(() -> "unused").replace(LANDLORD_ID, LEASE_ID, 1, List.of())); } + @Test + void alignsAnExistingEditableInventoryWhenTheLeaseTemplateChanges() { + FurnitureInventory inventory = FurnitureInventory.reconstitute( + new fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryId("inventory-1"), + LANDLORD_ID, LEASE_ID, "FR-MEUBLE-2025-01", List.of(), false, 2); + when(leases.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(lease())); + when(inventories.find(LANDLORD_ID, LEASE_ID)).thenReturn(Optional.of(inventory)); + when(inventories.save(inventory)).thenReturn(inventory); + + FurnitureInventory aligned = new FurnitureInventoryService(inventories, leases, apartments, + () -> "unused", Clock.fixed(Instant.parse("2026-10-02T12:00:00Z"), ZoneOffset.UTC)) + .get(LANDLORD_ID, LEASE_ID); + + assertEquals("FR-MEUBLE-2026-10", aligned.templateVersion()); + verify(inventories).save(inventory); + } + private FurnitureInventoryService service(IdentifierGenerator identifiers) { - return new FurnitureInventoryService(inventories, leases, apartments, identifiers); + return new FurnitureInventoryService(inventories, leases, apartments, identifiers, + Clock.fixed(Instant.parse("2026-07-19T12:00:00Z"), ZoneOffset.UTC)); } private static Lease lease() { diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/lease/GenerateLeaseDocumentRevisionServiceTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/lease/GenerateLeaseDocumentRevisionServiceTest.java index 1473eff..7c618c2 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/lease/GenerateLeaseDocumentRevisionServiceTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/lease/GenerateLeaseDocumentRevisionServiceTest.java @@ -382,7 +382,7 @@ class GenerateLeaseDocumentRevisionServiceTest { } @Test - void pdfTemplateIsIndependentFromTheInventoryCatalogAcrossTheOctober2026Cutover() { + void alignsAnEditableInventoryAcrossTheOctober2026Cutover() { LeaseRepository leases = mock(LeaseRepository.class); LandlordRepository landlords = mock(LandlordRepository.class); ApartmentRepository apartments = mock(ApartmentRepository.class); @@ -401,7 +401,7 @@ class GenerateLeaseDocumentRevisionServiceTest { LeaseDocumentRevision revision = october.generateRevision(LANDLORD_ID, LEASE_ID, "subject-1"); assertEquals("FR-MEUBLE-2026-10", revision.templateVersion()); - assertEquals("FR-MEUBLE-2025-01", revision.furnitureInventory().templateVersion()); + assertEquals("FR-MEUBLE-2026-10", revision.furnitureInventory().templateVersion()); } @Test @@ -420,7 +420,7 @@ class GenerateLeaseDocumentRevisionServiceTest { FurnitureInventory.reconstitute(new FurnitureInventoryId("inventory-1"), LANDLORD_ID, LEASE_ID, "FR-MEUBLE-2025-01", FurnitureInventory.legalCatalog().stream().map(category -> new FurnitureLine(category.name(), category, FurniturePresence.PRESENT, - "area-1", category.label(), 1, "")).toList(), false, 1))); + category.equipmentKind(), "area-1", category.label(), 1, "")).toList(), false, 1))); RentalDocumentRenderer renderer = mock(RentalDocumentRenderer.class); ApartmentDocumentService apartmentDocuments = mock(ApartmentDocumentService.class); when(apartmentDocuments.list(LANDLORD_ID, APARTMENT_ID)).thenReturn(List.of()); @@ -584,8 +584,9 @@ class GenerateLeaseDocumentRevisionServiceTest { private static FurnitureInventory inventory(FurniturePresence beddingPresence, int beddingQuantity) { List lines = FurnitureInventory.legalCatalog().stream().map(category -> new FurnitureLine( category.name().toLowerCase(), category, - category == FurnitureCategory.BEDDING ? beddingPresence : FurniturePresence.PRESENT, "area-1", - category.label(), category == FurnitureCategory.BEDDING ? beddingQuantity : 1, "")) + category == FurnitureCategory.BEDDING ? beddingPresence : FurniturePresence.PRESENT, + category.equipmentKind(), "area-1", category.label(), + category == FurnitureCategory.BEDDING ? beddingQuantity : 1, "")) .toList(); return FurnitureInventory.reconstitute(new FurnitureInventoryId("inventory-1"), LANDLORD_ID, LEASE_ID, "FR-MEUBLE-2025-01", lines, false, 1); diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/lease/LeaseFinalizationServiceTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/lease/LeaseFinalizationServiceTest.java index adeedee..82ce197 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/lease/LeaseFinalizationServiceTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/lease/LeaseFinalizationServiceTest.java @@ -13,6 +13,7 @@ import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportRe import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportStatus; import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportType; import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventory; +import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureEquipmentKind; import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryId; import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryRepository; import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventorySnapshot; @@ -434,13 +435,14 @@ class LeaseFinalizationServiceTest { private static FurnitureInventory inventory() { return FurnitureInventory.reconstitute(new FurnitureInventoryId("inventory-1"), LANDLORD_ID, LEASE_ID, "FR-MEUBLE-2025-01", FurnitureInventory.legalCatalog().stream().map(category -> new FurnitureLine( - category.name().toLowerCase(), category, FurniturePresence.PRESENT, "area-1", - category.label(), 1, "")).toList(), false, 1); + category.name().toLowerCase(), category, FurniturePresence.PRESENT, category.equipmentKind(), + "area-1", category.label(), 1, "")).toList(), false, 1); } private static FurnitureInventorySnapshot inventorySnapshot() { return new FurnitureInventorySnapshot("FR-MEUBLE-2025-01", List.of( - new FurnitureInventorySnapshot.Line("line-1", null, null, "area-1", "Chaise", 1, ""))); + new FurnitureInventorySnapshot.Line("line-1", null, null, FurnitureEquipmentKind.NONE, + "area-1", "Chaise", 1, ""))); } private static PrivateArea privateArea() { 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 501e463..0daacd7 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 @@ -31,6 +31,17 @@ class PropertyConditionReportTest { assertEquals(AccessMeansType.TELECOMMANDE, AccessMeansType.valueOf("TELECOMMANDE")); } + @Test + void anInaccessibleMeterHasNoInventedIndexAndRequiresAnObservation() { + assertThrows(IllegalArgumentException.class, () -> new MeterReading("water", MeterType.EAU_FROIDE, + MeterReadingState.INACCESSIBLE, null, "m3", "", "", "")); + assertThrows(IllegalArgumentException.class, () -> new MeterReading("water", MeterType.EAU_FROIDE, + MeterReadingState.INACCESSIBLE, BigDecimal.ZERO, "m3", "", "", "Compteur bloqué")); + + new MeterReading("water", MeterType.EAU_FROIDE, MeterReadingState.INACCESSIBLE, null, "m3", "", "", + "Local technique inaccessible"); + } + @Test void requiresObservationsForDegradedAbsentAndNotApplicableConditions() { PropertyConditionReport report = entryDraft(); @@ -100,20 +111,18 @@ class PropertyConditionReportTest { @Test void storesImmutableEntrySupplementsAndReconstitutesEveryExitField() { PropertyConditionReport entry = finalizedEntry(); - PropertyConditionReportSupplement supplement = new PropertyConditionReportSupplement("supplement-1", - new PropertyConditionReportSupplement.FileEvidence("supplement-reference", - "reports/supplement.pdf", "supplement-sha"), List.of(), "landlord-1", - Instant.parse("2026-07-22T10:00:00Z"), - LocalDate.of(2026, 7, 22), "Ajout accepté", - new LateSupplementAddition("PROPERTY_CONDITION_SUPPLEMENT_DEADLINE_2026-07", LocalDate.of(2026, 7, 30), - "Demande reçue après le délai", "landlord-1", - Instant.parse("2026-07-22T10:00:00Z"))); + PropertyConditionReportSupplement supplement = supplement("supplement-photo-1", "floor"); + + assertThrows(IllegalArgumentException.class, + () -> entry.appendSupplement(supplement("wrong-zone-photo", "outside"))); + assertThrows(IllegalArgumentException.class, + () -> entry.appendSupplement(supplement("entry-photo-1", "floor"))); entry.appendSupplement(supplement); assertEquals(List.of(supplement), entry.supplements()); PropertyConditionReport exit = PropertyConditionReport.draftExit(new PropertyConditionReportId("report-2"), - entry, LocalDate.of(2026, 7, 21), "Europe/Paris"); + entry, LocalDate.of(2026, 7, 23), "Europe/Paris"); PropertyConditionReport restored = PropertyConditionReport.reconstitute(exit.id(), exit.landlordId(), exit.leaseId(), exit.type(), exit.occurredOn(), exit.timeZone(), exit.participants(), exit.forwardingAddresses(), exit.zones(), exit.observations(), exit.meterReadings(), exit.accessMeans(), @@ -122,6 +131,8 @@ class PropertyConditionReportTest { exit.leaseRevisionNumber(), exit.leaseRevisionFingerprint(), exit.status(), exit.version()); assertEquals(exit.entrySnapshot(), restored.entrySnapshot()); + assertEquals(List.of("entry-photo-1", "supplement-photo-1"), restored.entrySnapshot().effectivePhotos() + .stream().map(ConditionPhoto::reference).toList()); assertEquals(exit.zones(), restored.zones()); } @@ -165,13 +176,26 @@ class PropertyConditionReportTest { assertThrows(IllegalStateException.class, () -> report.finalizeReport(finalization(1))); } + @Test + void requiresAValidLeaseRevisionWhenRestartingAnObsoleteEntry() { + PropertyConditionReport report = entryDraft(); + report.markObsolete(); + + assertThrows(IllegalArgumentException.class, () -> report.restartEntryDraft( + LocalDate.of(2026, 7, 21), "Europe/Paris", 0, "")); + + report.restartEntryDraft(LocalDate.of(2026, 7, 21), "Europe/Paris", 2, " lease-source-2 "); + assertEquals(2, report.leaseRevisionNumber()); + assertEquals("lease-source-2", report.leaseRevisionFingerprint()); + } + private static PropertyConditionReport finalizedEntry() { PropertyConditionReport entry = entryDraft(); entry.replaceParticipants(participants(ParticipantAttendance.PRESENT)); entry.replaceZones(List.of(new ConditionZone("floor", "living-room", "Sol", ZoneEquipment.NONE))); entry.replaceObservations(List.of(new ConditionObservation("floor", ConditionState.BON_ETAT, ""))); - entry.replaceMeterReadings(List.of(new MeterReading("water", MeterType.EAU_FROIDE, new BigDecimal("12.5"), - "m3", "", "", ""))); + entry.replaceMeterReadings(List.of(new MeterReading("water", MeterType.EAU_FROIDE, + MeterReadingState.RECORDED, new BigDecimal("12.5"), "m3", "", "", ""))); entry.replaceAccessMeans(List.of(new AccessMeans("key-1", AccessMeansType.CLE, "Front door", 2, ""))); entry.replaceInventoryConditions(List.of(new InventoryCondition("chair", "living-room", "Séjour", "Chaise", 3, List.of( @@ -201,6 +225,18 @@ class PropertyConditionReportTest { "landlord-1", Instant.parse("2026-07-20T10:00:00Z")); } + private static PropertyConditionReportSupplement supplement(String photoReference, String zoneId) { + return new PropertyConditionReportSupplement("supplement-" + photoReference, + PropertyConditionReportSupplementType.GENERAL, + new PropertyConditionReportSupplement.FileEvidence("file-" + photoReference, + "reports/supplement.pdf", "supplement-sha"), + List.of(photo(photoReference, zoneId, "supplement")), "landlord-1", + Instant.parse("2026-07-22T10:00:00Z"), LocalDate.of(2026, 7, 22), null, "Ajout accepté", + new LateSupplementAddition("PROPERTY_CONDITION_SUPPLEMENT_DEADLINE_2026-07", + LocalDate.of(2026, 7, 30), "Demande reçue après le délai", "landlord-1", + Instant.parse("2026-07-22T10:00:00Z"))); + } + private static PropertyConditionReportRevision revision(int number) { return new PropertyConditionReportRevision(number, "reports/revision-" + number + ".pdf", "revision-sha", "source-sha", "landlord-1", Instant.parse("2026-07-20T10:00:00Z"), false); diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/domain/furniture/FurnitureInventoryTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/domain/furniture/FurnitureInventoryTest.java index 2efa357..4febb97 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/domain/furniture/FurnitureInventoryTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/domain/furniture/FurnitureInventoryTest.java @@ -20,8 +20,8 @@ class FurnitureInventoryTest { assertFalse(inventory.isReadyForLeaseDocument()); inventory.replaceLines(FurnitureInventory.legalCatalog("FR-MEUBLE-2025-01").stream().map(category -> - new FurnitureLine(category.name().toLowerCase(), category, FurniturePresence.PRESENT, "area-1", - category.label(), 1, "")).toList()); + new FurnitureLine(category.name().toLowerCase(), category, FurniturePresence.PRESENT, + category.equipmentKind(), "area-1", category.label(), 1, "")).toList()); assertTrue(inventory.isReadyForLeaseDocument()); inventory.freeze(); @@ -36,7 +36,8 @@ class FurnitureInventoryTest { inventory.replaceLines(FurnitureInventory.legalCatalog("FR-MEUBLE-2025-01").stream().map(category -> new FurnitureLine(category.name().toLowerCase(), category, category == FurnitureCategory.BEDDING ? FurniturePresence.ABSENT : FurniturePresence.PRESENT, - "area-1", category.label(), category == FurnitureCategory.BEDDING ? 0 : 1, "")).toList()); + category.equipmentKind(), "area-1", category.label(), + category == FurnitureCategory.BEDDING ? 0 : 1, "")).toList()); assertTrue(inventory.isReadyForLeaseDocument()); assertEquals(List.of(FurnitureCategory.BEDDING), inventory.absentCategories()); @@ -46,17 +47,35 @@ class FurnitureInventoryTest { @Test void permitsOnlyTheEmptyPrefilledStateForAnUndeclaredLegalCategory() { - new FurnitureLine("line-1", FurnitureCategory.BEDDING, null, "area-1", "Literie", 0, ""); + new FurnitureLine("line-1", FurnitureCategory.BEDDING, null, FurnitureEquipmentKind.NONE, + "area-1", "Literie", 0, ""); assertThrows(IllegalArgumentException.class, () -> new FurnitureLine("line-1", FurnitureCategory.BEDDING, - null, "area-1", "Literie", 1, "")); + null, FurnitureEquipmentKind.NONE, "area-1", "Literie", 1, "")); + assertThrows(IllegalArgumentException.class, () -> new FurnitureLine("line-1", FurnitureCategory.BEDDING, + null, FurnitureEquipmentKind.OTHER, "area-1", "Literie", 0, "")); assertThrows(IllegalArgumentException.class, () -> FurnitureInventory.reconstitute( new FurnitureInventoryId("inventory-1"), new LandlordId("landlord-1"), new LeaseId("lease-1"), "FR-MEUBLE-2025-01", List.of( new FurnitureLine("line-1", FurnitureCategory.BEDDING, FurniturePresence.PRESENT, - "area-1", "Literie", 1, ""), + FurnitureEquipmentKind.NONE, "area-1", "Literie", 1, ""), new FurnitureLine("line-2", FurnitureCategory.BEDDING, FurniturePresence.PRESENT, - "area-1", "Literie", 1, "")), false, 0)); + FurnitureEquipmentKind.NONE, "area-1", "Literie", 1, "")), false, 0)); + } + + @Test + void alignsAnEditableInventoryWithTheCurrentLeaseTemplate() { + FurnitureInventory inventory = FurnitureInventory.prefilled(new FurnitureInventoryId("inventory-1"), + new LandlordId("landlord-1"), new LeaseId("lease-1"), "FR-MEUBLE-2025-01", id -> "line-" + id, + "area-1"); + + inventory.alignWithTemplate("FR-MEUBLE-2026-10"); + + assertEquals("FR-MEUBLE-2026-10", inventory.templateVersion()); + FurnitureInventory frozen = FurnitureInventory.reconstitute(inventory.id(), inventory.landlordId(), + inventory.leaseId(), inventory.templateVersion(), inventory.lines(), true, 0); + assertThrows(IllegalStateException.class, + () -> frozen.alignWithTemplate("FR-MEUBLE-2025-01")); } } diff --git a/frontend/src/app/components/condition-report-page/condition-report-editor.ts b/frontend/src/app/components/condition-report-page/condition-report-editor.ts index e2a06e2..1fa8c84 100644 --- a/frontend/src/app/components/condition-report-page/condition-report-editor.ts +++ b/frontend/src/app/components/condition-report-page/condition-report-editor.ts @@ -95,8 +95,9 @@ export class ConditionReportEditor { (meter) => !meter.id.trim() || !meter.unit.trim() || - !Number.isFinite(meter.index) || - meter.index < 0 || + (meter.state === 'RECORDED' && + (!Number.isFinite(meter.index) || meter.index === null || meter.index < 0)) || + (meter.state === 'INACCESSIBLE' && (meter.index !== null || !meter.observation.trim())) || (meter.type === 'OTHER' && !meter.label.trim()), ) ) diff --git a/frontend/src/app/components/condition-report-page/condition-report-inventory-section.html b/frontend/src/app/components/condition-report-page/condition-report-inventory-section.html index d83e5ad..d950d2a 100644 --- a/frontend/src/app/components/condition-report-page/condition-report-inventory-section.html +++ b/frontend/src/app/components/condition-report-page/condition-report-inventory-section.html @@ -24,6 +24,16 @@ (input)="updateMeter(meter.id, 'label', $any($event.target).value)" /> + - + @if (meter.state === 'RECORDED') { + + }