fix(rental): harden inventory and condition reports (#7)
This commit is contained in:
1 parent
e4be5cd05f
commit
b030ac579a
55 files changed
+1969
-217
No files matched your search
+2
-2
@@ -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
|
||||
|
||||
+9
-6
@@ -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) {
|
||||
|
||||
+4
-4
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+111
-13
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
-11
@@ -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,
|
||||
|
||||
+9
-7
@@ -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) { }
|
||||
}
|
||||
+8
-6
@@ -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) { }
|
||||
}
|
||||
+7
-5
@@ -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) {
|
||||
|
||||
+4
-2
@@ -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) {
|
||||
|
||||
+9
-7
@@ -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.");
|
||||
};
|
||||
}
|
||||
}
|
||||
+45
-18
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
-4
@@ -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) {
|
||||
|
||||
+19
-1
@@ -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()));
|
||||
|
||||
+5
@@ -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();
|
||||
}
|
||||
}
|
||||
+6
-4
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.condition;
|
||||
|
||||
public enum MeterReadingState { RECORDED, INACCESSIBLE }
|
||||
+29
-7
@@ -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) {
|
||||
|
||||
+13
-3
@@ -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.");
|
||||
}
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.condition;
|
||||
|
||||
public enum PropertyConditionReportSupplementType { GENERAL, HEATING }
|
||||
+8
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.furniture;
|
||||
|
||||
public enum FurnitureEquipmentKind { NONE, CLIMATISATION, OTHER }
|
||||
+10
-2
@@ -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();
|
||||
}
|
||||
|
||||
+13
-4
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-3
@@ -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>
|
||||
|
||||
+1
-1
@@ -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());
|
||||
|
||||
+3
-1
@@ -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());
|
||||
}
|
||||
|
||||
+116
@@ -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"));
|
||||
}
|
||||
}
|
||||
+96
@@ -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));
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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
-2
@@ -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() {
|
||||
|
||||
+2
-1
@@ -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")))));
|
||||
|
||||
+3
-1
@@ -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
-2
@@ -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());
|
||||
|
||||
|
||||
+40
-9
@@ -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;
|
||||
|
||||
+25
-2
@@ -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() {
|
||||
|
||||
+6
-5
@@ -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);
|
||||
|
||||
+5
-3
@@ -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() {
|
||||
|
||||
+47
-11
@@ -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);
|
||||
|
||||
+26
-7
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user