fix(rental): harden inventory and condition reports (#7)

This commit is contained in:
nessar authored and Nessar committed 2026-08-17 14:18:16 +02:00
1 parent e4be5cd05f
commit b030ac579a
55 files changed
+1969 -217

No files matched your search

@@ -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
@@ -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<LineRequest> 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) {
@@ -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());
}
}
@@ -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<PropertyConditionDocumentService.PropertyConditionComparison.Line<ConditionObservation>> observations,
List<PropertyConditionDocumentService.PropertyConditionComparison.Line<ConditionZone>> zones,
List<PropertyConditionDocumentService.PropertyConditionComparison.Line<InventoryCondition>> inventory,
List<PropertyConditionDocumentService.PropertyConditionComparison.Line<MeterReading>> meters,
List<PropertyConditionDocumentService.PropertyConditionComparison.Line<AccessMeans>> accessMeans,
List<PropertyConditionDocumentService.PropertyConditionComparison.Line<PropertyConditionReportSupplement>> supplements,
private static <I, O> List<LineResponse<O>> lines(
List<PropertyConditionDocumentService.PropertyConditionComparison.Line<I>> values,
Function<I, O> 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<LineResponse<ObservationResponse>> observations,
List<LineResponse<ZoneResponse>> zones,
List<LineResponse<InventoryResponse>> inventory,
List<LineResponse<MeterResponse>> meters,
List<LineResponse<AccessResponse>> accessMeans,
List<LineResponse<SupplementResponse>> supplements,
List<PhotoLineResponse> photos) { }
record PhotoLineResponse(String reference, PropertyConditionReportController.PhotoResponse entry,
PropertyConditionReportController.PhotoResponse exit,
fr.nessar.lmnporganizer.rental.domain.condition.AuditedPhotoAbsence absence,
record LineResponse<T>(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<SplitResponse> 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<PhotoResponse> 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());
}
}
}
@@ -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<MultipartFile> photos,
@RequestParam(required = false) List<String> 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<byte[]> photoContents = new java.util.ArrayList<>();
for (MultipartFile photo : photos == null ? List.<MultipartFile>of() : photos) {
List<MultipartFile> photoFiles = photos == null ? List.of() : photos;
List<String> 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<PropertyConditionReportService.SupplementPhotoSource> 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<ParticipantRequest> 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<SupplementFileResponse> photos,
record SupplementResponse(String id, PropertyConditionReportSupplementType type, SupplementFileResponse signedPdf,
List<PhotoResponse> 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<ReportParticipant> participants, List<ConditionZone> zones,
List<ConditionObservation> observations, List<MeterReading> meterReadings,
@@ -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<ParticipantDocument> documentSnapshotParticipants(List<ReportParticipant> 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<ParticipantDocument> participants, List<AddressDocument> forwardingAddresses, List<ZoneDocument> zones, List<ObservationDocument> observations, List<MeterDocument> meterReadings, List<AccessDocument> accessMeans, List<InventoryDocument> inventoryConditions, List<PhotoDocument> photos, List<ZoneAbsenceDocument> zonePhotoAbsences, List<DispositionDocument> entryPhotoDispositions, List<SupplementDocument> supplements, List<RevisionDocument> revisions, FinalizationDocument finalization, List<DeliveryDocument> 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<SplitDocument> 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<FileDocument> 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<ParticipantDocument> participants, List<ZoneDocument> zones, List<ObservationDocument> observations, List<MeterDocument> meterReadings, List<AccessDocument> accessMeans, List<InventoryDocument> inventoryConditions, List<PhotoDocument> photos, List<SupplementDocument> 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<SplitDocument> 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<PhotoDocument> 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<ParticipantDocument> participants, List<ZoneDocument> zones, List<ObservationDocument> observations, List<MeterDocument> meterReadings, List<AccessDocument> accessMeans, List<InventoryDocument> inventoryConditions, List<PhotoDocument> photos, List<SupplementDocument> supplements) { }
}
@@ -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) { }
}
@@ -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<FurnitureInventoryLineDocument> 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) {
@@ -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<String, BundleFile> files, List<ConditionPhoto> photos) {
@@ -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<ConditionZone> 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.");
};
}
}
@@ -101,22 +101,39 @@ public final class PropertyConditionReportService {
}
public PropertyConditionReport appendSupplement(LandlordId landlord, LeaseId leaseId, byte[] signedPdf,
List<byte[]> photoSources, String author, LocalDate requestedOn,
String acceptedAdditions, LocalDate heatingPeriodStartedOn, String reason) {
List<SupplementPhotoSource> 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<String> zoneIds = report.zones().stream().map(ConditionZone::id)
.collect(java.util.stream.Collectors.toSet());
if ((photoSources == null ? List.<SupplementPhotoSource>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<String> 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<PropertyConditionReportSupplement.FileEvidence> photos = new ArrayList<>();
for (byte[] source : photoSources == null ? List.<byte[]>of() : photoSources) {
byte[] content = normalizer.normalize(source).content();
List<ConditionPhoto> photos = new ArrayList<>();
for (SupplementPhotoSource source : photoSources == null ? List.<SupplementPhotoSource>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<ReportParticipant> participants, List<ForwardingAddress> forwardingAddresses, List<ConditionZone> zones, List<ConditionObservation> observations, List<MeterReading> meterReadings, List<AccessMeans> accessMeans, List<InventoryCondition> inventoryConditions, List<PhotoAbsence> zonePhotoAbsences, List<EntryPhotoDispositionInput> 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();
}
}
}
@@ -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<FurnitureLine> lines) {
@@ -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<fr.nessar.lmnporganizer.rental.domain.apartment.PrivateArea> privateAreas) {
Set<String> 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()));
@@ -28,4 +28,9 @@ public record EntryReportSnapshot(
private static <T> List<T> copy(List<T> values) {
return values == null ? List.of() : List.copyOf(values);
}
public List<ConditionPhoto> effectivePhotos() {
return java.util.stream.Stream.concat(photos.stream(), supplements.stream()
.flatMap(supplement -> supplement.photos().stream())).toList();
}
}
@@ -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.");
}
}
@@ -0,0 +1,3 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
public enum MeterReadingState { RECORDED, INACCESSIBLE }
@@ -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<PropertyConditionReportSupplement> appended = new ArrayList<>(supplements);
appended.add(Objects.requireNonNull(supplement, "Report supplement is required."));
supplements = unique(appended, PropertyConditionReportSupplement::id, "Report supplements");
appended.add(value);
List<PropertyConditionReportSupplement> 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<String, ConditionPhoto> entryPhotos = byId(entrySnapshot.photos(), ConditionPhoto::reference);
Map<String, ConditionPhoto> entryPhotos = byId(entrySnapshot.effectivePhotos(), ConditionPhoto::reference);
Map<String, ConditionPhoto> exitPhotos = byId(photos, ConditionPhoto::reference);
Set<String> 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<ConditionZone> zones, List<ConditionPhoto> photos,
List<PropertyConditionReportSupplement> supplements) {
Set<String> 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) {
@@ -5,17 +5,27 @@ import java.time.LocalDate;
import java.util.List;
import java.util.Objects;
public record PropertyConditionReportSupplement(String id, FileEvidence signedPdf, List<FileEvidence> photos,
String uploadedBy,
Instant uploadedAt, LocalDate requestedOn, String acceptedAdditions,
public record PropertyConditionReportSupplement(String id, PropertyConditionReportSupplementType type,
FileEvidence signedPdf, List<ConditionPhoto> 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.");
}
@@ -0,0 +1,3 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
public enum PropertyConditionReportSupplementType { GENERAL, HEATING }
@@ -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;
};
}
}
@@ -0,0 +1,3 @@
package fr.nessar.lmnporganizer.rental.domain.furniture;
public enum FurnitureEquipmentKind { NONE, CLIMATISATION, OTHER }
@@ -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<FurnitureLine> 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<FurnitureLine> 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();
}
@@ -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<Line> lines) {
@@ -17,11 +18,19 @@ public record FurnitureInventorySnapshot(String templateVersion, List<Line> 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());
}
}
}
@@ -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.");
}
@@ -11,7 +11,7 @@ body { font-family: sans-serif; font-size: 11pt; } table { border-collapse: coll
<h2>Observations</h2><table><thead><tr><th>Élément</th><th>État</th><th>Observation</th></tr></thead><tbody><tr th:each="observation : ${report.observations()}"><td th:text="${zoneLabels.get(observation.subjectId()) ?: 'Élément observé'}">Élément</td><td th:text="${labels.condition(observation.state())}">État</td><td th:text="${observation.observation()}">Observation</td></tr></tbody></table>
<h2>Photos</h2><p th:text="|Nombre : ${photoCount} — références : ${photoReferenceRange}|">Résumé des photos</p>
<h2>Absences et correspondances photo</h2><table><thead><tr><th>Zone/référence</th><th>Motif ou correspondance</th></tr></thead><tbody><tr th:each="absence : ${report.zonePhotoAbsences()}"><td th:text="${zoneLabels.get(absence.zoneId()) ?: 'Zone'}">Zone</td><td th:text="${absence.absence().reason()}">Motif</td></tr><tr th:each="disposition : ${report.entryPhotoDispositions()}"><td>Photo d’entrée</td><td th:text="${disposition instanceof T(fr.nessar.lmnporganizer.rental.domain.condition.MatchedEntryPhoto) ? 'Photo de sortie associée' : 'Motif d’absence renseigné'}">Correspondance</td></tr></tbody></table>
<h2>Relevés de compteurs</h2><table><thead><tr><th>Compteur</th><th>Type</th><th>Index</th><th>Unité</th><th>Observation</th></tr></thead><tbody><tr th:each="meter : ${report.meterReadings()}"><td th:text="${meter.label().isEmpty() ? 'Compteur' : meter.label()}">Compteur</td><td th:text="${labels.meter(meter.type())}">Type</td><td th:text="${meter.index()}">Index</td><td th:text="${meter.unit()}">Unité</td><td th:text="${meter.observation()}">Observation</td></tr></tbody></table>
<h2>Relevés de compteurs</h2><table><thead><tr><th>Compteur</th><th>Type</th><th>Index</th><th>Unité</th><th>Observation</th></tr></thead><tbody><tr th:each="meter : ${report.meterReadings()}"><td th:text="${meter.label().isEmpty() ? 'Compteur' : meter.label()}">Compteur</td><td th:text="${labels.meter(meter.type())}">Type</td><td th:text="${meter.state().name() == 'INACCESSIBLE' ? 'Inaccessible' : meter.index()}">Index</td><td th:text="${meter.unit()}">Unité</td><td th:text="${meter.observation()}">Observation</td></tr></tbody></table>
<h2>Moyens d’accès</h2><table><thead><tr><th>Type</th><th>Destination</th><th>Quantité</th><th>Observation</th></tr></thead><tbody><tr th:each="access : ${report.accessMeans()}"><td th:text="${labels.access(access.type())}">Type</td><td th:text="${access.destination()}">Destination</td><td th:text="${access.quantity()}">Quantité</td><td th:text="${access.observation()}">Observation</td></tr></tbody></table>
<h2>Inventaire constaté</h2><table><thead><tr><th>Élément</th><th>Quantité contractuelle</th><th>Répartition constatée</th></tr></thead><tbody><tr th:each="inventory : ${report.inventoryConditions()}"><td th:text="|${inventory.designation()} — ${inventory.privateAreaLabel()}|">Élément</td><td th:text="${inventory.contractualQuantity()}">Quantité</td><td><ul><li th:each="split : ${inventory.splits()}" th:text="|${split.quantity()} × ${labels.condition(split.state())}${split.observation().isEmpty() ? '' : ' — ' + split.observation()}|">Répartition</li></ul></td></tr></tbody></table>
<h2>Adresses de correspondance</h2><table><thead><tr><th>Locataire</th><th>Adresse</th></tr></thead><tbody><tr th:each="address : ${report.forwardingAddresses()}"><td th:text="${participantLabels.get(address.tenantPartyId()) ?: 'Locataire'}">Locataire</td><td th:text="${address.address()}">Adresse</td></tr></tbody></table>
@@ -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());
@@ -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());
}
@@ -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<byte[]> signedPdf = ArgumentCaptor.forClass(byte[].class);
ArgumentCaptor<List<PropertyConditionReportService.SupplementPhotoSource>> 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"));
}
}
@@ -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<MongoPropertyConditionReportRepository.ReportDocument> 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));
}
}
@@ -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);
@@ -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() {
@@ -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")))));
@@ -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)));
@@ -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());
@@ -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<PropertyConditionReport> 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;
@@ -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() {
@@ -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<FurnitureLine> 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);
@@ -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() {
@@ -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);
@@ -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"));
}
}