From 5d2d85aa8cdf6d5bf603743d7c4105d2d187bbc1 Mon Sep 17 00:00:00 2001
From: TO
Date: Fri, 24 Jul 2026 02:28:22 +0200
Subject: [PATCH] fix(rental): complete condition report invariants (#7)
---
backend/Dockerfile | 40 +++++++--
.../RentalApplicationConfiguration.java | 5 +-
...temPropertyConditionAttachmentStorage.java | 23 ++++-
...ongoPropertyConditionReportRepository.java | 3 +
.../PropertyConditionDocumentService.java | 1 -
.../PropertyConditionReportService.java | 80 ++++++++++++-----
.../condition/PropertyConditionReport.java | 13 ++-
.../PropertyConditionReportRepository.java | 1 +
.../rental/property-condition-report.html | 2 +-
...ropertyConditionAttachmentStorageTest.java | 19 ++--
...ditionPhotoNormalizerDockerAcceptance.java | 79 ++++++++++++++++
.../out/mongodb/RentalMongoTransactionIT.java | 60 +++++++++++++
...PropertyConditionReportRepositoryTest.java | 14 +++
...afPropertyConditionReportRendererTest.java | 12 ++-
.../PropertyConditionDocumentServiceTest.java | 49 +++++++++-
.../PropertyConditionReportServiceTest.java | 85 ++++++++++++++++--
.../PropertyConditionReportTest.java | 33 +++++++
.../condition-report-editor.ts | 31 +++++--
.../condition-report-evidence-section.html | 2 +-
.../condition-report-evidence-section.ts | 18 +++-
.../condition-report-page.html | 31 +++++--
.../condition-report-page.spec.ts | 90 ++++++++++++++++++-
.../condition-report-page.ts | 46 +++++++---
23 files changed, 652 insertions(+), 85 deletions(-)
create mode 100644 backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/image/ImageMagickPropertyConditionPhotoNormalizerDockerAcceptance.java
diff --git a/backend/Dockerfile b/backend/Dockerfile
index 08b9389..0d687d5 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -6,17 +6,25 @@ RUN mvn dependency:go-offline
COPY src ./src
RUN mvn clean package -DskipTests
-# Run stage
-FROM docker.io/library/eclipse-temurin:25-jre-alpine
+# Runtime shared by acceptance and the final image
+FROM docker.io/library/eclipse-temurin:25-jre-alpine AS runtime
WORKDIR /app
RUN apk add --no-cache imagemagick imagemagick-heic imagemagick-jpeg imagemagick-webp \
- && apk add --no-cache --virtual .fixture-tools exiftool \
&& magick -version \
&& magick identify -list format | awk '$1 ~ /^JPEG/ && $3 ~ /r/ { jpeg=1 } $1 ~ /^PNG/ && $3 ~ /r/ { png=1 } $1 ~ /^WEBP/ && $3 ~ /rw/ { webp=1 } $1 ~ /^HEIC/ && $3 ~ /r/ { heic=1 } END { exit !(jpeg && png && webp && heic) }' \
&& addgroup -S lmnp \
&& adduser -S -G lmnp lmnp \
- && mkdir -p /data/attachments /tmp/lmnp-condition-photos/build-check \
- && chown -R lmnp:lmnp /data/attachments /tmp/lmnp-condition-photos \
+ && mkdir -p /data/attachments /tmp/lmnp-condition-photos \
+ && chown -R lmnp:lmnp /data/attachments /tmp/lmnp-condition-photos
+COPY src/main/resources/imagemagick-policy.xml /etc/ImageMagick-7/policy.xml
+
+# Acceptance stage: fixtures never enter the final image's layer history
+FROM runtime AS acceptance
+COPY --from=build /app/target/backend-0.0.1-SNAPSHOT.jar app.jar
+COPY --from=build /app/target/test-classes/fr/nessar/lmnporganizer/rental/adapter/out/image/ImageMagickPropertyConditionPhotoNormalizerDockerAcceptance.class /tmp/docker-acceptance/fr/nessar/lmnporganizer/rental/adapter/out/image/ImageMagickPropertyConditionPhotoNormalizerDockerAcceptance.class
+RUN apk add --no-cache --virtual .fixture-tools exiftool unzip \
+ && mv /etc/ImageMagick-7/policy.xml /tmp/imagemagick-policy.xml \
+ && mkdir -p /tmp/lmnp-condition-photos/build-check \
&& magick -size 2400x1200 gradient: /tmp/lmnp-condition-photos/build-check/top-left.jpg \
&& magick -size 2400x1200 gradient: /tmp/lmnp-condition-photos/build-check/top-right.jpg \
&& magick -size 2400x1200 gradient: /tmp/lmnp-condition-photos/build-check/bottom-right.jpg \
@@ -36,8 +44,9 @@ RUN apk add --no-cache imagemagick imagemagick-heic imagemagick-jpeg imagemagick
&& magick -size 800x600 gradient: /tmp/lmnp-condition-photos/build-check/small.png \
&& magick -size 800x600 gradient: /tmp/lmnp-condition-photos/build-check/sample.webp \
&& magick -size 800x600 gradient: /tmp/lmnp-condition-photos/build-check/sample.heic \
- && apk del .fixture-tools
-COPY src/main/resources/imagemagick-policy.xml /etc/ImageMagick-7/policy.xml
+ && magick -size 10x10 xc:red -size 10x10 xc:blue -delay 10 -loop 0 /tmp/lmnp-condition-photos/build-check/animated.webp \
+ && magick -size 10x10 xc:red -size 10x10 xc:blue /tmp/lmnp-condition-photos/build-check/multipage.miff \
+ && mv /tmp/imagemagick-policy.xml /etc/ImageMagick-7/policy.xml
RUN set -eu; \
for orientation in top-left top-right bottom-right bottom-left; do \
magick "/tmp/lmnp-condition-photos/build-check/$orientation.jpg[0]" -auto-orient -resize '1920x1080>' -strip -orient Undefined -quality 75 -define webp:lossless=false "webp:/tmp/lmnp-condition-photos/build-check/$orientation.webp"; \
@@ -58,9 +67,24 @@ RUN set -eu; \
magick '/tmp/lmnp-condition-photos/build-check/small.png[0]' -auto-orient -resize '1920x1080>' -strip -orient Undefined -quality 75 -define webp:lossless=false webp:/tmp/lmnp-condition-photos/build-check/small.webp; \
test "$(magick identify -quiet -format '%m|%wx%h|%[orientation]' /tmp/lmnp-condition-photos/build-check/small.webp)" = 'WEBP|800x600|Undefined'; \
! magick identify -verbose /tmp/lmnp-condition-photos/build-check/small.webp | grep -Eiq 'exif:|xmp:|profile-|comment:'; \
+ printf invalid > /tmp/lmnp-condition-photos/build-check/invalid.upload; \
+ ! magick identify -quiet /etc/passwd; \
+ ! magick identify -quiet -; \
magick identify -list policy | grep -q '/tmp/lmnp-condition-photos'; \
- rm -rf /tmp/lmnp-condition-photos/build-check
+ for limit in 'Memory: 256MiB' 'Map: 512MiB' 'Disk: 1GiB' 'Thread: 2' 'Time: 30 seconds'; do magick identify -list resource | grep -Fq "$limit"; done; \
+ mkdir /tmp/docker-acceptance/classes; \
+ unzip -q /app/app.jar -d /tmp/docker-acceptance/classes; \
+ java -cp '/tmp/docker-acceptance:/tmp/docker-acceptance/classes/BOOT-INF/classes:/tmp/docker-acceptance/classes/BOOT-INF/lib/*' fr.nessar.lmnporganizer.rental.adapter.out.image.ImageMagickPropertyConditionPhotoNormalizerDockerAcceptance; \
+ rm -rf /tmp/docker-acceptance /tmp/lmnp-condition-photos/build-check; \
+ apk del .fixture-tools; \
+ test ! -e /tmp/lmnp-condition-photos/build-check; \
+ touch /tmp/acceptance-passed
+
+# Final image
+FROM runtime
COPY --from=build /app/target/backend-0.0.1-SNAPSHOT.jar app.jar
+COPY --from=acceptance /tmp/acceptance-passed /tmp/acceptance-passed
+RUN test -f /tmp/acceptance-passed && rm /tmp/acceptance-passed
USER lmnp
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/config/RentalApplicationConfiguration.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/config/RentalApplicationConfiguration.java
index 71d64bf..86be53d 100644
--- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/config/RentalApplicationConfiguration.java
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/config/RentalApplicationConfiguration.java
@@ -95,13 +95,16 @@ public class RentalApplicationConfiguration {
PropertyConditionReportService propertyConditionReportService(PropertyConditionReportRepository reports,
LeaseRepository leases,
LeaseDocumentRevisionRepository revisions,
+ FurnitureInventoryRepository inventories,
LandlordRepository landlords,
TenantRepository tenants,
PropertyConditionPhotoNormalizer normalizer,
PropertyConditionAttachmentStorage storage,
+ TransactionRunner transactions,
IdentifierGenerator identifiers,
Clock rentalClock) {
- return new PropertyConditionReportService(reports, leases, revisions, landlords, tenants, normalizer, storage, identifiers,
+ return new PropertyConditionReportService(reports, leases, revisions, inventories, landlords, tenants,
+ normalizer, storage, transactions, identifiers,
rentalClock);
}
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/out/filesystem/FileSystemPropertyConditionAttachmentStorage.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/out/filesystem/FileSystemPropertyConditionAttachmentStorage.java
index 0e84cfd..46f0e7e 100644
--- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/out/filesystem/FileSystemPropertyConditionAttachmentStorage.java
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/out/filesystem/FileSystemPropertyConditionAttachmentStorage.java
@@ -3,9 +3,11 @@ package fr.nessar.lmnporganizer.rental.adapter.out.filesystem;
import fr.nessar.lmnporganizer.rental.adapter.in.config.RentalStorageProperties;
import fr.nessar.lmnporganizer.rental.application.port.out.PropertyConditionAttachmentStorage;
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentStorageFailure;
+import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.io.IOException;
@@ -18,6 +20,7 @@ import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.Comparator;
import java.util.List;
+import java.util.function.Predicate;
@Component
public final class FileSystemPropertyConditionAttachmentStorage implements PropertyConditionAttachmentStorage {
@@ -27,10 +30,18 @@ public final class FileSystemPropertyConditionAttachmentStorage implements Prope
private static final Logger LOGGER = LoggerFactory.getLogger(FileSystemPropertyConditionAttachmentStorage.class);
private final Path root;
private final Path staging;
+ private final Predicate referenced;
- public FileSystemPropertyConditionAttachmentStorage(RentalStorageProperties properties) {
+ @Autowired
+ public FileSystemPropertyConditionAttachmentStorage(RentalStorageProperties properties,
+ PropertyConditionReportRepository reports) {
+ this(properties, reports::isPhotoStorageKeyReferenced);
+ }
+
+ FileSystemPropertyConditionAttachmentStorage(RentalStorageProperties properties, Predicate referenced) {
root = properties.attachments().toAbsolutePath().normalize().resolve("condition-reports");
staging = root.resolve(".staging");
+ this.referenced = referenced;
recoverInterruptedDeletions();
}
@@ -131,13 +142,17 @@ public final class FileSystemPropertyConditionAttachmentStorage implements Prope
if (Files.isRegularFile(committed, LinkOption.NOFOLLOW_LINKS)) {
discardArtifacts(deletionDirectory);
} else {
- Path active = active(staging.relativize(deletionDirectory).toString());
- if (!Files.exists(active, LinkOption.NOFOLLOW_LINKS)) {
+ String storageKey = staging.relativize(deletionDirectory).toString();
+ Path active = active(storageKey);
+ boolean stillReferenced = referenced.test(storageKey);
+ if (stillReferenced && !Files.exists(active, LinkOption.NOFOLLOW_LINKS)) {
Files.createDirectories(active.getParent());
Files.move(firstExisting(deletionDirectory.resolve(STAGED), deletionDirectory.resolve(BACKUP)),
active, StandardCopyOption.ATOMIC_MOVE);
- } else if (!Files.isRegularFile(active, LinkOption.NOFOLLOW_LINKS)) {
+ } else if (stillReferenced && !Files.isRegularFile(active, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Recovered condition attachment path is not a regular file.");
+ } else if (!stillReferenced) {
+ Files.deleteIfExists(active);
}
discardArtifacts(deletionDirectory);
}
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/condition/MongoPropertyConditionReportRepository.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/condition/MongoPropertyConditionReportRepository.java
index 9a01825..d48a24e 100644
--- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/condition/MongoPropertyConditionReportRepository.java
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/condition/MongoPropertyConditionReportRepository.java
@@ -26,6 +26,9 @@ public class MongoPropertyConditionReportRepository implements PropertyCondition
@Override public Optional find(LandlordId landlord, LeaseId lease, PropertyConditionReportType type) {
return Optional.ofNullable(mongo.findOne(query(landlord, lease, type), ReportDocument.class)).map(this::domain);
}
+ @Override public boolean isPhotoStorageKeyReferenced(String storageKey) {
+ return mongo.exists(Query.query(Criteria.where("photos.storageKey").is(storageKey)), ReportDocument.class);
+ }
@Override public PropertyConditionReport save(PropertyConditionReport report) {
ReportDocument document = document(report);
return domain(LandlordScopedMongoWriter.save(mongo, document, document.id(), document.landlordId(), report.version(), document::withVersion));
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionDocumentService.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionDocumentService.java
index e9e92af..440c23e 100644
--- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionDocumentService.java
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionDocumentService.java
@@ -165,7 +165,6 @@ public final class PropertyConditionDocumentService {
PropertyConditionReportFinalization finalization = report.finalization();
add(files, "scan-signe.pdf", finalization.signedStorageKey(), finalization.signedSha256());
addPhotos(files, report.photos());
- addSupplements(files, report.supplements());
if (report.entrySnapshot() != null) {
addPhotos(files, report.entrySnapshot().photos());
addSupplements(files, report.entrySnapshot().supplements());
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionReportService.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionReportService.java
index 32fc2ab..72b888e 100644
--- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionReportService.java
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionReportService.java
@@ -3,9 +3,12 @@ package fr.nessar.lmnporganizer.rental.application.condition;
import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
import fr.nessar.lmnporganizer.rental.application.port.out.PropertyConditionAttachmentStorage;
import fr.nessar.lmnporganizer.rental.application.port.out.PropertyConditionPhotoNormalizer;
+import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.domain.condition.*;
+import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryRepository;
+import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventorySnapshot;
import fr.nessar.lmnporganizer.rental.domain.furniture.FurniturePresence;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordRepository;
@@ -28,26 +31,33 @@ public final class PropertyConditionReportService {
private final PropertyConditionReportRepository reports;
private final LeaseRepository leases;
private final LeaseDocumentRevisionRepository revisions;
+ private final FurnitureInventoryRepository inventories;
private final LandlordRepository landlords;
private final TenantRepository tenants;
private final PropertyConditionPhotoNormalizer normalizer;
private final PropertyConditionAttachmentStorage storage;
+ private final TransactionRunner transactions;
private final IdentifierGenerator identifiers;
private final Clock clock;
public PropertyConditionReportService(PropertyConditionReportRepository reports, LeaseRepository leases,
- LeaseDocumentRevisionRepository revisions, LandlordRepository landlords, TenantRepository tenants,
+ LeaseDocumentRevisionRepository revisions, FurnitureInventoryRepository inventories,
+ LandlordRepository landlords, TenantRepository tenants,
PropertyConditionPhotoNormalizer normalizer,
- PropertyConditionAttachmentStorage storage, IdentifierGenerator identifiers, Clock clock) {
- this.reports = reports; this.leases = leases; this.revisions = revisions; this.landlords = landlords; this.tenants = tenants;
- this.normalizer = normalizer; this.storage = storage; this.identifiers = identifiers;
+ PropertyConditionAttachmentStorage storage, TransactionRunner transactions, IdentifierGenerator identifiers,
+ Clock clock) {
+ this.reports = reports; this.leases = leases; this.revisions = revisions; this.inventories = inventories;
+ this.landlords = landlords; this.tenants = tenants;
+ this.normalizer = normalizer; this.storage = storage; this.transactions = transactions; this.identifiers = identifiers;
this.clock = clock;
}
public PropertyConditionReport get(LandlordId landlord, LeaseId lease, PropertyConditionReportType type, String timeZone) {
lease(landlord, lease);
return reports.find(landlord, lease, type).map(report -> refreshEntryDraft(report, timeZone))
- .orElseGet(() -> createDraft(landlord, lease, type, timeZone));
+ .orElseGet(() -> type == PropertyConditionReportType.EXIT
+ ? transactions.run(() -> createExitDraft(landlord, lease, timeZone))
+ : createEntryDraft(landlord, lease, timeZone));
}
public PropertyConditionReport replace(LandlordId landlord, LeaseId leaseId, PropertyConditionReportType type,
@@ -151,9 +161,9 @@ public final class PropertyConditionReportService {
photos.add(new ConditionPhoto(reference, source.zoneId(), storageKey, sha256(content),
normalized.width(), normalized.height(), author, Instant.now(clock)));
}
- report.appendSupplement(new PropertyConditionReportSupplement(identifiers.next(), type, pdf, photos,
- author, Instant.now(clock), requestedOn, heatingPeriodStartedOn, acceptedAdditions, audit));
- return reports.save(report);
+ PropertyConditionReportSupplement supplement = new PropertyConditionReportSupplement(identifiers.next(), type, pdf,
+ photos, author, Instant.now(clock), requestedOn, heatingPeriodStartedOn, acceptedAdditions, audit);
+ return transactions.run(() -> appendSupplement(landlord, leaseId, supplement));
} catch (RuntimeException failure) {
storedKeys.forEach(key -> discard(key, failure));
throw failure;
@@ -164,28 +174,44 @@ public final class PropertyConditionReportService {
throw new IllegalStateException("Le scan signé et l'attestation de correspondance sont obligatoires.");
}
- private PropertyConditionReport createDraft(LandlordId landlord, LeaseId leaseId, PropertyConditionReportType type, String timeZone) {
+ private PropertyConditionReport createEntryDraft(LandlordId landlord, LeaseId leaseId, String timeZone) {
Lease lease = lease(landlord, leaseId); ZoneId zone = zone(timeZone); LocalDate today = LocalDate.now(clock.withZone(zone));
- if (type == PropertyConditionReportType.ENTRY) {
- LeaseDocumentRevision revision = revisions.findCurrent(landlord, leaseId).orElseThrow(() ->
- new IllegalStateException("Une révision du bail est requise avant de préparer l'état des lieux d'entrée."));
- PropertyConditionReport draft = PropertyConditionReport.draft(new PropertyConditionReportId(identifiers.next()),
- landlord, leaseId, type, today, zone.getId(), revision.number(), revision.sourceFingerprint());
- prefillEntryDraft(draft, lease, revision, landlord);
- return reports.save(draft);
+ LeaseDocumentRevision revision = currentEntryRevision(landlord, leaseId);
+ PropertyConditionReport draft = PropertyConditionReport.draft(new PropertyConditionReportId(identifiers.next()),
+ landlord, leaseId, PropertyConditionReportType.ENTRY, today, zone.getId(), revision.number(), revision.sourceFingerprint());
+ prefillEntryDraft(draft, lease, revision, landlord);
+ return reports.save(draft);
+ }
+ private PropertyConditionReport createExitDraft(LandlordId landlord, LeaseId leaseId, String timeZone) {
+ return reports.find(landlord, leaseId, PropertyConditionReportType.EXIT).orElseGet(() -> {
+ PropertyConditionReport entry = reports.find(landlord, leaseId, PropertyConditionReportType.ENTRY)
+ .orElseThrow(() -> new ResourceNotFound("Entry property condition report"));
+ if (!entry.finalized()) throw new IllegalStateException("L'état des lieux d'entrée doit être finalisé avant la sortie.");
+ ZoneId zone = zone(timeZone);
+ PropertyConditionReport exit = PropertyConditionReport.draftExit(new PropertyConditionReportId(identifiers.next()),
+ entry, LocalDate.now(clock.withZone(zone)), zone.getId());
+ reports.save(entry);
+ return reports.save(exit);
+ });
+ }
+ private PropertyConditionReport appendSupplement(LandlordId landlord, LeaseId leaseId,
+ PropertyConditionReportSupplement supplement) {
+ 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.");
}
- PropertyConditionReport entry = reports.find(landlord, leaseId, PropertyConditionReportType.ENTRY).orElseThrow(() -> new ResourceNotFound("Entry property condition report"));
- if (!entry.finalized()) throw new IllegalStateException("L'état des lieux d'entrée doit être finalisé avant la sortie.");
- return reports.save(PropertyConditionReport.draftExit(new PropertyConditionReportId(identifiers.next()), entry, today, zone.getId()));
+ PropertyConditionReport entry = reports.find(landlord, leaseId, PropertyConditionReportType.ENTRY)
+ .orElseThrow(() -> new ResourceNotFound("Entry property condition report"));
+ if (!entry.finalized()) throw new IllegalStateException("L'état des lieux d'entrée doit être finalisé.");
+ entry.appendSupplement(supplement);
+ return reports.save(entry);
}
private Lease lease(LandlordId landlord, LeaseId leaseId) { return leases.find(landlord, leaseId).orElseThrow(() -> new ResourceNotFound("Lease")); }
private PropertyConditionReport refreshEntryDraft(PropertyConditionReport report, String timeZone) {
if (report.type() != PropertyConditionReportType.ENTRY || report.status() == PropertyConditionReportStatus.FINALIZED) {
return report;
}
- LeaseDocumentRevision current = revisions.findCurrent(report.landlordId(), report.leaseId())
- .orElseThrow(() -> new IllegalStateException("La révision du bail est introuvable."));
- if (current != null && report.referencesLeaseRevision(current.number(), current.sourceFingerprint())) {
+ LeaseDocumentRevision current = currentEntryRevision(report.landlordId(), report.leaseId());
+ if (report.referencesLeaseRevision(current.number(), current.sourceFingerprint())) {
return report;
}
if (report.status() == PropertyConditionReportStatus.DRAFT) report.markObsolete();
@@ -195,6 +221,16 @@ public final class PropertyConditionReportService {
prefillEntryDraft(report, lease, current, report.landlordId());
return reports.save(report);
}
+ private LeaseDocumentRevision currentEntryRevision(LandlordId landlord, LeaseId leaseId) {
+ LeaseDocumentRevision revision = revisions.findCurrent(landlord, leaseId).orElseThrow(() ->
+ new IllegalStateException("Une révision du bail est requise avant de préparer l'état des lieux d'entrée."));
+ FurnitureInventorySnapshot inventory = inventories.find(landlord, leaseId).map(FurnitureInventorySnapshot::from)
+ .orElseThrow(() -> new IllegalStateException("L'inventaire du bail est introuvable."));
+ if (!revision.furnitureInventory().equals(inventory)) {
+ throw new IllegalStateException("La révision du bail ne correspond plus à l'inventaire.");
+ }
+ return revision;
+ }
private void prefillEntryDraft(PropertyConditionReport draft, Lease lease, LeaseDocumentRevision revision, LandlordId landlord) {
draft.replaceParticipants(participants(lease, landlord));
List zones = PropertyConditionDraftTemplate.requiredZones(revision);
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/condition/PropertyConditionReport.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/condition/PropertyConditionReport.java
index 1d3699a..a3cadba 100644
--- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/condition/PropertyConditionReport.java
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/condition/PropertyConditionReport.java
@@ -324,6 +324,7 @@ public final class PropertyConditionReport {
requireSameIds(zones, observations, ConditionZone::id, ConditionObservation::subjectId,
"Condition observations");
validateZoneEvidence();
+ validateMeterPhotoReferences();
if (type == PropertyConditionReportType.EXIT) validateExitAgainstEntrySnapshot();
}
@@ -332,8 +333,10 @@ public final class PropertyConditionReport {
if (participants.stream().noneMatch(participant -> participant.partyId().equals(attestation.partyId()))) {
throw new IllegalArgumentException("Delivery attestation party is not a report participant.");
}
+ if (deliveryAttestations.stream().anyMatch(value -> value.partyId().equals(attestation.partyId()))) {
+ throw new IllegalStateException("Delivery attestations are append-only.");
+ }
List values = new ArrayList<>(deliveryAttestations);
- values.removeIf(value -> value.partyId().equals(attestation.partyId()));
values.add(attestation);
deliveryAttestations = unique(values, PropertyConditionReportDeliveryAttestation::partyId,
"Report delivery attestations");
@@ -363,6 +366,14 @@ public final class PropertyConditionReport {
}
}
+ private void validateMeterPhotoReferences() {
+ Set photoReferences = byId(photos, ConditionPhoto::reference).keySet();
+ if (meterReadings.stream().map(MeterReading::photoId).filter(reference -> !reference.isEmpty())
+ .anyMatch(reference -> !photoReferences.contains(reference))) {
+ throw new IllegalStateException("Meter photos must reference a retained report photo.");
+ }
+ }
+
private void validateExitAgainstEntrySnapshot() {
if (!zones.equals(entrySnapshot.zones())) {
throw new IllegalStateException("Exit report zones must match the entry snapshot.");
diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/condition/PropertyConditionReportRepository.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/condition/PropertyConditionReportRepository.java
index 5ae67aa..7dbe0a3 100644
--- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/condition/PropertyConditionReportRepository.java
+++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/condition/PropertyConditionReportRepository.java
@@ -7,5 +7,6 @@ import java.util.Optional;
public interface PropertyConditionReportRepository {
Optional find(LandlordId landlordId, LeaseId leaseId, PropertyConditionReportType type);
+ boolean isPhotoStorageKeyReferenced(String storageKey);
PropertyConditionReport save(PropertyConditionReport report);
}
diff --git a/backend/src/main/resources/templates/rental/property-condition-report.html b/backend/src/main/resources/templates/rental/property-condition-report.html
index 5439889..2f7ab8a 100644
--- a/backend/src/main/resources/templates/rental/property-condition-report.html
+++ b/backend/src/main/resources/templates/rental/property-condition-report.html
@@ -11,7 +11,7 @@ body { font-family: sans-serif; font-size: 11pt; } table { border-collapse: coll
Observations
| Élément | État | Observation |
|---|
| Élément | État | Observation |
Photos
Résumé des photos
Absences et correspondances photo
| Zone/référence | Motif ou correspondance |
|---|
| Zone | Motif |
| Photo d’entrée | Correspondance |
-Relevés de compteurs
| Compteur | Type | Index | Unité | Observation |
|---|
| Compteur | Type | Index | Unité | Observation |
+Relevés de compteurs
| Compteur | Type | Index | Unité | Photo | Observation |
|---|
| Compteur | Type | Index | Unité | Photo | Observation |
Moyens d’accès
| Type | Destination | Quantité | Observation |
|---|
| Type | Destination | Quantité | Observation |
Inventaire constaté
| Élément | Quantité contractuelle | Répartition constatée |
|---|
| Élément | Quantité | |
Adresses de correspondance
| Locataire | Adresse |
|---|
| Locataire | Adresse |
diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/filesystem/FileSystemPropertyConditionAttachmentStorageTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/filesystem/FileSystemPropertyConditionAttachmentStorageTest.java
index f8c2d47..654a555 100644
--- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/filesystem/FileSystemPropertyConditionAttachmentStorageTest.java
+++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/filesystem/FileSystemPropertyConditionAttachmentStorageTest.java
@@ -21,21 +21,30 @@ class FileSystemPropertyConditionAttachmentStorageTest {
RentalStorageProperties properties = new RentalStorageProperties(temporaryDirectory.resolve("attachments"));
Path root = properties.attachments().resolve("condition-reports");
FileSystemPropertyConditionAttachmentStorage storage =
- new FileSystemPropertyConditionAttachmentStorage(properties);
+ new FileSystemPropertyConditionAttachmentStorage(properties, ignored -> true);
String key = storage.store(new LeaseId("lease-a"), "photo-a", "webp", new byte[]{1, 2, 3});
storage.stageDeletion(key);
- storage = new FileSystemPropertyConditionAttachmentStorage(properties);
+ storage = new FileSystemPropertyConditionAttachmentStorage(properties, ignored -> true);
try (var content = storage.read(key)) {
assertArrayEquals(new byte[]{1, 2, 3}, content.readAllBytes());
}
storage.stageDeletion(key);
Path deletion = root.resolve(".staging").resolve(key);
- Files.move(deletion.resolve("backup"), deletion.resolve("committed"), StandardCopyOption.ATOMIC_MOVE);
-
- new FileSystemPropertyConditionAttachmentStorage(properties);
+ new FileSystemPropertyConditionAttachmentStorage(properties, ignored -> false);
assertFalse(Files.exists(root.resolve(key)));
assertFalse(Files.exists(deletion));
+
+ storage = new FileSystemPropertyConditionAttachmentStorage(properties, ignored -> true);
+ String committedKey = storage.store(new LeaseId("lease-a"), "photo-b", "webp", new byte[]{4, 5, 6});
+ storage.stageDeletion(committedKey);
+ Path committedDeletion = root.resolve(".staging").resolve(committedKey);
+ Files.move(committedDeletion.resolve("backup"), committedDeletion.resolve("committed"),
+ StandardCopyOption.ATOMIC_MOVE);
+
+ new FileSystemPropertyConditionAttachmentStorage(properties, ignored -> true);
+ assertFalse(Files.exists(root.resolve(committedKey)));
+ assertFalse(Files.exists(committedDeletion));
}
}
diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/image/ImageMagickPropertyConditionPhotoNormalizerDockerAcceptance.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/image/ImageMagickPropertyConditionPhotoNormalizerDockerAcceptance.java
new file mode 100644
index 0000000..cd7ec31
--- /dev/null
+++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/image/ImageMagickPropertyConditionPhotoNormalizerDockerAcceptance.java
@@ -0,0 +1,79 @@
+package fr.nessar.lmnporganizer.rental.adapter.out.image;
+
+import fr.nessar.lmnporganizer.rental.adapter.in.config.PropertyConditionPhotoProperties;
+import fr.nessar.lmnporganizer.rental.application.shared.PropertyConditionPhotoNormalizationFailure;
+import org.springframework.util.unit.DataSize;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.Comparator;
+import java.util.List;
+
+public final class ImageMagickPropertyConditionPhotoNormalizerDockerAcceptance {
+ private static final Path ROOT = Path.of("/tmp/lmnp-condition-photos");
+ private static final Path FIXTURES = ROOT.resolve("build-check");
+
+ public static void main(String[] arguments) throws IOException {
+ var normalizer = new ImageMagickPropertyConditionPhotoNormalizer(properties());
+ var normalized = normalizer.normalize(Files.readAllBytes(FIXTURES.resolve("small.png")));
+ require(normalized.width() == 800 && normalized.height() == 600 && normalized.content().length >= 12,
+ "The normalized image was not post-validated");
+ rejects(normalizer, "animated.webp");
+ rejects(normalizer, "multipage.miff");
+ rejects(normalizer, "invalid.upload");
+ rejects(new ImageMagickPropertyConditionPhotoNormalizer(restrictiveProperties()), "small.png");
+ Path timeoutDirectory = Files.createTempDirectory(ROOT, "timeout-");
+ try {
+ try {
+ ImageMagickPropertyConditionPhotoNormalizer.run(
+ List.of("/bin/sh", "-c", "sleep 1"), timeoutDirectory, Duration.ofMillis(10));
+ throw new AssertionError("The ImageMagick process did not time out");
+ } catch (PropertyConditionPhotoNormalizationFailure expected) {
+ // expected
+ }
+ } finally {
+ delete(timeoutDirectory);
+ }
+ try (var files = Files.list(ROOT)) {
+ require(files.noneMatch(path -> path.getFileName().toString().startsWith("photo-")),
+ "The normalizer left temporary files behind");
+ }
+ }
+
+ private static void rejects(ImageMagickPropertyConditionPhotoNormalizer normalizer, String fixture)
+ throws IOException {
+ try {
+ normalizer.normalize(Files.readAllBytes(FIXTURES.resolve(fixture)));
+ throw new AssertionError(fixture + " was accepted");
+ } catch (PropertyConditionPhotoNormalizationFailure expected) {
+ // expected
+ }
+ }
+
+ private static PropertyConditionPhotoProperties properties() {
+ return new PropertyConditionPhotoProperties("magick", DataSize.ofMegabytes(10), 12_000, 12_000,
+ DataSize.ofMegabytes(192), DataSize.ofMegabytes(384), DataSize.ofMegabytes(768), 2,
+ Duration.ofSeconds(20));
+ }
+
+ private static PropertyConditionPhotoProperties restrictiveProperties() {
+ return new PropertyConditionPhotoProperties("magick", DataSize.ofMegabytes(10), 12_000, 12_000,
+ DataSize.ofBytes(1), DataSize.ofBytes(1), DataSize.ofBytes(1), 1, Duration.ofSeconds(20));
+ }
+
+ private static void require(boolean condition, String message) {
+ if (!condition) {
+ throw new AssertionError(message);
+ }
+ }
+
+ private static void delete(Path directory) throws IOException {
+ try (var paths = Files.walk(directory)) {
+ for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) {
+ Files.deleteIfExists(path);
+ }
+ }
+ }
+}
diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/RentalMongoTransactionIT.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/RentalMongoTransactionIT.java
index 83764e3..c961908 100644
--- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/RentalMongoTransactionIT.java
+++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/RentalMongoTransactionIT.java
@@ -3,6 +3,15 @@ package fr.nessar.lmnporganizer.rental.adapter.out.mongodb;
import fr.nessar.lmnporganizer.LmnpOrganizerApplication;
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
import fr.nessar.lmnporganizer.rental.application.rent.AllocateRentPaymentService;
+import fr.nessar.lmnporganizer.rental.domain.condition.OnTimeSupplement;
+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.PropertyConditionReportRepository;
+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.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentDue;
@@ -53,6 +62,9 @@ class RentalMongoTransactionIT {
@Autowired
private RentPaymentRepository rentPayments;
+ @Autowired
+ private PropertyConditionReportRepository conditionReports;
+
@Autowired
private MongoTemplate mongoTemplate;
@@ -68,6 +80,7 @@ class RentalMongoTransactionIT {
mongoTemplate.dropCollection(PROBE_COLLECTION);
mongoTemplate.getCollection("rentDues").deleteMany(new Document());
mongoTemplate.getCollection("rentPayments").deleteMany(new Document());
+ mongoTemplate.getCollection("propertyConditionReports").deleteMany(new Document());
}
@Test
@@ -143,6 +156,41 @@ class RentalMongoTransactionIT {
.allocations().stream().map(PaymentAllocation::rentDueId).toList());
}
+ @Test
+ void rejectsAnExitSnapshotCreatedBeforeAConcurrentEntrySupplementCommits() {
+ LandlordId landlordId = new LandlordId("landlord-1");
+ LeaseId leaseId = new LeaseId("lease-1");
+ conditionReports.save(finalizedEntry(landlordId, leaseId));
+ PropertyConditionReport staleEntry = conditionReports.find(
+ landlordId, leaseId, PropertyConditionReportType.ENTRY).orElseThrow();
+
+ transactions.run(() -> {
+ PropertyConditionReport currentEntry = conditionReports.find(
+ landlordId, leaseId, PropertyConditionReportType.ENTRY).orElseThrow();
+ currentEntry.appendSupplement(new PropertyConditionReportSupplement(
+ "supplement-1", PropertyConditionReportSupplementType.GENERAL,
+ new PropertyConditionReportSupplement.FileEvidence(
+ "supplement-pdf", "supplement-storage", "supplement-sha"),
+ List.of(), "author", Instant.parse("2026-07-25T10:00:00Z"),
+ LocalDate.of(2026, 7, 25), null, "Ajout accepté",
+ new OnTimeSupplement(LocalDate.of(2026, 7, 30))));
+ return conditionReports.save(currentEntry);
+ });
+
+ assertThrows(OptimisticLockingFailureException.class, () -> transactions.run(() -> {
+ PropertyConditionReport exit = PropertyConditionReport.draftExit(
+ new PropertyConditionReportId("report-exit"), staleEntry,
+ LocalDate.of(2026, 7, 31), "Europe/Paris");
+ conditionReports.save(staleEntry);
+ return conditionReports.save(exit);
+ }));
+
+ assertEquals(1, conditionReports.find(landlordId, leaseId, PropertyConditionReportType.ENTRY)
+ .orElseThrow().supplements().size());
+ assertEquals(Optional.empty(),
+ conditionReports.find(landlordId, leaseId, PropertyConditionReportType.EXIT));
+ }
+
private static MongoDBContainer startMongo() {
if (System.getenv("SPRING_DATA_MONGODB_URI") != null) {
return null;
@@ -156,4 +204,16 @@ class RentalMongoTransactionIT {
return RentDue.create(new RentDueId(dueId), new LandlordId(landlordId), leaseId, month,
month.plusMonths(1).atDay(5), new BigDecimal("900"), new BigDecimal("50"));
}
+
+ private static PropertyConditionReport finalizedEntry(LandlordId landlordId, LeaseId leaseId) {
+ return PropertyConditionReport.reconstitute(
+ new PropertyConditionReportId("report-entry"), landlordId, leaseId,
+ 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 PropertyConditionReportFinalization(1, "signed-storage", "signed-sha", "author",
+ Instant.parse("2026-07-20T10:00:00Z")),
+ List.of(), null, 1, "lease-revision-fingerprint",
+ PropertyConditionReportStatus.FINALIZED, -1);
+ }
}
diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/condition/MongoPropertyConditionReportRepositoryTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/condition/MongoPropertyConditionReportRepositoryTest.java
index baa1f18..13c0771 100644
--- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/condition/MongoPropertyConditionReportRepositoryTest.java
+++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/mongodb/condition/MongoPropertyConditionReportRepositoryTest.java
@@ -29,6 +29,7 @@ import java.time.LocalDate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
@@ -72,6 +73,19 @@ class MongoPropertyConditionReportRepositoryTest {
});
}
+ @Test
+ void checksWhetherAReportStillReferencesAStagedPhoto() {
+ when(mongo.exists(any(Query.class), eq(MongoPropertyConditionReportRepository.ReportDocument.class)))
+ .thenReturn(true);
+ MongoPropertyConditionReportRepository repository = new MongoPropertyConditionReportRepository(mongo);
+
+ assertTrue(repository.isPhotoStorageKeyReferenced("lease-1/photo.webp"));
+
+ ArgumentCaptor query = ArgumentCaptor.forClass(Query.class);
+ verify(mongo).exists(query.capture(), eq(MongoPropertyConditionReportRepository.ReportDocument.class));
+ assertThat(query.getValue().getQueryObject()).containsEntry("photos.storageKey", "lease-1/photo.webp");
+ }
+
private static PropertyConditionReport report() {
return PropertyConditionReport.reconstitute(new PropertyConditionReportId("report-1"), LANDLORD_ID,
LEASE_ID, PropertyConditionReportType.ENTRY, LocalDate.of(2026, 7, 20), "Europe/Paris",
diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/pdf/ThymeleafPropertyConditionReportRendererTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/pdf/ThymeleafPropertyConditionReportRendererTest.java
index 71ad048..9f5a825 100644
--- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/pdf/ThymeleafPropertyConditionReportRendererTest.java
+++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/out/pdf/ThymeleafPropertyConditionReportRendererTest.java
@@ -56,12 +56,14 @@ class ThymeleafPropertyConditionReportRendererTest {
assertTrue(occurrences(text, "Léa Durand") >= 2);
assertTrue(occurrences(text, "Marc Martin") >= 2);
assertTrue(text.contains("Mandataire de Camille Martin"));
- assertTrue(text.contains("Nombre : 2"));
- assertTrue(text.contains("photo-001 à photo-002"));
+ assertTrue(text.contains("Nombre : 3"));
+ assertTrue(text.contains("photo-001 à photo-meter-001"));
assertTrue(text.contains("Chaise de cuisine"));
assertTrue(text.contains("Cuisine — Murs"));
assertTrue(text.contains("Dégradé"));
assertTrue(text.contains("Clé"));
+ assertTrue(text.contains("Photo"));
+ assertTrue(text.contains("photo-meter-001"));
assertFalse(text.contains("chair-1") || text.contains("kitchen:walls") || text.contains("DEGRADE"));
}
@@ -79,7 +81,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,
- MeterReadingState.RECORDED, new BigDecimal("12.5"), "kWh", "Compteur cuisine", "", "")));
+ MeterReadingState.RECORDED, new BigDecimal("12.5"), "kWh", "Compteur cuisine", "photo-meter-001", "")));
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")))));
@@ -87,7 +89,9 @@ class ThymeleafPropertyConditionReportRendererTest {
new ConditionPhoto("photo-002", "kitchen:walls", "storage-2", "sha-2", 100, 100, "author",
Instant.parse("2026-07-20T10:00:00Z")),
new ConditionPhoto("photo-001", "kitchen:walls", "storage-1", "sha-1", 100, 100, "author",
- Instant.parse("2026-07-20T10:00:00Z"))));
+ Instant.parse("2026-07-20T10:00:00Z")),
+ new ConditionPhoto("photo-meter-001", "kitchen:walls", "storage-meter", "sha-meter", 100, 100,
+ "author", Instant.parse("2026-07-20T10:00:00Z"))));
return report;
}
diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionDocumentServiceTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionDocumentServiceTest.java
index 63ee90a..006e2f0 100644
--- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionDocumentServiceTest.java
+++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionDocumentServiceTest.java
@@ -10,11 +10,14 @@ import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportFi
import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportId;
import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportRepository;
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.ReportParticipant;
import fr.nessar.lmnporganizer.rental.domain.condition.ReportPartyRole;
import fr.nessar.lmnporganizer.rental.domain.condition.ParticipantAttendance;
import fr.nessar.lmnporganizer.rental.domain.condition.ConditionState;
+import fr.nessar.lmnporganizer.rental.domain.condition.OnTimeSupplement;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentRevision;
@@ -34,9 +37,12 @@ import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.HashMap;
+import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.Set;
+import java.util.zip.ZipInputStream;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -83,6 +89,31 @@ class PropertyConditionDocumentServiceTest {
assertEquals(ConditionState.DEGRADE, comparison.observations().getFirst().exit().state());
}
+ @Test
+ void keepsTheFinalizedEntryBundleByteIdenticalAfterAppendingASupplement() {
+ ConditionPhoto photo = new ConditionPhoto("report-photo", "zone-a", "report-photo-key", sha("%photo"),
+ 800, 600, "author-a", Instant.parse("2026-07-23T10:00:00Z"));
+ MemoryReports reports = new MemoryReports(finalized(PropertyConditionReportType.ENTRY, List.of(), null,
+ List.of(photo)));
+ MemoryStorage storage = new MemoryStorage();
+ storage.files.put("signed", "%PDF-signed".getBytes());
+ storage.files.put("report-photo-key", "%photo".getBytes());
+ storage.files.put("supplement-signed", "%PDF-supplement".getBytes());
+ PropertyConditionDocumentService service = service(reports, storage);
+
+ byte[] finalizedBundle = service.downloadBundle(LANDLORD, LEASE, PropertyConditionReportType.ENTRY).content();
+ assertEquals(Set.of("scan-signe.pdf", "photos/report-photo.webp", "manifest.sha256"), zipEntries(finalizedBundle));
+ reports.entry.appendSupplement(new PropertyConditionReportSupplement("supplement-1",
+ PropertyConditionReportSupplementType.GENERAL,
+ new PropertyConditionReportSupplement.FileEvidence("supplement-scan", "supplement-signed",
+ sha("%PDF-supplement")),
+ List.of(), "author-a", Instant.parse("2026-07-24T10:00:00Z"), LocalDate.of(2026, 7, 24), null,
+ "Ajout accepté", new OnTimeSupplement(LocalDate.of(2026, 7, 30))));
+
+ assertArrayEquals(finalizedBundle,
+ service.downloadBundle(LANDLORD, LEASE, PropertyConditionReportType.ENTRY).content());
+ }
+
@Test
void scopesEveryDocumentOperationToTheLandlordAndObsoletesThePriorRevision() {
MemoryReports reports = new MemoryReports(draft());
@@ -205,7 +236,22 @@ class PropertyConditionDocumentServiceTest {
}
private static String sha(String content) {
- return "878c16e93410be0acef881624a20807d767af39bd4a6665a94c0a4ee6905a8d1";
+ return switch (content) {
+ case "%PDF-signed" -> "878c16e93410be0acef881624a20807d767af39bd4a6665a94c0a4ee6905a8d1";
+ case "%photo" -> "23658f0b8465f98ec39ae43c49aa8a7c5094fb66cdbf40576dd82dc042c57d2d";
+ case "%PDF-supplement" -> "c8c4fe5890cd64b58b87aa29d60fc6b7a2d1680562c4b9ffec7fd3f0ae787d68";
+ default -> throw new IllegalArgumentException("Unexpected test file.");
+ };
+ }
+
+ private static Set zipEntries(byte[] content) {
+ Set entries = new HashSet<>();
+ try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(content))) {
+ for (var entry = zip.getNextEntry(); entry != null; entry = zip.getNextEntry()) entries.add(entry.getName());
+ return entries;
+ } catch (java.io.IOException failure) {
+ throw new IllegalStateException(failure);
+ }
}
private static final class MemoryReports implements PropertyConditionReportRepository {
@@ -218,6 +264,7 @@ class PropertyConditionDocumentServiceTest {
if (!LANDLORD.equals(landlord) || !LEASE.equals(lease)) return Optional.empty();
return Optional.ofNullable(type == PropertyConditionReportType.ENTRY ? entry : exit);
}
+ @Override public boolean isPhotoStorageKeyReferenced(String storageKey) { return false; }
@Override public PropertyConditionReport save(PropertyConditionReport report) {
PropertyConditionReport saved = PropertyConditionReport.reconstitute(report.id(), report.landlordId(), report.leaseId(),
diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionReportServiceTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionReportServiceTest.java
index 784ba0e..7dccf2d 100644
--- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionReportServiceTest.java
+++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/condition/PropertyConditionReportServiceTest.java
@@ -3,11 +3,16 @@ package fr.nessar.lmnporganizer.rental.application.condition;
import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
import fr.nessar.lmnporganizer.rental.application.port.out.PropertyConditionAttachmentStorage;
import fr.nessar.lmnporganizer.rental.application.port.out.PropertyConditionPhotoNormalizer;
+import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
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.FurnitureInventory;
+import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryId;
+import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryRepository;
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventorySnapshot;
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureEquipmentKind;
+import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureLine;
import fr.nessar.lmnporganizer.rental.domain.furniture.FurniturePresence;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.landlord.Landlord;
@@ -30,6 +35,7 @@ import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
class PropertyConditionReportServiceTest {
@@ -66,6 +72,33 @@ class PropertyConditionReportServiceTest {
assertEquals(2, restarted.leaseRevisionNumber());
}
+ @Test
+ void refusesAnEntryDraftUntilTheLeaseRevisionIsRegeneratedAfterAnInventoryEdit() {
+ Fixture fixture = fixture(revision(1, "source-1"));
+ fixture.inventories.current = inventory(3);
+
+ assertThrows(IllegalStateException.class, () -> fixture.service.get(LANDLORD, LEASE,
+ PropertyConditionReportType.ENTRY, "Europe/Paris"));
+ assertNull(fixture.reports.entry);
+
+ fixture.revisions.current = revision(2, "source-2", 3);
+ PropertyConditionReport report = fixture.service.get(LANDLORD, LEASE, PropertyConditionReportType.ENTRY,
+ "Europe/Paris");
+
+ assertEquals(2, report.leaseRevisionNumber());
+ }
+
+ @Test
+ void refusesToRestartAnEntryDraftFromARevisionWithAnOutdatedInventorySnapshot() {
+ Fixture fixture = fixture(revision(1, "source-1"));
+ fixture.service.get(LANDLORD, LEASE, PropertyConditionReportType.ENTRY, "Europe/Paris");
+ fixture.inventories.current = inventory(3);
+
+ assertThrows(IllegalStateException.class, () -> fixture.service.get(LANDLORD, LEASE,
+ PropertyConditionReportType.ENTRY, "Europe/Paris"));
+ assertEquals(1, fixture.reports.entry.leaseRevisionNumber());
+ }
+
@Test
void allowsAnIncompleteDraftButRejectsForeignPartiesAndZones() {
Fixture fixture = fixture(revision(1, "source-1"));
@@ -124,6 +157,21 @@ class PropertyConditionReportServiceTest {
PropertyConditionReportSupplementType.GENERAL, null, false, null));
}
+ @Test
+ void wrapsExitSnapshotCreationAndEntrySupplementsInTransactions() {
+ Fixture fixture = fixture(revision(1, "source-1"));
+ fixture.reports.entry = finalizedEntry();
+
+ fixture.service.appendSupplement(LANDLORD, LEASE, "%PDF".getBytes(), List.of(), "author",
+ LocalDate.of(2026, 7, 25), "Ajout accepté", PropertyConditionReportSupplementType.GENERAL,
+ null, false, null);
+ assertEquals(1, fixture.transactions.calls);
+
+ fixture.service.get(LANDLORD, LEASE, PropertyConditionReportType.EXIT, "Europe/Paris");
+ assertEquals(2, fixture.transactions.calls);
+ assertEquals(1, fixture.reports.exit.entrySnapshot().supplements().size());
+ }
+
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(),
@@ -135,11 +183,15 @@ class PropertyConditionReportServiceTest {
}
private static LeaseDocumentRevision revision(int number, String fingerprint) {
+ return revision(number, fingerprint, 2);
+ }
+
+ private static LeaseDocumentRevision revision(int number, String fingerprint, int quantity) {
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, FurnitureEquipmentKind.NONE, "kitchen", "Chaise", 2, ""))),
+ FurniturePresence.PRESENT, FurnitureEquipmentKind.NONE, "kitchen", "Chaise", quantity, ""))),
List.of(new PrivateArea("kitchen", PrivateArea.Family.PIECE_DE_SERVICE,
PrivateArea.Subtype.KITCHEN, "Cuisine")), List.of(), List.of());
}
@@ -150,6 +202,7 @@ class PropertyConditionReportServiceTest {
new RentTerms(new BigDecimal("800"), new BigDecimal("50"), new BigDecimal("800")));
MemoryReports reports = new MemoryReports();
MemoryRevisions revisions = new MemoryRevisions(revision);
+ MemoryInventories inventories = new MemoryInventories(inventory(revision.furnitureInventory().lines().getFirst().quantity()));
LeaseRepository leases = new LeaseRepository() {
@Override public List findAll(LandlordId landlord) { return List.of(lease); }
@Override public Optional find(LandlordId landlord, LeaseId leaseId) { return Optional.of(lease); }
@@ -169,20 +222,36 @@ class PropertyConditionReportServiceTest {
@Override public Tenant save(Tenant value) { return value; }
@Override public boolean delete(Tenant value) { return false; }
};
- PropertyConditionReportService service = new PropertyConditionReportService(reports, leases, revisions, landlords, tenants,
- source -> new PropertyConditionPhotoNormalizer.NormalizedPhoto(source, 1, 1), new Storage(),
+ RecordingTransactions transactions = new RecordingTransactions();
+ PropertyConditionReportService service = new PropertyConditionReportService(reports, leases, revisions, inventories,
+ landlords, tenants,
+ source -> new PropertyConditionPhotoNormalizer.NormalizedPhoto(source, 1, 1), new Storage(), transactions,
new IdentifierGenerator() { private int next; @Override public String next() { return "id-" + ++next; } },
Clock.fixed(Instant.parse("2026-07-31T10:00:00Z"), ZoneOffset.UTC));
- return new Fixture(service, reports, revisions);
+ return new Fixture(service, reports, revisions, inventories, transactions);
}
- private record Fixture(PropertyConditionReportService service, MemoryReports reports, MemoryRevisions revisions) { }
+ private static FurnitureInventory inventory(int quantity) {
+ return FurnitureInventory.reconstitute(new FurnitureInventoryId("inventory-1"), LANDLORD, LEASE,
+ "FR-MEUBLE-2025-01", List.of(new FurnitureLine("chair",
+ fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureCategory.TABLE_AND_SEATING,
+ FurniturePresence.PRESENT, FurnitureEquipmentKind.NONE, "kitchen", "Chaise", quantity, "")), false, 0);
+ }
+
+ private record Fixture(PropertyConditionReportService service, MemoryReports reports, MemoryRevisions revisions,
+ MemoryInventories inventories,
+ RecordingTransactions transactions) { }
+ private static final class RecordingTransactions implements TransactionRunner {
+ private int calls;
+ @Override public T run(java.util.function.Supplier operation) { calls++; return operation.get(); }
+ }
private static final class MemoryReports implements PropertyConditionReportRepository {
private PropertyConditionReport entry;
private PropertyConditionReport exit;
@Override public Optional find(LandlordId landlord, LeaseId lease, PropertyConditionReportType type) {
return Optional.ofNullable(type == PropertyConditionReportType.ENTRY ? entry : exit);
}
+ @Override public boolean isPhotoStorageKeyReferenced(String storageKey) { return false; }
@Override public PropertyConditionReport save(PropertyConditionReport value) {
if (value.type() == PropertyConditionReportType.ENTRY) entry = value; else exit = value;
return value;
@@ -196,6 +265,12 @@ class PropertyConditionReportServiceTest {
@Override public Optional find(LandlordId landlord, LeaseId lease, int number) { return current.number() == number ? Optional.of(current) : Optional.empty(); }
@Override public LeaseDocumentRevision save(LeaseDocumentRevision value) { current = value; return value; }
}
+ private static final class MemoryInventories implements FurnitureInventoryRepository {
+ private FurnitureInventory current;
+ private MemoryInventories(FurnitureInventory current) { this.current = current; }
+ @Override public Optional find(LandlordId landlord, LeaseId lease) { return Optional.ofNullable(current); }
+ @Override public FurnitureInventory save(FurnitureInventory value) { current = value; return value; }
+ }
private static final class Storage implements PropertyConditionAttachmentStorage {
@Override public String store(LeaseId lease, String reference, String extension, byte[] content) { return reference; }
@Override public InputStream read(String key) { return new ByteArrayInputStream(new byte[0]); }
diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/domain/condition/PropertyConditionReportTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/domain/condition/PropertyConditionReportTest.java
index 0daacd7..c9097aa 100644
--- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/domain/condition/PropertyConditionReportTest.java
+++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/domain/condition/PropertyConditionReportTest.java
@@ -91,6 +91,25 @@ class PropertyConditionReportTest {
report.finalizeReport(finalization(1));
}
+ @Test
+ void requiresMeterPhotoReferencesToBeRetainedAtFinalization() {
+ PropertyConditionReport report = entryDraft();
+ report.replaceParticipants(participants(ParticipantAttendance.PRESENT));
+ report.replaceZones(List.of(new ConditionZone("floor", "living-room", "Sol", ZoneEquipment.NONE)));
+ report.replaceObservations(List.of(new ConditionObservation("floor", ConditionState.BON_ETAT, "")));
+ report.replaceMeterReadings(List.of(new MeterReading("water", MeterType.EAU_FROIDE,
+ MeterReadingState.RECORDED, new BigDecimal("12.5"), "m3", "", "missing-photo", "")));
+ report.replacePhotos(List.of(photo("report-photo", "floor", "entry")));
+ report.addRevision(revision(1));
+
+ assertThrows(IllegalStateException.class, () -> report.finalizeReport(finalization(1)));
+
+ report.replaceMeterReadings(List.of(new MeterReading("water", MeterType.EAU_FROIDE,
+ MeterReadingState.RECORDED, new BigDecimal("12.5"), "m3", "", "report-photo", "")));
+ report.addRevision(revision(2));
+ report.finalizeReport(finalization(2));
+ }
+
@Test
void snapshotsZonesButAllowsExitAttendanceToChangeAndRequiresOnlyTenantAddresses() {
PropertyConditionReport entry = finalizedEntry();
@@ -136,6 +155,20 @@ class PropertyConditionReportTest {
assertEquals(exit.zones(), restored.zones());
}
+ @Test
+ void keepsDeliveryAttestationsAppendOnly() {
+ PropertyConditionReport entry = finalizedEntry();
+ var attestation = new PropertyConditionReportDeliveryAttestation("tenant-1", "landlord-1",
+ Instant.parse("2026-07-21T10:00:00Z"));
+
+ entry.attestExternalDelivery(attestation);
+
+ assertThrows(IllegalStateException.class, () -> entry.attestExternalDelivery(
+ new PropertyConditionReportDeliveryAttestation("tenant-1", "landlord-1",
+ Instant.parse("2026-07-22T10:00:00Z"))));
+ assertEquals(List.of(attestation), entry.deliveryAttestations());
+ }
+
@Test
void replacesTheDraftOccurrenceAndTimeZoneOnlyBeforeFinalization() {
PropertyConditionReport report = entryDraft();
diff --git a/frontend/src/app/components/condition-report-page/condition-report-editor.ts b/frontend/src/app/components/condition-report-page/condition-report-editor.ts
index 1fa8c84..86311c3 100644
--- a/frontend/src/app/components/condition-report-page/condition-report-editor.ts
+++ b/frontend/src/app/components/condition-report-page/condition-report-editor.ts
@@ -150,16 +150,31 @@ export class ConditionReportEditor {
)
)
return 'Renseignez l’adresse de correspondance de chaque locataire.';
- if (
- this.type() === 'EXIT' &&
- this.entryPhotos().some((photo) => {
+ if (this.type() === 'EXIT') {
+ const usedExitPhotoIds = new Set();
+ for (const entryPhoto of this.entryPhotos()) {
const disposition = report.entryPhotoDispositions.find(
- (item) => item.entryPhotoId === photo.reference,
+ (item) => item.entryPhotoId === entryPhoto.reference,
);
- return !disposition?.exitPhotoId && !disposition?.absenceReason?.trim();
- })
- )
- return 'Chaque photo d’entrée exige une photo de sortie ou un motif d’absence.';
+ if (!disposition?.exitPhotoId) {
+ if (!disposition?.absenceReason?.trim()) {
+ return 'Chaque photo d’entrée exige une photo de sortie ou un motif d’absence.';
+ }
+ continue;
+ }
+ const exitPhoto = report.photos.find(
+ (photo) => photo.reference === disposition.exitPhotoId,
+ );
+ if (
+ !exitPhoto ||
+ exitPhoto.zoneId !== entryPhoto.zoneId ||
+ usedExitPhotoIds.has(exitPhoto.reference)
+ ) {
+ return 'Chaque photo d’entrée doit être liée à une photo de sortie unique de la même zone.';
+ }
+ usedExitPhotoIds.add(exitPhoto.reference);
+ }
+ }
return '';
}
diff --git a/frontend/src/app/components/condition-report-page/condition-report-evidence-section.html b/frontend/src/app/components/condition-report-page/condition-report-evidence-section.html
index 3900b7b..78202cb 100644
--- a/frontend/src/app/components/condition-report-page/condition-report-evidence-section.html
+++ b/frontend/src/app/components/condition-report-page/condition-report-evidence-section.html
@@ -72,7 +72,7 @@
(change)="setEntryPhotoDisposition(entryPhoto.reference, 'exitPhotoId', $event)"
>
- @for (exitPhoto of report().photos; track exitPhoto.reference) {
+ @for (exitPhoto of exitPhotosFor(entryPhoto); track exitPhoto.reference) {
}
diff --git a/frontend/src/app/components/condition-report-page/condition-report-evidence-section.ts b/frontend/src/app/components/condition-report-page/condition-report-evidence-section.ts
index 7d648fe..44410bc 100644
--- a/frontend/src/app/components/condition-report-page/condition-report-evidence-section.ts
+++ b/frontend/src/app/components/condition-report-page/condition-report-evidence-section.ts
@@ -105,8 +105,8 @@ export class ConditionReportEvidenceSection implements OnDestroy {
.subscribe({
next: () => {
this.retakePhoto(zoneId);
- this.successOccurred.emit('Photo ajoutée.');
this.reloadRequested.emit();
+ this.successOccurred.emit('Photo ajoutée.');
},
error: (error) =>
this.errorOccurred.emit(
@@ -143,6 +143,22 @@ export class ConditionReportEvidenceSection implements OnDestroy {
);
}
+ exitPhotosFor(entryPhoto: ConditionPhoto): ConditionPhoto[] {
+ const report = this.report();
+ const current = this.disposition(entryPhoto.reference).exitPhotoId;
+ const used = new Set(
+ report.entryPhotoDispositions
+ .filter((item) => item.entryPhotoId !== entryPhoto.reference)
+ .map((item) => item.exitPhotoId)
+ .filter((reference): reference is string => Boolean(reference)),
+ );
+ return report.photos.filter(
+ (photo) =>
+ photo.zoneId === entryPhoto.zoneId &&
+ (photo.reference === current || !used.has(photo.reference)),
+ );
+ }
+
photoUrl(type: ConditionReportType, reference: string): string {
return this.reports.photoUrl(this.leaseId(), type, reference);
}
diff --git a/frontend/src/app/components/condition-report-page/condition-report-page.html b/frontend/src/app/components/condition-report-page/condition-report-page.html
index b7f116b..e62cc00 100644
--- a/frontend/src/app/components/condition-report-page/condition-report-page.html
+++ b/frontend/src/app/components/condition-report-page/condition-report-page.html
@@ -8,16 +8,19 @@
- @if (loading()) {
+ @if (loading() && !report()) {
Chargement de l’état des lieux…
} @else if (error() && !report()) {
{{ error() }}
} @else if (report(); as value) {
+ @if (loading()) {
+ Mise à jour de l’état des lieux…
+ }
@if (error()) {
{{ error() }}
}
- @if (success()) {
+ @if (success() && !error()) {
{{ success() }}
}
@if (value.leaseRevisionNumber !== null) {
@@ -47,9 +50,22 @@
Révision et finalisation
@if (value.status === 'DRAFT') {
-
} @else if (value.status === 'FINALIZED') {
État des lieux finalisé.
- Télécharger le dossier ZIP
+
+ {{ bundleDownloadPending() ? 'Téléchargement…' : 'Télécharger le dossier ZIP' }}
+
+ @if (bundleDownloadError()) {
+ {{ bundleDownloadError() }}
+ }
Attestations de remise
@for (participant of value.participants; track participant.partyId) {
@if (hasAttested(participant.partyId)) {
diff --git a/frontend/src/app/components/condition-report-page/condition-report-page.spec.ts b/frontend/src/app/components/condition-report-page/condition-report-page.spec.ts
index e29d229..62d163f 100644
--- a/frontend/src/app/components/condition-report-page/condition-report-page.spec.ts
+++ b/frontend/src/app/components/condition-report-page/condition-report-page.spec.ts
@@ -1,6 +1,6 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap } from '@angular/router';
-import { of, throwError } from 'rxjs';
+import { of, Subject, throwError } from 'rxjs';
import { vi } from 'vitest';
import {
ConditionPhoto,
@@ -332,6 +332,20 @@ describe('ConditionReportEditor', () => {
});
editor.save();
expect(errors.at(-1)).toContain('photo d’entrée');
+ fixture.componentRef.setInput('entryPhotos', [
+ ...valid.photos,
+ { ...valid.photos[0]!, reference: 'photo-2' },
+ ]);
+ fixture.componentRef.setInput('report', {
+ ...valid,
+ forwardingAddresses: [{ tenantPartyId: 'tenant-1', address: '12 rue A' }],
+ entryPhotoDispositions: [
+ { entryPhotoId: 'photo-1', exitPhotoId: 'photo-1' },
+ { entryPhotoId: 'photo-2', exitPhotoId: 'photo-1' },
+ ],
+ });
+ editor.save();
+ expect(errors.at(-1)).toContain('unique');
fixture.componentRef.setInput('type', 'ENTRY');
fixture.componentRef.setInput('entryPhotos', []);
fixture.componentRef.setInput('report', valid);
@@ -530,6 +544,24 @@ describe('Condition-report sections', () => {
entryPhotoId: 'entry-photo',
exitPhotoId: 'exit-photo',
});
+ fixture.componentRef.setInput('report', {
+ ...report,
+ photos: [
+ { ...supplementPhoto, reference: 'exit-photo', zoneId: 'kitchen' },
+ { ...supplementPhoto, reference: 'used-photo', zoneId: 'kitchen' },
+ ],
+ entryPhotoDispositions: [
+ { entryPhotoId: 'entry-photo', exitPhotoId: 'exit-photo' },
+ { entryPhotoId: 'other-entry-photo', exitPhotoId: 'used-photo' },
+ ],
+ });
+ expect(
+ section.exitPhotosFor({
+ ...supplementPhoto,
+ reference: 'entry-photo',
+ zoneId: 'kitchen',
+ }),
+ ).toEqual([expect.objectContaining({ reference: 'exit-photo' })]);
expect(service.uploadPhoto).toHaveBeenCalledWith('lease-1', 'EXIT', file, 'kitchen');
expect(service.deletePhoto).toHaveBeenCalledWith('lease-1', 'EXIT', 'photo-1', 1);
expect(section.photoUrl('ENTRY', 'entry-photo')).toBe('/photo.webp');
@@ -543,6 +575,38 @@ describe('Condition-report sections', () => {
});
describe('ConditionReportPage', () => {
+ it('preserves the established report while it refreshes', () => {
+ const refresh = new Subject();
+ const service = {
+ getReport: vi.fn().mockReturnValueOnce(of(report)).mockReturnValue(refresh),
+ };
+ TestBed.configureTestingModule({
+ providers: [
+ {
+ provide: ActivatedRoute,
+ useValue: {
+ snapshot: { paramMap: convertToParamMap({ leaseId: 'lease-1', type: 'ENTRY' }) },
+ },
+ },
+ { provide: ConditionReportService, useValue: service },
+ {
+ provide: FileDownloadService,
+ useValue: { download: vi.fn(), isPending: vi.fn(() => false), error: vi.fn(() => '') },
+ },
+ ],
+ });
+ const fixture = TestBed.createComponent(ConditionReportPage);
+ fixture.detectChanges();
+
+ fixture.componentInstance.load();
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.textContent).toContain('Révision de bail source 3');
+ expect(fixture.nativeElement.textContent).toContain('Mise à jour de l’état des lieux');
+ refresh.next({ ...report, version: 2 });
+ refresh.complete();
+ });
+
it('refreshes the revision version, requires attestation, and renders every comparison section', () => {
const refreshed = { ...report, version: 2 };
const comparison: ConditionReportComparison = {
@@ -642,7 +706,11 @@ describe('ConditionReportPage', () => {
getComparison: vi.fn().mockReturnValue(of(comparison)),
photoUrl: vi.fn().mockReturnValue('/photo.webp'),
};
- const downloads = { download: vi.fn() };
+ const downloads = {
+ download: vi.fn(),
+ isPending: vi.fn(() => false),
+ error: vi.fn(() => ''),
+ };
TestBed.configureTestingModule({
providers: [
{
@@ -730,7 +798,11 @@ describe('ConditionReportPage', () => {
appendSupplement: vi.fn().mockReturnValue(of(exitReport)),
photoUrl: vi.fn().mockReturnValue('/photo.webp'),
};
- const downloads = { download: vi.fn() };
+ const downloads = {
+ download: vi.fn(),
+ isPending: vi.fn((key: string) => key.includes('bundle')),
+ error: vi.fn((key: string) => (key.includes('bundle') ? 'Téléchargement impossible.' : '')),
+ };
TestBed.configureTestingModule({
providers: [
{
@@ -765,6 +837,9 @@ describe('ConditionReportPage', () => {
page.dirty.set(false);
page.finalize();
page.downloadBundle();
+ fixture.detectChanges();
+ expect(fixture.nativeElement.textContent).toContain('Téléchargement…');
+ expect(fixture.nativeElement.textContent).toContain('Téléchargement impossible.');
page.loadComparison();
page.attestDelivery('tenant-2');
page.changeReport({ ...exitReport, occurredOn: '2026-07-23' });
@@ -838,7 +913,10 @@ describe('ConditionReportPage', () => {
},
},
{ provide: ConditionReportService, useValue: service },
- { provide: FileDownloadService, useValue: { download: vi.fn() } },
+ {
+ provide: FileDownloadService,
+ useValue: { download: vi.fn(), isPending: vi.fn(() => false), error: vi.fn(() => '') },
+ },
],
});
const fixture = TestBed.createComponent(ConditionReportPage);
@@ -850,5 +928,9 @@ describe('ConditionReportPage', () => {
expect(page.loading()).toBe(false);
expect(page.comparisonPending()).toBe(false);
expect(page.error()).toContain('Impossible de charger la comparaison');
+ page.report.set(report);
+ page.success.set('Ancienne réussite');
+ fixture.detectChanges();
+ expect(fixture.nativeElement.textContent).not.toContain('Ancienne réussite');
});
});
diff --git a/frontend/src/app/components/condition-report-page/condition-report-page.ts b/frontend/src/app/components/condition-report-page/condition-report-page.ts
index 28fa556..a9bc129 100644
--- a/frontend/src/app/components/condition-report-page/condition-report-page.ts
+++ b/frontend/src/app/components/condition-report-page/condition-report-page.ts
@@ -93,7 +93,7 @@ export class ConditionReportPage {
load(): void {
this.loading.set(true);
- this.error.set('');
+ this.clearNotices();
this.reports
.getReport(this.leaseId, this.type)
.pipe(rxFinalize(() => this.loading.set(false)))
@@ -117,7 +117,7 @@ export class ConditionReportPage {
save(request: SaveConditionReportRequest): void {
if (this.saving()) return;
this.saving.set(true);
- this.error.set('');
+ this.clearNotices();
this.reports
.saveReport(this.leaseId, this.type, request)
.pipe(rxFinalize(() => this.saving.set(false)))
@@ -135,6 +135,7 @@ export class ConditionReportPage {
createRevision(): void {
const report = this.report();
if (!report || this.revisionPending()) return;
+ this.clearNotices();
if (this.dirty()) {
this.error.set('Enregistrez le brouillon avant de générer sa révision.');
return;
@@ -151,8 +152,8 @@ export class ConditionReportPage {
`etat-des-lieux-${this.type.toLowerCase()}-revision-${revision.number}.pdf`,
'Impossible de télécharger la révision PDF.',
);
- this.success.set(`Révision ${revision.number} générée.`);
this.load();
+ this.success.set(`Révision ${revision.number} générée.`);
},
error: (error) =>
this.error.set(httpErrorMessage(error, 'Impossible de générer la révision PDF.')),
@@ -210,13 +211,9 @@ export class ConditionReportPage {
finalize(): void {
const report = this.report();
const scan = this.signedScan();
- if (
- !report ||
- !scan ||
- !this.finalizationAttested() ||
- !this.allPartiesSigned() ||
- this.finalizing()
- ) {
+ if (this.finalizing()) return;
+ this.clearNotices();
+ if (!report || !scan || !this.finalizationAttested() || !this.allPartiesSigned()) {
if (!scan || !this.finalizationAttested() || !this.allPartiesSigned()) {
this.error.set('Téléversez le scan PDF et confirmez les deux attestations.');
}
@@ -255,6 +252,7 @@ export class ConditionReportPage {
loadComparison(): void {
if (this.comparisonPending()) return;
+ this.clearNotices();
this.comparisonPending.set(true);
this.reports
.getComparison(this.leaseId)
@@ -269,6 +267,7 @@ export class ConditionReportPage {
attestDelivery(partyId: string): void {
const report = this.report();
if (!report || this.busyAttestationPartyId()) return;
+ this.clearNotices();
this.busyAttestationPartyId.set(partyId);
this.reports
.attestDelivery(this.leaseId, this.type, report.version, partyId)
@@ -312,6 +311,8 @@ export class ConditionReportPage {
}
appendSupplement(): void {
+ if (this.supplementPending()) return;
+ this.clearNotices();
const signedPdf = this.supplementSignedPdf();
const requestedOn = this.supplementRequestedOn().trim();
const acceptedAdditions = this.supplementAcceptedAdditions().trim();
@@ -321,9 +322,7 @@ export class ConditionReportPage {
!requestedOn ||
!acceptedAdditions ||
(this.supplementType() === 'HEATING' && !heatingPeriodStartedOn) ||
- (this.supplementIsLate() &&
- (!this.lateWarningConfirmed() || !this.supplementReason().trim())) ||
- this.supplementPending()
+ (this.supplementIsLate() && (!this.lateWarningConfirmed() || !this.supplementReason().trim()))
) {
if (!signedPdf || !requestedOn || !acceptedAdditions) {
this.error.set('Renseignez la date, les ajouts acceptés et le PDF signé du complément.');
@@ -374,6 +373,27 @@ export class ConditionReportPage {
return this.reports.photoUrl(this.leaseId, type, reference);
}
+ revisionDownloadPending(): boolean {
+ return this.downloads.isPending(`condition-report-revision-${this.leaseId}-${this.type}`);
+ }
+
+ revisionDownloadError(): string {
+ return this.downloads.error(`condition-report-revision-${this.leaseId}-${this.type}`);
+ }
+
+ bundleDownloadPending(): boolean {
+ return this.downloads.isPending(`condition-report-bundle-${this.leaseId}-${this.type}`);
+ }
+
+ bundleDownloadError(): string {
+ return this.downloads.error(`condition-report-bundle-${this.leaseId}-${this.type}`);
+ }
+
+ private clearNotices(): void {
+ this.error.set('');
+ this.success.set('');
+ }
+
private value(event: Event): string {
const target = event.target;
return target instanceof HTMLInputElement ||