feat(rental): add inventory and condition reports (#7)

This commit is contained in:
nessar authored and Nessar committed 2026-08-17 14:18:16 +02:00
1 parent 92d8a3e351
commit e4be5cd05f
193 files changed
+9697 -483

No files matched your search

+53 -1
View File
@@ -7,8 +7,60 @@ COPY src ./src
RUN mvn clean package -DskipTests
# Run stage
FROM docker.io/library/eclipse-temurin:25-jre
FROM docker.io/library/eclipse-temurin:25-jre-alpine
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 \
&& 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 \
&& magick -size 2400x1200 gradient: /tmp/lmnp-condition-photos/build-check/bottom-left.jpg \
&& magick -size 2400x1200 gradient: /tmp/lmnp-condition-photos/build-check/left-top.jpg \
&& magick -size 2400x1200 gradient: /tmp/lmnp-condition-photos/build-check/right-top.jpg \
&& magick -size 2400x1200 gradient: /tmp/lmnp-condition-photos/build-check/right-bottom.jpg \
&& magick -size 2400x1200 gradient: /tmp/lmnp-condition-photos/build-check/left-bottom.jpg \
&& exiftool -overwrite_original -Orientation#=1 /tmp/lmnp-condition-photos/build-check/top-left.jpg \
&& exiftool -overwrite_original -Orientation#=2 /tmp/lmnp-condition-photos/build-check/top-right.jpg \
&& exiftool -overwrite_original -Orientation#=3 /tmp/lmnp-condition-photos/build-check/bottom-right.jpg \
&& exiftool -overwrite_original -Orientation#=4 /tmp/lmnp-condition-photos/build-check/bottom-left.jpg \
&& exiftool -overwrite_original -Orientation#=5 /tmp/lmnp-condition-photos/build-check/left-top.jpg \
&& exiftool -overwrite_original -Orientation#=6 /tmp/lmnp-condition-photos/build-check/right-top.jpg \
&& exiftool -overwrite_original -Orientation#=7 /tmp/lmnp-condition-photos/build-check/right-bottom.jpg \
&& exiftool -overwrite_original -Orientation#=8 /tmp/lmnp-condition-photos/build-check/left-bottom.jpg \
&& 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
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"; \
actual="$(magick identify -quiet -format '%m|%wx%h|%[orientation]' "/tmp/lmnp-condition-photos/build-check/$orientation.webp")"; \
printf '%s\n' "$orientation=$actual"; \
test "$actual" = 'WEBP|1920x960|Undefined'; \
done; \
for orientation in left-top right-top right-bottom left-bottom; do \
magick "/tmp/lmnp-condition-photos/build-check/$orientation.jpg[0]" -auto-orient -resize '1080x1920>' -strip -orient Undefined -quality 75 -define webp:lossless=false "webp:/tmp/lmnp-condition-photos/build-check/$orientation.webp"; \
actual="$(magick identify -quiet -format '%m|%wx%h|%[orientation]' "/tmp/lmnp-condition-photos/build-check/$orientation.webp")"; \
printf '%s\n' "$orientation=$actual"; \
test "$actual" = 'WEBP|960x1920|Undefined'; \
done; \
for input in small.png sample.webp sample.heic; do \
cp "/tmp/lmnp-condition-photos/build-check/$input" "/tmp/lmnp-condition-photos/build-check/$input.upload"; \
magick identify -quiet "/tmp/lmnp-condition-photos/build-check/$input.upload"; \
done; \
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:'; \
magick identify -list policy | grep -q '/tmp/lmnp-condition-photos'; \
rm -rf /tmp/lmnp-condition-photos/build-check
COPY --from=build /app/target/backend-0.0.1-SNAPSHOT.jar app.jar
USER lmnp
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
@@ -0,0 +1,9 @@
package fr.nessar.lmnporganizer.rental.adapter.in.config;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(PropertyConditionPhotoProperties.class)
class PropertyConditionPhotoConfiguration {
}
@@ -0,0 +1,42 @@
package fr.nessar.lmnporganizer.rental.adapter.in.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.util.unit.DataSize;
import java.time.Duration;
@ConfigurationProperties("lmnp.condition-photos")
public record PropertyConditionPhotoProperties(
String executable,
DataSize maxUploadSize,
int maxSourceWidth,
int maxSourceHeight,
DataSize memoryLimit,
DataSize mapLimit,
DataSize diskLimit,
int threads,
Duration timeout) {
public PropertyConditionPhotoProperties {
if (executable == null || executable.isBlank()) {
throw new IllegalArgumentException("ImageMagick executable is required.");
}
positive(maxUploadSize, "Maximum photo upload size");
positive(memoryLimit, "ImageMagick memory limit");
positive(mapLimit, "ImageMagick mapped-memory limit");
positive(diskLimit, "ImageMagick disk limit");
if (maxSourceWidth <= 0 || maxSourceHeight <= 0 || threads <= 0) {
throw new IllegalArgumentException("ImageMagick dimensions and thread limit must be positive.");
}
if (timeout == null || timeout.isZero() || timeout.isNegative()) {
throw new IllegalArgumentException("ImageMagick timeout must be positive.");
}
executable = executable.trim();
}
private static void positive(DataSize value, String name) {
if (value == null || value.toBytes() <= 0) {
throw new IllegalArgumentException(name + " must be positive.");
}
}
}
@@ -10,6 +10,9 @@ import fr.nessar.lmnporganizer.rental.application.lease.AmendLeaseService;
import fr.nessar.lmnporganizer.rental.application.landlord.LandlordAccountService;
import fr.nessar.lmnporganizer.rental.application.apartment.ApartmentCatalogService;
import fr.nessar.lmnporganizer.rental.application.apartment.ApartmentDocumentService;
import fr.nessar.lmnporganizer.rental.application.furniture.FurnitureInventoryService;
import fr.nessar.lmnporganizer.rental.application.condition.PropertyConditionDocumentService;
import fr.nessar.lmnporganizer.rental.application.condition.PropertyConditionReportService;
import fr.nessar.lmnporganizer.rental.application.tenant.DeleteTenantService;
import fr.nessar.lmnporganizer.rental.application.tenant.TenantAttachmentService;
import fr.nessar.lmnporganizer.rental.application.tenant.TenantDirectoryService;
@@ -17,6 +20,9 @@ import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
import fr.nessar.lmnporganizer.rental.application.port.out.IrlIndexProvider;
import fr.nessar.lmnporganizer.rental.application.port.out.LeaseDocumentStorage;
import fr.nessar.lmnporganizer.rental.application.port.out.LeaseInformationNoticeCatalog;
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.PropertyConditionReportRenderer;
import fr.nessar.lmnporganizer.rental.application.port.out.RentalDocumentRenderer;
import fr.nessar.lmnporganizer.rental.application.port.out.TenantAttachmentStorage;
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
@@ -43,6 +49,8 @@ import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentRepository;
import fr.nessar.lmnporganizer.rental.domain.rent.RentPaymentReversalRepository;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevisionRepository;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantRepository;
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryRepository;
import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportRepository;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -76,6 +84,40 @@ public class RentalApplicationConfiguration {
properties.maxSize().toBytes());
}
@Bean
FurnitureInventoryService furnitureInventoryService(FurnitureInventoryRepository inventories,
LeaseRepository leases, ApartmentRepository apartments,
IdentifierGenerator identifiers) {
return new FurnitureInventoryService(inventories, leases, apartments, identifiers);
}
@Bean
PropertyConditionReportService propertyConditionReportService(PropertyConditionReportRepository reports,
LeaseRepository leases,
LeaseDocumentRevisionRepository revisions,
LandlordRepository landlords,
TenantRepository tenants,
PropertyConditionPhotoNormalizer normalizer,
PropertyConditionAttachmentStorage storage,
IdentifierGenerator identifiers,
Clock rentalClock) {
return new PropertyConditionReportService(reports, leases, revisions, landlords, tenants, normalizer, storage, identifiers,
rentalClock);
}
@Bean
PropertyConditionDocumentService propertyConditionDocumentService(PropertyConditionReportRepository reports,
PropertyConditionAttachmentStorage storage,
PropertyConditionReportRenderer renderer,
LeaseFinalizationRepository finalizations,
LeaseRepository leases,
LeaseDocumentRevisionRepository revisions,
IdentifierGenerator identifiers,
Clock rentalClock) {
return new PropertyConditionDocumentService(reports, storage, renderer, finalizations, leases, revisions,
identifiers, rentalClock);
}
@Bean
TenantDirectoryService tenantDirectoryService(TenantRepository tenants, IdentifierGenerator identifiers) {
return new TenantDirectoryService(tenants, identifiers);
@@ -123,11 +165,12 @@ public class RentalApplicationConfiguration {
@Bean
LeaseEndingService leaseEndingService(LeaseRepository leases, ApartmentOccupancyRepository occupancies,
RentDueRepository rentDues, RentRevisionRepository revisions,
PropertyConditionReportRepository reports,
LeaseDocumentStorage storage,
TransactionRunner transactions, IdentifierGenerator identifiers,
Clock rentalClock, RentalAttachmentProperties properties) {
return new LeaseEndingService(leases, occupancies, rentDues, revisions, storage, transactions, identifiers,
rentalClock, properties.maxSize().toBytes());
return new LeaseEndingService(leases, occupancies, rentDues, revisions, reports, storage, transactions,
identifiers, rentalClock, properties.maxSize().toBytes());
}
@Bean
@@ -139,9 +182,10 @@ public class RentalApplicationConfiguration {
LeaseDocumentRevisionRepository revisions,
LeaseDocumentStorage storage,
ApartmentDocumentService apartmentDocuments,
LeaseInformationNoticeCatalog notices) {
LeaseInformationNoticeCatalog notices,
FurnitureInventoryRepository inventories) {
return new GenerateLeaseDocumentService(leases, renderer, rentalClock, landlords, apartments, tenants,
revisions, storage, apartmentDocuments, notices);
revisions, storage, apartmentDocuments, notices, inventories);
}
@Bean
@@ -154,10 +198,12 @@ public class RentalApplicationConfiguration {
LeaseDocumentStorage storage,
ActivateLeaseService activation, RentDueRepository rentDues,
TransactionRunner transactions, Clock rentalClock,
RentalAttachmentProperties properties) {
RentalAttachmentProperties properties,
FurnitureInventoryRepository inventories,
PropertyConditionReportRepository reports) {
return new LeaseFinalizationService(leases, apartments, tenants, documents, revisions, guaranteeDeeds,
finalizations, storage, activation, rentDues, transactions, rentalClock,
properties.maxSize().toBytes());
properties.maxSize().toBytes(), inventories, reports);
}
@Bean
@@ -4,6 +4,7 @@ import fr.nessar.lmnporganizer.rental.domain.apartment.Apartment;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile.BuildingType;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile.ConstructionPeriod;
import fr.nessar.lmnporganizer.rental.domain.apartment.PrivateArea;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
@@ -13,6 +14,7 @@ import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import java.math.BigDecimal;
import java.util.List;
final class ApartmentApi {
private ApartmentApi() {
@@ -20,7 +22,7 @@ final class ApartmentApi {
record LegalProfileRequest(String taxIdentifier, BuildingType buildingType, String ownershipRegime,
String constructionPeriod, @PositiveOrZero BigDecimal habitableSurface,
@PositiveOrZero Integer roomCount, String otherPrivateAreas, String equipment,
String otherPrivateAreas, String equipment,
String heating, String hotWater, String energyRating,
@PositiveOrZero BigDecimal estimatedAnnualEnergyCost,
@Min(1900) @Max(2100) Integer estimatedAnnualEnergyCostReferenceYear,
@@ -33,14 +35,14 @@ final class ApartmentApi {
}
return new ApartmentLegalProfile(taxIdentifier, buildingType, ownershipRegime,
ConstructionPeriod.fromCode(constructionPeriod), habitableSurface,
roomCount == null ? 0 : roomCount, otherPrivateAreas, equipment, heating, hotWater,
otherPrivateAreas, equipment, heating, hotWater,
energyRating, estimatedAnnualEnergyCost, estimatedAnnualEnergyCostReferenceYear, destination,
privateAccessories, commonAreas, internetAccess, departmentCode);
}
}
record LegalProfileResponse(String taxIdentifier, BuildingType buildingType, String ownershipRegime,
String constructionPeriod, BigDecimal habitableSurface, int roomCount,
String constructionPeriod, BigDecimal habitableSurface,
String otherPrivateAreas, String equipment, String heating, String hotWater,
String energyRating, BigDecimal estimatedAnnualEnergyCost,
Integer estimatedAnnualEnergyCostReferenceYear, String destination,
@@ -50,7 +52,7 @@ final class ApartmentApi {
return new LegalProfileResponse(profile.taxIdentifier(), profile.buildingType(),
profile.ownershipRegime(),
profile.constructionPeriod() == null ? "" : profile.constructionPeriod().code(),
profile.habitableSurface(), profile.roomCount(), profile.otherPrivateAreas(),
profile.habitableSurface(), profile.otherPrivateAreas(),
profile.equipment(), profile.heating(), profile.hotWater(), profile.energyRating(),
profile.estimatedAnnualEnergyCost(), profile.estimatedAnnualEnergyCostReferenceYear(),
profile.destination(), profile.privateAccessories(), profile.commonAreas(),
@@ -58,17 +60,33 @@ final class ApartmentApi {
}
}
record PrivateAreaRequest(@NotBlank String id, @NotNull PrivateArea.Family family,
@NotNull PrivateArea.Subtype subtype, @NotBlank String label) {
PrivateArea toDomain() {
return new PrivateArea(id, family, subtype, label);
}
}
record PrivateAreaResponse(String id, PrivateArea.Family family, PrivateArea.Subtype subtype, String label) {
static PrivateAreaResponse from(PrivateArea area) {
return new PrivateAreaResponse(area.id(), area.family(), area.subtype(), area.label());
}
}
record Request(@NotBlank String name, @NotBlank String address,
@NotNull @Positive BigDecimal purchasePrice, String description,
@Valid LegalProfileRequest legalProfile) {
@Valid LegalProfileRequest legalProfile,
@NotNull List<@Valid PrivateAreaRequest> privateAreas) {
}
record Response(String id, String name, String address, BigDecimal purchasePrice, String description,
LegalProfileResponse legalProfile, boolean referenced, long version) {
LegalProfileResponse legalProfile, List<PrivateAreaResponse> privateAreas, int mainRoomCount,
boolean referenced, long version) {
static Response from(Apartment apartment) {
return new Response(apartment.id().value(), apartment.name(), apartment.address(),
apartment.purchasePrice(), apartment.description(), LegalProfileResponse.from(
apartment.legalProfile()), apartment.isReferenced(),
apartment.legalProfile()), apartment.privateAreas().stream()
.map(PrivateAreaResponse::from).toList(), apartment.mainRoomCount(), apartment.isReferenced(),
apartment.version());
}
}
@@ -63,6 +63,7 @@ public class ApartmentController {
private static ApartmentCatalogService.Details details(ApartmentApi.Request request) {
return new ApartmentCatalogService.Details(request.name(), request.address(), request.purchasePrice(),
request.description(), request.legalProfile() == null ? null : request.legalProfile().toDomain());
request.description(), request.legalProfile() == null ? null : request.legalProfile().toDomain(),
request.privateAreas().stream().map(ApartmentApi.PrivateAreaRequest::toDomain).toList());
}
}
@@ -7,6 +7,7 @@ import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
import fr.nessar.lmnporganizer.rental.application.shared.IrlIndexUnavailable;
import fr.nessar.lmnporganizer.rental.application.shared.ReferencedResource;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.application.shared.PropertyConditionPhotoNormalizationFailure;
import fr.nessar.lmnporganizer.rental.domain.landlord.EmailAlreadyUsed;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalizationConflict;
@@ -53,6 +54,11 @@ public class RentalApiExceptionHandler {
return error(HttpStatus.INTERNAL_SERVER_ERROR, "Impossible de traiter la pièce jointe.");
}
@ExceptionHandler(PropertyConditionPhotoNormalizationFailure.class)
ResponseEntity<ApiError> photoNormalizationFailure(PropertyConditionPhotoNormalizationFailure exception) {
return error(HttpStatus.UNPROCESSABLE_CONTENT, exception.getMessage());
}
@ExceptionHandler(IrlIndexUnavailable.class)
ResponseEntity<CodedApiError> irlUnavailable(IrlIndexUnavailable exception) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
@@ -0,0 +1,81 @@
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.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.FurnitureInventory;
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureLine;
import fr.nessar.lmnporganizer.rental.domain.furniture.FurniturePresence;
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryWarning;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.PathVariable;
import java.util.List;
@RestController
@RequestMapping("/api/leases/{leaseId}/inventory")
public class FurnitureInventoryController {
private final FurnitureInventoryService inventories; private final AuthenticatedLandlord landlord;
public FurnitureInventoryController(FurnitureInventoryService inventories, AuthenticatedLandlord landlord) {
this.inventories = inventories; this.landlord = landlord;
}
@GetMapping
Response get(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId) {
var landlordId = landlord.id(principal);
var id = new LeaseId(leaseId);
return Response.from(inventories.get(landlordId, id), inventories.privateAreas(landlordId, id));
}
@PutMapping
Response replace(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
@Valid @RequestBody Request request) {
var landlordId = landlord.id(principal);
var id = new LeaseId(leaseId);
return Response.from(inventories.replace(landlordId, id, request.version(),
request.lines().stream().map(LineRequest::toDomain).toList()), inventories.privateAreas(landlordId, id));
}
record Request(@NotNull @Min(0) Long version, @NotNull @Valid List<LineRequest> lines) { }
record LineRequest(@NotBlank String id, FurnitureCategory category, FurniturePresence presence,
@NotBlank String privateAreaId,
@NotBlank String designation, @Min(0) int quantity, String description) {
FurnitureLine toDomain() {
return new FurnitureLine(id, category, presence, privateAreaId, designation, quantity, description);
}
}
record LineResponse(String id, FurnitureCategory category, FurniturePresence presence, 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());
}
}
record PrivateAreaResponse(String id, String label) {
static PrivateAreaResponse from(PrivateArea area) {
return new PrivateAreaResponse(area.id(), area.label());
}
}
record Response(String id, String templateVersion, List<LineResponse> lines,
List<PrivateAreaResponse> privateAreas, List<FurnitureCategory> missingCategories,
List<FurnitureCategory> absentCategories, List<FurnitureInventoryWarning> legalWarnings,
boolean readyForLeaseDocument, boolean frozen, long version) {
static Response from(FurnitureInventory inventory, List<PrivateArea> privateAreas) {
return new Response(inventory.id().value(), inventory.templateVersion(),
inventory.lines().stream().map(LineResponse::from).toList(),
privateAreas.stream().map(PrivateAreaResponse::from).toList(), inventory.missingCategories(),
inventory.absentCategories(), inventory.legalWarnings(), inventory.isReadyForLeaseDocument(),
inventory.frozen(), inventory.version());
}
}
}
@@ -3,6 +3,8 @@ package fr.nessar.lmnporganizer.rental.adapter.in.web.lease;
import fr.nessar.lmnporganizer.rental.adapter.in.web.rent.RentDueApi;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile.BuildingType;
import fr.nessar.lmnporganizer.rental.domain.apartment.PrivateArea;
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventorySnapshot;
import fr.nessar.lmnporganizer.rental.domain.lease.ChargeMethod;
import fr.nessar.lmnporganizer.rental.domain.lease.Guarantor;
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
@@ -204,7 +206,7 @@ final class LeaseApi {
}
record LegalProfileFacts(String taxIdentifier, BuildingType buildingType, String ownershipRegime,
String constructionPeriod, BigDecimal habitableSurface, int roomCount,
String constructionPeriod, BigDecimal habitableSurface,
String otherPrivateAreas, String equipment, String heating, String hotWater,
String energyRating, BigDecimal estimatedAnnualEnergyCost,
Integer estimatedAnnualEnergyCostReferenceYear, String destination,
@@ -213,7 +215,7 @@ final class LeaseApi {
static LegalProfileFacts from(ApartmentLegalProfile profile) {
return new LegalProfileFacts(profile.taxIdentifier(), profile.buildingType(), profile.ownershipRegime(),
profile.constructionPeriod() == null ? "" : profile.constructionPeriod().code(),
profile.habitableSurface(), profile.roomCount(), profile.otherPrivateAreas(), profile.equipment(),
profile.habitableSurface(), profile.otherPrivateAreas(), profile.equipment(),
profile.heating(), profile.hotWater(), profile.energyRating(),
profile.estimatedAnnualEnergyCost(), profile.estimatedAnnualEnergyCostReferenceYear(),
profile.destination(), profile.privateAccessories(), profile.commonAreas(),
@@ -308,12 +310,39 @@ final class LeaseApi {
}
}
record RevisionInventoryLineResponse(String id, String category, String presence, 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());
}
}
record RevisionInventoryResponse(String templateVersion, List<RevisionInventoryLineResponse> lines) {
static RevisionInventoryResponse from(FurnitureInventorySnapshot inventory) {
return new RevisionInventoryResponse(inventory.templateVersion(), inventory.lines().stream()
.map(RevisionInventoryLineResponse::from).toList());
}
}
record RevisionPrivateAreaResponse(String id, PrivateArea.Family family, PrivateArea.Subtype subtype,
String label) {
static RevisionPrivateAreaResponse from(PrivateArea privateArea) {
return new RevisionPrivateAreaResponse(privateArea.id(), privateArea.family(), privateArea.subtype(),
privateArea.label());
}
}
record RevisionResponse(int number, String templateVersion, String sourceFingerprint, String sha256,
Instant generatedAt, String authorId, List<RevisionAnnexResponse> annexes,
Instant generatedAt, String authorId, RevisionInventoryResponse furnitureInventory,
List<RevisionPrivateAreaResponse> privateAreas, List<RevisionAnnexResponse> annexes,
List<String> warningCodes, List<RevisionWarningResponse> warnings) {
static RevisionResponse from(fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentRevision revision) {
return new RevisionResponse(revision.number(), revision.templateVersion(), revision.sourceFingerprint(),
revision.sha256(), revision.generatedAt(), revision.authorId(),
RevisionInventoryResponse.from(revision.furnitureInventory()),
revision.privateAreas().stream().map(RevisionPrivateAreaResponse::from).toList(),
revision.annexes().stream().map(RevisionAnnexResponse::from).toList(),
revision.warnings().stream().map(LeaseDocumentWarning::code).toList(),
revision.warnings().stream().map(RevisionWarningResponse::from).toList());
@@ -97,11 +97,6 @@ public class LeaseController {
return ResponseEntity.status(HttpStatus.CREATED).body(response(amended));
}
@GetMapping("/{leaseId}/pdf")
ResponseEntity<byte[]> document(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId) {
return document(documents.generate(authenticatedLandlord.id(principal), new LeaseId(leaseId)));
}
@PostMapping("/{leaseId}/revisions")
ResponseEntity<LeaseApi.RevisionResponse> generateRevision(@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId) {
@@ -25,7 +25,7 @@ final class LeaseEndingApi {
LeaseType leaseType, LocalDate fixedEndOn, LocalDate effectiveEndOn,
boolean correctionRequired, List<NoticeResponse> notices,
AgreedEarlyEndResponse agreedEarlyEnd, List<AgreedEarlyEndResponse> agreedEarlyEnds,
ClosureResponse closure) {
ExitConditionReportResponse exitConditionReport, ClosureResponse closure) {
static StateResponse from(LeaseEndingState state) {
LeaseEndingHistory history = state.history();
return new StateResponse(state.leaseId().value(), state.supported(),
@@ -35,10 +35,17 @@ final class LeaseEndingApi {
.map(notice -> NoticeResponse.from(notice, history)).toList(),
history.latestAgreement().map(AgreedEarlyEndResponse::from).orElse(null),
history.agreedEarlyEnds().stream().map(AgreedEarlyEndResponse::from).toList(),
state.exitConditionReport().map(ExitConditionReportResponse::from).orElse(null),
history.closure().map(ClosureResponse::from).orElse(null));
}
}
record ExitConditionReportResponse(LocalDate occurredOn, boolean finalized) {
static ExitConditionReportResponse from(LeaseEndingState.ExitConditionReport report) {
return new ExitConditionReportResponse(report.occurredOn(), report.finalized());
}
}
record NoticeResponse(String id, TenantNoticeStatus status, LocalDate receivedOn,
LocalDate requestedDepartureOn, NoticeDeliveryMode deliveryMode,
String otherDeliveryMode, int noticeMonths, LocalDate minimumEndOn,
@@ -123,7 +123,7 @@ public class LeaseEndingController {
@Valid @RequestBody ClosureRequest request) {
return LeaseEndingApi.StateResponse.from(endings.close(authenticatedLandlord.id(principal),
new LeaseId(leaseId), principal.getSubject(), new LeaseEndingService.Closure(
zone(request.timeZone()), request.keysReturnedOn(), request.confirmed())));
zone(request.timeZone()), request.confirmed())));
}
private static LeaseEndingService.EvidenceUpload upload(MultipartFile file, String timeZone, String reason) {
@@ -166,7 +166,6 @@ public class LeaseEndingController {
record AgreedEndRequest(@NotBlank String timeZone, @NotNull LocalDate endOn, @NotBlank String reason) {
}
record ClosureRequest(@NotBlank String timeZone, @NotNull LocalDate keysReturnedOn,
boolean confirmed) {
record ClosureRequest(@NotBlank String timeZone, boolean confirmed) {
}
}
@@ -8,6 +8,11 @@ import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentWarning;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalization;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalizationWarning;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseGuaranteeDeed;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import java.time.Instant;
import java.time.LocalDate;
@@ -37,11 +42,16 @@ final class LeaseFinalizationApi {
}
record RevisionResponse(int number, String templateVersion, String sourceFingerprint, String sha256,
Instant generatedAt, String authorId, List<RevisionAnnexResponse> annexes,
Instant generatedAt, String authorId,
LeaseApi.RevisionInventoryResponse furnitureInventory,
List<LeaseApi.RevisionPrivateAreaResponse> privateAreas,
List<RevisionAnnexResponse> annexes,
List<String> warningCodes, List<WarningResponse> warnings) {
static RevisionResponse from(LeaseDocumentRevision revision) {
return revision == null ? null : new RevisionResponse(revision.number(), revision.templateVersion(),
revision.sourceFingerprint(), revision.sha256(), revision.generatedAt(), revision.authorId(),
LeaseApi.RevisionInventoryResponse.from(revision.furnitureInventory()),
revision.privateAreas().stream().map(LeaseApi.RevisionPrivateAreaResponse::from).toList(),
revision.annexes().stream().map(RevisionAnnexResponse::from).toList(),
revision.warnings().stream().map(LeaseDocumentWarning::code).toList(),
revision.warnings().stream().map(WarningResponse::from).toList());
@@ -73,14 +83,52 @@ final class LeaseFinalizationApi {
}
}
record InventoryCorrectionResponse(String id, String inventoryLineId, String privateAreaId, String designation,
int quantity, String description) {
static InventoryCorrectionResponse from(LeaseFinalization.InventoryCorrection correction) {
return new InventoryCorrectionResponse(correction.id(), correction.inventoryLineId(),
correction.privateAreaId(), correction.designation(), correction.quantity(),
correction.description());
}
}
record FinalizationMetadata(int revisionNumber, String templateVersion, LocalDate lastSignatureDate,
String signedFileName, String signedSha256, String authorId, Instant finalizedAt,
List<OverrideResponse> overrides) {
List<OverrideResponse> overrides, boolean entryInventoryDiscrepancies,
List<InventoryCorrectionResponse> inventoryCorrections,
boolean allInventoryCorrectionsInitialed) {
static FinalizationMetadata from(LeaseFinalization finalization) {
return finalization == null ? null : new FinalizationMetadata(finalization.revisionNumber(),
finalization.templateVersion(), finalization.lastSignatureDate(), finalization.signedFileName(),
finalization.signedSha256(), finalization.authorId(), finalization.finalizedAt(),
finalization.overrides().stream().map(OverrideResponse::from).toList());
finalization.overrides().stream().map(OverrideResponse::from).toList(),
finalization.entryInventoryDiscrepancies(),
finalization.inventoryCorrections().stream().map(InventoryCorrectionResponse::from).toList(),
finalization.allInventoryCorrectionsInitialed());
}
}
record FinalizationRequest(@NotNull @Positive Integer revisionNumber, @NotNull LocalDate lastSignatureDate,
@NotNull Boolean allPartiesSigned, @NotNull Boolean revisionMatches,
@NotNull List<String> confirmedWarningCodes,
@NotNull Boolean entryInventoryDiscrepancies,
@NotNull List<@Valid InventoryCorrectionRequest> inventoryCorrections,
@NotNull Boolean allInventoryCorrectionsInitialed) {
LeaseFinalizationService.FinalizationRequest toServiceRequest() {
return new LeaseFinalizationService.FinalizationRequest(revisionNumber, lastSignatureDate,
allPartiesSigned, revisionMatches, java.util.Set.copyOf(confirmedWarningCodes),
entryInventoryDiscrepancies,
inventoryCorrections.stream().map(InventoryCorrectionRequest::toDomain).toList(),
allInventoryCorrectionsInitialed);
}
}
record InventoryCorrectionRequest(@NotBlank String id, String inventoryLineId, @NotBlank String privateAreaId,
@NotBlank String designation, @NotNull @PositiveOrZero Integer quantity,
@NotBlank String description) {
LeaseFinalization.InventoryCorrection toDomain() {
return new LeaseFinalization.InventoryCorrection(id, inventoryLineId, privateAreaId, designation,
quantity, description);
}
}
@@ -96,12 +144,14 @@ final class LeaseFinalizationApi {
record StateResponse(RevisionResponse revision, String guaranteeSimulatorUrl, List<String> warningCodes,
List<WarningResponse> warnings,
List<GuaranteeRequirementResponse> guaranteeRequirements,
List<InventoryCorrectionResponse> suggestedInventoryCorrections,
FinalizationMetadata finalization) {
static StateResponse from(LeaseFinalizationService.State state) {
return new StateResponse(RevisionResponse.from(state.revision()),
LeaseFinalizationService.GUARANTEE_SIMULATOR_URL, state.warningCodes(),
state.warningCodes().stream().map(code -> WarningResponse.from(code, state.revision())).toList(),
state.guaranteeRequirements().stream().map(GuaranteeRequirementResponse::from).toList(),
state.suggestedInventoryCorrections().stream().map(InventoryCorrectionResponse::from).toList(),
FinalizationMetadata.from(state.finalization()));
}
}
@@ -3,11 +3,11 @@ package fr.nessar.lmnporganizer.rental.adapter.in.web.lease;
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord;
import fr.nessar.lmnporganizer.rental.adapter.in.web.rent.RentDueApi;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseFinalizationService;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseFinalizationService.FinalizationRequest;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseFinalizationService.PdfUpload;
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentStorageFailure;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -25,8 +25,6 @@ import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.time.Clock;
import java.time.LocalDate;
import java.util.List;
import java.util.Set;
@RestController("rentalLeaseFinalizationController")
@RequestMapping("/api/leases/{leaseId}")
@@ -63,15 +61,10 @@ public class LeaseFinalizationController {
ResponseEntity<LeaseFinalizationApi.FinalizationResponse> finalizeLease(
@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
@RequestPart("signedLease") MultipartFile signedLease,
@RequestParam("revisionNumber") int revisionNumber,
@RequestParam("lastSignatureDate") LocalDate lastSignatureDate,
@RequestParam("allPartiesSigned") boolean allPartiesSigned,
@RequestParam("revisionMatches") boolean revisionMatches,
@RequestParam(value = "confirmedWarningCodes", required = false) List<String> confirmedWarningCodes) {
@RequestPart("metadata") @Valid LeaseFinalizationApi.FinalizationRequest metadata) {
LocalDate today = LocalDate.now(clock);
var result = finalizations.finalizeLease(authenticatedLandlord.id(principal), new LeaseId(leaseId),
new FinalizationRequest(revisionNumber, lastSignatureDate, allPartiesSigned, revisionMatches,
confirmedWarningCodes == null ? Set.of() : Set.copyOf(confirmedWarningCodes)),
metadata.toServiceRequest(),
upload(signedLease), principal.getSubject());
var response = new LeaseFinalizationApi.FinalizationResponse(LeaseApi.Response.from(result.lease(), today),
result.rentDues().stream().map(due -> RentDueApi.Response.from(due, today)).toList(),
@@ -0,0 +1,56 @@
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.ConditionObservation;
import fr.nessar.lmnporganizer.rental.domain.condition.ConditionZone;
import fr.nessar.lmnporganizer.rental.domain.condition.InventoryCondition;
import fr.nessar.lmnporganizer.rental.domain.condition.MeterReading;
import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportSupplement;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/leases/{leaseId}/condition-report-comparison")
public final class PropertyConditionComparisonController {
private final PropertyConditionDocumentService documents;
private final AuthenticatedLandlord landlord;
public PropertyConditionComparisonController(PropertyConditionDocumentService documents, AuthenticatedLandlord landlord) {
this.documents = documents;
this.landlord = landlord;
}
@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(),
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,
List<PhotoLineResponse> photos) { }
record PhotoLineResponse(String reference, PropertyConditionReportController.PhotoResponse entry,
PropertyConditionReportController.PhotoResponse exit,
fr.nessar.lmnporganizer.rental.domain.condition.AuditedPhotoAbsence 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());
}
}
}
@@ -0,0 +1,175 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.lease;
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord;
import fr.nessar.lmnporganizer.rental.adapter.in.config.PropertyConditionPhotoProperties;
import fr.nessar.lmnporganizer.rental.adapter.in.config.RentalAttachmentProperties;
import fr.nessar.lmnporganizer.rental.application.condition.PropertyConditionReportService;
import fr.nessar.lmnporganizer.rental.application.condition.PropertyConditionDocumentService;
import fr.nessar.lmnporganizer.rental.application.shared.GeneratedDocument;
import fr.nessar.lmnporganizer.rental.domain.condition.*;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
@RestController
@RequestMapping("/api/leases/{leaseId}/condition-reports/{type}")
public final class PropertyConditionReportController {
private final PropertyConditionReportService reports;
private final PropertyConditionDocumentService documents;
private final AuthenticatedLandlord landlord;
private final PropertyConditionPhotoProperties photoProperties;
private final RentalAttachmentProperties attachmentProperties;
public PropertyConditionReportController(PropertyConditionReportService reports, PropertyConditionDocumentService documents,
AuthenticatedLandlord landlord, PropertyConditionPhotoProperties photoProperties,
RentalAttachmentProperties attachmentProperties) { this.reports = reports; this.documents = documents; this.landlord = landlord; this.photoProperties = photoProperties; this.attachmentProperties = attachmentProperties; }
@GetMapping Response get(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId, @PathVariable PropertyConditionReportType type,
@RequestParam(defaultValue = "Europe/Paris") String timeZone) { return Response.from(reports.get(landlord.id(principal), new LeaseId(leaseId), type, timeZone)); }
@PutMapping Response replace(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId, @PathVariable PropertyConditionReportType type, @Valid @RequestBody Request request) {
return Response.from(reports.replace(landlord.id(principal), new LeaseId(leaseId), type, request.version(), request.toDomain(principal.getSubject())));
}
@PostMapping(path = "/photos", consumes = "multipart/form-data") Response upload(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId, @PathVariable PropertyConditionReportType type,
@RequestParam String zoneId, @RequestPart MultipartFile file) throws java.io.IOException {
if (file.getSize() > photoProperties.maxUploadSize().toBytes()) throw new IllegalArgumentException("La photo est trop volumineuse.");
return Response.from(reports.uploadPhoto(landlord.id(principal), new LeaseId(leaseId), type, zoneId, file.getBytes(), principal.getSubject()));
}
@DeleteMapping("/photos/{reference}") @ResponseStatus(HttpStatus.NO_CONTENT) void deletePhoto(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId, @PathVariable PropertyConditionReportType type,
@PathVariable String reference, @RequestParam long version) { reports.deletePhoto(landlord.id(principal), new LeaseId(leaseId), type, reference, version); }
@GetMapping("/photos/{reference}") ResponseEntity<byte[]> downloadPhoto(@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId, @PathVariable PropertyConditionReportType type,
@PathVariable String reference) {
return download(documents.downloadPhoto(landlord.id(principal), new LeaseId(leaseId), type, reference));
}
@PostMapping("/revisions") RevisionResponse revision(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
@PathVariable PropertyConditionReportType type, @RequestParam long version) {
return RevisionResponse.from(documents.generateRevision(landlord.id(principal), new LeaseId(leaseId), type, version,
principal.getSubject()));
}
@GetMapping("/revisions/{number}") ResponseEntity<byte[]> revisionDownload(@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId, @PathVariable PropertyConditionReportType type, @PathVariable int number) {
return download(documents.downloadRevision(landlord.id(principal), new LeaseId(leaseId), type, number));
}
@PostMapping(path = "/finalization", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) Response finalizeReport(
@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId, @PathVariable PropertyConditionReportType type,
@RequestParam long version, @RequestParam boolean attestation, @RequestParam boolean allPartiesSigned,
@RequestPart("file") MultipartFile file) throws java.io.IOException {
if (file.getSize() > attachmentProperties.maxSize().toBytes()) throw new IllegalArgumentException("Le scan signé est trop volumineux.");
return Response.from(documents.finalizeReport(landlord.id(principal), new LeaseId(leaseId), type, version,
attestation, allPartiesSigned,
file.getBytes(), principal.getSubject()));
}
@GetMapping("/bundle") ResponseEntity<byte[]> bundle(@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId,
@PathVariable PropertyConditionReportType type) { return download(documents.downloadBundle(landlord.id(principal), new LeaseId(leaseId), type)); }
@PostMapping("/delivery-attestations") Response deliveryAttestation(@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId, @PathVariable PropertyConditionReportType type, @RequestParam long version,
@RequestParam String partyId) { return Response.from(documents.attestExternalDelivery(landlord.id(principal), new LeaseId(leaseId), type, version, partyId, principal.getSubject())); }
@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 LocalDate requestedOn, @RequestParam String acceptedAdditions,
@RequestParam(required = false) LocalDate heatingPeriodStartedOn, @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) {
if (photo.getSize() > photoProperties.maxUploadSize().toBytes()) {
throw new IllegalArgumentException("Une photo du complément est trop volumineuse.");
}
photoContents.add(photo.getBytes());
}
return Response.from(reports.appendSupplement(landlord.id(principal), new LeaseId(leaseId), pdf,
photoContents, principal.getSubject(), requestedOn, acceptedAdditions, heatingPeriodStartedOn, reason));
}
record Request(@NotNull Long version, @NotNull LocalDate occurredOn, @NotBlank String timeZone, @Valid List<ParticipantRequest> participants,
@Valid List<AddressRequest> forwardingAddresses, @Valid List<ZoneRequest> zones, @Valid List<ObservationRequest> observations,
@Valid List<MeterRequest> meterReadings, @Valid List<AccessRequest> accessMeans, @Valid List<InventoryRequest> inventoryConditions,
@Valid List<ZoneAbsenceRequest> zonePhotoAbsences, @Valid List<DispositionRequest> entryPhotoDispositions) {
PropertyConditionReportService.Draft toDomain(String author) { return new PropertyConditionReportService.Draft(occurredOn, timeZone, author,
list(participants, ParticipantRequest::toDomain), list(forwardingAddresses, AddressRequest::toDomain), list(zones, ZoneRequest::toDomain), list(observations, ObservationRequest::toDomain),
list(meterReadings, MeterRequest::toDomain), list(accessMeans, AccessRequest::toDomain), list(inventoryConditions, InventoryRequest::toDomain),
list(zonePhotoAbsences, value -> new PropertyConditionReportService.PhotoAbsence(value.zoneId(), value.reason())), list(entryPhotoDispositions, value -> new PropertyConditionReportService.EntryPhotoDispositionInput(value.entryPhotoId(), value.exitPhotoId(), value.absenceReason()))); }
}
record ParticipantRequest(@NotBlank String partyId, @NotNull ReportPartyRole role, @NotBlank String name, @NotNull ParticipantAttendance attendance, String representativeName) { ReportParticipant toDomain() { return new ReportParticipant(partyId, role, name, attendance, representativeName); } }
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 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,
int contractualQuantity, @Valid List<SplitRequest> splits) {
InventoryCondition toDomain() { return new InventoryCondition(inventoryLineId, privateAreaId,
privateAreaLabel, designation, contractualQuantity, list(splits, SplitRequest::toDomain)); }
}
record SplitRequest(@NotBlank String inventoryLineId, @NotNull ConditionState state, int quantity, String observation) { InventoryQuantitySplit toDomain() { return new InventoryQuantitySplit(inventoryLineId, state, quantity, observation); } }
record ZoneAbsenceRequest(@NotBlank String zoneId, @NotBlank String reason) { }
record DispositionRequest(@NotBlank String entryPhotoId, String exitPhotoId, String absenceReason) { }
record DispositionResponse(String entryPhotoId, String exitPhotoId, String absenceReason) {
static DispositionResponse from(EntryPhotoDisposition value) {
return value instanceof MatchedEntryPhoto matched
? new DispositionResponse(matched.entryPhotoId(), matched.exitPhotoId(), null)
: new DispositionResponse(value.entryPhotoId(), null,
((MissingEntryPhoto) value).absence().reason());
}
}
private static <I, O> List<O> list(List<I> values, java.util.function.Function<I, O> mapper) { return values == null ? List.of() : values.stream().map(mapper).toList(); }
private static boolean startsWithPdf(byte[] content) { return content.length >= 5 && content[0] == '%' && content[1] == 'P' && content[2] == 'D' && content[3] == 'F' && content[4] == '-'; }
private static ResponseEntity<byte[]> download(GeneratedDocument document) { return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + document.fileName() + "\"").contentType(MediaType.parseMediaType(document.contentType())).body(document.content()); }
record Response(String id, PropertyConditionReportType type, PropertyConditionReportStatus status, LocalDate occurredOn, String timeZone,
List<ReportParticipant> participants, List<ForwardingAddress> forwardingAddresses, List<ConditionZone> zones, List<ConditionObservation> observations,
List<MeterReading> meterReadings, List<AccessMeans> accessMeans, List<InventoryCondition> inventoryConditions, List<PhotoResponse> photos,
List<ZonePhotoAbsence> zonePhotoAbsences, List<DispositionResponse> entryPhotoDispositions,
List<SupplementResponse> supplements, EntrySnapshotResponse entrySnapshot,
List<RevisionResponse> revisions, RevisionResponse currentRevision, FinalizationResponse finalization,
List<PropertyConditionReportDeliveryAttestation> deliveryAttestations, Integer leaseRevisionNumber,
String leaseRevisionFingerprint, long version) {
static Response from(PropertyConditionReport value) { return new Response(value.id().value(), value.type(), value.status(), value.occurredOn(), value.timeZone(), value.participants(), value.forwardingAddresses(), value.zones(), value.observations(), value.meterReadings(), value.accessMeans(), value.inventoryConditions(), value.photos().stream().map(PhotoResponse::from).toList(), value.zonePhotoAbsences(), value.entryPhotoDispositions().stream().map(DispositionResponse::from).toList(), value.supplements().stream().map(SupplementResponse::from).toList(), EntrySnapshotResponse.from(value.entrySnapshot()), value.revisions().stream().map(RevisionResponse::from).toList(), RevisionResponse.from(value.currentRevision()), FinalizationResponse.from(value.finalization()), value.deliveryAttestations(), value.leaseRevisionNumber(), value.leaseRevisionFingerprint(), value.version()); }
}
record RevisionResponse(int number, String sha256, String sourceFingerprint, boolean obsolete) {
static RevisionResponse from(PropertyConditionReportRevision revision) { return revision == null ? null : new RevisionResponse(revision.number(), revision.sha256(), revision.sourceFingerprint(), revision.obsolete()); }
}
record PhotoResponse(String reference, String zoneId, String sha256, int width, int height, String uploadedBy, java.time.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 SupplementFileResponse(String reference, String sha256) {
static SupplementFileResponse from(PropertyConditionReportSupplement.FileEvidence value) {
return new SupplementFileResponse(value.reference(), value.sha256());
}
}
record SupplementResponse(String id, SupplementFileResponse signedPdf, List<SupplementFileResponse> photos,
String uploadedBy, java.time.Instant uploadedAt, LocalDate requestedOn,
String acceptedAdditions, SupplementAudit 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()); }
}
record EntrySnapshotResponse(String reportId, List<ReportParticipant> participants, List<ConditionZone> zones,
List<ConditionObservation> observations, List<MeterReading> meterReadings,
List<AccessMeans> accessMeans, List<InventoryCondition> inventoryConditions,
List<PhotoResponse> photos, List<SupplementResponse> supplements) {
static EntrySnapshotResponse from(EntryReportSnapshot value) { return value == null ? null : new EntrySnapshotResponse(value.reportId().value(), value.participants(), value.zones(), value.observations(), value.meterReadings(), value.accessMeans(), value.inventoryConditions(), value.photos().stream().map(PhotoResponse::from).toList(), value.supplements().stream().map(SupplementResponse::from).toList()); }
}
record FinalizationResponse(int revisionNumber, String signedSha256, String authorId, java.time.Instant finalizedAt) {
static FinalizationResponse from(PropertyConditionReportFinalization value) { return value == null ? null : new FinalizationResponse(value.revisionNumber(), value.signedSha256(), value.authorId(), value.finalizedAt()); }
}
}
@@ -0,0 +1,239 @@
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.lease.LeaseId;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.DirectoryNotEmptyException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.Comparator;
import java.util.List;
@Component
public final class FileSystemPropertyConditionAttachmentStorage implements PropertyConditionAttachmentStorage {
private static final String STAGED = "staged";
private static final String BACKUP = "backup";
private static final String COMMITTED = "committed";
private static final Logger LOGGER = LoggerFactory.getLogger(FileSystemPropertyConditionAttachmentStorage.class);
private final Path root;
private final Path staging;
public FileSystemPropertyConditionAttachmentStorage(RentalStorageProperties properties) {
root = properties.attachments().toAbsolutePath().normalize().resolve("condition-reports");
staging = root.resolve(".staging");
recoverInterruptedDeletions();
}
@Override
public String store(LeaseId leaseId, String reference, String extension, byte[] content) {
sweepCommittedTombstones();
if (!"webp".equals(extension) && !"pdf".equals(extension)) throw new IllegalArgumentException("Unsupported condition attachment.");
Path target = inside(root.resolve(leaseId.value()).resolve(reference + "." + extension));
try {
Files.createDirectories(target.getParent());
Files.write(target, content, StandardOpenOption.CREATE_NEW);
return root.relativize(target).toString();
} catch (IOException failure) {
throw failure("Condition attachment could not be stored.", failure);
}
}
@Override public InputStream read(String storageKey) {
Path file = active(storageKey);
try {
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Condition attachment not found.");
return Files.newInputStream(file);
} catch (IOException failure) { throw failure("Condition attachment could not be read.", failure); }
}
@Override public void discard(String storageKey) {
try { Files.deleteIfExists(active(storageKey)); }
catch (IOException failure) { throw failure("Condition attachment could not be discarded.", failure); }
}
@Override public StagedDeletion stageDeletion(String storageKey) {
sweepCommittedTombstones();
Path active = active(storageKey);
Path deletionDirectory = insideStaging(staging.resolve(storageKey));
try {
if (!Files.isRegularFile(active, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Condition attachment not found.");
Files.createDirectories(deletionDirectory);
Path staged = deletionDirectory.resolve(STAGED);
Path backup = deletionDirectory.resolve(BACKUP);
Path committed = deletionDirectory.resolve(COMMITTED);
Files.move(active, staged, StandardCopyOption.ATOMIC_MOVE);
try {
createBackup(staged, backup);
} catch (IOException | RuntimeException failure) {
Files.deleteIfExists(backup);
Files.move(staged, active, StandardCopyOption.ATOMIC_MOVE);
cleanEmptyParents(deletionDirectory);
throw failure;
}
return new Deletion(active, staged, backup, committed);
} catch (IOException | RuntimeException failure) {
throw failure("Condition attachment deletion could not be staged.", failure);
}
}
@Override public void restore(StagedDeletion deletion) {
Deletion files = deletion(deletion);
try {
Files.createDirectories(files.active().getParent());
Files.move(firstExisting(files.staged(), files.backup(), files.committed()), files.active(),
StandardCopyOption.ATOMIC_MOVE);
discardArtifacts(files.staged().getParent());
cleanEmptyParents(files.staged().getParent());
} catch (IOException | RuntimeException failure) {
throw failure("Condition attachment could not be restored.", failure);
}
}
@Override public void completeDeletion(StagedDeletion deletion) {
Deletion files = deletion(deletion);
try {
Files.move(files.backup(), files.committed(), StandardCopyOption.ATOMIC_MOVE);
Files.deleteIfExists(files.staged());
Files.deleteIfExists(files.committed());
cleanEmptyParents(files.staged().getParent());
} catch (IOException | RuntimeException failure) {
LOGGER.warn("Committed condition attachment tombstone cleanup deferred", failure);
}
}
private void recoverInterruptedDeletions() {
if (!Files.isDirectory(staging, LinkOption.NOFOLLOW_LINKS)) return;
try {
List<Path> deletionDirectories;
try (var paths = Files.walk(staging)) {
deletionDirectories = paths.filter(path -> Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS))
.filter(this::hasArtifacts).sorted(Comparator.reverseOrder()).toList();
}
deletionDirectories.forEach(this::recoverInterruptedDeletion);
} catch (IOException | RuntimeException failure) {
throw failure("Interrupted condition attachment deletions could not be recovered.", failure);
}
}
private void recoverInterruptedDeletion(Path deletionDirectory) {
try {
Path committed = deletionDirectory.resolve(COMMITTED);
if (Files.isRegularFile(committed, LinkOption.NOFOLLOW_LINKS)) {
discardArtifacts(deletionDirectory);
} else {
Path active = active(staging.relativize(deletionDirectory).toString());
if (!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)) {
throw new IOException("Recovered condition attachment path is not a regular file.");
}
discardArtifacts(deletionDirectory);
}
cleanEmptyParents(deletionDirectory);
} catch (IOException | RuntimeException failure) {
throw failure("Interrupted condition attachment deletion could not be recovered.", failure);
}
}
private void sweepCommittedTombstones() {
if (!Files.isDirectory(staging, LinkOption.NOFOLLOW_LINKS)) return;
try (var paths = Files.walk(staging)) {
paths.filter(candidate -> Files.isRegularFile(candidate, LinkOption.NOFOLLOW_LINKS))
.filter(candidate -> candidate.getFileName().toString().equals(COMMITTED))
.forEach(this::discardCommitted);
} catch (IOException | RuntimeException failure) {
LOGGER.warn("Committed condition attachment tombstones could not be scanned", failure);
}
}
private void discardCommitted(Path committed) {
try {
discardArtifacts(committed.getParent());
cleanEmptyParents(committed.getParent());
} catch (IOException | RuntimeException failure) {
LOGGER.warn("Committed condition attachment tombstone cleanup deferred: {}", committed, failure);
}
}
private boolean hasArtifacts(Path directory) {
return Files.isRegularFile(directory.resolve(STAGED), LinkOption.NOFOLLOW_LINKS)
|| Files.isRegularFile(directory.resolve(BACKUP), LinkOption.NOFOLLOW_LINKS)
|| Files.isRegularFile(directory.resolve(COMMITTED), LinkOption.NOFOLLOW_LINKS);
}
private static void discardArtifacts(Path directory) throws IOException {
Files.deleteIfExists(directory.resolve(STAGED));
Files.deleteIfExists(directory.resolve(BACKUP));
Files.deleteIfExists(directory.resolve(COMMITTED));
}
private void cleanEmptyParents(Path directory) throws IOException {
Path candidate = directory;
while (candidate != null && candidate.startsWith(staging)) {
try {
Files.deleteIfExists(candidate);
} catch (DirectoryNotEmptyException ignored) {
return;
}
if (candidate.equals(staging)) return;
candidate = candidate.getParent();
}
}
private static Path firstExisting(Path... candidates) throws IOException {
for (Path candidate : candidates) {
if (Files.isRegularFile(candidate, LinkOption.NOFOLLOW_LINKS)) return candidate;
}
throw new IOException("Condition attachment backup not found.");
}
private static void createBackup(Path staged, Path backup) throws IOException {
try {
Files.createLink(backup, staged);
} catch (IOException | UnsupportedOperationException linkFailure) {
try {
Files.copy(staged, backup);
} catch (IOException | RuntimeException copyFailure) {
copyFailure.addSuppressed(linkFailure);
throw copyFailure;
}
}
}
private Path active(String storageKey) { return inside(root.resolve(storageKey)); }
private Path inside(Path candidate) {
Path normalized = candidate.normalize();
if (!normalized.startsWith(root) || normalized.equals(root) || normalized.startsWith(staging)) {
throw new AttachmentStorageFailure("Invalid condition attachment path.", new IllegalArgumentException());
}
return normalized;
}
private Path insideStaging(Path candidate) {
Path normalized = candidate.normalize();
if (!normalized.startsWith(staging) || normalized.equals(staging)) {
throw new AttachmentStorageFailure("Invalid staged condition attachment path.", new IllegalArgumentException());
}
return normalized;
}
private static Deletion deletion(StagedDeletion deletion) {
if (deletion instanceof Deletion value) return value;
throw new IllegalArgumentException("Unsupported condition attachment deletion.");
}
private static AttachmentStorageFailure failure(String message, Throwable cause) {
return cause instanceof AttachmentStorageFailure storageFailure
? storageFailure : new AttachmentStorageFailure(message, cause);
}
private record Deletion(Path active, Path staged, Path backup, Path committed) implements StagedDeletion { }
}
@@ -0,0 +1,293 @@
package fr.nessar.lmnporganizer.rental.adapter.out.image;
import fr.nessar.lmnporganizer.rental.adapter.in.config.PropertyConditionPhotoProperties;
import fr.nessar.lmnporganizer.rental.application.port.out.PropertyConditionPhotoNormalizer;
import fr.nessar.lmnporganizer.rental.application.shared.PropertyConditionPhotoNormalizationFailure;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.function.BiFunction;
@Component
public final class ImageMagickPropertyConditionPhotoNormalizer implements PropertyConditionPhotoNormalizer {
private static final Logger LOGGER = LoggerFactory.getLogger(ImageMagickPropertyConditionPhotoNormalizer.class);
private static final Path TEMPORARY_ROOT = Path.of("/tmp/lmnp-condition-photos");
private static final Set<String> ACCEPTED_FORMATS = Set.of("JPEG", "PNG", "WEBP", "HEIC", "HEIF");
private static final int LANDSCAPE_WIDTH = 1920;
private static final int LANDSCAPE_HEIGHT = 1080;
private static final int PORTRAIT_WIDTH = 1080;
private static final int PORTRAIT_HEIGHT = 1920;
private final PropertyConditionPhotoProperties properties;
private final BiFunction<List<String>, Path, CommandResult> commandRunner;
@Autowired
public ImageMagickPropertyConditionPhotoNormalizer(PropertyConditionPhotoProperties properties) {
this(properties, (command, directory) -> run(command, directory, properties.timeout()));
}
ImageMagickPropertyConditionPhotoNormalizer(
PropertyConditionPhotoProperties properties,
BiFunction<List<String>, Path, CommandResult> commandRunner) {
this.properties = properties;
this.commandRunner = commandRunner;
}
@Override
public NormalizedPhoto normalize(byte[] source) {
if (source == null || source.length == 0) {
throw invalidImage();
}
if (source.length > properties.maxUploadSize().toBytes()) {
throw new PropertyConditionPhotoNormalizationFailure(
"La photo est trop volumineuse. Réduisez sa taille puis réessayez.");
}
Path directory = null;
try {
Files.createDirectories(TEMPORARY_ROOT);
directory = Files.createTempDirectory(TEMPORARY_ROOT, "photo-");
Path input = directory.resolve("source.upload");
Path output = directory.resolve("normalized.webp");
Files.write(input, source, StandardOpenOption.CREATE_NEW);
ImageDetails original = identify(input, directory);
requireAcceptedSource(original);
convert(input, output, original, directory);
byte[] normalized = Files.readAllBytes(output);
ImageDetails result = identify(output, directory);
requireValidResult(original, result, normalized, output, directory);
return new NormalizedPhoto(normalized, result.width(), result.height());
} catch (PropertyConditionPhotoNormalizationFailure failure) {
throw failure;
} catch (IOException | RuntimeException failure) {
throw new PropertyConditionPhotoNormalizationFailure(
"La photo n'a pas pu être traitée. Choisissez une autre image puis réessayez.", failure);
} finally {
delete(directory);
}
}
private ImageDetails identify(Path image, Path directory) {
List<String> command = command("identify");
command.add("-quiet");
command.add("-format");
command.add("%m|%w|%h|%[orientation]\n");
command.add(image.toString());
CommandResult result = execute(command, directory,
"La photo n'a pas pu être lue. Choisissez une image JPEG, PNG, WebP ou HEIC/HEIF valide.");
return parseDetails(result.standardOutput());
}
private void convert(Path input, Path output, ImageDetails original, Path directory) {
List<String> command = command(null);
command.add(input + "[0]");
command.add("-auto-orient");
command.add("-resize");
command.add(resizeFrame(original));
command.add("-strip");
command.add("-orient");
command.add("Undefined");
command.add("-quality");
command.add("75");
command.add("-define");
command.add("webp:lossless=false");
command.add("webp:" + output);
execute(command, directory,
"La photo n'a pas pu être normalisée. Réessayez avec une autre image.");
}
private void requireAcceptedSource(ImageDetails details) {
if (!ACCEPTED_FORMATS.contains(details.format())) {
throw invalidImage();
}
if (details.width() <= 0 || details.height() <= 0
|| details.width() > properties.maxSourceWidth()
|| details.height() > properties.maxSourceHeight()) {
throw new PropertyConditionPhotoNormalizationFailure(
"Les dimensions de la photo dépassent la limite autorisée. Réduisez l'image puis réessayez.");
}
}
private void requireValidResult(ImageDetails source, ImageDetails result, byte[] content, Path output,
Path directory) {
if (!"WEBP".equals(result.format()) || !isWebP(content)) {
throw new PropertyConditionPhotoNormalizationFailure(
"La photo normalisée est invalide. Réessayez avec une autre image.");
}
int sourceWidth = source.orientedWidth();
int sourceHeight = source.orientedHeight();
int frameWidth = sourceWidth >= sourceHeight ? LANDSCAPE_WIDTH : PORTRAIT_WIDTH;
int frameHeight = sourceWidth >= sourceHeight ? LANDSCAPE_HEIGHT : PORTRAIT_HEIGHT;
if (result.width() <= 0 || result.height() <= 0 || result.width() > frameWidth
|| result.height() > frameHeight || result.width() > sourceWidth || result.height() > sourceHeight) {
throw new PropertyConditionPhotoNormalizationFailure(
"Les dimensions de la photo normalisée sont invalides. Réessayez avec une autre image.");
}
List<String> command = command("identify");
command.add("-quiet");
command.add("-verbose");
command.add(output.toString());
String description = execute(command, directory,
"La photo normalisée n'a pas pu être vérifiée.").standardOutput().toLowerCase(Locale.ROOT);
if (description.contains("exif:") || description.contains("xmp:")
|| description.contains("profile-") || description.contains("comment:")) {
throw new PropertyConditionPhotoNormalizationFailure(
"Les métadonnées de la photo n'ont pas pu être supprimées. Réessayez avec une autre image.");
}
}
private List<String> command(String operation) {
List<String> command = new ArrayList<>();
command.add(properties.executable());
if (operation != null) {
command.add(operation);
}
limit(command, "memory", properties.memoryLimit().toBytes() + "B");
limit(command, "map", properties.mapLimit().toBytes() + "B");
limit(command, "disk", properties.diskLimit().toBytes() + "B");
limit(command, "width", Integer.toString(properties.maxSourceWidth()));
limit(command, "height", Integer.toString(properties.maxSourceHeight()));
// ImageMagick counts the source and destination during conversion; the application still requires one input.
limit(command, "list-length", "2");
limit(command, "thread", Integer.toString(properties.threads()));
limit(command, "time", Long.toString(Math.max(1, properties.timeout().toSeconds())));
return command;
}
private static void limit(List<String> command, String resource, String value) {
command.add("-limit");
command.add(resource);
command.add(value);
}
private CommandResult execute(List<String> command, Path directory, String message) {
CommandResult result = commandRunner.apply(List.copyOf(command), directory);
if (result.exitCode() != 0) {
LOGGER.info("ImageMagick rejected a property-condition photo: {}", result.standardError());
throw new PropertyConditionPhotoNormalizationFailure(message);
}
return result;
}
static ImageDetails parseDetails(String output) {
if (output == null) {
throw invalidImage();
}
List<String> lines = output.lines().filter(line -> !line.isBlank()).toList();
if (lines.size() != 1) {
throw new PropertyConditionPhotoNormalizationFailure(
"La photo doit contenir une seule image, sans animation. Choisissez un autre fichier.");
}
String[] values = lines.getFirst().split("\\|", -1);
if (values.length != 4) {
throw invalidImage();
}
try {
return new ImageDetails(values[0].toUpperCase(Locale.ROOT), Integer.parseInt(values[1]),
Integer.parseInt(values[2]), values[3]);
} catch (NumberFormatException failure) {
throw new PropertyConditionPhotoNormalizationFailure(
"La photo n'a pas pu être lue. Choisissez une autre image.", failure);
}
}
static String resizeFrame(ImageDetails details) {
return details.orientedWidth() >= details.orientedHeight()
? LANDSCAPE_WIDTH + "x" + LANDSCAPE_HEIGHT + ">"
: PORTRAIT_WIDTH + "x" + PORTRAIT_HEIGHT + ">";
}
private static boolean isWebP(byte[] content) {
return content.length >= 12 && content[0] == 'R' && content[1] == 'I' && content[2] == 'F'
&& content[3] == 'F' && content[8] == 'W' && content[9] == 'E' && content[10] == 'B'
&& content[11] == 'P';
}
static CommandResult run(List<String> command, Path directory, Duration timeout) {
Path standardOutput = directory.resolve("command-stdout");
Path standardError = directory.resolve("command-stderr");
try {
ProcessBuilder builder = new ProcessBuilder(command)
.directory(directory.toFile())
.redirectOutput(standardOutput.toFile())
.redirectError(standardError.toFile());
builder.environment().put("MAGICK_TEMPORARY_PATH", directory.toString());
Process process = builder.start();
if (!process.waitFor(timeout.toMillis(), TimeUnit.MILLISECONDS)) {
process.destroyForcibly();
process.waitFor();
throw new PropertyConditionPhotoNormalizationFailure(
"Le traitement de la photo a expiré. Réessayez avec une image moins grande.");
}
return new CommandResult(process.exitValue(), readLimited(standardOutput), readLimited(standardError));
} catch (InterruptedException failure) {
Thread.currentThread().interrupt();
throw new PropertyConditionPhotoNormalizationFailure(
"Le traitement de la photo a été interrompu. Réessayez.", failure);
} catch (IOException failure) {
throw new PropertyConditionPhotoNormalizationFailure(
"Le service de traitement des photos est indisponible. Réessayez plus tard.", failure);
}
}
private static String readLimited(Path path) throws IOException {
try (InputStream input = Files.newInputStream(path)) {
return new String(input.readNBytes(16_384), StandardCharsets.UTF_8);
}
}
private static PropertyConditionPhotoNormalizationFailure invalidImage() {
return new PropertyConditionPhotoNormalizationFailure(
"La photo doit être une image unique JPEG, PNG, WebP ou HEIC/HEIF valide.");
}
private static void delete(Path directory) {
if (directory == null) {
return;
}
try (var paths = Files.walk(directory)) {
for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) {
Files.deleteIfExists(path);
}
} catch (IOException failure) {
// ponytail: /tmp is the recovery boundary; add a sweeper only if real cleanup failures are observed.
LOGGER.warn("Property-condition photo temporary files could not be deleted", failure);
}
}
record ImageDetails(String format, int width, int height, String orientation) {
int orientedWidth() {
return swapsAxes(orientation) ? height : width;
}
int orientedHeight() {
return swapsAxes(orientation) ? width : height;
}
private static boolean swapsAxes(String orientation) {
return Set.of("LEFTTOP", "RIGHTTOP", "RIGHTBOTTOM", "LEFTBOTTOM")
.contains(orientation == null ? "" : orientation.replace("-", "")
.replace(" ", "").toUpperCase(Locale.ROOT));
}
}
record CommandResult(int exitCode, String standardOutput, String standardError) {
}
}
@@ -7,6 +7,7 @@ import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile.BuildingType;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile.ConstructionPeriod;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentRepository;
import fr.nessar.lmnporganizer.rental.domain.apartment.PrivateArea;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import java.math.BigDecimal;
@@ -82,6 +83,7 @@ public class MongoApartmentRepository implements ApartmentRepository {
apartment.purchasePrice(),
apartment.description(),
toDocument(apartment.legalProfile()),
apartment.privateAreas().stream().map(MongoApartmentRepository::toDocument).toList(),
apartment.documents().stream().map(MongoApartmentRepository::toDocument).toList(),
apartment.leaseReferences().stream().map(LeaseId::value).toList(),
persistedVersion(apartment.version()));
@@ -98,6 +100,7 @@ public class MongoApartmentRepository implements ApartmentRepository {
document.legalProfile() == null ? ApartmentLegalProfile.empty() : toDomain(document.legalProfile()),
document.leaseReferences() == null ? Set.of() : document.leaseReferences().stream()
.map(LeaseId::new).collect(java.util.stream.Collectors.toUnmodifiableSet()),
document.privateAreas().stream().map(MongoApartmentRepository::toDomain).toList(),
document.documents() == null ? List.of()
: document.documents().stream().map(MongoApartmentRepository::toDomain).toList(),
domainVersion(document.version()));
@@ -122,7 +125,7 @@ public class MongoApartmentRepository implements ApartmentRepository {
return new LegalProfileDocument(profile.taxIdentifier(),
profile.buildingType() == null ? null : profile.buildingType().name(), profile.ownershipRegime(),
profile.constructionPeriod() == null ? null : profile.constructionPeriod().code(),
profile.habitableSurface(), profile.roomCount(), profile.otherPrivateAreas(), profile.equipment(),
profile.habitableSurface(), profile.otherPrivateAreas(), profile.equipment(),
profile.heating(), profile.hotWater(), profile.energyRating(), profile.estimatedAnnualEnergyCost(),
profile.estimatedAnnualEnergyCostReferenceYear(), profile.destination(), profile.privateAccessories(),
profile.commonAreas(), profile.internetAccess(), profile.departmentCode());
@@ -132,13 +135,22 @@ public class MongoApartmentRepository implements ApartmentRepository {
return new ApartmentLegalProfile(document.taxIdentifier(),
document.buildingType() == null ? null : BuildingType.valueOf(document.buildingType()),
document.ownershipRegime(), ConstructionPeriod.fromCode(document.constructionPeriod()),
document.habitableSurface(), document.roomCount() == null ? 0 : document.roomCount(),
document.habitableSurface(),
document.otherPrivateAreas(), document.equipment(), document.heating(), document.hotWater(),
document.energyRating(), document.estimatedAnnualEnergyCost(),
document.estimatedAnnualEnergyCostReferenceYear(), document.destination(),
document.privateAccessories(), document.commonAreas(), document.internetAccess(), document.departmentCode());
}
private static PrivateAreaDocument toDocument(PrivateArea area) {
return new PrivateAreaDocument(area.id(), area.family().name(), area.subtype().name(), area.label());
}
private static PrivateArea toDomain(PrivateAreaDocument area) {
return new PrivateArea(area.id(), PrivateArea.Family.valueOf(area.family()),
PrivateArea.Subtype.valueOf(area.subtype()), area.label());
}
private static Long persistedVersion(long version) {
return version == -1 ? null : version;
}
@@ -157,12 +169,13 @@ record ApartmentDocument(
@Field(targetType = FieldType.DECIMAL128) BigDecimal purchasePrice,
String description,
LegalProfileDocument legalProfile,
List<PrivateAreaDocument> privateAreas,
List<ApartmentDocumentMetadata> documents,
List<String> leaseReferences,
@Version Long version) {
ApartmentDocument withVersion(Long version) {
return new ApartmentDocument(id, landlordId, name, address, purchasePrice, description, legalProfile,
documents, leaseReferences, version);
privateAreas, documents, leaseReferences, version);
}
}
@@ -177,7 +190,6 @@ record LegalProfileDocument(
String ownershipRegime,
String constructionPeriod,
@Field(targetType = FieldType.DECIMAL128) BigDecimal habitableSurface,
Integer roomCount,
String otherPrivateAreas,
String equipment,
String heating,
@@ -191,3 +203,6 @@ record LegalProfileDocument(
String internetAccess,
String departmentCode) {
}
record PrivateAreaDocument(String id, String family, String subtype, String label) {
}
@@ -0,0 +1,86 @@
package fr.nessar.lmnporganizer.rental.adapter.out.mongodb.condition;
import fr.nessar.lmnporganizer.rental.adapter.out.mongodb.LandlordScopedMongoWriter;
import fr.nessar.lmnporganizer.rental.domain.condition.*;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.Version;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.index.CompoundIndex;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
@Repository
public class MongoPropertyConditionReportRepository implements PropertyConditionReportRepository {
private final MongoTemplate mongo;
public MongoPropertyConditionReportRepository(MongoTemplate mongo) { this.mongo = mongo; }
@Override public Optional<PropertyConditionReport> find(LandlordId landlord, LeaseId lease, PropertyConditionReportType type) {
return Optional.ofNullable(mongo.findOne(query(landlord, lease, type), ReportDocument.class)).map(this::domain);
}
@Override public PropertyConditionReport save(PropertyConditionReport report) {
ReportDocument document = document(report);
return domain(LandlordScopedMongoWriter.save(mongo, document, document.id(), document.landlordId(), report.version(), document::withVersion));
}
private static Query query(LandlordId landlord, LeaseId lease, PropertyConditionReportType type) {
return Query.query(Criteria.where("landlordId").is(landlord.value()).and("leaseId").is(lease.value()).and("type").is(type.name()));
}
private ReportDocument document(PropertyConditionReport r) {
return new ReportDocument(r.id().value(), r.landlordId().value(), r.leaseId().value(), r.type().name(), r.occurredOn(), r.timeZone(),
r.participants().stream().map(x -> new ParticipantDocument(x.partyId(), x.role().name(), x.name(), x.attendance().name(), x.representativeName())).toList(),
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.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 ->
new SplitDocument(s.inventoryLineId(), s.state().name(), s.quantity(), s.observation())).toList())).toList(),
r.photos().stream().map(x -> new PhotoDocument(x.reference(), x.zoneId(), x.storageKey(), x.sha256(), x.width(), x.height(), x.uploadedBy(), x.uploadedAt())).toList(),
r.zonePhotoAbsences().stream().map(x -> new ZoneAbsenceDocument(x.zoneId(), absence(x.absence()))).toList(),
r.entryPhotoDispositions().stream().map(this::disposition).toList(), r.supplements().stream().map(this::supplement).toList(),
r.revisions().stream().map(this::revision).toList(), finalization(r.finalization()),
r.deliveryAttestations().stream().map(this::delivery).toList(), r.entrySnapshot() == null ? null : snapshot(r.entrySnapshot()),
r.leaseRevisionNumber(), r.leaseRevisionFingerprint(), r.status().name(), r.version() < 0 ? null : r.version());
}
private PropertyConditionReport domain(ReportDocument d) {
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.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(),
d.revisions().stream().map(this::revision).toList(), finalization(d.finalization()), d.deliveryAttestations().stream().map(this::delivery).toList(),
d.entrySnapshot() == null ? null : snapshot(d.entrySnapshot()), d.leaseRevisionNumber(), d.leaseRevisionFingerprint(),
PropertyConditionReportStatus.valueOf(d.status()), d.version() == null ? -1 : d.version());
}
private static AbsenceDocument absence(AuditedPhotoAbsence x) { return new AbsenceDocument(x.reason(), x.auditedBy(), x.auditedAt()); }
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 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 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 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) { }
}
@@ -0,0 +1,75 @@
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.FurnitureInventory;
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryId;
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryRepository;
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.lease.LeaseId;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.Version;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.index.CompoundIndex;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public class MongoFurnitureInventoryRepository implements FurnitureInventoryRepository {
private final MongoTemplate mongo;
public MongoFurnitureInventoryRepository(MongoTemplate mongo) { this.mongo = mongo; }
@Override
public Optional<FurnitureInventory> find(LandlordId landlordId, LeaseId leaseId) {
return Optional.ofNullable(mongo.findOne(query(landlordId, leaseId), InventoryDocument.class)).map(this::domain);
}
@Override
public FurnitureInventory save(FurnitureInventory inventory) {
InventoryDocument document = document(inventory);
return domain(LandlordScopedMongoWriter.save(mongo, document, document.id(), document.landlordId(),
inventory.version(), document::withVersion));
}
private static Query query(LandlordId landlordId, LeaseId leaseId) {
return Query.query(Criteria.where("landlordId").is(landlordId.value()).and("leaseId").is(leaseId.value()));
}
private InventoryDocument document(FurnitureInventory inventory) {
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(),
inventory.version() == -1 ? null : inventory.version());
}
private FurnitureInventory domain(InventoryDocument document) {
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(),
document.frozen(), document.version() == null ? -1 : document.version());
}
@Document(collection = "furnitureInventories")
@CompoundIndex(name = "furniture_inventory_lease_unique", def = "{'landlordId': 1, 'leaseId': 1}", unique = true)
record InventoryDocument(@Id String id, String landlordId, String leaseId, String templateVersion,
List<LineDocument> lines, boolean frozen, @Version Long version) {
InventoryDocument withVersion(Long version) {
return new InventoryDocument(id, landlordId, leaseId, templateVersion, lines, frozen, version);
}
}
record LineDocument(String id, String category, String presence, String privateAreaId, String designation, int quantity,
String description) { }
}
@@ -2,6 +2,10 @@ package fr.nessar.lmnporganizer.rental.adapter.out.mongodb.lease;
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.FurnitureInventorySnapshot;
import fr.nessar.lmnporganizer.rental.domain.furniture.FurniturePresence;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentAnnex;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentRevision;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentRevisionConflict;
@@ -13,7 +17,6 @@ import org.springframework.data.annotation.Id;
import org.springframework.data.domain.Sort;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.index.CompoundIndex;
import org.springframework.data.mongodb.core.index.CompoundIndexes;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
@@ -70,6 +73,7 @@ public class MongoLeaseDocumentRevisionRepository implements LeaseDocumentRevisi
return new RevisionDocument(revision.id(), revision.landlordId().value(), revision.leaseId().value(),
revision.number(), revision.templateVersion(), revision.sourceFingerprint(), revision.storageKey(),
revision.sha256(), revision.generatedAt(), revision.authorId(),
toDocument(revision.furnitureInventory()), revision.privateAreas().stream().map(this::toDocument).toList(),
revision.annexes().stream().map(this::toDocument).toList(),
revision.warnings().stream().map(this::toDocument).toList());
}
@@ -78,7 +82,33 @@ public class MongoLeaseDocumentRevisionRepository implements LeaseDocumentRevisi
return new LeaseDocumentRevision(document.id(), new LandlordId(document.landlordId()),
new LeaseId(document.leaseId()), document.number(), document.templateVersion(),
document.sourceFingerprint(), document.storageKey(), document.sha256(), document.generatedAt(),
document.authorId(), annexes(document.annexes()), warnings(document.warnings()));
document.authorId(), toDomain(document.furnitureInventory()), privateAreas(document.privateAreas()),
annexes(document.annexes()), warnings(document.warnings()));
}
private FurnitureInventoryDocument toDocument(FurnitureInventorySnapshot inventory) {
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());
}
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());
}
private PrivateAreaDocument toDocument(PrivateArea privateArea) {
return new PrivateAreaDocument(privateArea.id(), privateArea.family().name(), privateArea.subtype().name(),
privateArea.label());
}
private static List<PrivateArea> privateAreas(List<PrivateAreaDocument> privateAreas) {
return privateAreas.stream().map(area -> new PrivateArea(area.id(), PrivateArea.Family.valueOf(area.family()),
PrivateArea.Subtype.valueOf(area.subtype()), area.label())).toList();
}
private AnnexDocument toDocument(LeaseDocumentAnnex annex) {
@@ -109,15 +139,23 @@ public class MongoLeaseDocumentRevisionRepository implements LeaseDocumentRevisi
}
@Document(collection = "leaseDocumentRevisions")
@CompoundIndexes({
@CompoundIndex(name = "lease_revision_number_unique",
def = "{'landlordId': 1, 'leaseId': 1, 'number': 1}", unique = true),
@CompoundIndex(name = "lease_revision_fingerprint_unique",
def = "{'landlordId': 1, 'leaseId': 1, 'sourceFingerprint': 1}", unique = true)
})
@CompoundIndex(name = "lease_revision_number_unique", def = "{'landlordId': 1, 'leaseId': 1, 'number': 1}",
unique = true)
record RevisionDocument(@Id String id, String landlordId, String leaseId, int number, String templateVersion,
String sourceFingerprint, String storageKey, String sha256, Instant generatedAt,
String authorId, List<AnnexDocument> annexes, List<WarningDocument> warnings) {
String authorId, FurnitureInventoryDocument furnitureInventory,
List<PrivateAreaDocument> privateAreas, List<AnnexDocument> annexes,
List<WarningDocument> warnings) {
}
record FurnitureInventoryDocument(String templateVersion, List<FurnitureInventoryLineDocument> lines) {
}
record FurnitureInventoryLineDocument(String id, String category, String presence, String privateAreaId,
String designation, int quantity, String description) {
}
record PrivateAreaDocument(String id, String family, String subtype, String label) {
}
record AnnexDocument(String category, String documentType, String sourceId, String version, String fileName,
@@ -51,7 +51,10 @@ public class MongoLeaseFinalizationRepository implements LeaseFinalizationReposi
finalization.leaseId().value(), finalization.revisionNumber(), finalization.templateVersion(),
finalization.lastSignatureDate(), finalization.signedStorageKey(), finalization.signedFileName(),
finalization.signedSha256(), finalization.authorId(), finalization.finalizedAt(),
finalization.overrides().stream().map(this::toDocument).toList());
finalization.overrides().stream().map(this::toDocument).toList(),
finalization.entryInventoryDiscrepancies(),
finalization.inventoryCorrections().stream().map(this::toDocument).toList(),
finalization.allInventoryCorrectionsInitialed());
}
private OverrideDocument toDocument(LeaseFinalization.LegalOverride override) {
@@ -59,13 +62,21 @@ public class MongoLeaseFinalizationRepository implements LeaseFinalizationReposi
override.confirmedAt());
}
private InventoryCorrectionDocument toDocument(LeaseFinalization.InventoryCorrection correction) {
return new InventoryCorrectionDocument(correction.id(), correction.inventoryLineId(),
correction.privateAreaId(), correction.designation(), correction.quantity(), correction.description());
}
private LeaseFinalization toDomain(FinalizationDocument document) {
List<LeaseFinalization.LegalOverride> overrides = document.overrides() == null ? List.of()
: document.overrides().stream().map(this::toDomain).toList();
List<LeaseFinalization.LegalOverride> overrides = document.overrides().stream().map(this::toDomain).toList();
List<LeaseFinalization.InventoryCorrection> inventoryCorrections = document.inventoryCorrections().stream()
.map(this::toDomain).toList();
return new LeaseFinalization(document.id(), new LandlordId(document.landlordId()),
new LeaseId(document.leaseId()), document.revisionNumber(), document.templateVersion(),
document.lastSignatureDate(), document.signedStorageKey(), document.signedFileName(),
document.signedSha256(), document.authorId(), document.finalizedAt(), overrides);
document.signedSha256(), document.authorId(), document.finalizedAt(), overrides,
document.entryInventoryDiscrepancies(), inventoryCorrections,
document.allInventoryCorrectionsInitialed());
}
private LeaseFinalization.LegalOverride toDomain(OverrideDocument document) {
@@ -73,14 +84,25 @@ public class MongoLeaseFinalizationRepository implements LeaseFinalizationReposi
document.confirmedAt());
}
private LeaseFinalization.InventoryCorrection toDomain(InventoryCorrectionDocument document) {
return new LeaseFinalization.InventoryCorrection(document.id(), document.inventoryLineId(),
document.privateAreaId(), document.designation(), document.quantity(), document.description());
}
@Document(collection = "leaseFinalizations")
@CompoundIndex(name = "lease_finalization_unique", def = "{'landlordId': 1, 'leaseId': 1}", unique = true)
record FinalizationDocument(@Id String id, String landlordId, String leaseId, int revisionNumber,
String templateVersion, LocalDate lastSignatureDate, String signedStorageKey,
String signedFileName, String signedSha256, String authorId, Instant finalizedAt,
List<OverrideDocument> overrides) {
List<OverrideDocument> overrides, boolean entryInventoryDiscrepancies,
List<InventoryCorrectionDocument> inventoryCorrections,
boolean allInventoryCorrectionsInitialed) {
}
record OverrideDocument(String code, String ruleVersion, String authorId, Instant confirmedAt) {
}
record InventoryCorrectionDocument(String id, String inventoryLineId, String privateAreaId, String designation,
int quantity, String description) {
}
}
@@ -5,6 +5,7 @@ import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile.BuildingType;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile.ConstructionPeriod;
import fr.nessar.lmnporganizer.rental.domain.apartment.PrivateArea;
import fr.nessar.lmnporganizer.rental.domain.lease.ChargeMethod;
import fr.nessar.lmnporganizer.rental.domain.lease.AgreedEarlyLeaseEnd;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
@@ -205,7 +206,8 @@ public class MongoLeaseRepository implements LeaseRepository {
snapshot.landlord().lastName(), snapshot.landlord().email(), snapshot.landlord().phone(),
snapshot.landlord().address()),
new ApartmentSnapshotDocument(snapshot.apartment().id().value(), snapshot.apartment().name(),
snapshot.apartment().address(), toDocument(snapshot.apartment().legalProfile())),
snapshot.apartment().address(), toDocument(snapshot.apartment().legalProfile()),
snapshot.apartment().privateAreas().stream().map(MongoLeaseRepository::toDocument).toList()),
snapshot.tenants().stream().map(tenant -> new TenantSnapshotDocument(tenant.id().value(),
tenant.firstName(), tenant.lastName(), tenant.email(), tenant.phone(), tenant.address()))
.toList(),
@@ -225,7 +227,8 @@ public class MongoLeaseRepository implements LeaseRepository {
document.landlord().phone(), document.landlord().address()),
new LeaseSnapshot.Apartment(new ApartmentId(document.apartment().id()),
document.apartment().name(), document.apartment().address(),
toDomain(document.apartment().legalProfile())),
toDomain(document.apartment().legalProfile()), document.apartment().privateAreas().stream()
.map(MongoLeaseRepository::toDomain).toList()),
document.tenants().stream().map(tenant -> new LeaseSnapshot.Tenant(new TenantId(tenant.id()),
tenant.firstName(), tenant.lastName(), tenant.email(), tenant.phone(), tenant.address()))
.toList(),
@@ -253,7 +256,8 @@ public class MongoLeaseRepository implements LeaseRepository {
version.landlord().lastName(), version.landlord().email(), version.landlord().phone(),
version.landlord().address()),
new ApartmentSnapshotDocument(version.apartment().id().value(), version.apartment().name(),
version.apartment().address(), toDocument(version.apartment().legalProfile())),
version.apartment().address(), toDocument(version.apartment().legalProfile()),
version.apartment().privateAreas().stream().map(MongoLeaseRepository::toDocument).toList()),
version.tenants().stream().map(tenant -> new TenantSnapshotDocument(tenant.id().value(),
tenant.firstName(), tenant.lastName(), tenant.email(), tenant.phone(), tenant.address()))
.toList(),
@@ -273,7 +277,8 @@ public class MongoLeaseRepository implements LeaseRepository {
document.landlord().phone(), document.landlord().address()),
new LeaseSnapshot.Apartment(new ApartmentId(document.apartment().id()),
document.apartment().name(), document.apartment().address(),
toDomain(document.apartment().legalProfile())),
toDomain(document.apartment().legalProfile()), document.apartment().privateAreas().stream()
.map(MongoLeaseRepository::toDomain).toList()),
document.tenants().stream().map(tenant -> new LeaseSnapshot.Tenant(new TenantId(tenant.id()),
tenant.firstName(), tenant.lastName(), tenant.email(), tenant.phone(), tenant.address()))
.toList(),
@@ -352,7 +357,7 @@ public class MongoLeaseRepository implements LeaseRepository {
return new ApartmentLegalProfileDocument(profile.taxIdentifier(),
profile.buildingType() == null ? null : profile.buildingType().name(), profile.ownershipRegime(),
profile.constructionPeriod() == null ? null : profile.constructionPeriod().code(),
profile.habitableSurface(), profile.roomCount(), profile.otherPrivateAreas(), profile.equipment(),
profile.habitableSurface(), profile.otherPrivateAreas(), profile.equipment(),
profile.heating(), profile.hotWater(), profile.energyRating(), profile.estimatedAnnualEnergyCost(),
profile.estimatedAnnualEnergyCostReferenceYear(), profile.destination(), profile.privateAccessories(),
profile.commonAreas(), profile.internetAccess(), profile.departmentCode());
@@ -365,7 +370,7 @@ public class MongoLeaseRepository implements LeaseRepository {
return new ApartmentLegalProfile(document.taxIdentifier(),
document.buildingType() == null ? null : BuildingType.valueOf(document.buildingType()),
document.ownershipRegime(), ConstructionPeriod.fromCode(document.constructionPeriod()),
document.habitableSurface(), document.roomCount() == null ? 0 : document.roomCount(),
document.habitableSurface(),
document.otherPrivateAreas(), document.equipment(), document.heating(), document.hotWater(),
document.energyRating(), document.estimatedAnnualEnergyCost(),
document.estimatedAnnualEnergyCostReferenceYear(), document.destination(),
@@ -373,6 +378,15 @@ public class MongoLeaseRepository implements LeaseRepository {
document.departmentCode());
}
private static PrivateAreaSnapshotDocument toDocument(PrivateArea area) {
return new PrivateAreaSnapshotDocument(area.id(), area.family().name(), area.subtype().name(), area.label());
}
private static PrivateArea toDomain(PrivateAreaSnapshotDocument area) {
return new PrivateArea(area.id(), PrivateArea.Family.valueOf(area.family()),
PrivateArea.Subtype.valueOf(area.subtype()), area.label());
}
private static RentTerms rentTerms(BigDecimal monthlyRent, BigDecimal monthlyCharges, BigDecimal securityDeposit,
Integer dueDay, String chargeMethod, Boolean indexFlatRate) {
return new RentTerms(monthlyRent, monthlyCharges, securityDeposit,
@@ -464,13 +478,17 @@ public class MongoLeaseRepository implements LeaseRepository {
}
record ApartmentSnapshotDocument(String id, String name, String address,
ApartmentLegalProfileDocument legalProfile) {
ApartmentLegalProfileDocument legalProfile,
List<PrivateAreaSnapshotDocument> privateAreas) {
}
record PrivateAreaSnapshotDocument(String id, String family, String subtype, String label) {
}
record ApartmentLegalProfileDocument(String taxIdentifier, String buildingType, String ownershipRegime,
String constructionPeriod,
@Field(targetType = FieldType.DECIMAL128) BigDecimal habitableSurface,
Integer roomCount, String otherPrivateAreas, String equipment,
String otherPrivateAreas, String equipment,
String heating, String hotWater, String energyRating,
@Field(targetType = FieldType.DECIMAL128)
BigDecimal estimatedAnnualEnergyCost,
@@ -0,0 +1,109 @@
package fr.nessar.lmnporganizer.rental.adapter.out.pdf;
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
import fr.nessar.lmnporganizer.rental.application.port.out.PropertyConditionReportRenderer;
import fr.nessar.lmnporganizer.rental.domain.condition.AccessMeansType;
import fr.nessar.lmnporganizer.rental.domain.condition.ConditionState;
import fr.nessar.lmnporganizer.rental.domain.condition.MeterType;
import fr.nessar.lmnporganizer.rental.domain.condition.ParticipantAttendance;
import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReport;
import fr.nessar.lmnporganizer.rental.domain.condition.ReportPartyRole;
import fr.nessar.lmnporganizer.rental.domain.condition.ZoneEquipment;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import org.springframework.stereotype.Component;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Comparator;
import java.util.Locale;
import java.util.Optional;
import java.util.stream.Collectors;
@Component
public final class ThymeleafPropertyConditionReportRenderer implements PropertyConditionReportRenderer {
private final TemplateEngine templates;
public ThymeleafPropertyConditionReportRenderer(TemplateEngine templates) {
this.templates = templates;
}
@Override
public byte[] render(PropertyConditionReport report, int revisionNumber, String sourceFingerprint) {
Context context = new Context(Locale.FRANCE);
context.setVariable("report", report);
context.setVariable("revisionNumber", revisionNumber);
context.setVariable("sourceFingerprint", sourceFingerprint);
context.setVariable("labels", new PropertyConditionReportPrintLabels());
context.setVariable("zoneLabels", report.zones().stream()
.collect(Collectors.toMap(value -> value.id(), value -> value.label())));
context.setVariable("participantLabels", report.participants().stream()
.collect(Collectors.toMap(value -> value.partyId(), value -> value.name())));
context.setVariable("photoCount", report.photos().size());
context.setVariable("photoReferenceRange", photoReferenceRange(report));
try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
new PdfRendererBuilder().withHtmlContent(templates.process("rental/property-condition-report", context), null)
.toStream(output).run();
return output.toByteArray();
} catch (IOException failure) {
throw new IllegalStateException("Property condition report PDF could not be generated.", failure);
}
}
private String photoReferenceRange(PropertyConditionReport report) {
Optional<String> first = report.photos().stream().map(value -> value.reference()).min(Comparator.naturalOrder());
Optional<String> last = report.photos().stream().map(value -> value.reference()).max(Comparator.naturalOrder());
if (first.isEmpty()) {
return "Aucune référence";
}
return first.equals(last) ? first.get() : first.get() + " à " + last.orElseThrow();
}
public static final class PropertyConditionReportPrintLabels {
public String role(ReportPartyRole value) {
return value == ReportPartyRole.LANDLORD ? "Bailleur" : "Locataire";
}
public String attendance(ParticipantAttendance value) {
return value == ParticipantAttendance.PRESENT ? "Présent" : "Représenté";
}
public String equipment(ZoneEquipment value) {
return switch (value) {
case NONE -> "Aucun";
case CLIMATISATION -> "Climatisation";
case OTHER -> "Autre";
};
}
public String condition(ConditionState value) {
return switch (value) {
case NEUF -> "Neuf";
case BON_ETAT -> "Bon état";
case ETAT_USAGE -> "État d’usage";
case DEGRADE -> "Dégradé";
case ABSENT -> "Absent";
case NON_APPLICABLE -> "Non applicable";
};
}
public String meter(MeterType value) {
return switch (value) {
case ELECTRICITE -> "Électricité";
case GAZ -> "Gaz";
case EAU_FROIDE -> "Eau froide";
case EAU_CHAUDE -> "Eau chaude";
case OTHER -> "Autre";
};
}
public String access(AccessMeansType value) {
return switch (value) {
case CLE -> "Clé";
case BADGE -> "Badge";
case TELECOMMANDE -> "Télécommande";
case OTHER -> "Autre";
};
}
}
}
@@ -4,6 +4,8 @@ import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
import fr.nessar.lmnporganizer.rental.application.port.out.RentalDocumentRenderer;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile.BuildingType;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile.ConstructionPeriod;
import fr.nessar.lmnporganizer.rental.domain.apartment.PrivateArea;
import fr.nessar.lmnporganizer.rental.domain.furniture.FurniturePresence;
import fr.nessar.lmnporganizer.rental.domain.lease.ChargeMethod;
import fr.nessar.lmnporganizer.rental.domain.lease.FurnishedLeaseTemplate;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
@@ -200,9 +202,17 @@ public class ThymeleafRentalDocumentRenderer implements RentalDocumentRenderer {
return type == LeaseType.STANDARD;
}
public String privateArea(List<PrivateArea> areas, String id) {
return areas.stream().filter(area -> area.id().equals(id)).findFirst().map(PrivateArea::label)
.orElse(id);
}
public String furniturePresence(FurniturePresence presence) {
return presence == FurniturePresence.ABSENT ? "Absent" : "Présent";
}
public boolean october2026(String version) {
return !"LEGACY".equals(version) && !FurnishedLeaseTemplate.fromVisibleId(version).effectiveFrom()
.isBefore(LocalDate.of(2026, 10, 1));
return !FurnishedLeaseTemplate.fromVisibleId(version).effectiveFrom().isBefore(LocalDate.of(2026, 10, 1));
}
}
}
@@ -9,6 +9,7 @@ import fr.nessar.lmnporganizer.rental.domain.apartment.Apartment;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
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.landlord.LandlordId;
import java.math.BigDecimal;
@@ -37,7 +38,8 @@ public final class ApartmentCatalogService {
public Apartment create(LandlordId landlordId, Details details) {
return apartments.save(new Apartment(new ApartmentId(identifiers.next()), landlordId, details.name(),
details.address(), details.purchasePrice(), details.description(),
details.legalProfile() == null ? ApartmentLegalProfile.empty() : details.legalProfile()));
details.legalProfile() == null ? ApartmentLegalProfile.empty() : details.legalProfile(),
details.privateAreas()));
}
public Apartment revise(LandlordId landlordId, ApartmentId apartmentId, Details details) {
@@ -46,6 +48,7 @@ public final class ApartmentCatalogService {
if (details.legalProfile() != null) {
apartment.reviseLegalProfile(details.legalProfile());
}
apartment.replacePrivateAreas(details.privateAreas());
return apartments.save(apartment);
}
@@ -63,9 +66,9 @@ public final class ApartmentCatalogService {
}
public record Details(String name, String address, BigDecimal purchasePrice, String description,
ApartmentLegalProfile legalProfile) {
public Details(String name, String address, BigDecimal purchasePrice, String description) {
this(name, address, purchasePrice, description, null);
ApartmentLegalProfile legalProfile, List<PrivateArea> privateAreas) {
public Details {
privateAreas = List.copyOf(privateAreas);
}
}
}
@@ -0,0 +1,402 @@
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.PropertyConditionReportRenderer;
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
import fr.nessar.lmnporganizer.rental.application.shared.GeneratedDocument;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.domain.condition.ConditionObservation;
import fr.nessar.lmnporganizer.rental.domain.condition.ConditionPhoto;
import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReport;
import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportDeliveryAttestation;
import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportFinalization;
import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportRepository;
import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportRevision;
import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportSupplement;
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.lease.LeaseFinalizationRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentRevisionRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
/** Document lifecycle for the independent PropertyConditionReport aggregate. */
public final class PropertyConditionDocumentService {
private final PropertyConditionReportRepository reports;
private final PropertyConditionAttachmentStorage storage;
private final PropertyConditionReportRenderer renderer;
private final LeaseFinalizationRepository leaseFinalizations;
private final LeaseRepository leases;
private final LeaseDocumentRevisionRepository leaseRevisions;
private final IdentifierGenerator identifiers;
private final Clock clock;
public PropertyConditionDocumentService(PropertyConditionReportRepository reports,
PropertyConditionAttachmentStorage storage,
PropertyConditionReportRenderer renderer,
LeaseFinalizationRepository leaseFinalizations,
LeaseRepository leases,
LeaseDocumentRevisionRepository leaseRevisions,
IdentifierGenerator identifiers, Clock clock) {
this.reports = reports;
this.storage = storage;
this.renderer = renderer;
this.leaseFinalizations = leaseFinalizations;
this.leases = leases;
this.leaseRevisions = leaseRevisions;
this.identifiers = identifiers;
this.clock = clock;
}
public PropertyConditionReportRevision generateRevision(LandlordId landlord, LeaseId lease,
PropertyConditionReportType type, long version,
String authorId) {
PropertyConditionReport report = report(landlord, lease, type);
requireVersion(report, version);
requireCurrentLeaseRevision(report, landlord, lease);
validateCurrentLease(report, leases.find(landlord, lease)
.orElseThrow(() -> new ResourceNotFound("Lease")), landlord, lease);
if (report.finalized()) throw new IllegalStateException("L'état des lieux est finalisé.");
requireEntryCompleteness(report, landlord, lease);
report.requireReadyForSignature();
String fingerprint = fingerprint(report);
PropertyConditionReportRevision current = report.currentRevision();
if (current != null && current.sourceFingerprint().equals(fingerprint)) return current;
int number = report.revisions().stream().mapToInt(PropertyConditionReportRevision::number).max().orElse(0) + 1;
byte[] pdf = renderer.render(report, number, fingerprint);
String storageKey = storage.store(lease, identifiers.next(), "pdf", pdf);
try {
report.addRevision(new PropertyConditionReportRevision(number, storageKey, sha256(pdf), fingerprint,
authorId, Instant.now(clock), false));
return reports.save(report).currentRevision();
} catch (RuntimeException failure) {
discard(storageKey, failure);
throw failure;
}
}
public GeneratedDocument downloadRevision(LandlordId landlord, LeaseId lease, PropertyConditionReportType type,
int number) {
PropertyConditionReportRevision revision = report(landlord, lease, type).revisions().stream()
.filter(value -> value.number() == number).findFirst()
.orElseThrow(() -> new ResourceNotFound("Property condition report revision"));
byte[] content = read(revision.storageKey());
if (!revision.sha256().equals(sha256(content))) throw new IllegalStateException("Report revision fingerprint changed.");
return new GeneratedDocument("etat-des-lieux-" + lease.value() + "-revision-" + number + ".pdf",
"application/pdf", content);
}
public GeneratedDocument downloadPhoto(LandlordId landlord, LeaseId lease, PropertyConditionReportType type,
String reference) {
ConditionPhoto photo = report(landlord, lease, type).photos().stream()
.filter(value -> value.reference().equals(reference)).findFirst()
.orElseThrow(() -> new ResourceNotFound("Property condition report photo"));
byte[] content = read(photo.storageKey());
if (!photo.sha256().equals(sha256(content))) {
throw new IllegalStateException("Report photo fingerprint changed.");
}
return new GeneratedDocument(reference + ".webp", "image/webp", content);
}
public PropertyConditionReport finalizeReport(LandlordId landlord, LeaseId lease, PropertyConditionReportType type,
long version, boolean attestation, boolean allPartiesSigned,
byte[] signedScan,
String authorId) {
if (!attestation) throw new IllegalArgumentException("L'attestation de correspondance est obligatoire.");
if (!allPartiesSigned) throw new IllegalArgumentException("La signature de toutes les parties est obligatoire.");
if (!isPdf(signedScan)) throw new IllegalArgumentException("Le scan signé doit être un PDF valide.");
PropertyConditionReport report = report(landlord, lease, type);
requireVersion(report, version);
requireCurrentLeaseRevision(report, landlord, lease);
if (leaseFinalizations.find(landlord, lease).isEmpty()) {
throw new IllegalStateException("Le bail doit être finalisé avant l'état des lieux.");
}
validateFinalizedLease(report, leases.find(landlord, lease)
.orElseThrow(() -> new ResourceNotFound("Lease")));
requireEntryCompleteness(report, landlord, lease);
PropertyConditionReportRevision revision = report.currentRevision();
if (revision == null) throw new IllegalStateException("La dernière révision doit être générée avant finalisation.");
String storageKey = storage.store(lease, identifiers.next(), "pdf", signedScan);
try {
report.finalizeReport(new PropertyConditionReportFinalization(revision.number(), storageKey,
sha256(signedScan), authorId, Instant.now(clock)));
return reports.save(report);
} catch (RuntimeException failure) {
discard(storageKey, failure);
throw failure;
}
}
public PropertyConditionReport attestExternalDelivery(LandlordId landlord, LeaseId lease,
PropertyConditionReportType type, long version,
String partyId, String authorId) {
PropertyConditionReport report = report(landlord, lease, type);
requireVersion(report, version);
report.attestExternalDelivery(new PropertyConditionReportDeliveryAttestation(partyId, authorId, Instant.now(clock)));
return reports.save(report);
}
public GeneratedDocument downloadBundle(LandlordId landlord, LeaseId lease, PropertyConditionReportType type) {
PropertyConditionReport report = report(landlord, lease, type);
if (!report.finalized()) throw new IllegalStateException("L'état des lieux doit être finalisé.");
TreeMap<String, BundleFile> files = new TreeMap<>();
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());
}
String manifest = manifest(files);
try (ByteArrayOutputStream output = new ByteArrayOutputStream();
ZipOutputStream zip = new ZipOutputStream(output, StandardCharsets.UTF_8)) {
for (BundleFile file : files.values()) write(zip, file.name(), file.content());
write(zip, "manifest.sha256", manifest.getBytes(StandardCharsets.UTF_8));
zip.finish();
return new GeneratedDocument("dossier-etat-des-lieux-" + lease.value() + ".zip", "application/zip",
output.toByteArray());
} catch (IOException failure) {
throw new IllegalStateException("Property condition report bundle could not be generated.", failure);
}
}
public PropertyConditionComparison comparison(LandlordId landlord, LeaseId lease) {
PropertyConditionReport entry = report(landlord, lease, PropertyConditionReportType.ENTRY);
PropertyConditionReport exit = report(landlord, lease, PropertyConditionReportType.EXIT);
if (!entry.finalized() || !exit.finalized()) throw new IllegalStateException("Les deux états des lieux doivent être finalisés.");
var snapshot = exit.entrySnapshot();
if (snapshot == null) throw new IllegalStateException("L'instantané d'entrée est introuvable.");
return new PropertyConditionComparison(aligned(snapshot.observations(), exit.observations(), ConditionObservation::subjectId),
aligned(snapshot.zones(), exit.zones(), value -> value.id()),
aligned(snapshot.inventoryConditions(), exit.inventoryConditions(), value -> value.inventoryLineId()),
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));
}
private void addPhotos(Map<String, BundleFile> files, List<ConditionPhoto> photos) {
photos.stream().sorted(Comparator.comparing(ConditionPhoto::reference))
.forEach(photo -> add(files, "photos/" + photo.reference() + ".webp", photo.storageKey(), photo.sha256()));
}
private void addSupplements(Map<String, BundleFile> files, List<PropertyConditionReportSupplement> supplements) {
supplements.stream().sorted(Comparator.comparing(PropertyConditionReportSupplement::id)).forEach(supplement -> {
var pdf = supplement.signedPdf();
add(files, "complements/" + supplement.id() + "/signe-" + pdf.reference() + ".pdf",
pdf.storageKey(), pdf.sha256());
supplement.photos().stream().sorted(Comparator.comparing(value -> value.reference())).forEach(photo ->
add(files, "complements/" + supplement.id() + "/photos/" + photo.reference() + ".webp",
photo.storageKey(), photo.sha256()));
});
}
private void add(Map<String, BundleFile> files, String name, String storageKey, String expectedSha256) {
byte[] content = read(storageKey);
if (!expectedSha256.equals(sha256(content))) throw new IllegalStateException("Report bundle fingerprint changed.");
BundleFile prior = files.putIfAbsent(name, new BundleFile(name, expectedSha256, content));
if (prior != null && !prior.sha256().equals(expectedSha256)) throw new IllegalStateException("Report bundle has duplicate names.");
}
private static String manifest(Map<String, BundleFile> files) {
return files.values().stream().map(file -> file.sha256() + " " + file.name()).reduce("", (left, line) -> left + line + "\n");
}
private static void write(ZipOutputStream zip, String name, byte[] content) throws IOException {
ZipEntry entry = new ZipEntry(name);
entry.setTime(0L);
zip.putNextEntry(entry);
zip.write(content);
zip.closeEntry();
}
private static <T> List<PropertyConditionComparison.Line<T>> aligned(List<T> entry, List<T> exit,
java.util.function.Function<T, String> id) {
Map<String, T> entries = entry.stream().collect(java.util.stream.Collectors.toMap(id, value -> value));
Map<String, T> exits = exit.stream().collect(java.util.stream.Collectors.toMap(id, value -> value));
Set<String> references = new java.util.TreeSet<>();
references.addAll(entries.keySet());
references.addAll(exits.keySet());
return references.stream().map(reference -> new PropertyConditionComparison.Line<>(reference,
entries.get(reference), exits.get(reference), !java.util.Objects.equals(entries.get(reference), exits.get(reference)))).toList();
}
private static List<PropertyConditionComparison.PhotoLine> photoComparison(List<ConditionPhoto> snapshotPhotos,
PropertyConditionReport exit) {
Map<String, ConditionPhoto> entryPhotos = snapshotPhotos.stream().collect(java.util.stream.Collectors.toMap(
ConditionPhoto::reference, value -> value));
Map<String, ConditionPhoto> exitPhotos = exit.photos().stream().collect(java.util.stream.Collectors.toMap(
ConditionPhoto::reference, value -> value));
Map<String, fr.nessar.lmnporganizer.rental.domain.condition.EntryPhotoDisposition> dispositions = exit.entryPhotoDispositions()
.stream().collect(java.util.stream.Collectors.toMap(value -> value.entryPhotoId(), value -> value));
List<PropertyConditionComparison.PhotoLine> lines = new ArrayList<>();
entryPhotos.keySet().stream().sorted().forEach(reference -> {
var disposition = dispositions.get(reference);
ConditionPhoto exitPhoto = disposition instanceof fr.nessar.lmnporganizer.rental.domain.condition.MatchedEntryPhoto matched
? exitPhotos.remove(matched.exitPhotoId()) : null;
fr.nessar.lmnporganizer.rental.domain.condition.AuditedPhotoAbsence absence = disposition instanceof fr.nessar.lmnporganizer.rental.domain.condition.MissingEntryPhoto missing
? missing.absence() : null;
lines.add(new PropertyConditionComparison.PhotoLine(reference, entryPhotos.get(reference), exitPhoto, absence,
exitPhoto == null || absence != null));
});
exitPhotos.values().stream().sorted(Comparator.comparing(ConditionPhoto::reference)).forEach(photo ->
lines.add(new PropertyConditionComparison.PhotoLine(photo.reference(), null, photo, null, true)));
return List.copyOf(lines);
}
private PropertyConditionReport report(LandlordId landlord, LeaseId lease, PropertyConditionReportType type) {
leases.find(landlord, lease).orElseThrow(() -> new ResourceNotFound("Lease"));
return reports.find(landlord, lease, type).orElseThrow(() -> new ResourceNotFound("Property condition report"));
}
private void requireCurrentLeaseRevision(PropertyConditionReport report, LandlordId landlord, LeaseId lease) {
if (report.type() != PropertyConditionReportType.ENTRY) return;
var current = leaseRevisions.findCurrent(landlord, lease)
.orElseThrow(() -> new IllegalStateException("La révision du bail est introuvable."));
if (!report.referencesLeaseRevision(current.number(), current.sourceFingerprint())) {
report.markObsolete();
reports.save(report);
throw new IllegalStateException("Le brouillon d'état des lieux est obsolète.");
}
}
private static void validateFinalizedLease(PropertyConditionReport report,
fr.nessar.lmnporganizer.rental.domain.lease.Lease lease) {
var snapshot = lease.snapshot();
if (snapshot == null) throw new IllegalStateException("Le bail doit être finalisé avant l'état des lieux.");
Map<String, fr.nessar.lmnporganizer.rental.domain.condition.ReportPartyRole> expected = new TreeMap<>();
expected.put(snapshot.landlord().id().value(), fr.nessar.lmnporganizer.rental.domain.condition.ReportPartyRole.LANDLORD);
snapshot.tenants().forEach(tenant -> expected.put(tenant.id().value(),
fr.nessar.lmnporganizer.rental.domain.condition.ReportPartyRole.TENANT));
Map<String, String> actual = new TreeMap<>();
report.participants().forEach(participant -> actual.put(participant.partyId(), participant.role() + "|" + participant.name()));
Map<String, String> expectedNames = new TreeMap<>();
expectedNames.put(snapshot.landlord().id().value(), "LANDLORD|" + snapshot.landlord().firstName() + " " + snapshot.landlord().lastName());
snapshot.tenants().forEach(tenant -> expectedNames.put(tenant.id().value(), "TENANT|" + tenant.firstName() + " " + tenant.lastName()));
if (!expectedNames.equals(actual)) throw new IllegalStateException("Les parties doivent correspondre au bail finalisé.");
Set<String> areas = snapshot.apartment().privateAreas().stream().map(area -> area.id())
.collect(java.util.stream.Collectors.toSet());
if (report.zones().stream().anyMatch(zone -> !zone.privateAreaId().isEmpty() && !areas.contains(zone.privateAreaId()))) {
throw new IllegalStateException("Une zone ne figure pas dans l'instantané du logement.");
}
}
private void validateCurrentLease(PropertyConditionReport report,
fr.nessar.lmnporganizer.rental.domain.lease.Lease lease,
LandlordId landlord, LeaseId leaseId) {
Map<String, fr.nessar.lmnporganizer.rental.domain.condition.ReportPartyRole> expected = new TreeMap<>();
if (lease.snapshot() == null) {
expected.put(lease.landlordId().value(), fr.nessar.lmnporganizer.rental.domain.condition.ReportPartyRole.LANDLORD);
lease.tenantIds().forEach(tenant -> expected.put(tenant.value(), fr.nessar.lmnporganizer.rental.domain.condition.ReportPartyRole.TENANT));
} else {
expected.put(lease.snapshot().landlord().id().value(), fr.nessar.lmnporganizer.rental.domain.condition.ReportPartyRole.LANDLORD);
lease.snapshot().tenants().forEach(tenant -> expected.put(tenant.id().value(), fr.nessar.lmnporganizer.rental.domain.condition.ReportPartyRole.TENANT));
}
Map<String, fr.nessar.lmnporganizer.rental.domain.condition.ReportPartyRole> actual = new TreeMap<>();
report.participants().forEach(participant -> actual.put(participant.partyId(), participant.role()));
if (!expected.equals(actual)) throw new IllegalStateException("Les parties doivent correspondre au bail.");
if (report.type() == PropertyConditionReportType.ENTRY) {
var revision = leaseRevisions.find(landlord, leaseId, report.leaseRevisionNumber()).orElseThrow(() -> new IllegalStateException("La révision du bail est introuvable."));
Set<String> areas = revision.privateAreas().stream().map(area -> area.id()).collect(java.util.stream.Collectors.toSet());
if (report.zones().stream().anyMatch(zone -> !areas.contains(zone.privateAreaId()))) throw new IllegalStateException("Une zone ne figure pas dans la révision du bail.");
}
}
private void requireEntryCompleteness(PropertyConditionReport report, LandlordId landlord, LeaseId lease) {
if (report.type() != PropertyConditionReportType.ENTRY) return;
var revision = leaseRevisions.find(landlord, lease, report.leaseRevisionNumber()).orElseThrow(() -> new IllegalStateException("La révision du bail est introuvable."));
List<fr.nessar.lmnporganizer.rental.domain.condition.ConditionZone> zones =
PropertyConditionDraftTemplate.requiredZones(revision);
Map<String, Integer> inventory = revision.furnitureInventory().lines().stream()
.collect(java.util.stream.Collectors.toMap(line -> line.id(), line -> line.quantity()));
report.requireEntryCompleteness(zones, inventory);
}
private byte[] read(String storageKey) {
try (InputStream input = storage.read(storageKey)) {
return input.readAllBytes();
} catch (IOException failure) {
throw new IllegalStateException("Property condition attachment could not be read.", failure);
}
}
private static String fingerprint(PropertyConditionReport report) {
StringBuilder facts = new StringBuilder(report.type().name()).append('|').append(report.occurredOn()).append('|')
.append(report.timeZone()).append('\n');
report.participants().stream().sorted(Comparator.comparing(value -> value.partyId())).forEach(value -> facts.append(value).append('\n'));
report.forwardingAddresses().stream().sorted(Comparator.comparing(value -> value.tenantPartyId())).forEach(value -> facts.append(value).append('\n'));
report.zones().stream().sorted(Comparator.comparing(value -> value.id())).forEach(value -> facts.append(value).append('\n'));
report.observations().stream().sorted(Comparator.comparing(ConditionObservation::subjectId)).forEach(value -> facts.append(value).append('\n'));
report.meterReadings().stream().sorted(Comparator.comparing(value -> value.id())).forEach(value -> facts.append(value).append('\n'));
report.accessMeans().stream().sorted(Comparator.comparing(value -> value.id())).forEach(value -> facts.append(value).append('\n'));
report.inventoryConditions().stream().sorted(Comparator.comparing(value -> value.inventoryLineId())).forEach(value -> facts.append(value).append('\n'));
report.zonePhotoAbsences().stream().sorted(Comparator.comparing(value -> value.zoneId())).forEach(value -> facts.append(value).append('\n'));
report.entryPhotoDispositions().stream().sorted(Comparator.comparing(value -> value.entryPhotoId())).forEach(value -> facts.append(value).append('\n'));
report.photos().stream().sorted(Comparator.comparing(ConditionPhoto::reference)).forEach(value -> facts.append(value.reference()).append('|').append(value.sha256()).append('\n'));
report.supplements().stream().sorted(Comparator.comparing(PropertyConditionReportSupplement::id)).forEach(value -> {
facts.append(value.id()).append('|').append(value.signedPdf().sha256()).append('\n');
value.photos().stream().sorted(Comparator.comparing(photo -> photo.reference())).forEach(photo ->
facts.append(photo.reference()).append('|').append(photo.sha256()).append('\n'));
});
return sha256(facts.toString().getBytes(StandardCharsets.UTF_8));
}
private static boolean isPdf(byte[] content) {
return content != null && content.length >= 5 && content[0] == '%' && content[1] == 'P' && content[2] == 'D'
&& content[3] == 'F' && content[4] == '-';
}
private static void requireVersion(PropertyConditionReport report, long version) {
if (report.version() != version) throw new ConcurrentChange("Property condition report");
}
private static String sha256(byte[] content) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(content)); }
catch (NoSuchAlgorithmException failure) { throw new IllegalStateException(failure); }
}
private void discard(String storageKey, RuntimeException failure) {
try { storage.discard(storageKey); } catch (RuntimeException cleanup) { failure.addSuppressed(cleanup); }
}
public record PropertyConditionComparison(List<Line<ConditionObservation>> observations,
List<Line<fr.nessar.lmnporganizer.rental.domain.condition.ConditionZone>> zones,
List<Line<fr.nessar.lmnporganizer.rental.domain.condition.InventoryCondition>> inventory,
List<Line<fr.nessar.lmnporganizer.rental.domain.condition.MeterReading>> meters,
List<Line<fr.nessar.lmnporganizer.rental.domain.condition.AccessMeans>> accessMeans,
List<Line<PropertyConditionReportSupplement>> supplements,
List<PhotoLine> photos) {
public PropertyConditionComparison {
observations = List.copyOf(observations); zones = List.copyOf(zones); inventory = List.copyOf(inventory);
meters = List.copyOf(meters); accessMeans = List.copyOf(accessMeans);
supplements = List.copyOf(supplements); photos = List.copyOf(photos);
}
public record Line<T>(String reference, T entry, T exit, boolean changed) { }
public record PhotoLine(String reference, ConditionPhoto entry, ConditionPhoto exit,
fr.nessar.lmnporganizer.rental.domain.condition.AuditedPhotoAbsence absence,
boolean changed) { }
}
private record BundleFile(String name, String sha256, byte[] content) { }
}
@@ -0,0 +1,32 @@
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.lease.LeaseDocumentRevision;
import java.util.List;
import java.util.Locale;
import java.util.stream.Stream;
final class PropertyConditionDraftTemplate {
private PropertyConditionDraftTemplate() {
}
static List<ConditionZone> requiredZones(LeaseDocumentRevision revision) {
Stream<ConditionZone> surfaces = revision.privateAreas().stream().flatMap(area -> Stream.of(
new ConditionZone(area.id() + ":floor", area.id(), area.label() + " — Sol", ZoneEquipment.NONE),
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)
.map(line -> new ConditionZone("inventory:" + line.id(), line.privateAreaId(), line.designation(),
equipment(line.designation())));
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;
}
}
@@ -0,0 +1,299 @@
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.shared.ConcurrentChange;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.domain.condition.*;
import fr.nessar.lmnporganizer.rental.domain.furniture.FurniturePresence;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.*;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantRepository;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Set;
public final class PropertyConditionReportService {
private final PropertyConditionReportRepository reports;
private final LeaseRepository leases;
private final LeaseDocumentRevisionRepository revisions;
private final LandlordRepository landlords;
private final TenantRepository tenants;
private final PropertyConditionPhotoNormalizer normalizer;
private final PropertyConditionAttachmentStorage storage;
private final IdentifierGenerator identifiers;
private final Clock clock;
public PropertyConditionReportService(PropertyConditionReportRepository reports, LeaseRepository leases,
LeaseDocumentRevisionRepository revisions, 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;
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));
}
public PropertyConditionReport replace(LandlordId landlord, LeaseId leaseId, PropertyConditionReportType type,
long version, Draft draft) {
PropertyConditionReport report = get(landlord, leaseId, type, draft.timeZone());
requireVersion(report, version);
validateDate(draft.occurredOn(), draft.timeZone());
if (type == PropertyConditionReportType.EXIT && draft.occurredOn().isBefore(reports.find(landlord, leaseId,
PropertyConditionReportType.ENTRY).orElseThrow().occurredOn())) throw new IllegalArgumentException("La sortie ne peut pas précéder l'entrée.");
validateReferences(lease(landlord, leaseId), report, landlord, draft);
report.replaceOccurrence(draft.occurredOn(), draft.timeZone());
report.replaceParticipants(draft.participants());
report.replaceZones(draft.zones());
report.replaceObservations(draft.observations());
report.replaceMeterReadings(draft.meterReadings());
report.replaceAccessMeans(draft.accessMeans());
report.replaceInventoryConditions(draft.inventoryConditions());
report.replaceZonePhotoAbsences(draft.zonePhotoAbsences().stream()
.map(value -> zoneAbsence(report, value, draft.author())).toList());
if (type == PropertyConditionReportType.EXIT) {
report.replaceForwardingAddresses(draft.forwardingAddresses());
report.replaceEntryPhotoDispositions(draft.entryPhotoDispositions().stream()
.map(value -> entryPhotoDisposition(report, value, draft.author())).toList());
}
return reports.save(report);
}
public PropertyConditionReport uploadPhoto(LandlordId landlord, LeaseId leaseId, PropertyConditionReportType type,
String zoneId, byte[] source, String author) {
PropertyConditionReport report = get(landlord, leaseId, type, "Europe/Paris");
if (report.finalized()) throw new IllegalStateException("L'état des lieux est finalisé.");
if (report.zones().stream().noneMatch(zone -> zone.id().equals(zoneId))) throw new IllegalArgumentException("La zone de photo est inconnue.");
PropertyConditionPhotoNormalizer.NormalizedPhoto normalized = normalizer.normalize(source);
String reference = identifiers.next();
String storageKey = storage.store(leaseId, reference, "webp", normalized.content());
ConditionPhoto photo = new ConditionPhoto(reference, zoneId, storageKey, sha256(normalized.content()), normalized.width(), normalized.height(), author, Instant.now(clock));
List<ConditionPhoto> photos = new ArrayList<>(report.photos()); photos.add(photo); report.replacePhotos(photos);
try { return reports.save(report); }
catch (RuntimeException failure) { discard(storageKey, failure); throw failure; }
}
public void deletePhoto(LandlordId landlord, LeaseId leaseId, PropertyConditionReportType type, String reference, long version) {
PropertyConditionReport report = get(landlord, leaseId, type, "Europe/Paris"); requireVersion(report, version);
if (report.finalized()) throw new IllegalStateException("L'état des lieux est finalisé.");
ConditionPhoto photo = report.photos().stream().filter(value -> value.reference().equals(reference)).findFirst().orElseThrow(() -> new ResourceNotFound("Condition photo"));
PropertyConditionAttachmentStorage.StagedDeletion deletion = storage.stageDeletion(photo.storageKey());
List<ConditionPhoto> remaining = report.photos().stream().filter(value -> !value.reference().equals(reference)).toList(); report.replacePhotos(remaining);
try { reports.save(report); }
catch (RuntimeException failure) { restore(deletion, failure); throw failure; }
storage.completeDeletion(deletion);
}
public PropertyConditionReport appendSupplement(LandlordId landlord, LeaseId leaseId, byte[] signedPdf,
List<byte[]> photoSources, String author, LocalDate requestedOn,
String acceptedAdditions, LocalDate heatingPeriodStartedOn, 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é.");
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 (heatingPeriodStartedOn != null && heatingPeriodStartedOn.isAfter(today)) {
throw new IllegalArgumentException("Le début de période de chauffe ne peut pas être futur.");
}
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();
String pdfStorageKey = storage.store(leaseId, pdfReference, "pdf", signedPdf);
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();
String reference = identifiers.next();
String storageKey = storage.store(leaseId, reference, "webp", content);
storedKeys.add(storageKey);
photos.add(new PropertyConditionReportSupplement.FileEvidence(reference, storageKey,
sha256(content)));
}
report.appendSupplement(new PropertyConditionReportSupplement(identifiers.next(), pdf, photos, author,
Instant.now(clock), requestedOn, acceptedAdditions, audit));
return reports.save(report);
} catch (RuntimeException failure) {
storedKeys.forEach(key -> discard(key, failure));
throw failure;
}
}
public PropertyConditionReport finalizeReport(LandlordId landlord, LeaseId leaseId, PropertyConditionReportType type, long version) {
throw new IllegalStateException("Le scan signé et l'attestation de correspondance sont obligatoires.");
}
private PropertyConditionReport createDraft(LandlordId landlord, LeaseId leaseId, PropertyConditionReportType type, 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);
}
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()));
}
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())) {
return report;
}
if (report.status() == PropertyConditionReportStatus.DRAFT) report.markObsolete();
Lease lease = lease(report.landlordId(), report.leaseId());
ZoneId zone = zone(timeZone);
report.restartEntryDraft(LocalDate.now(clock.withZone(zone)), zone.getId(), current.number(), current.sourceFingerprint());
prefillEntryDraft(report, lease, current, report.landlordId());
return reports.save(report);
}
private void prefillEntryDraft(PropertyConditionReport draft, Lease lease, LeaseDocumentRevision revision, LandlordId landlord) {
draft.replaceParticipants(participants(lease, landlord));
List<ConditionZone> zones = PropertyConditionDraftTemplate.requiredZones(revision);
draft.replaceZones(zones);
draft.replaceObservations(zones.stream().map(value -> new ConditionObservation(value.id(), ConditionState.BON_ETAT, "")).toList());
Map<String, String> areaLabels = revision.privateAreas().stream().collect(java.util.stream.Collectors.toMap(
value -> value.id(), value -> value.label()));
draft.replaceInventoryConditions(revision.furnitureInventory().lines().stream().map(line -> {
ConditionState state = line.quantity() == 0 ? ConditionState.ABSENT : ConditionState.BON_ETAT;
String observation = line.quantity() == 0 ? "Absent de l’inventaire contractuel." : "";
return new InventoryCondition(line.id(), line.privateAreaId(), areaLabels.get(line.privateAreaId()),
line.designation(), line.quantity(), List.of(new InventoryQuantitySplit(line.id(), state,
line.quantity(), observation)));
}).toList());
}
private List<ReportParticipant> participants(Lease lease, LandlordId landlord) {
if (lease.snapshot() != null) {
var snapshot = lease.snapshot();
List<ReportParticipant> values = new ArrayList<>();
values.add(new ReportParticipant(snapshot.landlord().id().value(), ReportPartyRole.LANDLORD, snapshot.landlord().firstName() + " " + snapshot.landlord().lastName(), ParticipantAttendance.PRESENT, ""));
snapshot.tenants().forEach(value -> values.add(new ReportParticipant(value.id().value(), ReportPartyRole.TENANT, value.firstName() + " " + value.lastName(), ParticipantAttendance.PRESENT, "")));
return values;
}
var owner = landlords.find(landlord).orElseThrow(() -> new ResourceNotFound("Landlord"));
List<ReportParticipant> values = new ArrayList<>();
values.add(new ReportParticipant(owner.id().value(), ReportPartyRole.LANDLORD, owner.firstName() + " " + owner.lastName(), ParticipantAttendance.PRESENT, ""));
lease.tenantIds().forEach(id -> {
var tenant = tenants.find(landlord, id).orElseThrow(() -> new ResourceNotFound("Tenant"));
values.add(new ReportParticipant(tenant.id().value(), ReportPartyRole.TENANT, tenant.firstName() + " " + tenant.lastName(), ParticipantAttendance.PRESENT, ""));
});
return values;
}
private void validateReferences(Lease lease, PropertyConditionReport report, LandlordId landlord, Draft draft) {
Set<String> tenantIds;
Set<String> areas;
if (lease.snapshot() == null) {
tenantIds = Set.copyOf(lease.tenantIds().stream().map(value -> value.value()).toList());
LeaseDocumentRevision revision = revisions.find(report.landlordId(), report.leaseId(),
report.leaseRevisionNumber()).orElseThrow(() -> new IllegalStateException("La révision du bail est introuvable."));
areas = Set.copyOf(revision.privateAreas().stream().map(value -> value.id()).toList());
} else {
tenantIds = Set.copyOf(lease.snapshot().tenants().stream().map(value -> value.id().value()).toList());
areas = Set.copyOf(lease.snapshot().apartment().privateAreas().stream().map(value -> value.id()).toList());
}
List<ReportParticipant> expectedParticipants = report.type() == PropertyConditionReportType.EXIT
? report.entrySnapshot().participants() : participants(lease, landlord);
if (!sameParties(expectedParticipants, draft.participants())) throw new IllegalArgumentException("Les parties doivent correspondre au bail.");
for (ReportParticipant participant : draft.participants()) {
if (participant.partyId().equals(landlord.value())) {
if (participant.role() != ReportPartyRole.LANDLORD) throw new IllegalArgumentException("Le bailleur doit avoir le rôle bailleur.");
} else if (tenantIds.contains(participant.partyId())) {
if (participant.role() != ReportPartyRole.TENANT) throw new IllegalArgumentException("Un locataire doit avoir le rôle locataire.");
} else throw new IllegalArgumentException("Un participant ne figure pas dans le bail.");
}
if (draft.forwardingAddresses().stream().anyMatch(value -> !tenantIds.contains(value.tenantPartyId()))) {
throw new IllegalArgumentException("Une adresse de correspondance ne figure pas dans le bail.");
}
if (draft.zones().stream().anyMatch(value -> !value.privateAreaId().isEmpty()
&& !areas.contains(value.privateAreaId()))) {
throw new IllegalArgumentException("Une zone ne figure pas dans l'instantané du logement.");
}
Set<String> zoneIds = Set.copyOf(draft.zones().stream().map(ConditionZone::id).toList());
if (draft.observations().stream().anyMatch(value -> !zoneIds.contains(value.subjectId()))) {
throw new IllegalArgumentException("Une observation ne figure pas dans une zone.");
}
Map<String, InventoryCondition> expectedInventory = report.type() == PropertyConditionReportType.ENTRY
? expectedInventory(revisions.find(landlord, lease.id(), report.leaseRevisionNumber()).orElseThrow())
: report.entrySnapshot().inventoryConditions().stream().collect(java.util.stream.Collectors.toMap(
InventoryCondition::inventoryLineId, value -> value));
if (draft.inventoryConditions().stream().anyMatch(value -> {
InventoryCondition expected = expectedInventory.get(value.inventoryLineId());
return expected == null || !expected.privateAreaId().equals(value.privateAreaId())
|| !expected.privateAreaLabel().equals(value.privateAreaLabel())
|| !expected.designation().equals(value.designation())
|| expected.contractualQuantity() != value.contractualQuantity();
})) {
throw new IllegalArgumentException("Une ligne d'inventaire ne figure pas dans la révision du bail.");
}
}
private static Map<String, InventoryCondition> expectedInventory(LeaseDocumentRevision revision) {
Map<String, String> labels = revision.privateAreas().stream().collect(java.util.stream.Collectors.toMap(
value -> value.id(), value -> value.label()));
return revision.furnitureInventory().lines().stream().collect(java.util.stream.Collectors.toMap(value -> value.id(),
value -> new InventoryCondition(value.id(), value.privateAreaId(), labels.get(value.privateAreaId()),
value.designation(), value.quantity(), List.of(new InventoryQuantitySplit(value.id(),
value.quantity() == 0 ? ConditionState.ABSENT : ConditionState.BON_ETAT, value.quantity(),
value.quantity() == 0 ? "Absent de l’inventaire contractuel." : "")))));
}
private static boolean sameParties(List<ReportParticipant> expected, List<ReportParticipant> actual) {
return expected.stream().map(value -> value.partyId() + "|" + value.role() + "|" + value.name()).collect(java.util.stream.Collectors.toSet())
.equals(actual.stream().map(value -> value.partyId() + "|" + value.role() + "|" + value.name()).collect(java.util.stream.Collectors.toSet()))
&& expected.size() == actual.size();
}
private ZonePhotoAbsence zoneAbsence(PropertyConditionReport report, PhotoAbsence input, String author) {
return report.zonePhotoAbsences().stream().filter(value -> value.zoneId().equals(input.zoneId())
&& value.absence().reason().equals(input.reason())).findFirst()
.orElseGet(() -> new ZonePhotoAbsence(input.zoneId(), new AuditedPhotoAbsence(input.reason(), author,
Instant.now(clock))));
}
private EntryPhotoDisposition entryPhotoDisposition(PropertyConditionReport report,
EntryPhotoDispositionInput input, String author) {
if (input.exitPhotoId() != null && !input.exitPhotoId().isBlank()) {
return new MatchedEntryPhoto(input.entryPhotoId(), input.exitPhotoId());
}
return report.entryPhotoDispositions().stream().filter(value -> value instanceof MissingEntryPhoto missing
&& missing.entryPhotoId().equals(input.entryPhotoId())
&& missing.absence().reason().equals(input.absenceReason())).findFirst()
.orElseGet(() -> new MissingEntryPhoto(input.entryPhotoId(), new AuditedPhotoAbsence(
input.absenceReason(), author, Instant.now(clock))));
}
private void validateDate(LocalDate date, String timeZone) { if (date == null || date.isAfter(LocalDate.now(clock.withZone(zone(timeZone))))) throw new IllegalArgumentException("La date de l'état des lieux ne peut pas être future."); }
private static ZoneId zone(String value) { try { return ZoneId.of(value); } catch (RuntimeException failure) { throw new IllegalArgumentException("Le fuseau horaire IANA est invalide.", failure); } }
private static void requireVersion(PropertyConditionReport report, long version) { if (report.version() != version) throw new ConcurrentChange("Property condition report"); }
private static String sha256(byte[] bytes) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); } catch (NoSuchAlgorithmException failure) { throw new IllegalStateException(failure); } }
private void discard(String key, RuntimeException failure) { try { storage.discard(key); } catch (RuntimeException cleanup) { failure.addSuppressed(cleanup); } }
private void restore(PropertyConditionAttachmentStorage.StagedDeletion deletion, RuntimeException failure) { try { storage.restore(deletion); } catch (RuntimeException cleanup) { failure.addSuppressed(cleanup); } }
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) { }
}
@@ -0,0 +1,85 @@
package fr.nessar.lmnporganizer.rental.application.furniture;
import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
import fr.nessar.lmnporganizer.rental.application.shared.ConcurrentChange;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.domain.apartment.Apartment;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentRepository;
import fr.nessar.lmnporganizer.rental.domain.apartment.PrivateArea;
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.FurnitureLine;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.FurnishedLeaseTemplate;
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.util.List;
import java.util.Set;
public final class FurnitureInventoryService {
private final FurnitureInventoryRepository inventories;
private final LeaseRepository leases;
private final ApartmentRepository apartments;
private final IdentifierGenerator identifiers;
public FurnitureInventoryService(FurnitureInventoryRepository inventories, LeaseRepository leases,
ApartmentRepository apartments, IdentifierGenerator identifiers) {
this.inventories = inventories;
this.leases = leases;
this.apartments = apartments;
this.identifiers = identifiers;
}
public FurnitureInventory get(LandlordId landlordId, LeaseId leaseId) {
Lease lease = lease(landlordId, leaseId);
return inventories.find(landlordId, leaseId).orElseGet(() -> initialize(landlordId, lease));
}
public FurnitureInventory replace(LandlordId landlordId, LeaseId leaseId, long expectedVersion,
List<FurnitureLine> lines) {
FurnitureInventory inventory = get(landlordId, leaseId);
if (inventory.version() != expectedVersion) {
throw new ConcurrentChange("Furniture inventory");
}
validatePrivateAreas(landlordId, lease(landlordId, leaseId), lines);
inventory.replaceLines(lines);
return inventories.save(inventory);
}
public FurnitureInventory freeze(LandlordId landlordId, LeaseId leaseId) {
FurnitureInventory inventory = get(landlordId, leaseId);
inventory.freeze();
return inventories.save(inventory);
}
public List<PrivateArea> privateAreas(LandlordId landlordId, LeaseId leaseId) {
return apartment(landlordId, lease(landlordId, leaseId)).privateAreas();
}
private FurnitureInventory initialize(LandlordId landlordId, Lease lease) {
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));
}
private void validatePrivateAreas(LandlordId landlordId, Lease lease, List<FurnitureLine> lines) {
Set<String> privateAreaIds = apartment(landlordId, lease).privateAreas().stream().map(area -> area.id())
.collect(java.util.stream.Collectors.toSet());
if (lines == null || lines.stream().anyMatch(line -> line == null || !privateAreaIds.contains(line.privateAreaId()))) {
throw new IllegalArgumentException("Chaque ligne d'inventaire doit référencer un espace privatif du logement.");
}
}
private Lease lease(LandlordId landlordId, LeaseId leaseId) {
return leases.find(landlordId, leaseId).orElseThrow(() -> new ResourceNotFound("Lease"));
}
private Apartment apartment(LandlordId landlordId, Lease lease) {
return apartments.find(landlordId, lease.apartmentId()).orElseThrow(() -> new ResourceNotFound("Apartment"));
}
}
@@ -80,7 +80,7 @@ public final class ActivateLeaseService {
new LeaseSnapshot.Landlord(landlord.id(), landlord.firstName(), landlord.lastName(), landlord.email(),
landlord.phone(), landlord.address()),
new LeaseSnapshot.Apartment(apartment.id(), apartment.name(), apartment.address(),
apartment.legalProfile()),
apartment.legalProfile(), apartment.privateAreas()),
leaseTenants.stream()
.map(tenant -> new LeaseSnapshot.Tenant(tenant.id(), tenant.firstName(), tenant.lastName(),
tenant.email(), tenant.phone(), tenant.address()))
@@ -13,6 +13,9 @@ import fr.nessar.lmnporganizer.rental.domain.apartment.Apartment;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentDocument;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentDocumentType;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentRepository;
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventory;
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryRepository;
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventorySnapshot;
import fr.nessar.lmnporganizer.rental.domain.landlord.Landlord;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordRepository;
@@ -28,6 +31,7 @@ import fr.nessar.lmnporganizer.rental.domain.lease.LeaseDocumentWarning;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalizationWarning;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSnapshot;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseStatus;
import fr.nessar.lmnporganizer.rental.domain.tenant.Tenant;
@@ -65,23 +69,14 @@ public final class GenerateLeaseDocumentService {
private final LeaseDocumentStorage storage;
private final ApartmentDocumentService apartmentDocuments;
private final LeaseInformationNoticeCatalog notices;
public GenerateLeaseDocumentService(LeaseRepository leases, RentalDocumentRenderer renderer, Clock clock) {
this(leases, renderer, clock, null, null, null, null, null, null, null);
}
public GenerateLeaseDocumentService(LeaseRepository leases, RentalDocumentRenderer renderer, Clock clock,
LandlordRepository landlords, ApartmentRepository apartments,
TenantRepository tenants, LeaseDocumentRevisionRepository revisions,
LeaseDocumentStorage storage) {
this(leases, renderer, clock, landlords, apartments, tenants, revisions, storage, null, null);
}
private final FurnitureInventoryRepository inventories;
public GenerateLeaseDocumentService(LeaseRepository leases, RentalDocumentRenderer renderer, Clock clock,
LandlordRepository landlords, ApartmentRepository apartments,
TenantRepository tenants, LeaseDocumentRevisionRepository revisions,
LeaseDocumentStorage storage, ApartmentDocumentService apartmentDocuments,
LeaseInformationNoticeCatalog notices) {
LeaseInformationNoticeCatalog notices,
FurnitureInventoryRepository inventories) {
this.leases = leases;
this.renderer = renderer;
this.clock = clock;
@@ -92,14 +87,7 @@ public final class GenerateLeaseDocumentService {
this.storage = storage;
this.apartmentDocuments = apartmentDocuments;
this.notices = notices;
}
/** Kept for callers that only need an ephemeral PDF from an activated lease. */
public GeneratedDocument generate(LandlordId landlordId, LeaseId leaseId) {
Lease lease = lease(landlordId, leaseId);
LeaseContractVersion version = lease.contractVersionOn(LocalDate.now(clock));
LeaseDocument document = document(lease.id(), version, "LEGACY", 0);
return generated(leaseId, 0, renderer.renderLease(document));
this.inventories = inventories;
}
public LeaseDocumentRevision generateRevision(LandlordId landlordId, LeaseId leaseId, String authorId) {
@@ -108,11 +96,12 @@ public final class GenerateLeaseDocumentService {
throw new IllegalArgumentException("Author ID is required.");
}
Lease lease = lease(landlordId, leaseId);
FurnishedLeaseTemplate template = FurnishedLeaseTemplate.effectiveOn(LocalDate.now(clock));
Source source = source(landlordId, lease, template);
var existing = revisions.findByFingerprint(landlordId, leaseId, source.fingerprint());
if (existing.isPresent()) {
return existing.orElseThrow();
FurnitureInventory inventory = inventory(landlordId, lease);
FurnishedLeaseTemplate template = FurnishedLeaseTemplate.effectiveOn(java.time.LocalDate.now(clock));
Source source = source(landlordId, lease, template, inventory);
var current = revisions.findCurrent(landlordId, leaseId);
if (current.filter(revision -> revision.sourceFingerprint().equals(source.fingerprint())).isPresent()) {
return current.orElseThrow();
}
for (int attempt = 0; attempt < MAX_INSERT_ATTEMPTS; attempt++) {
@@ -124,15 +113,16 @@ public final class GenerateLeaseDocumentService {
List<LeaseDocumentAnnex> annexes = copyAnnexes(landlordId, lease, source, storedKeys);
LeaseDocumentRevision revision = new LeaseDocumentRevision(UUID.randomUUID().toString(), landlordId,
leaseId, number, template.visibleId(), source.fingerprint(), storageKey, sha256(bytes),
Instant.now(clock), authorId, annexes, source.warnings());
Instant.now(clock), authorId, source.document().furnitureInventory(),
source.document().apartment().privateAreas(), annexes, source.warnings());
return revisions.save(revision);
} catch (LeaseDocumentRevisionConflict conflict) {
discard(storedKeys, conflict);
if (conflict.getSuppressed().length > 0) {
throw conflict;
}
var winner = revisions.findByFingerprint(landlordId, leaseId, source.fingerprint());
if (winner.isPresent()) {
var winner = revisions.findCurrent(landlordId, leaseId);
if (winner.filter(revision -> revision.sourceFingerprint().equals(source.fingerprint())).isPresent()) {
return winner.orElseThrow();
}
} catch (RuntimeException failure) {
@@ -187,31 +177,59 @@ public final class GenerateLeaseDocumentService {
if (revision.annexes().stream().noneMatch(annex -> annex.category() == Category.NOTICE)) {
return false;
}
ContractSource current = contractSource(landlordId, lease, template);
FurnitureInventory inventory = inventory(landlordId, lease);
FurnitureInventorySnapshot furnitureInventory = FurnitureInventorySnapshot.from(inventory);
ContractSource current = contractSource(landlordId, lease, template, furnitureInventory);
validateInventoryPrivateAreas(furnitureInventory, current.document().apartment().privateAreas());
appendFurnitureFacts(current.facts(), furnitureInventory);
appendDossierFacts(current.facts(), revision.annexes(), revision.warnings());
return revision.landlordId().equals(landlordId) && revision.leaseId().equals(leaseId)
&& revision.sourceFingerprint().equals(sha256(current.facts().toString()
.getBytes(StandardCharsets.UTF_8)));
}
private Source source(LandlordId landlordId, Lease lease, FurnishedLeaseTemplate template) {
ContractSource contract = contractSource(landlordId, lease, template);
private Source source(LandlordId landlordId, Lease lease, FurnishedLeaseTemplate template,
FurnitureInventory inventory) {
FurnitureInventorySnapshot furnitureInventory = FurnitureInventorySnapshot.from(inventory);
ContractSource contract = contractSource(landlordId, lease, template, furnitureInventory);
validateInventoryPrivateAreas(furnitureInventory, contract.document().apartment().privateAreas());
List<ApartmentDocument> currentDocuments = apartmentDocuments.list(landlordId, lease.apartmentId()).stream()
.sorted(Comparator.comparing(ApartmentDocument::type)).toList();
InformationNotice notice = notices.current();
List<LeaseDocumentWarning> dossierWarnings = warnings(lease, currentDocuments);
List<LeaseDocumentWarning> dossierWarnings = new ArrayList<>(warnings(lease, currentDocuments));
inventory.legalWarnings().forEach(warning -> dossierWarnings.add(
new LeaseDocumentWarning(warning.code(), warning.ruleVersion())));
appendFurnitureFacts(contract.facts(), furnitureInventory);
appendDossierFacts(contract.facts(), notice, currentDocuments, dossierWarnings);
return new Source(contract.document(), sha256(contract.facts().toString().getBytes(StandardCharsets.UTF_8)),
notice, currentDocuments, dossierWarnings);
return new Source(contract.document(), sha256(contract.facts().toString().getBytes(StandardCharsets.UTF_8)), notice, currentDocuments,
dossierWarnings);
}
private ContractSource contractSource(LandlordId landlordId, Lease lease, FurnishedLeaseTemplate template) {
private FurnitureInventory inventory(LandlordId landlordId, Lease lease) {
FurnitureInventory inventory = inventories.find(landlordId, lease.id())
.orElseThrow(() -> new LeaseRuleViolation("L'inventaire du mobilier doit être renseigné."));
if (!inventory.isReadyForLeaseDocument()) {
throw new LeaseRuleViolation("Chaque catégorie réglementaire de mobilier doit être renseignée.");
}
return inventory;
}
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());
if (inventory.lines().stream().anyMatch(line -> !ids.contains(line.privateAreaId()))) {
throw new LeaseRuleViolation("Chaque ligne d'inventaire doit référencer un espace privatif du bail.");
}
}
private ContractSource contractSource(LandlordId landlordId, Lease lease, FurnishedLeaseTemplate template,
FurnitureInventorySnapshot furnitureInventory) {
if (lease.status() != LeaseStatus.DRAFT) {
LeaseContractVersion version = lease.contractVersionOn(LocalDate.now(clock));
StringBuilder facts = new StringBuilder(template.visibleId());
append(facts, "lease", lease.id().value(), lease.version());
facts.append('\n').append("contract:").append(version);
return new ContractSource(document(lease.id(), version, template.visibleId(), 0), facts);
return new ContractSource(document(lease.id(), version, template.visibleId(), 0, furnitureInventory), facts);
}
Landlord landlord = landlords.find(landlordId).orElseThrow(() -> new ResourceNotFound("Landlord"));
@@ -225,11 +243,11 @@ public final class GenerateLeaseDocumentService {
new LeaseSnapshot.Landlord(landlord.id(), landlord.firstName(), landlord.lastName(), landlord.email(),
landlord.phone(), landlord.address()),
new LeaseSnapshot.Apartment(apartment.id(), apartment.name(), apartment.address(),
apartment.legalProfile()),
apartment.legalProfile(), apartment.privateAreas()),
leaseTenants.stream().map(tenant -> new LeaseSnapshot.Tenant(tenant.id(), tenant.firstName(),
tenant.lastName(), tenant.email(), tenant.phone(), tenant.address())).toList(),
lease.guarantors(), lease.period(), lease.terms(), lease.leaseType(), lease.specialClauses(),
lease.legalTerms(), template.visibleId(), 0);
lease.legalTerms(), template.visibleId(), 0, furnitureInventory);
StringBuilder facts = new StringBuilder(template.visibleId());
append(facts, "lease", lease.id().value(), lease.version());
append(facts, "landlord", landlord.id().value(), landlord.version());
@@ -321,6 +339,19 @@ public final class GenerateLeaseDocumentService {
appendWarningFacts(facts, warnings);
}
private static void appendFurnitureFacts(StringBuilder facts, FurnitureInventorySnapshot inventory) {
append(facts, "furniture-template", inventory.templateVersion());
for (FurnitureInventorySnapshot.Line line : inventory.lines()) {
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-private-area", line.privateAreaId());
append(facts, "furniture-designation", line.designation());
append(facts, "furniture-quantity", Integer.toString(line.quantity()));
append(facts, "furniture-description", line.description());
}
}
private static void appendDossierFacts(StringBuilder facts, List<LeaseDocumentAnnex> annexes,
List<LeaseDocumentWarning> warnings) {
LeaseDocumentAnnex notice = annexes.stream().filter(annex -> annex.category() == Category.NOTICE)
@@ -350,10 +381,10 @@ public final class GenerateLeaseDocumentService {
}
private static LeaseDocument document(LeaseId leaseId, LeaseContractVersion version, String templateVersion,
int revisionNumber) {
int revisionNumber, FurnitureInventorySnapshot furnitureInventory) {
return new LeaseDocument(leaseId, version.landlord(), version.apartment(), version.tenants(),
version.guarantors(), version.period(), version.terms(), version.leaseType(),
version.specialClauses(), version.legalTerms(), templateVersion, revisionNumber);
version.specialClauses(), version.legalTerms(), templateVersion, revisionNumber, furnitureInventory);
}
private Lease lease(LandlordId landlordId, LeaseId leaseId) {
@@ -396,7 +427,7 @@ public final class GenerateLeaseDocumentService {
private void requireRevisionDependencies() {
if (landlords == null || apartments == null || tenants == null || revisions == null || storage == null
|| apartmentDocuments == null || notices == null) {
|| apartmentDocuments == null || notices == null || inventories == null) {
throw new IllegalStateException("Lease document revisions are not configured.");
}
}
@@ -460,7 +491,7 @@ public final class GenerateLeaseDocumentService {
return new LeaseDocument(document.leaseId(), document.landlord(), document.apartment(),
document.tenants(), document.guarantors(), document.period(), document.terms(),
document.leaseType(), document.specialClauses(), document.legalTerms(),
document.templateVersion(), number);
document.templateVersion(), number, document.furnitureInventory());
}
}
@@ -5,6 +5,9 @@ import fr.nessar.lmnporganizer.rental.application.port.out.LeaseDocumentStorage;
import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentTooLarge;
import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReport;
import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportRepository;
import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportType;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.ApartmentOccupancy;
import fr.nessar.lmnporganizer.rental.domain.lease.ApartmentOccupancyRepository;
@@ -43,27 +46,22 @@ public final class LeaseEndingService {
private final ApartmentOccupancyRepository occupancies;
private final RentDueRepository rentDues;
private final RentRevisionRepository revisions;
private final PropertyConditionReportRepository reports;
private final LeaseDocumentStorage storage;
private final TransactionRunner transactions;
private final IdentifierGenerator identifiers;
private final Clock clock;
private final long maxEvidenceBytes;
public LeaseEndingService(LeaseRepository leases, ApartmentOccupancyRepository occupancies,
RentDueRepository rentDues, LeaseDocumentStorage storage,
TransactionRunner transactions, IdentifierGenerator identifiers,
Clock clock, long maxEvidenceBytes) {
this(leases, occupancies, rentDues, null, storage, transactions, identifiers, clock, maxEvidenceBytes);
}
public LeaseEndingService(LeaseRepository leases, ApartmentOccupancyRepository occupancies,
RentDueRepository rentDues, RentRevisionRepository revisions,
LeaseDocumentStorage storage, TransactionRunner transactions,
PropertyConditionReportRepository reports, LeaseDocumentStorage storage, TransactionRunner transactions,
IdentifierGenerator identifiers, Clock clock, long maxEvidenceBytes) {
this.leases = leases;
this.occupancies = occupancies;
this.rentDues = rentDues;
this.revisions = revisions;
this.reports = reports;
this.storage = storage;
this.transactions = transactions;
this.identifiers = identifiers;
@@ -77,7 +75,7 @@ public final class LeaseEndingService {
public LeaseEndingState state(LandlordId landlordId, LeaseId leaseId, ZoneId timeZone) {
Lease lease = findLease(landlordId, leaseId);
requireFinalized(lease);
return state(lease, rentDues.findAll(landlordId, leaseId), today(timeZone));
return state(landlordId, lease, rentDues.findAll(landlordId, leaseId), today(timeZone));
}
public LeaseEndingState recordNotice(LandlordId landlordId, LeaseId leaseId, String authorId,
@@ -128,9 +126,13 @@ public final class LeaseEndingService {
public LeaseEndingState close(LandlordId landlordId, LeaseId leaseId, String authorId, Closure request) {
return transactions.run(() -> {
Lease lease = findLease(landlordId, leaseId);
lease.close(request.keysReturnedOn(), request.confirmed(), today(request.timeZone()), audit(authorId));
PropertyConditionReport exitReport = reports.find(landlordId, leaseId, PropertyConditionReportType.EXIT)
.filter(PropertyConditionReport::finalized)
.orElseThrow(() -> new LeaseRuleViolation(
"Un état des lieux de sortie finalisé est requis pour clôturer le bail."));
lease.close(exitReport.occurredOn(), request.confirmed(), today(request.timeZone()), audit(authorId));
Lease saved = leases.save(lease);
return state(saved, rentDues.findAll(landlordId, leaseId), today(request.timeZone()));
return state(landlordId, saved, rentDues.findAll(landlordId, leaseId), today(request.timeZone()));
});
}
@@ -142,7 +144,7 @@ public final class LeaseEndingService {
Lease lease = findLease(landlordId, leaseId);
lease.appendNoticeEvidence(noticeId, stored.file(), evidence.reason(), audit(authorId));
Lease saved = leases.save(lease);
return state(saved, rentDues.findAll(landlordId, leaseId), today(evidence.timeZone()));
return state(landlordId, saved, rentDues.findAll(landlordId, leaseId), today(evidence.timeZone()));
});
} catch (RuntimeException failure) {
discard(stored, failure);
@@ -189,16 +191,19 @@ public final class LeaseEndingService {
}
occupancies.save(occupancy);
Lease saved = leases.save(lease);
return state(saved, dues, today(timeZone));
return state(landlordId, saved, dues, today(timeZone));
}
private LeaseEndingState state(Lease lease, List<RentDue> dues, LocalDate today) {
private LeaseEndingState state(LandlordId landlordId, Lease lease, List<RentDue> dues, LocalDate today) {
boolean supported = lease.endingSupported();
return new LeaseEndingState(lease.id(), supported,
supported ? Optional.empty()
: Optional.of("La fin de bail locataire n'est pas prise en charge pour une colocation."),
lease.endPhaseOn(today), lease.leaseType(), lease.fixedEndOn(), lease.contractEndOn(),
dues.stream().anyMatch(RentDue::correctionRequired), lease.endingHistory());
dues.stream().anyMatch(RentDue::correctionRequired),
reports.find(landlordId, lease.id(), PropertyConditionReportType.EXIT)
.map(report -> new LeaseEndingState.ExitConditionReport(report.occurredOn(), report.finalized())),
lease.endingHistory());
}
private RentSchedule.EndAmounts finalMonthAmounts(LandlordId landlordId, Lease lease, LocalDate endOn) {
@@ -318,10 +323,9 @@ public final class LeaseEndingService {
}
}
public record Closure(ZoneId timeZone, LocalDate keysReturnedOn, boolean confirmed) {
public record Closure(ZoneId timeZone, boolean confirmed) {
public Closure {
Objects.requireNonNull(timeZone);
Objects.requireNonNull(keysReturnedOn);
}
}
@@ -12,7 +12,7 @@ import java.util.Optional;
public record LeaseEndingState(LeaseId leaseId, boolean supported, Optional<String> unsupportedReason,
LeaseEndPhase phase, LeaseType leaseType, Optional<LocalDate> fixedEndOn,
Optional<LocalDate> effectiveEndOn, boolean correctionRequired,
LeaseEndingHistory history) {
Optional<ExitConditionReport> exitConditionReport, LeaseEndingHistory history) {
public LeaseEndingState {
Objects.requireNonNull(leaseId);
unsupportedReason = unsupportedReason == null ? Optional.empty() : unsupportedReason;
@@ -20,6 +20,13 @@ public record LeaseEndingState(LeaseId leaseId, boolean supported, Optional<Stri
Objects.requireNonNull(leaseType);
fixedEndOn = fixedEndOn == null ? Optional.empty() : fixedEndOn;
effectiveEndOn = effectiveEndOn == null ? Optional.empty() : effectiveEndOn;
exitConditionReport = exitConditionReport == null ? Optional.empty() : exitConditionReport;
Objects.requireNonNull(history);
}
public record ExitConditionReport(LocalDate occurredOn, boolean finalized) {
public ExitConditionReport {
Objects.requireNonNull(occurredOn);
}
}
}
@@ -5,6 +5,14 @@ import fr.nessar.lmnporganizer.rental.application.port.out.TransactionRunner;
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentTooLarge;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentRepository;
import fr.nessar.lmnporganizer.rental.domain.condition.ConditionState;
import fr.nessar.lmnporganizer.rental.domain.condition.InventoryQuantitySplit;
import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportRepository;
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.FurnitureInventoryRepository;
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventorySnapshot;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.FurnishedLeaseTemplate;
import fr.nessar.lmnporganizer.rental.domain.lease.Guarantor;
@@ -62,6 +70,8 @@ public final class LeaseFinalizationService {
private final TransactionRunner transactions;
private final Clock clock;
private final long maximumPdfSize;
private final FurnitureInventoryRepository inventories;
private final PropertyConditionReportRepository conditionReports;
public LeaseFinalizationService(LeaseRepository leases, ApartmentRepository apartments, TenantRepository tenants,
GenerateLeaseDocumentService documents,
@@ -69,7 +79,9 @@ public final class LeaseFinalizationService {
LeaseGuaranteeDeedRepository guaranteeDeeds,
LeaseFinalizationRepository finalizations, LeaseDocumentStorage storage,
ActivateLeaseService activation, RentDueRepository rentDues,
TransactionRunner transactions, Clock clock, long maximumPdfSize) {
TransactionRunner transactions, Clock clock, long maximumPdfSize,
FurnitureInventoryRepository inventories,
PropertyConditionReportRepository conditionReports) {
this.leases = leases;
this.apartments = apartments;
this.tenants = tenants;
@@ -86,6 +98,8 @@ public final class LeaseFinalizationService {
throw new IllegalArgumentException("Maximum PDF size must be positive.");
}
this.maximumPdfSize = maximumPdfSize;
this.inventories = inventories;
this.conditionReports = conditionReports;
}
public LeaseGuaranteeDeed uploadGuaranteeDeed(LandlordId landlordId, LeaseId leaseId, String guarantorId,
@@ -125,9 +139,44 @@ public final class LeaseFinalizationService {
LeaseDocumentRevision revision = revisions.findCurrent(landlordId, leaseId).orElse(null);
return new State(revision, warnings(landlordId, lease, revision).stream()
.map(LeaseDocumentWarning::code).toList(), requirements(landlordId, lease, deeds),
suggestedInventoryCorrections(landlordId, leaseId, revision),
finalizations.find(landlordId, leaseId).orElse(null));
}
private List<LeaseFinalization.InventoryCorrection> suggestedInventoryCorrections(
LandlordId landlordId, LeaseId leaseId, LeaseDocumentRevision revision) {
if (revision == null) {
return List.of();
}
return conditionReports.find(landlordId, leaseId, PropertyConditionReportType.ENTRY)
.filter(report -> report.status() != PropertyConditionReportStatus.OBSOLETE)
.filter(report -> report.referencesLeaseRevision(revision.number(), revision.sourceFingerprint()))
.map(report -> report.inventoryConditions().stream().flatMap(condition -> {
int absent = condition.splits().stream()
.filter(split -> split.state() == ConditionState.ABSENT)
.mapToInt(InventoryQuantitySplit::quantity).sum();
if (absent == 0) {
return java.util.stream.Stream.<LeaseFinalization.InventoryCorrection>empty();
}
FurnitureInventorySnapshot.Line line = revision.furnitureInventory().lines().stream()
.filter(candidate -> candidate.id().equals(condition.inventoryLineId())).findFirst()
.orElse(null);
if (line == null) {
return java.util.stream.Stream.<LeaseFinalization.InventoryCorrection>empty();
}
String observations = condition.splits().stream()
.filter(split -> split.state() == ConditionState.ABSENT)
.map(InventoryQuantitySplit::observation).filter(value -> !value.isBlank())
.distinct().collect(java.util.stream.Collectors.joining("; "));
String description = observations.isEmpty()
? absent + " élément(s) absent(s) constaté(s) à l’entrée."
: observations;
return java.util.stream.Stream.of(new LeaseFinalization.InventoryCorrection(
"entry-" + line.id(), line.id(), line.privateAreaId(), line.designation(),
condition.contractualQuantity() - absent, description));
}).toList()).orElseGet(List::of);
}
public Result finalizeLease(LandlordId landlordId, LeaseId leaseId, FinalizationRequest request,
PdfUpload signedLease, String authorId) {
Lease lease = lease(landlordId, leaseId);
@@ -142,12 +191,14 @@ public final class LeaseFinalizationService {
if (request.lastSignatureDate() == null) {
throw new LeaseRuleViolation("La date de dernière signature est obligatoire.");
}
requireInventoryCorrectionAttestation(request);
byte[] content = pdf(signedLease);
LeaseDocumentRevision revision = revisions.findCurrent(landlordId, leaseId)
.orElseThrow(() -> new LeaseRuleViolation("Une révision du bail doit être générée avant finalisation."));
if (request.revisionNumber() != revision.number()) {
throw new LeaseRuleViolation("La révision signée n'est plus la révision courante du bail.");
}
validateInventoryCorrections(revision, request.inventoryCorrections());
String expectedTemplate = FurnishedLeaseTemplate.effectiveOn(request.lastSignatureDate()).visibleId();
if (!expectedTemplate.equals(revision.templateVersion())) {
throw new LeaseRuleViolation("Le modèle signé ne correspond pas à la date de dernière signature.");
@@ -166,7 +217,9 @@ public final class LeaseFinalizationService {
Instant now = Instant.now(clock);
LeaseFinalization proposed = new LeaseFinalization(UUID.randomUUID().toString(), landlordId, leaseId,
revision.number(), revision.templateVersion(), request.lastSignatureDate(), storageKey,
safeFileName(signedLease.fileName()), sha256(content), authorId, now, overrides);
safeFileName(signedLease.fileName()), sha256(content), authorId, now, overrides,
request.entryInventoryDiscrepancies(), request.inventoryCorrections(),
request.allInventoryCorrectionsInitialed());
try {
return transactions.run(() -> finalizeInTransaction(landlordId, leaseId, revision, proposed));
} catch (RuntimeException failure) {
@@ -183,6 +236,7 @@ public final class LeaseFinalizationService {
if (!documents.matchesCurrentSource(landlordId, leaseId, revision)) {
throw new LeaseRuleViolation("Le bail a changé depuis la génération de la révision signée.");
}
freezeInventory(landlordId, leaseId);
Lease activated = activation.activateInTransaction(landlordId, leaseId);
LeaseFinalization saved = finalizations.save(proposed);
List<RentDue> dues = rentDues.findAll(landlordId, leaseId).stream()
@@ -221,15 +275,12 @@ public final class LeaseFinalizationService {
LeaseDocumentRevision revision,
Set<String> confirmedCodes, String authorId) {
Set<String> confirmed = confirmedCodes == null ? Set.of() : Set.copyOf(confirmedCodes);
for (String code : confirmed) {
try {
LeaseFinalizationWarning.valueOf(code);
} catch (IllegalArgumentException failure) {
throw new LeaseRuleViolation("Le code d'alerte confirmé est inconnu.");
}
List<LeaseDocumentWarning> warnings = warnings(landlordId, lease, revision);
if (confirmed.stream().anyMatch(code -> warnings.stream().noneMatch(warning -> warning.code().equals(code)))) {
throw new LeaseRuleViolation("Le code d'alerte confirmé est inconnu.");
}
Instant now = Instant.now(clock);
return warnings(landlordId, lease, revision).stream().map(warning -> {
return warnings.stream().map(warning -> {
if (!confirmed.contains(warning.code())) {
throw new LeaseRuleViolation("L'alerte " + warning.code() + " doit être confirmée.");
}
@@ -261,6 +312,29 @@ public final class LeaseFinalizationService {
return "F".equals(rating) || "G".equals(rating);
}
private void freezeInventory(LandlordId landlordId, LeaseId leaseId) {
FurnitureInventory inventory = inventories.find(landlordId, leaseId)
.orElseThrow(() -> new LeaseRuleViolation("L'inventaire du mobilier doit être renseigné."));
inventory.freeze();
inventories.save(inventory);
}
private static void validateInventoryCorrections(LeaseDocumentRevision revision,
List<LeaseFinalization.InventoryCorrection> corrections) {
Set<String> privateAreaIds = revision.privateAreas().stream().map(area -> area.id())
.collect(java.util.stream.Collectors.toSet());
Set<String> inventoryLineIds = revision.furnitureInventory().lines().stream().map(FurnitureInventorySnapshot.Line::id)
.collect(java.util.stream.Collectors.toSet());
for (LeaseFinalization.InventoryCorrection correction : corrections) {
if (!privateAreaIds.contains(correction.privateAreaId())) {
throw new LeaseRuleViolation("L'espace privatif corrigé n'appartient pas à la révision du bail.");
}
if (correction.inventoryLineId() != null && !inventoryLineIds.contains(correction.inventoryLineId())) {
throw new LeaseRuleViolation("La ligne d'inventaire corrigée n'appartient pas à la révision du bail.");
}
}
}
private Lease draft(LandlordId landlordId, LeaseId leaseId) {
Lease lease = lease(landlordId, leaseId);
requireDraft(lease);
@@ -308,6 +382,17 @@ public final class LeaseFinalizationService {
}
}
private static void requireInventoryCorrectionAttestation(FinalizationRequest request) {
if (!request.entryInventoryDiscrepancies()
&& (!request.inventoryCorrections().isEmpty() || request.allInventoryCorrectionsInitialed())) {
throw new LeaseRuleViolation("Les corrections d’inventaire exigent une réponse positive.");
}
if (request.entryInventoryDiscrepancies()
&& (request.inventoryCorrections().isEmpty() || !request.allInventoryCorrectionsInitialed())) {
throw new LeaseRuleViolation("Chaque correction manuscrite d’inventaire doit être déclarée et paraphée.");
}
}
private void discard(String storageKey, RuntimeException failure) {
try {
storage.discard(storageKey);
@@ -346,7 +431,14 @@ public final class LeaseFinalizationService {
}
public record FinalizationRequest(int revisionNumber, LocalDate lastSignatureDate, boolean allPartiesSigned,
boolean revisionMatches, Set<String> confirmedWarningCodes) {
boolean revisionMatches, Set<String> confirmedWarningCodes,
boolean entryInventoryDiscrepancies,
List<LeaseFinalization.InventoryCorrection> inventoryCorrections,
boolean allInventoryCorrectionsInitialed) {
public FinalizationRequest {
confirmedWarningCodes = confirmedWarningCodes == null ? Set.of() : Set.copyOf(confirmedWarningCodes);
inventoryCorrections = inventoryCorrections == null ? List.of() : List.copyOf(inventoryCorrections);
}
}
public record GuaranteeRequirement(String guarantorId, String guarantorName, TenantId tenantId,
@@ -354,10 +446,13 @@ public final class LeaseFinalizationService {
}
public record State(LeaseDocumentRevision revision, List<String> warningCodes,
List<GuaranteeRequirement> guaranteeRequirements, LeaseFinalization finalization) {
List<GuaranteeRequirement> guaranteeRequirements,
List<LeaseFinalization.InventoryCorrection> suggestedInventoryCorrections,
LeaseFinalization finalization) {
public State {
warningCodes = List.copyOf(warningCodes);
guaranteeRequirements = List.copyOf(guaranteeRequirements);
suggestedInventoryCorrections = List.copyOf(suggestedInventoryCorrections);
}
}
@@ -0,0 +1,21 @@
package fr.nessar.lmnporganizer.rental.application.port.out;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import java.io.InputStream;
public interface PropertyConditionAttachmentStorage {
String store(LeaseId leaseId, String reference, String extension, byte[] content);
InputStream read(String storageKey);
void discard(String storageKey);
StagedDeletion stageDeletion(String storageKey);
void restore(StagedDeletion deletion);
void completeDeletion(StagedDeletion deletion);
interface StagedDeletion { }
}
@@ -0,0 +1,8 @@
package fr.nessar.lmnporganizer.rental.application.port.out;
public interface PropertyConditionPhotoNormalizer {
NormalizedPhoto normalize(byte[] source);
record NormalizedPhoto(byte[] content, int width, int height) {
}
}
@@ -0,0 +1,7 @@
package fr.nessar.lmnporganizer.rental.application.port.out;
import fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReport;
public interface PropertyConditionReportRenderer {
byte[] render(PropertyConditionReport report, int revisionNumber, String sourceFingerprint);
}
@@ -1,6 +1,7 @@
package fr.nessar.lmnporganizer.rental.application.port.out;
import fr.nessar.lmnporganizer.rental.domain.lease.Guarantor;
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventorySnapshot;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseContractVersion;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseLegalTerms;
@@ -42,27 +43,15 @@ public interface RentalDocumentRenderer {
record LeaseDocument(LeaseId leaseId, LeaseSnapshot.Landlord landlord, LeaseSnapshot.Apartment apartment,
List<LeaseSnapshot.Tenant> tenants, List<Guarantor> guarantors, RentalPeriod period,
RentTerms terms, LeaseType leaseType, List<LeaseSpecialClause> specialClauses,
LeaseLegalTerms legalTerms, String templateVersion, int revisionNumber) {
LeaseLegalTerms legalTerms, String templateVersion, int revisionNumber,
FurnitureInventorySnapshot furnitureInventory) {
public LeaseDocument {
tenants = List.copyOf(tenants);
guarantors = List.copyOf(guarantors);
leaseType = LeaseType.orStandard(leaseType);
specialClauses = specialClauses == null ? List.of() : List.copyOf(specialClauses);
legalTerms = LeaseLegalTerms.orEmpty(legalTerms);
}
public LeaseDocument(LeaseId leaseId, LeaseSnapshot.Landlord landlord, LeaseSnapshot.Apartment apartment,
List<LeaseSnapshot.Tenant> tenants, List<Guarantor> guarantors, RentalPeriod period,
RentTerms terms, String templateVersion, int revisionNumber) {
this(leaseId, landlord, apartment, tenants, guarantors, period, terms, LeaseType.STANDARD, List.of(),
LeaseLegalTerms.empty(), templateVersion, revisionNumber);
}
public LeaseDocument(LeaseId leaseId, LeaseSnapshot.Landlord landlord, LeaseSnapshot.Apartment apartment,
List<LeaseSnapshot.Tenant> tenants, List<Guarantor> guarantors, RentalPeriod period,
RentTerms terms) {
this(leaseId, landlord, apartment, tenants, guarantors, period, terms, LeaseType.STANDARD, List.of(),
LeaseLegalTerms.empty(), "LEGACY", 0);
furnitureInventory = Objects.requireNonNull(furnitureInventory, "Furniture inventory is required.");
}
}
@@ -0,0 +1,11 @@
package fr.nessar.lmnporganizer.rental.application.shared;
public final class PropertyConditionPhotoNormalizationFailure extends RuntimeException {
public PropertyConditionPhotoNormalizationFailure(String message, Throwable cause) {
super(message, cause);
}
public PropertyConditionPhotoNormalizationFailure(String message) {
super(message);
}
}
@@ -22,21 +22,18 @@ public final class Apartment {
private BigDecimal purchasePrice;
private String description;
private ApartmentLegalProfile legalProfile;
private List<PrivateArea> privateAreas;
private List<ApartmentDocument> documents;
public Apartment(ApartmentId id, LandlordId landlordId, String name, String address, BigDecimal purchasePrice,
String description) {
this(id, landlordId, name, address, purchasePrice, description, ApartmentLegalProfile.empty());
}
public Apartment(ApartmentId id, LandlordId landlordId, String name, String address, BigDecimal purchasePrice,
String description, ApartmentLegalProfile legalProfile) {
this(id, landlordId, name, address, purchasePrice, description, legalProfile, Set.of(), List.of(), -1);
String description, ApartmentLegalProfile legalProfile, List<PrivateArea> privateAreas) {
this(id, landlordId, name, address, purchasePrice, description, legalProfile, Set.of(), privateAreas,
List.of(), -1);
}
private Apartment(ApartmentId id, LandlordId landlordId, String name, String address, BigDecimal purchasePrice,
String description, ApartmentLegalProfile legalProfile, Set<LeaseId> leaseReferences,
List<ApartmentDocument> documents, long version) {
List<PrivateArea> privateAreas, List<ApartmentDocument> documents, long version) {
this.id = Objects.requireNonNull(id, "Apartment ID is required.");
this.landlordId = Objects.requireNonNull(landlordId, "Landlord ID is required.");
if (version < -1) {
@@ -45,37 +42,18 @@ public final class Apartment {
this.version = version;
this.leaseReferences = leaseReferences == null ? Set.of() : Set.copyOf(leaseReferences);
this.legalProfile = Objects.requireNonNullElseGet(legalProfile, ApartmentLegalProfile::empty);
this.privateAreas = privateAreas(privateAreas);
this.documents = currentDocuments(documents);
reviseDetails(name, address, purchasePrice, description);
}
public static Apartment reconstitute(ApartmentId id, LandlordId landlordId, String name, String address,
BigDecimal purchasePrice, String description, long version) {
return reconstitute(id, landlordId, name, address, purchasePrice, description,
ApartmentLegalProfile.empty(), Set.of(), version);
}
public static Apartment reconstitute(ApartmentId id, LandlordId landlordId, String name, String address,
BigDecimal purchasePrice, String description, Set<LeaseId> leaseReferences,
long version) {
return reconstitute(id, landlordId, name, address, purchasePrice, description,
ApartmentLegalProfile.empty(), leaseReferences, version);
}
public static Apartment reconstitute(ApartmentId id, LandlordId landlordId, String name, String address,
BigDecimal purchasePrice, String description,
ApartmentLegalProfile legalProfile, Set<LeaseId> leaseReferences,
List<PrivateArea> privateAreas, List<ApartmentDocument> documents,
long version) {
return new Apartment(id, landlordId, name, address, purchasePrice, description, legalProfile,
leaseReferences, List.of(), version);
}
public static Apartment reconstitute(ApartmentId id, LandlordId landlordId, String name, String address,
BigDecimal purchasePrice, String description,
ApartmentLegalProfile legalProfile, Set<LeaseId> leaseReferences,
List<ApartmentDocument> documents, long version) {
return new Apartment(id, landlordId, name, address, purchasePrice, description, legalProfile,
leaseReferences, documents, version);
leaseReferences, privateAreas, documents, version);
}
public void reviseDetails(String name, String address, BigDecimal purchasePrice, String description) {
@@ -94,6 +72,10 @@ public final class Apartment {
this.legalProfile = Objects.requireNonNullElseGet(legalProfile, ApartmentLegalProfile::empty);
}
public void replacePrivateAreas(List<PrivateArea> privateAreas) {
this.privateAreas = privateAreas(privateAreas);
}
public ApartmentDocument replaceDocument(ApartmentDocument document) {
Objects.requireNonNull(document, "Apartment document is required.");
ApartmentDocument replaced = documents.stream().filter(existing -> existing.type() == document.type())
@@ -135,6 +117,21 @@ public final class Apartment {
return List.copyOf(current);
}
private static List<PrivateArea> privateAreas(List<PrivateArea> values) {
Objects.requireNonNull(values, "Private areas are required.");
if (values.isEmpty()) {
return List.of();
}
Set<String> ids = new HashSet<>();
for (PrivateArea value : values) {
Objects.requireNonNull(value, "Private area is required.");
if (!ids.add(value.id())) {
throw new IllegalArgumentException("Private area IDs must be unique.");
}
}
return List.copyOf(values);
}
private static String required(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " is required.");
@@ -192,6 +189,14 @@ public final class Apartment {
return legalProfile;
}
public List<PrivateArea> privateAreas() {
return privateAreas;
}
public int mainRoomCount() {
return (int) privateAreas.stream().filter(area -> area.family() == PrivateArea.Family.PIECE_PRINCIPALE).count();
}
public List<ApartmentDocument> documents() {
return documents;
}
@@ -9,7 +9,6 @@ public record ApartmentLegalProfile(
String ownershipRegime,
ConstructionPeriod constructionPeriod,
BigDecimal habitableSurface,
int roomCount,
String otherPrivateAreas,
String equipment,
String heating,
@@ -46,9 +45,6 @@ public record ApartmentLegalProfile(
if (estimatedAnnualEnergyCost == null) {
estimatedAnnualEnergyCostReferenceYear = null;
}
if (roomCount < 0) {
throw new IllegalArgumentException("Room count cannot be negative.");
}
if (!energyRating.isEmpty()
&& (energyRating.length() != 1 || energyRating.charAt(0) < 'A' || energyRating.charAt(0) > 'G')) {
throw new IllegalArgumentException("Energy rating must be between A and G.");
@@ -59,25 +55,25 @@ public record ApartmentLegalProfile(
}
public ApartmentLegalProfile(String taxIdentifier, BuildingType buildingType, String ownershipRegime,
ConstructionPeriod constructionPeriod, BigDecimal habitableSurface, int roomCount,
ConstructionPeriod constructionPeriod, BigDecimal habitableSurface,
String otherPrivateAreas, String equipment, String heating, String hotWater,
String energyRating, BigDecimal estimatedAnnualEnergyCost, String destination,
String privateAccessories, String commonAreas, String internetAccess,
String departmentCode) {
this(taxIdentifier, buildingType, ownershipRegime, constructionPeriod, habitableSurface, roomCount,
this(taxIdentifier, buildingType, ownershipRegime, constructionPeriod, habitableSurface,
otherPrivateAreas, equipment, heating, hotWater, energyRating, estimatedAnnualEnergyCost, null,
destination, privateAccessories, commonAreas, internetAccess, departmentCode);
}
public static ApartmentLegalProfile empty() {
return new ApartmentLegalProfile("", null, "", null, null, 0, "", "", "", "", "", null, null,
return new ApartmentLegalProfile("", null, "", null, null, "", "", "", "", "", null, null,
"", "", "", "", "");
}
public void requireLeaseReady() {
if (taxIdentifier.isEmpty() || buildingType == null || ownershipRegime.isEmpty()
|| constructionPeriod == null || habitableSurface == null || habitableSurface.signum() <= 0
|| roomCount <= 0 || heating.isEmpty() || hotWater.isEmpty() || energyRating.isEmpty()
|| heating.isEmpty() || hotWater.isEmpty() || energyRating.isEmpty()
|| estimatedAnnualEnergyCost == null || estimatedAnnualEnergyCostReferenceYear == null
|| destination.isEmpty()) {
throw new IllegalArgumentException(
@@ -0,0 +1,56 @@
package fr.nessar.lmnporganizer.rental.domain.apartment;
import java.util.Objects;
import java.util.Set;
public record PrivateArea(String id, Family family, Subtype subtype, String label) {
public PrivateArea {
id = required(id, "Private area ID");
family = Objects.requireNonNull(family, "Private area family is required.");
subtype = Objects.requireNonNull(subtype, "Private area subtype is required.");
if (!family.accepts(subtype)) {
throw new IllegalArgumentException("Private area subtype does not belong to its family.");
}
label = required(label, "Private area label");
}
private static String required(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " is required.");
}
return value.trim();
}
public enum Family {
PIECE_PRINCIPALE,
PIECE_DE_SERVICE,
ANNEXE;
public boolean accepts(Subtype subtype) {
return switch (this) {
case PIECE_PRINCIPALE -> Set.of(Subtype.LIVING_ROOM, Subtype.DINING_ROOM, Subtype.BEDROOM,
Subtype.OTHER).contains(subtype);
case PIECE_DE_SERVICE -> Set.of(Subtype.KITCHEN, Subtype.BATHROOM, Subtype.TOILET,
Subtype.HALLWAY, Subtype.LAUNDRY_ROOM, Subtype.OTHER).contains(subtype);
case ANNEXE -> Set.of(Subtype.BALCONY, Subtype.TERRACE, Subtype.CELLAR, Subtype.PARKING,
Subtype.OTHER).contains(subtype);
};
}
}
public enum Subtype {
LIVING_ROOM,
DINING_ROOM,
BEDROOM,
KITCHEN,
BATHROOM,
TOILET,
HALLWAY,
LAUNDRY_ROOM,
BALCONY,
TERRACE,
CELLAR,
PARKING,
OTHER
}
}
@@ -0,0 +1,19 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
import java.util.Objects;
public record AccessMeans(String id, AccessMeansType type, String destination, int quantity, String observation) {
public AccessMeans {
if (id == null || id.isBlank() || destination == null || destination.isBlank() || quantity < 0) {
throw new IllegalArgumentException("Access means is invalid.");
}
id = id.trim();
type = Objects.requireNonNull(type, "Access means type is required.");
destination = destination.trim();
observation = observation == null ? "" : observation.trim();
}
boolean hasSameIdentityAs(AccessMeans other) {
return type == other.type && destination.equals(other.destination);
}
}
@@ -0,0 +1,3 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
public enum AccessMeansType { CLE, BADGE, TELECOMMANDE, OTHER }
@@ -0,0 +1,15 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
import java.time.Instant;
import java.util.Objects;
public record AuditedPhotoAbsence(String reason, String auditedBy, Instant auditedAt) {
public AuditedPhotoAbsence {
if (reason == null || reason.isBlank() || auditedBy == null || auditedBy.isBlank()) {
throw new IllegalArgumentException("Audited photo absence is invalid.");
}
reason = reason.trim();
auditedBy = auditedBy.trim();
auditedAt = Objects.requireNonNull(auditedAt, "Photo absence audit time is required.");
}
}
@@ -0,0 +1,15 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
public record ConditionObservation(String subjectId, ConditionState state, String observation) {
public ConditionObservation {
if (subjectId == null || subjectId.isBlank() || state == null) {
throw new IllegalArgumentException("Condition observation is invalid.");
}
subjectId = subjectId.trim();
observation = observation == null ? "" : observation.trim();
if ((state == ConditionState.DEGRADE || state == ConditionState.ABSENT
|| state == ConditionState.NON_APPLICABLE) && observation.isEmpty()) {
throw new IllegalArgumentException("An observation is required for this condition state.");
}
}
}
@@ -0,0 +1,26 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
import java.time.Instant;
import java.util.Objects;
public record ConditionPhoto(String reference, String zoneId, String storageKey, String sha256, int width, int height,
String uploadedBy, Instant uploadedAt) {
public ConditionPhoto {
reference = required(reference, "Photo reference");
zoneId = required(zoneId, "Photo zone ID");
storageKey = required(storageKey, "Photo storage key");
sha256 = required(sha256, "Photo SHA-256");
uploadedBy = required(uploadedBy, "Photo uploader");
uploadedAt = Objects.requireNonNull(uploadedAt, "Photo upload time is required.");
if (width <= 0 || height <= 0) {
throw new IllegalArgumentException("Photo dimensions must be positive.");
}
}
private static String required(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " is required.");
}
return value.trim();
}
}
@@ -0,0 +1,3 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
public enum ConditionState { NEUF, BON_ETAT, ETAT_USAGE, DEGRADE, ABSENT, NON_APPLICABLE }
@@ -0,0 +1,15 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
import java.util.Objects;
public record ConditionZone(String id, String privateAreaId, String label, ZoneEquipment equipment) {
public ConditionZone {
if (id == null || id.isBlank() || label == null || label.isBlank()) {
throw new IllegalArgumentException("Condition zone is invalid.");
}
id = id.trim();
privateAreaId = privateAreaId == null ? "" : privateAreaId.trim();
label = label.trim();
equipment = Objects.requireNonNull(equipment, "Zone equipment is required.");
}
}
@@ -0,0 +1,5 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
public sealed interface EntryPhotoDisposition permits MatchedEntryPhoto, MissingEntryPhoto {
String entryPhotoId();
}
@@ -0,0 +1,31 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
import java.util.List;
import java.util.Objects;
public record EntryReportSnapshot(
PropertyConditionReportId reportId,
List<ReportParticipant> participants,
List<ConditionZone> zones,
List<ConditionObservation> observations,
List<MeterReading> meterReadings,
List<AccessMeans> accessMeans,
List<InventoryCondition> inventoryConditions,
List<ConditionPhoto> photos,
List<PropertyConditionReportSupplement> supplements) {
public EntryReportSnapshot {
Objects.requireNonNull(reportId, "Entry report ID is required.");
participants = copy(participants);
zones = copy(zones);
observations = copy(observations);
meterReadings = copy(meterReadings);
accessMeans = copy(accessMeans);
inventoryConditions = copy(inventoryConditions);
photos = copy(photos);
supplements = copy(supplements);
}
private static <T> List<T> copy(List<T> values) {
return values == null ? List.of() : List.copyOf(values);
}
}
@@ -0,0 +1,11 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
public record ForwardingAddress(String tenantPartyId, String address) {
public ForwardingAddress {
if (tenantPartyId == null || tenantPartyId.isBlank() || address == null || address.isBlank()) {
throw new IllegalArgumentException("Forwarding address is invalid.");
}
tenantPartyId = tenantPartyId.trim();
address = address.trim();
}
}
@@ -0,0 +1,26 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
import java.util.List;
public record InventoryCondition(String inventoryLineId, String privateAreaId, String privateAreaLabel,
String designation, int contractualQuantity,
List<InventoryQuantitySplit> splits) {
public InventoryCondition {
if (inventoryLineId == null || inventoryLineId.isBlank() || privateAreaId == null || privateAreaId.isBlank()
|| privateAreaLabel == null || privateAreaLabel.isBlank() || designation == null
|| designation.isBlank() || contractualQuantity < 0) {
throw new IllegalArgumentException("Inventory condition is invalid.");
}
String lineId = inventoryLineId.trim();
inventoryLineId = lineId;
privateAreaId = privateAreaId.trim();
privateAreaLabel = privateAreaLabel.trim();
designation = designation.trim();
splits = splits == null ? List.of() : List.copyOf(splits);
int declaredQuantity = splits.stream().mapToInt(InventoryQuantitySplit::quantity).sum();
if (splits.stream().anyMatch(split -> !lineId.equals(split.inventoryLineId()))
|| declaredQuantity != contractualQuantity) {
throw new IllegalArgumentException("Inventory quantities must exactly match the contractual quantity.");
}
}
}
@@ -0,0 +1,15 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
public record InventoryQuantitySplit(String inventoryLineId, ConditionState state, int quantity, String observation) {
public InventoryQuantitySplit {
if (inventoryLineId == null || inventoryLineId.isBlank() || state == null || quantity < 0) {
throw new IllegalArgumentException("Inventory quantity split is invalid.");
}
inventoryLineId = inventoryLineId.trim();
observation = observation == null ? "" : observation.trim();
if ((state == ConditionState.DEGRADE || state == ConditionState.ABSENT
|| state == ConditionState.NON_APPLICABLE) && observation.isEmpty()) {
throw new IllegalArgumentException("An observation is required for this condition state.");
}
}
}
@@ -0,0 +1,20 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Objects;
public record LateSupplementAddition(String ruleVersion, LocalDate deadlineOn, String reason, String auditedBy,
Instant auditedAt) implements SupplementAudit {
public LateSupplementAddition {
if (ruleVersion == null || ruleVersion.isBlank() || reason == null || reason.isBlank()
|| auditedBy == null || auditedBy.isBlank()) {
throw new IllegalArgumentException("Late supplement addition is invalid.");
}
ruleVersion = ruleVersion.trim();
deadlineOn = Objects.requireNonNull(deadlineOn, "Late supplement deadline is required.");
reason = reason.trim();
auditedBy = auditedBy.trim();
auditedAt = Objects.requireNonNull(auditedAt, "Late supplement audit time is required.");
}
}
@@ -0,0 +1,11 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
public record MatchedEntryPhoto(String entryPhotoId, String exitPhotoId) implements EntryPhotoDisposition {
public MatchedEntryPhoto {
if (entryPhotoId == null || entryPhotoId.isBlank() || exitPhotoId == null || exitPhotoId.isBlank()) {
throw new IllegalArgumentException("Photo match is invalid.");
}
entryPhotoId = entryPhotoId.trim();
exitPhotoId = exitPhotoId.trim();
}
}
@@ -0,0 +1,31 @@
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 MeterReading {
id = required(id, "Meter ID");
type = Objects.requireNonNull(type, "Meter type is required.");
index = Objects.requireNonNull(index, "Meter index 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())) {
throw new IllegalArgumentException("Meter reading is invalid.");
}
}
boolean hasSameIdentityAs(MeterReading other) {
return type == other.type && unit.equals(other.unit) && label.equals(other.label);
}
private static String required(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " is required.");
}
return value.trim();
}
}
@@ -0,0 +1,3 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
public enum MeterType { ELECTRICITE, GAZ, EAU_FROIDE, EAU_CHAUDE, OTHER }
@@ -0,0 +1,13 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
import java.util.Objects;
public record MissingEntryPhoto(String entryPhotoId, AuditedPhotoAbsence absence) implements EntryPhotoDisposition {
public MissingEntryPhoto {
if (entryPhotoId == null || entryPhotoId.isBlank()) {
throw new IllegalArgumentException("Entry photo ID is required.");
}
entryPhotoId = entryPhotoId.trim();
absence = Objects.requireNonNull(absence, "Photo absence is required.");
}
}
@@ -0,0 +1,10 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
import java.time.LocalDate;
import java.util.Objects;
public record OnTimeSupplement(LocalDate deadlineOn) implements SupplementAudit {
public OnTimeSupplement {
deadlineOn = Objects.requireNonNull(deadlineOn, "Supplement deadline is required.");
}
}
@@ -0,0 +1,3 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
public enum ParticipantAttendance { PRESENT, REPRESENTED }
@@ -0,0 +1,511 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
public final class PropertyConditionReport {
private final PropertyConditionReportId id;
private final LandlordId landlordId;
private final LeaseId leaseId;
private final PropertyConditionReportType type;
private final long version;
private final EntryReportSnapshot entrySnapshot;
private Integer leaseRevisionNumber;
private String leaseRevisionFingerprint;
private LocalDate occurredOn;
private String timeZone;
private List<ReportParticipant> participants;
private List<ForwardingAddress> forwardingAddresses;
private List<ConditionZone> zones;
private List<ConditionObservation> observations;
private List<MeterReading> meterReadings;
private List<AccessMeans> accessMeans;
private List<InventoryCondition> inventoryConditions;
private List<ConditionPhoto> photos;
private List<ZonePhotoAbsence> zonePhotoAbsences;
private List<EntryPhotoDisposition> entryPhotoDispositions;
private List<PropertyConditionReportSupplement> supplements;
private List<PropertyConditionReportRevision> revisions;
private PropertyConditionReportFinalization finalization;
private List<PropertyConditionReportDeliveryAttestation> deliveryAttestations;
private PropertyConditionReportStatus status;
private PropertyConditionReport(PropertyConditionReportId id, LandlordId landlordId, LeaseId leaseId,
PropertyConditionReportType type, LocalDate occurredOn, String timeZone,
List<ReportParticipant> participants, List<ForwardingAddress> forwardingAddresses,
List<ConditionZone> zones, List<ConditionObservation> observations,
List<MeterReading> meterReadings, List<AccessMeans> accessMeans,
List<InventoryCondition> inventoryConditions, List<ConditionPhoto> photos,
List<ZonePhotoAbsence> zonePhotoAbsences,
List<EntryPhotoDisposition> entryPhotoDispositions,
List<PropertyConditionReportSupplement> supplements,
List<PropertyConditionReportRevision> revisions,
PropertyConditionReportFinalization finalization,
List<PropertyConditionReportDeliveryAttestation> deliveryAttestations,
EntryReportSnapshot entrySnapshot, Integer leaseRevisionNumber,
String leaseRevisionFingerprint, PropertyConditionReportStatus status, long version) {
this.id = Objects.requireNonNull(id);
this.landlordId = Objects.requireNonNull(landlordId);
this.leaseId = Objects.requireNonNull(leaseId);
this.type = Objects.requireNonNull(type);
this.occurredOn = Objects.requireNonNull(occurredOn);
this.timeZone = timeZone(timeZone);
if (version < -1) {
throw new IllegalArgumentException("Property condition report version is invalid.");
}
this.participants = unique(participants, ReportParticipant::partyId, "Report participants");
this.forwardingAddresses = unique(forwardingAddresses, ForwardingAddress::tenantPartyId, "Forwarding addresses");
this.zones = unique(zones, ConditionZone::id, "Condition zones");
this.observations = unique(observations, ConditionObservation::subjectId, "Condition observations");
this.meterReadings = unique(meterReadings, MeterReading::id, "Meter readings");
this.accessMeans = unique(accessMeans, AccessMeans::id, "Access means");
this.inventoryConditions = unique(inventoryConditions, InventoryCondition::inventoryLineId,
"Inventory conditions");
this.photos = unique(photos, ConditionPhoto::reference, "Condition photos");
this.zonePhotoAbsences = unique(zonePhotoAbsences, ZonePhotoAbsence::zoneId, "Zone photo absences");
this.entryPhotoDispositions = unique(entryPhotoDispositions, EntryPhotoDisposition::entryPhotoId,
"Entry photo dispositions");
this.supplements = unique(supplements, PropertyConditionReportSupplement::id, "Report supplements");
this.revisions = unique(revisions, revision -> Integer.toString(revision.number()), "Report revisions");
this.finalization = finalization;
this.deliveryAttestations = unique(deliveryAttestations, PropertyConditionReportDeliveryAttestation::partyId,
"Report delivery attestations");
this.entrySnapshot = entrySnapshot;
this.leaseRevisionNumber = leaseRevisionNumber;
this.leaseRevisionFingerprint = leaseRevisionFingerprint == null ? null : leaseRevisionFingerprint.trim();
this.status = Objects.requireNonNull(status);
this.version = version;
if ((status == PropertyConditionReportStatus.FINALIZED) != (finalization != null)) {
throw new IllegalArgumentException("Property condition report finalization is invalid.");
}
if ((type == PropertyConditionReportType.ENTRY) != (entrySnapshot == null)) {
throw new IllegalArgumentException("Property condition report snapshot is invalid.");
}
if (type == PropertyConditionReportType.ENTRY
&& (leaseRevisionNumber == null || leaseRevisionNumber < 1
|| this.leaseRevisionFingerprint == null || this.leaseRevisionFingerprint.isEmpty())) {
throw new IllegalArgumentException("An entry report must reference its lease document revision.");
}
if (type == PropertyConditionReportType.EXIT
&& (leaseRevisionNumber != null || this.leaseRevisionFingerprint != null)) {
throw new IllegalArgumentException("An exit report cannot reference a lease document revision.");
}
if (entrySnapshot != null) {
validateSnapshot(entrySnapshot);
}
}
public static PropertyConditionReport draft(PropertyConditionReportId id, LandlordId landlordId, LeaseId leaseId,
PropertyConditionReportType type, LocalDate occurredOn, String timeZone,
int leaseRevisionNumber, String leaseRevisionFingerprint) {
if (type != PropertyConditionReportType.ENTRY) {
throw new IllegalArgumentException("An exit report must be created from a finalized entry report.");
}
return new PropertyConditionReport(id, landlordId, leaseId, type, occurredOn, timeZone, List.of(), List.of(),
List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of(),
List.of(), null, List.of(), null, leaseRevisionNumber, leaseRevisionFingerprint,
PropertyConditionReportStatus.DRAFT, -1);
}
public static PropertyConditionReport draftExit(PropertyConditionReportId id, PropertyConditionReport entry,
LocalDate occurredOn, String timeZone) {
Objects.requireNonNull(entry, "Entry report is required.");
if (entry.type != PropertyConditionReportType.ENTRY || !entry.finalized()) {
throw new IllegalArgumentException("A finalized entry report is required for an exit report.");
}
if (occurredOn.isBefore(entry.occurredOn)) {
throw new IllegalArgumentException("Exit report cannot precede entry report.");
}
EntryReportSnapshot snapshot = snapshotOf(entry);
return new PropertyConditionReport(id, entry.landlordId, entry.leaseId, PropertyConditionReportType.EXIT,
occurredOn, timeZone, snapshot.participants(), List.of(), snapshot.zones(), snapshot.observations(),
snapshot.meterReadings(), snapshot.accessMeans(), snapshot.inventoryConditions(), List.of(), List.of(),
List.of(), List.of(), List.of(), null, List.of(), snapshot, null, null,
PropertyConditionReportStatus.DRAFT, -1);
}
public static PropertyConditionReport reconstitute(
PropertyConditionReportId id, LandlordId landlordId, LeaseId leaseId, PropertyConditionReportType type,
LocalDate occurredOn, String timeZone, List<ReportParticipant> participants,
List<ForwardingAddress> forwardingAddresses, List<ConditionZone> zones,
List<ConditionObservation> observations, List<MeterReading> meterReadings, List<AccessMeans> accessMeans,
List<InventoryCondition> inventoryConditions, List<ConditionPhoto> photos,
List<ZonePhotoAbsence> zonePhotoAbsences, List<EntryPhotoDisposition> entryPhotoDispositions,
List<PropertyConditionReportSupplement> supplements, List<PropertyConditionReportRevision> revisions,
PropertyConditionReportFinalization finalization,
List<PropertyConditionReportDeliveryAttestation> deliveryAttestations, EntryReportSnapshot entrySnapshot,
Integer leaseRevisionNumber, String leaseRevisionFingerprint, PropertyConditionReportStatus status,
long version) {
return new PropertyConditionReport(id, landlordId, leaseId, type, occurredOn, timeZone, participants,
forwardingAddresses, zones, observations, meterReadings, accessMeans, inventoryConditions, photos,
zonePhotoAbsences, entryPhotoDispositions, supplements, revisions, finalization, deliveryAttestations,
entrySnapshot, leaseRevisionNumber, leaseRevisionFingerprint, status, version);
}
public void replaceOccurrence(LocalDate occurredOn, String timeZone) {
ensureDraft();
LocalDate nextDate = Objects.requireNonNull(occurredOn, "Property condition report date is required.");
String nextTimeZone = timeZone(timeZone);
if (this.occurredOn.equals(nextDate) && this.timeZone.equals(nextTimeZone)) return;
obsoleteRevision(); this.occurredOn = nextDate; this.timeZone = nextTimeZone;
}
public void replaceParticipants(List<ReportParticipant> participants) {
ensureDraft();
List<ReportParticipant> next = unique(participants, ReportParticipant::partyId, "Report participants");
if (this.participants.equals(next)) return;
obsoleteRevision(); this.participants = next;
}
public void replaceForwardingAddresses(List<ForwardingAddress> forwardingAddresses) {
ensureExitDraft();
List<ForwardingAddress> next = unique(forwardingAddresses, ForwardingAddress::tenantPartyId,
"Forwarding addresses");
if (this.forwardingAddresses.equals(next)) return;
obsoleteRevision(); this.forwardingAddresses = next;
}
public void replaceZones(List<ConditionZone> zones) {
ensureDraft();
List<ConditionZone> next = unique(zones, ConditionZone::id, "Condition zones");
if (this.zones.equals(next)) return;
obsoleteRevision(); this.zones = next;
}
public void replaceObservations(List<ConditionObservation> observations) {
ensureDraft();
List<ConditionObservation> next = unique(observations, ConditionObservation::subjectId, "Condition observations");
if (this.observations.equals(next)) return;
obsoleteRevision(); this.observations = next;
}
public void replaceMeterReadings(List<MeterReading> meterReadings) {
ensureDraft();
List<MeterReading> next = unique(meterReadings, MeterReading::id, "Meter readings");
if (this.meterReadings.equals(next)) return;
obsoleteRevision(); this.meterReadings = next;
}
public void replaceAccessMeans(List<AccessMeans> accessMeans) {
ensureDraft();
List<AccessMeans> next = unique(accessMeans, AccessMeans::id, "Access means");
if (this.accessMeans.equals(next)) return;
obsoleteRevision(); this.accessMeans = next;
}
public void replaceInventoryConditions(List<InventoryCondition> inventoryConditions) {
ensureDraft();
List<InventoryCondition> next = unique(inventoryConditions, InventoryCondition::inventoryLineId,
"Inventory conditions");
if (this.inventoryConditions.equals(next)) return;
obsoleteRevision(); this.inventoryConditions = next;
}
public void replacePhotos(List<ConditionPhoto> photos) {
ensureDraft();
List<ConditionPhoto> next = unique(photos, ConditionPhoto::reference, "Condition photos");
if (this.photos.equals(next)) return;
obsoleteRevision(); this.photos = next;
}
public void replaceZonePhotoAbsences(List<ZonePhotoAbsence> zonePhotoAbsences) {
ensureDraft();
List<ZonePhotoAbsence> next = unique(zonePhotoAbsences, ZonePhotoAbsence::zoneId,
"Zone photo absences");
if (this.zonePhotoAbsences.equals(next)) return;
obsoleteRevision(); this.zonePhotoAbsences = next;
}
public void replaceEntryPhotoDispositions(List<EntryPhotoDisposition> entryPhotoDispositions) {
ensureExitDraft();
List<EntryPhotoDisposition> next = unique(entryPhotoDispositions, EntryPhotoDisposition::entryPhotoId,
"Entry photo dispositions");
if (this.entryPhotoDispositions.equals(next)) return;
obsoleteRevision(); this.entryPhotoDispositions = next;
}
public void appendSupplement(PropertyConditionReportSupplement supplement) {
if (type != PropertyConditionReportType.ENTRY || !finalized()) {
throw new IllegalStateException("Supplements can only be appended to a finalized entry report.");
}
List<PropertyConditionReportSupplement> appended = new ArrayList<>(supplements);
appended.add(Objects.requireNonNull(supplement, "Report supplement is required."));
supplements = unique(appended, PropertyConditionReportSupplement::id, "Report supplements");
}
public void addRevision(PropertyConditionReportRevision revision) {
ensureDraft();
if (currentRevision() != null) throw new IllegalStateException("A report revision is already current.");
List<PropertyConditionReportRevision> generated = new ArrayList<>(revisions);
generated.add(Objects.requireNonNull(revision, "Report revision is required."));
revisions = unique(generated, value -> Integer.toString(value.number()), "Report revisions");
}
public void markObsolete() {
if (status == PropertyConditionReportStatus.DRAFT) {
obsoleteRevision();
status = PropertyConditionReportStatus.OBSOLETE;
}
}
public void restartEntryDraft(LocalDate occurredOn, String timeZone, int leaseRevisionNumber,
String leaseRevisionFingerprint) {
if (type != PropertyConditionReportType.ENTRY || status != PropertyConditionReportStatus.OBSOLETE) {
throw new IllegalStateException("Only an obsolete entry report can be restarted.");
}
this.occurredOn = Objects.requireNonNull(occurredOn, "Property condition report date is required.");
this.timeZone = timeZone(timeZone);
this.participants = List.of();
this.forwardingAddresses = List.of();
this.zones = List.of();
this.observations = List.of();
this.meterReadings = List.of();
this.accessMeans = List.of();
this.inventoryConditions = List.of();
this.photos = List.of();
this.zonePhotoAbsences = List.of();
this.entryPhotoDispositions = List.of();
this.deliveryAttestations = List.of();
this.leaseRevisionNumber = leaseRevisionNumber;
this.leaseRevisionFingerprint = leaseRevisionFingerprint;
this.status = PropertyConditionReportStatus.DRAFT;
}
public void requireEntryCompleteness(List<ConditionZone> requiredZones,
Map<String, Integer> requiredInventoryQuantities) {
if (type != PropertyConditionReportType.ENTRY) return;
Set<String> actualZoneIds = byId(zones, ConditionZone::id).keySet();
if (!actualZoneIds.containsAll(byId(requiredZones, ConditionZone::id).keySet())) {
throw new IllegalStateException("Condition zones must cover every required area surface.");
}
Map<String, InventoryCondition> inventory = byId(inventoryConditions, InventoryCondition::inventoryLineId);
if (!inventory.keySet().equals(requiredInventoryQuantities.keySet())
|| requiredInventoryQuantities.entrySet().stream().anyMatch(entry ->
inventory.get(entry.getKey()).contractualQuantity() != entry.getValue())) {
throw new IllegalStateException("Inventory conditions must cover every contractual line.");
}
}
public void finalizeReport(PropertyConditionReportFinalization evidence) {
ensureDraft();
PropertyConditionReportRevision revision = currentRevision();
if (revision == null || revision.number() != Objects.requireNonNull(evidence, "Finalization evidence is required.").revisionNumber()) {
throw new IllegalStateException("The latest report revision must be signed.");
}
requireReadyForSignature();
finalization = evidence;
status = PropertyConditionReportStatus.FINALIZED;
}
public void requireReadyForSignature() {
ensureDraft();
if (participants.isEmpty()) {
throw new IllegalStateException("A property condition report requires participants.");
}
requireSameIds(zones, observations, ConditionZone::id, ConditionObservation::subjectId,
"Condition observations");
validateZoneEvidence();
if (type == PropertyConditionReportType.EXIT) validateExitAgainstEntrySnapshot();
}
public void attestExternalDelivery(PropertyConditionReportDeliveryAttestation attestation) {
if (!finalized()) throw new IllegalStateException("A finalized report is required for delivery attestation.");
if (participants.stream().noneMatch(participant -> participant.partyId().equals(attestation.partyId()))) {
throw new IllegalArgumentException("Delivery attestation party is not a report participant.");
}
List<PropertyConditionReportDeliveryAttestation> values = new ArrayList<>(deliveryAttestations);
values.removeIf(value -> value.partyId().equals(attestation.partyId()));
values.add(attestation);
deliveryAttestations = unique(values, PropertyConditionReportDeliveryAttestation::partyId,
"Report delivery attestations");
}
private void obsoleteRevision() {
PropertyConditionReportRevision current = currentRevision();
if (current == null) return;
revisions = revisions.stream().map(revision -> revision.number() == current.number() ? revision.markedObsolete() : revision)
.toList();
}
private PropertyConditionReportRevision currentRevisionValue() {
return revisions.stream().filter(revision -> !revision.obsolete()).findFirst().orElse(null);
}
private void validateZoneEvidence() {
Set<String> zoneIds = byId(zones, ConditionZone::id).keySet();
if (photos.stream().anyMatch(photo -> !zoneIds.contains(photo.zoneId()))
|| zonePhotoAbsences.stream().anyMatch(absence -> !zoneIds.contains(absence.zoneId()))) {
throw new IllegalStateException("Photos and photo absences must reference a report zone.");
}
Set<String> evidencedZones = new HashSet<>(photos.stream().map(ConditionPhoto::zoneId).toList());
evidencedZones.addAll(zonePhotoAbsences.stream().map(ZonePhotoAbsence::zoneId).toList());
if (!evidencedZones.containsAll(zoneIds)) {
throw new IllegalStateException("Every condition zone requires a photo or an audited absence.");
}
}
private void validateExitAgainstEntrySnapshot() {
if (!zones.equals(entrySnapshot.zones())) {
throw new IllegalStateException("Exit report zones must match the entry snapshot.");
}
Set<String> tenantIds = participants.stream().filter(participant -> participant.role() == ReportPartyRole.TENANT)
.map(ReportParticipant::partyId).collect(java.util.stream.Collectors.toSet());
if (!tenantIds.equals(byId(forwardingAddresses, ForwardingAddress::tenantPartyId).keySet())) {
throw new IllegalStateException("Exit forwarding addresses must cover each tenant participant.");
}
requireSameIds(entrySnapshot.observations(), observations, ConditionObservation::subjectId,
ConditionObservation::subjectId, "Exit observations");
requireSameIds(entrySnapshot.meterReadings(), meterReadings, MeterReading::id, MeterReading::id,
"Exit meter readings");
requireSameIds(entrySnapshot.accessMeans(), accessMeans, AccessMeans::id, AccessMeans::id,
"Exit access means");
requireSameIds(entrySnapshot.inventoryConditions(), inventoryConditions, InventoryCondition::inventoryLineId,
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()) {
throw new IllegalStateException("A replaced or inaccessible meter requires an observation.");
}
}
for (AccessMeans entryAccessMeans : entrySnapshot.accessMeans()) {
AccessMeans exitAccessMeans = byId(accessMeans, AccessMeans::id).get(entryAccessMeans.id());
if ((!entryAccessMeans.hasSameIdentityAs(exitAccessMeans)
|| entryAccessMeans.quantity() != exitAccessMeans.quantity()) && exitAccessMeans.observation().isEmpty()) {
throw new IllegalStateException("An access means discrepancy requires an observation.");
}
}
for (InventoryCondition entryInventory : entrySnapshot.inventoryConditions()) {
InventoryCondition exitInventory = byId(inventoryConditions, InventoryCondition::inventoryLineId)
.get(entryInventory.inventoryLineId());
if (entryInventory.contractualQuantity() != exitInventory.contractualQuantity()) {
throw new IllegalStateException("Exit inventory conditions must keep contractual quantities.");
}
}
validatePhotoDispositions();
}
private void validatePhotoDispositions() {
requireSameIds(entrySnapshot.photos(), entryPhotoDispositions, ConditionPhoto::reference,
EntryPhotoDisposition::entryPhotoId, "Entry photo dispositions");
Map<String, ConditionPhoto> entryPhotos = byId(entrySnapshot.photos(), ConditionPhoto::reference);
Map<String, ConditionPhoto> exitPhotos = byId(photos, ConditionPhoto::reference);
Set<String> matchedExitPhotos = new HashSet<>();
for (EntryPhotoDisposition disposition : entryPhotoDispositions) {
if (disposition instanceof MatchedEntryPhoto matched) {
ConditionPhoto exitPhoto = exitPhotos.get(matched.exitPhotoId());
if (exitPhoto == null || !matchedExitPhotos.add(exitPhoto.reference())
|| !entryPhotos.get(matched.entryPhotoId()).zoneId().equals(exitPhoto.zoneId())) {
throw new IllegalStateException("Every entry photo needs one matching exit photo in its zone.");
}
}
}
}
private void ensureExitDraft() {
ensureDraft();
if (type != PropertyConditionReportType.EXIT) {
throw new IllegalStateException("This operation only belongs to an exit report.");
}
}
private void ensureDraft() {
if (status == PropertyConditionReportStatus.OBSOLETE) {
throw new IllegalStateException("Property condition report draft is obsolete.");
}
if (status != PropertyConditionReportStatus.DRAFT) {
throw new IllegalStateException("Property condition report is finalized.");
}
}
private static EntryReportSnapshot snapshotOf(PropertyConditionReport entry) {
return new EntryReportSnapshot(entry.id, entry.participants, entry.zones, entry.observations,
entry.meterReadings, entry.accessMeans, entry.inventoryConditions, entry.photos, entry.supplements);
}
private static void validateSnapshot(EntryReportSnapshot snapshot) {
unique(snapshot.participants(), ReportParticipant::partyId, "Entry snapshot participants");
unique(snapshot.zones(), ConditionZone::id, "Entry snapshot zones");
unique(snapshot.observations(), ConditionObservation::subjectId, "Entry snapshot observations");
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");
}
private static String timeZone(String value) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("Property condition report time zone is required.");
}
return value.trim();
}
private static <T> List<T> unique(List<T> values, Function<T, String> id, String name) {
List<T> copy = values == null ? List.of() : List.copyOf(values);
Set<String> ids = new HashSet<>();
for (T value : copy) {
Objects.requireNonNull(value, name + " value is required.");
if (!ids.add(id.apply(value))) {
throw new IllegalArgumentException(name + " must use stable unique IDs.");
}
}
return copy;
}
private static <A, B> void requireSameIds(List<A> expected, List<B> actual, Function<A, String> expectedId,
Function<B, String> actualId, String name) {
if (!byId(expected, expectedId).keySet().equals(byId(actual, actualId).keySet())) {
throw new IllegalStateException(name + " must preserve the entry snapshot IDs.");
}
}
private static <T> Map<String, T> byId(List<T> values, Function<T, String> id) {
Map<String, T> result = new HashMap<>();
for (T value : values) {
result.put(id.apply(value), value);
}
return result;
}
public PropertyConditionReportId id() { return id; }
public LandlordId landlordId() { return landlordId; }
public LeaseId leaseId() { return leaseId; }
public PropertyConditionReportType type() { return type; }
public LocalDate occurredOn() { return occurredOn; }
public String timeZone() { return timeZone; }
public List<ReportParticipant> participants() { return participants; }
public List<ForwardingAddress> forwardingAddresses() { return forwardingAddresses; }
public List<ConditionZone> zones() { return zones; }
public List<ConditionObservation> observations() { return observations; }
public List<MeterReading> meterReadings() { return meterReadings; }
public List<AccessMeans> accessMeans() { return accessMeans; }
public List<InventoryCondition> inventoryConditions() { return inventoryConditions; }
public List<ConditionPhoto> photos() { return photos; }
public List<ZonePhotoAbsence> zonePhotoAbsences() { return zonePhotoAbsences; }
public List<EntryPhotoDisposition> entryPhotoDispositions() { return entryPhotoDispositions; }
public List<PropertyConditionReportSupplement> supplements() { return supplements; }
public List<PropertyConditionReportRevision> revisions() { return revisions; }
public PropertyConditionReportRevision currentRevision() { return currentRevisionValue(); }
public PropertyConditionReportFinalization finalization() { return finalization; }
public List<PropertyConditionReportDeliveryAttestation> deliveryAttestations() { return deliveryAttestations; }
public EntryReportSnapshot entrySnapshot() { return entrySnapshot; }
public Integer leaseRevisionNumber() { return leaseRevisionNumber; }
public String leaseRevisionFingerprint() { return leaseRevisionFingerprint; }
public boolean referencesLeaseRevision(int number, String fingerprint) {
return type == PropertyConditionReportType.ENTRY && leaseRevisionNumber != null
&& leaseRevisionNumber == number && leaseRevisionFingerprint.equals(fingerprint);
}
public PropertyConditionReportStatus status() { return status; }
public boolean finalized() { return status == PropertyConditionReportStatus.FINALIZED; }
public long version() { return version; }
}
@@ -0,0 +1,17 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
import java.time.Instant;
import java.util.Objects;
public record PropertyConditionReportDeliveryAttestation(String partyId, String attestedBy, Instant attestedAt) {
public PropertyConditionReportDeliveryAttestation {
partyId = required(partyId, "Attested party");
attestedBy = required(attestedBy, "Attestation author");
attestedAt = Objects.requireNonNull(attestedAt, "Attestation time is required.");
}
private static String required(String value, String field) {
if (value == null || value.isBlank()) throw new IllegalArgumentException(field + " is required.");
return value.trim();
}
}
@@ -0,0 +1,20 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
import java.time.Instant;
import java.util.Objects;
public record PropertyConditionReportFinalization(int revisionNumber, String signedStorageKey, String signedSha256,
String authorId, Instant finalizedAt) {
public PropertyConditionReportFinalization {
if (revisionNumber < 1) throw new IllegalArgumentException("Finalized report revision is invalid.");
signedStorageKey = required(signedStorageKey, "Signed report storage key");
signedSha256 = required(signedSha256, "Signed report SHA-256");
authorId = required(authorId, "Finalization author");
finalizedAt = Objects.requireNonNull(finalizedAt, "Finalization time is required.");
}
private static String required(String value, String field) {
if (value == null || value.isBlank()) throw new IllegalArgumentException(field + " is required.");
return value.trim();
}
}
@@ -0,0 +1,10 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
public record PropertyConditionReportId(String value) {
public PropertyConditionReportId {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("Property condition report ID is required.");
}
value = value.trim();
}
}
@@ -0,0 +1,11 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import java.util.Optional;
public interface PropertyConditionReportRepository {
Optional<PropertyConditionReport> find(LandlordId landlordId, LeaseId leaseId, PropertyConditionReportType type);
PropertyConditionReport save(PropertyConditionReport report);
}
@@ -0,0 +1,26 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
import java.time.Instant;
import java.util.Objects;
public record PropertyConditionReportRevision(int number, String storageKey, String sha256, String sourceFingerprint,
String authorId, Instant generatedAt, boolean obsolete) {
public PropertyConditionReportRevision {
if (number < 1) throw new IllegalArgumentException("Report revision number is invalid.");
storageKey = required(storageKey, "Report revision storage key");
sha256 = required(sha256, "Report revision SHA-256");
sourceFingerprint = required(sourceFingerprint, "Report revision source fingerprint");
authorId = required(authorId, "Report revision author");
generatedAt = Objects.requireNonNull(generatedAt, "Report revision generation time is required.");
}
public PropertyConditionReportRevision markedObsolete() {
return obsolete ? this : new PropertyConditionReportRevision(number, storageKey, sha256, sourceFingerprint,
authorId, generatedAt, true);
}
private static String required(String value, String field) {
if (value == null || value.isBlank()) throw new IllegalArgumentException(field + " is required.");
return value.trim();
}
}
@@ -0,0 +1,3 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
public enum PropertyConditionReportStatus { DRAFT, OBSOLETE, FINALIZED }
@@ -0,0 +1,37 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
import java.time.Instant;
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,
SupplementAudit audit) {
public PropertyConditionReportSupplement {
id = required(id, "Supplement ID");
signedPdf = Objects.requireNonNull(signedPdf, "Signed supplement PDF is required.");
photos = List.copyOf(photos == null ? List.of() : photos);
uploadedBy = required(uploadedBy, "Supplement uploader");
uploadedAt = Objects.requireNonNull(uploadedAt, "Supplement upload time is required.");
requestedOn = Objects.requireNonNull(requestedOn, "Supplement request date is required.");
acceptedAdditions = required(acceptedAdditions, "Accepted supplement additions");
audit = Objects.requireNonNull(audit, "Supplement audit is required.");
}
public record FileEvidence(String reference, String storageKey, String sha256) {
public FileEvidence {
reference = required(reference, "Supplement file reference");
storageKey = required(storageKey, "Supplement file storage key");
sha256 = required(sha256, "Supplement file SHA-256");
}
}
private static String required(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " is required.");
}
return value.trim();
}
}
@@ -0,0 +1,3 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
public enum PropertyConditionReportType { ENTRY, EXIT }
@@ -0,0 +1,27 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
import java.util.Objects;
public record ReportParticipant(String partyId, ReportPartyRole role, String name, ParticipantAttendance attendance,
String representativeName) {
public ReportParticipant {
partyId = required(partyId, "Participant party ID");
role = Objects.requireNonNull(role, "Participant role is required.");
name = required(name, "Participant name");
attendance = Objects.requireNonNull(attendance, "Participant attendance is required.");
representativeName = representativeName == null ? "" : representativeName.trim();
if (attendance == ParticipantAttendance.REPRESENTED && representativeName.isEmpty()) {
throw new IllegalArgumentException("A represented participant requires a representative name.");
}
if (attendance == ParticipantAttendance.PRESENT && !representativeName.isEmpty()) {
throw new IllegalArgumentException("A present participant cannot have a representative.");
}
}
private static String required(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " is required.");
}
return value.trim();
}
}
@@ -0,0 +1,3 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
public enum ReportPartyRole { LANDLORD, TENANT }
@@ -0,0 +1,3 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
public sealed interface SupplementAudit permits OnTimeSupplement, LateSupplementAddition { }
@@ -0,0 +1,3 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
public enum ZoneEquipment { NONE, CLIMATISATION, OTHER }
@@ -0,0 +1,13 @@
package fr.nessar.lmnporganizer.rental.domain.condition;
import java.util.Objects;
public record ZonePhotoAbsence(String zoneId, AuditedPhotoAbsence absence) {
public ZonePhotoAbsence {
if (zoneId == null || zoneId.isBlank()) {
throw new IllegalArgumentException("Zone ID is required.");
}
zoneId = zoneId.trim();
absence = Objects.requireNonNull(absence, "Zone photo absence is required.");
}
}
@@ -0,0 +1,25 @@
package fr.nessar.lmnporganizer.rental.domain.furniture;
public enum FurnitureCategory {
BEDDING("Literie"),
SHUTTERS("Dispositif d'occultation"),
HOTPLATES("Plaques de cuisson"),
OVEN_OR_MICROWAVE("Four ou micro-ondes"),
REFRIGERATOR_WITH_FREEZER("Réfrigérateur avec congélateur ou compartiment congélation"),
DISHES("Vaisselle"),
UTENSILS("Ustensiles de cuisine"),
TABLE_AND_SEATING("Table et sièges"),
STORAGE("Étagères de rangement"),
LIGHTING("Luminaires"),
CLEANING("Matériel d'entretien");
private final String label;
FurnitureCategory(String label) {
this.label = label;
}
public String label() {
return label;
}
}
@@ -0,0 +1,121 @@
package fr.nessar.lmnporganizer.rental.domain.furniture;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.FurnishedLeaseTemplate;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
public final class FurnitureInventory {
private final FurnitureInventoryId id;
private final LandlordId landlordId;
private final LeaseId leaseId;
private final String templateVersion;
private final long version;
private List<FurnitureLine> lines;
private boolean frozen;
private FurnitureInventory(FurnitureInventoryId id, LandlordId landlordId, LeaseId leaseId, String templateVersion,
List<FurnitureLine> lines, boolean frozen, long version) {
this.id = Objects.requireNonNull(id);
this.landlordId = Objects.requireNonNull(landlordId);
this.leaseId = Objects.requireNonNull(leaseId);
if (templateVersion == null || templateVersion.isBlank() || version < -1) {
throw new IllegalArgumentException("Furniture inventory is invalid.");
}
this.templateVersion = FurnishedLeaseTemplate.fromVisibleId(templateVersion.trim()).visibleId();
this.lines = validated(lines);
this.frozen = frozen;
this.version = version;
}
public static FurnitureInventory prefilled(FurnitureInventoryId id, LandlordId landlordId, LeaseId leaseId,
String templateVersion, Function<Integer, String> lineIds,
String privateAreaId) {
Objects.requireNonNull(lineIds, "Furniture line ID generator is required.");
if (privateAreaId == null || privateAreaId.isBlank()) {
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();
return new FurnitureInventory(id, landlordId, leaseId, templateVersion, lines, false, -1);
}
public static FurnitureInventory reconstitute(FurnitureInventoryId id, LandlordId landlordId, LeaseId leaseId,
String templateVersion, List<FurnitureLine> lines, boolean frozen,
long version) {
return new FurnitureInventory(id, landlordId, leaseId, templateVersion, lines, frozen, version);
}
public static List<FurnitureCategory> legalCatalog() {
return List.copyOf(EnumSet.allOf(FurnitureCategory.class));
}
public static List<FurnitureCategory> legalCatalog(String templateVersion) {
FurnishedLeaseTemplate.fromVisibleId(templateVersion);
return legalCatalog();
}
public void replaceLines(List<FurnitureLine> lines) {
if (frozen) {
throw new IllegalStateException("Furniture inventory is frozen.");
}
this.lines = validated(lines);
}
public boolean isReadyForLeaseDocument() {
return missingCategories().isEmpty();
}
public List<FurnitureCategory> missingCategories() {
Set<FurnitureCategory> declared = lines.stream().filter(line -> line.presence() != null)
.map(FurnitureLine::category).collect(java.util.stream.Collectors.toSet());
return legalCatalog(templateVersion).stream().filter(category -> !declared.contains(category)).toList();
}
public List<FurnitureCategory> absentCategories() {
return lines.stream().filter(line -> line.presence() == FurniturePresence.ABSENT).map(FurnitureLine::category)
.toList();
}
public List<FurnitureInventoryWarning> legalWarnings() {
return absentCategories().stream().map(category -> new FurnitureInventoryWarning(
"FURNITURE_CATEGORY_ABSENT_" + category.name(), templateVersion, category)).toList();
}
public void freeze() {
if (!isReadyForLeaseDocument()) {
throw new IllegalStateException("Every legal furniture category must be declared.");
}
frozen = true;
}
private static List<FurnitureLine> validated(List<FurnitureLine> lines) {
List<FurnitureLine> values = lines == null ? List.of() : List.copyOf(lines);
Set<String> ids = new HashSet<>();
Set<FurnitureCategory> categories = EnumSet.noneOf(FurnitureCategory.class);
for (FurnitureLine line : values) {
Objects.requireNonNull(line, "Furniture line is required.");
if (!ids.add(line.id())) {
throw new IllegalArgumentException("Furniture line IDs must be unique.");
}
if (line.category() != null && !categories.add(line.category())) {
throw new IllegalArgumentException("Each legal furniture category must have one line.");
}
}
return values;
}
public FurnitureInventoryId id() { return id; }
public LandlordId landlordId() { return landlordId; }
public LeaseId leaseId() { return leaseId; }
public String templateVersion() { return templateVersion; }
public List<FurnitureLine> lines() { return lines; }
public boolean frozen() { return frozen; }
public long version() { return version; }
}
@@ -0,0 +1,10 @@
package fr.nessar.lmnporganizer.rental.domain.furniture;
public record FurnitureInventoryId(String value) {
public FurnitureInventoryId {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("Furniture inventory ID is required.");
}
value = value.trim();
}
}
@@ -0,0 +1,11 @@
package fr.nessar.lmnporganizer.rental.domain.furniture;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import java.util.Optional;
public interface FurnitureInventoryRepository {
Optional<FurnitureInventory> find(LandlordId landlordId, LeaseId leaseId);
FurnitureInventory save(FurnitureInventory inventory);
}
@@ -0,0 +1,27 @@
package fr.nessar.lmnporganizer.rental.domain.furniture;
import java.util.List;
/** Immutable inventory facts printed in a lease document revision. */
public record FurnitureInventorySnapshot(String templateVersion, List<Line> lines) {
public FurnitureInventorySnapshot {
if (templateVersion == null || templateVersion.isBlank()) {
throw new IllegalArgumentException("Furniture inventory template version is required.");
}
templateVersion = templateVersion.trim();
lines = lines == null ? List.of() : List.copyOf(lines);
}
public static FurnitureInventorySnapshot from(FurnitureInventory inventory) {
return new FurnitureInventorySnapshot(inventory.templateVersion(), inventory.lines().stream()
.map(Line::from).toList());
}
public record Line(String id, FurnitureCategory category, FurniturePresence presence, String privateAreaId,
String designation, int quantity, String description) {
private static Line from(FurnitureLine line) {
return new Line(line.id(), line.category(), line.presence(), line.privateAreaId(), line.designation(),
line.quantity(), line.description());
}
}
}
@@ -0,0 +1,11 @@
package fr.nessar.lmnporganizer.rental.domain.furniture;
public record FurnitureInventoryWarning(String code, String ruleVersion, FurnitureCategory category) {
public FurnitureInventoryWarning {
if (code == null || code.isBlank() || ruleVersion == null || ruleVersion.isBlank() || category == null) {
throw new IllegalArgumentException("Furniture inventory warning is invalid.");
}
code = code.trim();
ruleVersion = ruleVersion.trim();
}
}
@@ -0,0 +1,27 @@
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 FurnitureLine {
if (id == null || id.isBlank() || privateAreaId == null || privateAreaId.isBlank()
|| designation == null || designation.isBlank() || quantity < 0) {
throw new IllegalArgumentException("Furniture line is invalid.");
}
id = id.trim();
privateAreaId = privateAreaId.trim();
designation = designation.trim();
description = description == null ? "" : description.trim();
if (category == null && presence != null) {
throw new IllegalArgumentException("Only legal furniture categories may declare a presence.");
}
if (category != null && presence == null && quantity != 0) {
throw new IllegalArgumentException("An undeclared legal furniture category must have a zero quantity.");
}
if (presence == FurniturePresence.PRESENT && quantity == 0) {
throw new IllegalArgumentException("Present furniture must have a positive quantity.");
}
if (presence == FurniturePresence.ABSENT && quantity != 0) {
throw new IllegalArgumentException("Absent furniture must have a zero quantity.");
}
}
}
@@ -0,0 +1,6 @@
package fr.nessar.lmnporganizer.rental.domain.furniture;
public enum FurniturePresence {
PRESENT,
ABSENT
}
@@ -1,13 +1,18 @@
package fr.nessar.lmnporganizer.rental.domain.lease;
import fr.nessar.lmnporganizer.rental.domain.apartment.PrivateArea;
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventorySnapshot;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import java.time.Instant;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public record LeaseDocumentRevision(String id, LandlordId landlordId, LeaseId leaseId, int number,
String templateVersion, String sourceFingerprint, String storageKey,
String sha256, Instant generatedAt, String authorId,
FurnitureInventorySnapshot furnitureInventory, List<PrivateArea> privateAreas,
List<LeaseDocumentAnnex> annexes, List<LeaseDocumentWarning> warnings) {
public LeaseDocumentRevision {
id = required(id, "Revision ID");
@@ -25,15 +30,18 @@ public record LeaseDocumentRevision(String id, LandlordId landlordId, LeaseId le
throw new IllegalArgumentException("Generation instant is required.");
}
authorId = required(authorId, "Author ID");
annexes = annexes == null ? List.of() : List.copyOf(annexes);
warnings = warnings == null ? List.of() : List.copyOf(warnings);
}
public LeaseDocumentRevision(String id, LandlordId landlordId, LeaseId leaseId, int number,
String templateVersion, String sourceFingerprint, String storageKey,
String sha256, Instant generatedAt, String authorId) {
this(id, landlordId, leaseId, number, templateVersion, sourceFingerprint, storageKey, sha256, generatedAt,
authorId, List.of(), List.of());
if (furnitureInventory == null) {
throw new IllegalArgumentException("Furniture inventory snapshot is required.");
}
privateAreas = List.copyOf(privateAreas);
Set<String> privateAreaIds = new HashSet<>();
for (PrivateArea privateArea : privateAreas) {
if (!privateAreaIds.add(privateArea.id())) {
throw new IllegalArgumentException("Private area snapshot IDs must be unique.");
}
}
annexes = List.copyOf(annexes);
warnings = List.copyOf(warnings);
}
private static String required(String value, String field) {
@@ -4,12 +4,16 @@ import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import java.time.Instant;
import java.time.LocalDate;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public record LeaseFinalization(String id, LandlordId landlordId, LeaseId leaseId, int revisionNumber,
String templateVersion, LocalDate lastSignatureDate, String signedStorageKey,
String signedFileName, String signedSha256, String authorId, Instant finalizedAt,
List<LegalOverride> overrides) {
List<LegalOverride> overrides, boolean entryInventoryDiscrepancies,
List<InventoryCorrection> inventoryCorrections,
boolean allInventoryCorrectionsInitialed) {
public LeaseFinalization {
id = required(id, "Finalization ID");
if (landlordId == null || leaseId == null || lastSignatureDate == null || finalizedAt == null) {
@@ -24,6 +28,20 @@ public record LeaseFinalization(String id, LandlordId landlordId, LeaseId leaseI
signedSha256 = required(signedSha256, "Signed SHA-256");
authorId = required(authorId, "Author ID");
overrides = overrides == null ? List.of() : List.copyOf(overrides);
inventoryCorrections = inventoryCorrections == null ? List.of() : List.copyOf(inventoryCorrections);
if (!entryInventoryDiscrepancies
&& (!inventoryCorrections.isEmpty() || allInventoryCorrectionsInitialed)) {
throw new IllegalArgumentException("Inventory corrections require a declared discrepancy.");
}
if (entryInventoryDiscrepancies && (inventoryCorrections.isEmpty() || !allInventoryCorrectionsInitialed)) {
throw new IllegalArgumentException("Declared inventory corrections must be initialed.");
}
Set<String> correctionIds = new HashSet<>();
for (InventoryCorrection correction : inventoryCorrections) {
if (!correctionIds.add(correction.id())) {
throw new IllegalArgumentException("Inventory correction IDs must be unique.");
}
}
}
public record LegalOverride(String code, String ruleVersion, String authorId, Instant confirmedAt) {
@@ -37,10 +55,33 @@ public record LeaseFinalization(String id, LandlordId landlordId, LeaseId leaseI
}
}
public record InventoryCorrection(String id, String inventoryLineId, String privateAreaId, String designation,
int quantity, String description) {
public InventoryCorrection {
id = required(id, "Inventory correction ID");
inventoryLineId = optionalId(inventoryLineId);
privateAreaId = required(privateAreaId, "Private area ID");
designation = required(designation, "Inventory designation");
if (quantity < 0) {
throw new IllegalArgumentException("Inventory quantity cannot be negative.");
}
description = required(description, "Inventory correction description");
}
}
private static String required(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " is required.");
}
return value.trim();
}
private static String optional(String value) {
return value == null ? "" : value.trim();
}
private static String optionalId(String value) {
String trimmed = optional(value);
return trimmed.isEmpty() ? null : trimmed;
}
}
@@ -2,6 +2,7 @@ package fr.nessar.lmnporganizer.rental.domain.lease;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile;
import fr.nessar.lmnporganizer.rental.domain.apartment.PrivateArea;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
@@ -66,16 +67,18 @@ public record LeaseSnapshot(
}
}
public record Apartment(ApartmentId id, String name, String address, ApartmentLegalProfile legalProfile) {
public Apartment(ApartmentId id, String name, String address) {
this(id, name, address, ApartmentLegalProfile.empty());
}
public record Apartment(ApartmentId id, String name, String address, ApartmentLegalProfile legalProfile,
List<PrivateArea> privateAreas) {
public Apartment {
Objects.requireNonNull(id, "Apartment ID is required.");
name = required(name, "Apartment name");
address = required(address, "Apartment address");
legalProfile = legalProfile == null ? ApartmentLegalProfile.empty() : legalProfile;
privateAreas = List.copyOf(Objects.requireNonNull(privateAreas, "Private areas are required."));
}
public int mainRoomCount() {
return (int) privateAreas.stream().filter(area -> area.family() == PrivateArea.Family.PIECE_PRINCIPALE).count();
}
}
@@ -2,6 +2,15 @@ spring.application.name=backend
spring.mongodb.uri=${SPRING_DATA_MONGODB_URI:mongodb://localhost:27017/lmnp?replicaSet=rs0&directConnection=true}
lmnp.storage.attachments=${LMNP_ATTACHMENT_STORAGE_PATH:${user.home}/.lmnp-organizer/attachments}
lmnp.attachments.max-size=${LMNP_ATTACHMENT_MAX_SIZE:250MB}
lmnp.condition-photos.executable=${IMAGEMAGICK_EXECUTABLE:magick}
lmnp.condition-photos.max-upload-size=${LMNP_CONDITION_PHOTO_MAX_UPLOAD_SIZE:40MB}
lmnp.condition-photos.max-source-width=${LMNP_CONDITION_PHOTO_MAX_WIDTH:12000}
lmnp.condition-photos.max-source-height=${LMNP_CONDITION_PHOTO_MAX_HEIGHT:12000}
lmnp.condition-photos.memory-limit=${LMNP_CONDITION_PHOTO_MEMORY_LIMIT:192MB}
lmnp.condition-photos.map-limit=${LMNP_CONDITION_PHOTO_MAP_LIMIT:384MB}
lmnp.condition-photos.disk-limit=${LMNP_CONDITION_PHOTO_DISK_LIMIT:768MB}
lmnp.condition-photos.threads=${LMNP_CONDITION_PHOTO_THREADS:2}
lmnp.condition-photos.timeout=${LMNP_CONDITION_PHOTO_TIMEOUT:20s}
lmnp.insee-irl.base-uri=${INSEE_IRL_BASE_URI:https://bdm.insee.fr/series/sdmx/data/SERIES_BDM/}
lmnp.insee-irl.request-timeout=${INSEE_IRL_REQUEST_TIMEOUT:5s}
spring.servlet.multipart.max-file-size=${lmnp.attachments.max-size}
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policymap [
<!ELEMENT policymap (policy)+>
<!ELEMENT policy EMPTY>
<!ATTLIST policy domain CDATA #REQUIRED>
<!ATTLIST policy name CDATA #IMPLIED>
<!ATTLIST policy pattern CDATA #IMPLIED>
<!ATTLIST policy rights CDATA #IMPLIED>
<!ATTLIST policy value CDATA #IMPLIED>
]>
<policymap>
<!-- Hard container ceilings; the lower per-process values remain runtime-configurable. -->
<policy domain="resource" name="memory" value="256MiB"/>
<policy domain="resource" name="map" value="512MiB"/>
<policy domain="resource" name="disk" value="1GiB"/>
<policy domain="resource" name="area" value="128MP"/>
<policy domain="resource" name="width" value="16000"/>
<policy domain="resource" name="height" value="16000"/>
<policy domain="resource" name="list-length" value="2"/>
<policy domain="resource" name="thread" value="2"/>
<policy domain="resource" name="time" value="30"/>
<policy domain="resource" name="temporary-path" value="/tmp/lmnp-condition-photos"/>
<policy domain="delegate" rights="none" pattern="*"/>
<policy domain="filter" rights="none" pattern="*"/>
<policy domain="module" rights="none" pattern="*"/>
<policy domain="module" rights="read | write" pattern="{JPEG,JPG,PNG,WEBP,HEIC,HEIF}"/>
<policy domain="coder" rights="none" pattern="*"/>
<policy domain="coder" rights="read" pattern="{JPEG,JPG,PNG,HEIC,HEIF}"/>
<policy domain="coder" rights="read | write" pattern="WEBP"/>
<policy domain="path" rights="none" pattern="*"/>
<policy domain="path" rights="read | write" pattern="/tmp/lmnp-condition-photos/*"/>
<policy domain="path" rights="none" pattern="@*"/>
<policy domain="path" rights="none" pattern="-"/>
</policymap>
@@ -180,9 +180,9 @@
42,5 m²
</td>
</tr>
<tr th:if="${profile.roomCount > 0}">
<tr th:if="${document.apartment.mainRoomCount > 0}">
<th>Pièces principales</th>
<td th:text="${profile.roomCount}">2</td>
<td th:text="${document.apartment.mainRoomCount}">2</td>
</tr>
<tr th:if="${formats.present(profile.otherPrivateAreas)}">
<th>Autres parties</th>
@@ -535,6 +535,29 @@
<section>
<h2>XI. Annexes</h2>
<h3>Inventaire et état détaillé du mobilier</h3>
<table th:if="${!document.furnitureInventory.lines.empty}">
<thead>
<tr>
<th>Emplacement</th>
<th>Catégorie réglementaire</th>
<th>Déclaration</th>
<th>Désignation</th>
<th>Quantité</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr th:each="line : ${document.furnitureInventory.lines}">
<td th:text="${formats.privateArea(document.apartment.privateAreas, line.privateAreaId)}">Cuisine</td>
<td th:text="${line.category != null ? line.category.label : 'Élément ajouté'}">Table et sièges</td>
<td th:text="${formats.furniturePresence(line.presence)}">Présent</td>
<td th:text="${line.designation}">Table</td>
<td th:text="${line.quantity}">1</td>
<td th:text="${line.description}">Bois clair</td>
</tr>
</tbody>
</table>
<p>
Les pièces suivantes doivent être jointes séparément au contrat, selon
leur applicabilité :
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="fr">
<head><meta charset="UTF-8"/><style>
body { font-family: sans-serif; font-size: 11pt; } table { border-collapse: collapse; width: 100%; } th, td { border: 1px solid #333; padding: 4px; text-align: left; } footer { position: running(footer); font-size: 8pt; } @page { @bottom-center { content: element(footer); } }
</style></head>
<body>
<h1 th:text="${report.type() == T(fr.nessar.lmnporganizer.rental.domain.condition.PropertyConditionReportType).ENTRY ? 'État des lieux d’entrée' : 'État des lieux de sortie'}">État des lieux</h1>
<p th:text="|Date : ${report.occurredOn()}|">Date</p>
<h2>Participants</h2><table><thead><tr><th>Partie</th><th>Rôle</th><th>Présence</th><th>Mandataire</th></tr></thead><tbody><tr th:each="participant : ${report.participants()}"><td th:text="${participant.name()}">Partie</td><td th:text="${labels.role(participant.role())}">Rôle</td><td th:text="${labels.attendance(participant.attendance())}">Présence</td><td th:text="${participant.representativeName()}">Mandataire</td></tr></tbody></table>
<h2>Espaces et zones</h2><table><thead><tr><th>Zone</th><th>Équipement</th></tr></thead><tbody><tr th:each="zone : ${report.zones()}"><td th:text="${zone.label()}">Zone</td><td th:text="${labels.equipment(zone.equipment())}">Équipement</td></tr></tbody></table>
<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>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>
<h2>Signatures et paraphes</h2><table><thead><tr><th>Signataire</th><th>Pour</th><th>Signature</th><th>Paraphe</th></tr></thead><tbody><tr th:each="participant : ${report.participants()}"><td th:text="${participant.attendance() == T(fr.nessar.lmnporganizer.rental.domain.condition.ParticipantAttendance).REPRESENTED ? participant.representativeName() : participant.name()}">Signataire</td><td th:text="${participant.attendance() == T(fr.nessar.lmnporganizer.rental.domain.condition.ParticipantAttendance).REPRESENTED ? 'Mandataire de ' + participant.name() : labels.role(participant.role())}">Pour</td><td style="height: 48px"></td><td style="height: 48px"></td></tr></tbody></table>
<footer th:text="|Révision ${revisionNumber} — empreinte ${sourceFingerprint}|">Révision</footer>
</body></html>
@@ -0,0 +1,31 @@
package fr.nessar.lmnporganizer.rental.adapter.in.config;
import org.junit.jupiter.api.Test;
import org.springframework.util.unit.DataSize;
import java.time.Duration;
import static org.junit.jupiter.api.Assertions.assertThrows;
class PropertyConditionPhotoPropertiesTest {
@Test
void rejectsMissingOrNonPositiveOperationalLimits() {
assertThrows(IllegalArgumentException.class, () -> properties(" ", DataSize.ofBytes(1), 1, 1,
DataSize.ofBytes(1), DataSize.ofBytes(1), DataSize.ofBytes(1), 1, Duration.ofSeconds(1)));
assertThrows(IllegalArgumentException.class, () -> properties("magick", DataSize.ofBytes(0), 1, 1,
DataSize.ofBytes(1), DataSize.ofBytes(1), DataSize.ofBytes(1), 1, Duration.ofSeconds(1)));
assertThrows(IllegalArgumentException.class, () -> properties("magick", DataSize.ofBytes(1), 0, 1,
DataSize.ofBytes(1), DataSize.ofBytes(1), DataSize.ofBytes(1), 1, Duration.ofSeconds(1)));
assertThrows(IllegalArgumentException.class, () -> properties("magick", DataSize.ofBytes(1), 1, 1,
DataSize.ofBytes(1), DataSize.ofBytes(1), DataSize.ofBytes(1), 0, Duration.ofSeconds(1)));
assertThrows(IllegalArgumentException.class, () -> properties("magick", DataSize.ofBytes(1), 1, 1,
DataSize.ofBytes(1), DataSize.ofBytes(1), DataSize.ofBytes(1), 1, Duration.ZERO));
}
private static PropertyConditionPhotoProperties properties(
String executable, DataSize maxSize, int width, int height, DataSize memory, DataSize map,
DataSize disk, int threads, Duration timeout) {
return new PropertyConditionPhotoProperties(executable, maxSize, width, height, memory, map, disk, threads,
timeout);
}
}
@@ -31,6 +31,7 @@ import fr.nessar.lmnporganizer.rental.application.tenant.TenantDirectoryService;
import fr.nessar.lmnporganizer.rental.domain.apartment.Apartment;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile;
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventorySnapshot;
import fr.nessar.lmnporganizer.rental.domain.landlord.ExternalIdentity;
import fr.nessar.lmnporganizer.rental.domain.landlord.Landlord;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
@@ -77,6 +78,7 @@ import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Set;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
@@ -159,7 +161,7 @@ class RentalWebAdaptersTest {
void protectedRentalMutationsRejectMissingCsrfTokens() throws Exception {
mockMvc.perform(post("/api/apartments").with(asLandlord()).contentType(MediaType.APPLICATION_JSON).content("""
{"name":"Studio République","address":"12 rue de la République",
"purchasePrice":200000.00,"description":"Meublé"}
"purchasePrice":200000.00,"description":"Meublé","privateAreas":[]}
"""))
.andExpect(status().isForbidden());
}
@@ -167,20 +169,21 @@ class RentalWebAdaptersTest {
@Test
void oidcClaimsScopeApartmentCreationAndLookupAndResponsesAreMapped() throws Exception {
Apartment apartment = Apartment.reconstitute(APARTMENT_ID, LANDLORD_ID, "Studio République",
"12 rue de la République", new BigDecimal("200000.00"), "Meublé", 7);
"12 rue de la République", new BigDecimal("200000.00"), "Meublé", ApartmentLegalProfile.empty(),
Set.of(), List.of(), List.of(), 7);
when(apartments.create(eq(LANDLORD_ID), any())).thenReturn(apartment);
when(apartments.get(LANDLORD_ID, APARTMENT_ID)).thenReturn(apartment);
mockMvc.perform(post("/api/apartments").with(asLandlord()).with(csrf())
.contentType(MediaType.APPLICATION_JSON).content("""
{"name":"Studio République","address":"12 rue de la République",
"purchasePrice":200000.00,"description":"Meublé"}
"purchasePrice":200000.00,"description":"Meublé","privateAreas":[]}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value("apartment-1"))
.andExpect(jsonPath("$.purchasePrice").value(200000.00))
.andExpect(jsonPath("$.legalProfile.taxIdentifier").value(""))
.andExpect(jsonPath("$.legalProfile.roomCount").value(0))
.andExpect(jsonPath("$.mainRoomCount").value(0))
.andExpect(jsonPath("$.legalProfile.energyRating").value(""))
.andExpect(jsonPath("$.referenced").value(false))
.andExpect(jsonPath("$.version").value(7));
@@ -194,7 +197,7 @@ class RentalWebAdaptersTest {
"subject-1", "Camille", "Martin", "camille@example.com");
verify(accounts, times(2)).identify(claims);
verify(apartments).create(LANDLORD_ID, new ApartmentCatalogService.Details("Studio République",
"12 rue de la République", new BigDecimal("200000.00"), "Meublé"));
"12 rue de la République", new BigDecimal("200000.00"), "Meublé", null, List.of()));
verify(apartments).get(LANDLORD_ID, APARTMENT_ID);
}
@@ -202,7 +205,7 @@ class RentalWebAdaptersTest {
void apartmentLegalProfileIsMappedAtTheApiBoundary() throws Exception {
ApartmentLegalProfile profile = legalProfile();
Apartment apartment = new Apartment(APARTMENT_ID, LANDLORD_ID, "Studio République",
"12 rue de la République", new BigDecimal("200000.00"), "Meublé", profile);
"12 rue de la République", new BigDecimal("200000.00"), "Meublé", profile, List.of());
when(apartments.create(eq(LANDLORD_ID), any())).thenReturn(apartment);
mockMvc.perform(post("/api/apartments").with(asLandlord()).with(csrf())
@@ -217,7 +220,7 @@ class RentalWebAdaptersTest {
"estimatedAnnualEnergyCost":750.00,
"estimatedAnnualEnergyCostReferenceYear":2024,"destination":"Habitation",
"privateAccessories":"Balcon","commonAreas":"Local vélos",
"internetAccess":"Fibre","departmentCode":"2a"}}
"internetAccess":"Fibre","departmentCode":"2a"},"privateAreas":[]}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.legalProfile.buildingType").value("APARTMENT_BUILDING"))
@@ -227,7 +230,7 @@ class RentalWebAdaptersTest {
.andExpect(jsonPath("$.legalProfile.departmentCode").value("2A"));
verify(apartments).create(LANDLORD_ID, new ApartmentCatalogService.Details("Studio République",
"12 rue de la République", new BigDecimal("200000.00"), "Meublé", profile));
"12 rue de la République", new BigDecimal("200000.00"), "Meublé", profile, List.of()));
}
@Test
@@ -428,18 +431,17 @@ class RentalWebAdaptersTest {
}
@Test
void leaseRevisionEndpointsKeepAuthorAndLandlordScopeAndLegacyPdfHeaders() throws Exception {
void leaseRevisionEndpointsKeepAuthorAndLandlordScope() throws Exception {
byte[] pdf = "%PDF-revision".getBytes();
LeaseDocumentRevision revision = new LeaseDocumentRevision("revision-1", LANDLORD_ID, LEASE_ID, 2,
"FR-MEUBLE-2025-01", "source-hash", "storage-key", "pdf-hash",
Instant.parse("2026-03-01T12:00:00Z"), "subject-1", List.of(),
Instant.parse("2026-03-01T12:00:00Z"), "subject-1",
new FurnitureInventorySnapshot("FR-MEUBLE-2025-01", List.of()), List.of(), List.of(),
List.of(new LeaseDocumentWarning("DPE_MISSING", "2026-07")));
when(leaseDocuments.generateRevision(LANDLORD_ID, LEASE_ID, "subject-1")).thenReturn(revision);
when(leaseDocuments.current(LANDLORD_ID, LEASE_ID)).thenReturn(revision);
when(leaseDocuments.download(LANDLORD_ID, LEASE_ID, 2)).thenReturn(
new GeneratedDocument("bail-lease-1-revision-2.pdf", "application/pdf", pdf));
when(leaseDocuments.generate(LANDLORD_ID, LEASE_ID)).thenReturn(
new GeneratedDocument("bail-lease-1.pdf", "application/pdf", pdf));
mockMvc.perform(post("/api/leases/lease-1/revisions").with(asLandlord()).with(csrf()))
.andExpect(status().isCreated())
@@ -457,16 +459,10 @@ class RentalWebAdaptersTest {
.andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"bail-lease-1-revision-2.pdf\""))
.andExpect(content().bytes(pdf));
mockMvc.perform(get("/api/leases/lease-1/pdf").with(asLandlord()))
.andExpect(status().isOk())
.andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"bail-lease-1.pdf\""))
.andExpect(content().bytes(pdf));
verify(leaseDocuments).generateRevision(LANDLORD_ID, LEASE_ID, "subject-1");
verify(leaseDocuments).current(LANDLORD_ID, LEASE_ID);
verify(leaseDocuments).download(LANDLORD_ID, LEASE_ID, 2);
verify(leaseDocuments).generate(LANDLORD_ID, LEASE_ID);
verify(leaseDocuments, never()).generateAndDownload(any(), any(), any());
}
@@ -603,7 +599,7 @@ class RentalWebAdaptersTest {
private static ApartmentLegalProfile legalProfile() {
return new ApartmentLegalProfile("tax-1", ApartmentLegalProfile.BuildingType.APARTMENT_BUILDING,
"Copropriété", ApartmentLegalProfile.ConstructionPeriod.PERIOD_1949_1974,
new BigDecimal("31.50"), 2, "Cave", "Cuisine équipée", "Individuel", "Individuelle", "B",
new BigDecimal("31.50"), "Cave", "Cuisine équipée", "Individuel", "Individuelle", "B",
new BigDecimal("750.00"), 2024, "Habitation", "Balcon", "Local vélos", "Fibre", "2A");
}
@@ -617,7 +613,7 @@ class RentalWebAdaptersTest {
new LeaseSnapshot.Landlord(LANDLORD_ID, "Camille", "Martin", "camille@example.com",
"+33600000000", "1 rue A"),
new LeaseSnapshot.Apartment(APARTMENT_ID, "Studio République", "12 rue de la République",
legalProfile()),
legalProfile(), List.of()),
List.of(new LeaseSnapshot.Tenant(TENANT_ID, "Alex", "Bernard", "alex@example.com", "",
"2 rue B")),
List.of(), period, terms, LeaseType.STANDARD, lease.specialClauses()));
@@ -0,0 +1,97 @@
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.web.common.AuthenticatedLandlord;
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.RentalApiExceptionHandler;
import fr.nessar.lmnporganizer.rental.application.furniture.FurnitureInventoryService;
import fr.nessar.lmnporganizer.rental.application.landlord.LandlordAccountService;
import fr.nessar.lmnporganizer.rental.domain.apartment.PrivateArea;
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventory;
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.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.security.oauth2.core.oidc.user.OidcUser;
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 java.util.List;
import static org.mockito.ArgumentMatchers.any;
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.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(controllers = FurnitureInventoryController.class)
@ContextConfiguration(classes = LmnpOrganizerApplication.class)
@Import({AuthenticatedLandlord.class, RentalApiExceptionHandler.class, SecurityConfiguration.class})
class FurnitureInventoryWebTest {
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
private static final LeaseId LEASE_ID = new LeaseId("lease-1");
@Autowired MockMvc mockMvc;
@MockitoBean FurnitureInventoryService inventories;
@MockitoBean LandlordAccountService accounts;
@BeforeEach
void authenticate() {
when(accounts.identify(any())).thenReturn(Landlord.register(LANDLORD_ID,
new ExternalIdentity("subject-1"), "Camille", "Martin", "camille@example.com"));
}
@Test
void getInitializesAndExposesUndeclaredLegalCategories() throws Exception {
when(inventories.get(LANDLORD_ID, LEASE_ID)).thenReturn(inventory());
when(inventories.privateAreas(LANDLORD_ID, LEASE_ID)).thenReturn(List.of(new PrivateArea("area-1",
PrivateArea.Family.PIECE_PRINCIPALE, PrivateArea.Subtype.LIVING_ROOM, "Séjour")));
mockMvc.perform(get("/api/leases/lease-1/inventory").with(landlord()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.templateVersion").value("FR-MEUBLE-2025-01"))
.andExpect(jsonPath("$.lines[0].category").value("BEDDING"))
.andExpect(jsonPath("$.lines[0].presence").doesNotExist())
.andExpect(jsonPath("$.privateAreas[0].id").value("area-1"))
.andExpect(jsonPath("$.privateAreas[0].label").value("Séjour"))
.andExpect(jsonPath("$.readyForLeaseDocument").value(false));
}
@Test
void replaceUsesTheClientVersionAndRejectsRequestsWithoutIt() throws Exception {
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}]}")
.with(landlord()).with(csrf()))
.andExpect(status().isOk());
verify(inventories).replace(eq(LANDLORD_ID), eq(LEASE_ID), eq(0L), any());
mockMvc.perform(put("/api/leases/lease-1/inventory").contentType(MediaType.APPLICATION_JSON)
.content("{\"lines\":[]}").with(landlord()).with(csrf()))
.andExpect(status().isBadRequest());
}
private static FurnitureInventory inventory() {
return FurnitureInventory.prefilled(new fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryId("inventory-1"),
LANDLORD_ID, LEASE_ID, "FR-MEUBLE-2025-01", id -> "line-" + id, "area-1");
}
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"));
}
}
@@ -102,7 +102,9 @@ class LeaseEndingWebTest {
.andExpect(jsonPath("$.notices[0].recordedAt").value(NOW.toString()))
.andExpect(jsonPath("$.notices[0].evidenceVersions[0].sha256").value("a".repeat(64)))
.andExpect(jsonPath("$.agreedEarlyEnds.length()").value(1))
.andExpect(jsonPath("$.agreedEarlyEnds[0].authorId").value("subject-1"));
.andExpect(jsonPath("$.agreedEarlyEnds[0].authorId").value("subject-1"))
.andExpect(jsonPath("$.exitConditionReport.occurredOn").value("2026-08-18"))
.andExpect(jsonPath("$.exitConditionReport.finalized").value(true));
verify(endings).state(LANDLORD_ID, LEASE_ID, ZoneId.of("America/Los_Angeles"));
}
@@ -145,7 +147,7 @@ class LeaseEndingWebTest {
}
@Test
void jsonMutationsForwardCivilDatesReasonsConfirmationAndAuthenticatedAuthor() throws Exception {
void jsonMutationsForwardFactsAndClosureConfirmationWithTheAuthenticatedAuthor() throws Exception {
when(endings.acceptWithdrawal(any(), any(), any(), any(), any())).thenReturn(state());
when(endings.invalidateNotice(any(), any(), any(), any(), any())).thenReturn(state());
when(endings.recordAgreedEarlyEnd(any(), any(), any(), any())).thenReturn(state());
@@ -171,7 +173,7 @@ class LeaseEndingWebTest {
.andExpect(status().isOk());
mockMvc.perform(post("/api/leases/lease-1/end/closure")
.with(landlord()).with(csrf()).contentType(MediaType.APPLICATION_JSON).content("""
{"timeZone":"Europe/Paris","keysReturnedOn":"2026-08-18","confirmed":true}
{"timeZone":"Europe/Paris","confirmed":true}
"""))
.andExpect(status().isOk());
@@ -185,8 +187,7 @@ class LeaseEndingWebTest {
new LeaseEndingService.AgreedEnd(ZoneId.of("Europe/Paris"),
LocalDate.of(2026, 8, 18), "Accord écrit"));
verify(endings).close(LANDLORD_ID, LEASE_ID, "subject-1",
new LeaseEndingService.Closure(ZoneId.of("Europe/Paris"),
LocalDate.of(2026, 8, 18), true));
new LeaseEndingService.Closure(ZoneId.of("Europe/Paris"), true));
}
@Test
@@ -218,6 +219,7 @@ class LeaseEndingWebTest {
lease.recordAgreedEarlyEnd(LocalDate.of(2026, 8, 18), "Fin convenue", audit);
return new LeaseEndingState(LEASE_ID, true, Optional.empty(), LeaseEndPhase.NOTICE_PERIOD,
LeaseType.STANDARD, Optional.empty(), Optional.of(LocalDate.of(2026, 8, 18)), false,
Optional.of(new LeaseEndingState.ExitConditionReport(LocalDate.of(2026, 8, 18), true)),
lease.endingHistory());
}
@@ -231,7 +233,7 @@ class LeaseEndingWebTest {
lease.activate(new LeaseSnapshot(
new LeaseSnapshot.Landlord(LANDLORD_ID, "Camille", "Martin", "camille@example.com", "+331",
"1 rue A"),
new LeaseSnapshot.Apartment(apartmentId, "Studio", "2 rue B"),
new LeaseSnapshot.Apartment(apartmentId, "Studio", "2 rue B", fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile.empty(), java.util.List.of()),
List.of(new LeaseSnapshot.Tenant(tenantId, "Alex", "Durand", "alex@example.com", "", "3 rue C")),
List.of(), lease.period(), terms, LeaseType.STANDARD));
return lease;
@@ -7,6 +7,8 @@ import fr.nessar.lmnporganizer.rental.adapter.in.web.common.RentalApiExceptionHa
import fr.nessar.lmnporganizer.rental.application.landlord.LandlordAccountService;
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.landlord.ExternalIdentity;
import fr.nessar.lmnporganizer.rental.domain.landlord.Landlord;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
@@ -102,8 +104,10 @@ class LeaseFinalizationWebTest {
LeaseDocumentRevision revision = revision();
var requirement = new LeaseFinalizationService.GuaranteeRequirement("guarantor-1", "Alice Garant",
TENANT_ID, "Camille Locataire", deed());
var suggestion = new LeaseFinalization.InventoryCorrection("entry-line-1", "line-1", "area-1", "Chaise",
0, "Chaise manquante");
when(finalizations.state(LANDLORD_ID, LEASE_ID)).thenReturn(new LeaseFinalizationService.State(revision,
List.of("SECURITY_DEPOSIT_ABOVE_TWO_MONTHS"), List.of(requirement), null));
List.of("SECURITY_DEPOSIT_ABOVE_TWO_MONTHS"), List.of(requirement), List.of(suggestion), null));
mockMvc.perform(get("/api/leases/lease-1/finalization").with(landlord()))
.andExpect(status().isOk())
@@ -111,6 +115,9 @@ class LeaseFinalizationWebTest {
.andExpect(jsonPath("$.guaranteeSimulatorUrl").value(
LeaseFinalizationService.GUARANTEE_SIMULATOR_URL))
.andExpect(jsonPath("$.warningCodes[0]").value("SECURITY_DEPOSIT_ABOVE_TWO_MONTHS"))
.andExpect(jsonPath("$.revision.furnitureInventory.lines[0].designation").value("Chaise"))
.andExpect(jsonPath("$.revision.privateAreas[0].label").value("Séjour"))
.andExpect(jsonPath("$.suggestedInventoryCorrections[0].quantity").value(0))
.andExpect(jsonPath("$.guaranteeRequirements[0].guarantorName").value("Alice Garant"))
.andExpect(jsonPath("$.guaranteeRequirements[0].tenantName").value("Camille Locataire"))
.andExpect(jsonPath("$.guaranteeRequirements[0].deed.sha256").value("deed-hash"));
@@ -122,10 +129,7 @@ class LeaseFinalizationWebTest {
when(finalizations.finalizeLease(any(), any(), any(), any(), any())).thenReturn(
new LeaseFinalizationService.Result(activeLease(), List.of(), finalization));
mockMvc.perform(multipart("/api/leases/lease-1/finalization").file(pdf("signedLease"))
.param("revisionNumber", "1").param("lastSignatureDate", "2026-07-19")
.param("allPartiesSigned", "true").param("revisionMatches", "true")
.param("confirmedWarningCodes", "SECURITY_DEPOSIT_ABOVE_TWO_MONTHS")
mockMvc.perform(multipart("/api/leases/lease-1/finalization").file(pdf("signedLease")).file(metadata())
.with(landlord()).with(csrf()))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.lease.status").value("ACTIVE"))
@@ -137,7 +141,9 @@ class LeaseFinalizationWebTest {
org.mockito.ArgumentMatchers.eq(LEASE_ID),
org.mockito.ArgumentMatchers.eq(new LeaseFinalizationService.FinalizationRequest(1,
LocalDate.of(2026, 7, 19), true, true,
Set.of("SECURITY_DEPOSIT_ABOVE_TWO_MONTHS"))), any(),
Set.of("SECURITY_DEPOSIT_ABOVE_TWO_MONTHS"), true,
List.of(new LeaseFinalization.InventoryCorrection("correction-1", null, "area-1",
"Chaise", 0, "Absente")), true)), any(),
org.mockito.ArgumentMatchers.eq("subject-1"));
}
@@ -152,9 +158,7 @@ class LeaseFinalizationWebTest {
.param("guarantorId", "guarantor-1").param("tenantId", "tenant-1")
.param("attestation", "true").with(landlord()).with(csrf()))
.andExpect(status().isConflict());
mockMvc.perform(multipart("/api/leases/lease-1/finalization").file(pdf("signedLease"))
.param("revisionNumber", "1").param("lastSignatureDate", "2026-07-19")
.param("allPartiesSigned", "true").param("revisionMatches", "true")
mockMvc.perform(multipart("/api/leases/lease-1/finalization").file(pdf("signedLease")).file(metadata())
.with(landlord()).with(csrf()))
.andExpect(status().isConflict());
}
@@ -164,6 +168,16 @@ class LeaseFinalizationWebTest {
"%PDF-signed".getBytes());
}
private static MockMultipartFile metadata() {
return new MockMultipartFile("metadata", "metadata.json", MediaType.APPLICATION_JSON_VALUE, """
{"revisionNumber":1,"lastSignatureDate":"2026-07-19","allPartiesSigned":true,
"revisionMatches":true,"confirmedWarningCodes":["SECURITY_DEPOSIT_ABOVE_TWO_MONTHS"],
"entryInventoryDiscrepancies":true,"inventoryCorrections":[{"id":"correction-1",
"inventoryLineId":null,"privateAreaId":"area-1","designation":"Chaise","quantity":0,
"description":"Absente"}],"allInventoryCorrectionsInitialed":true}
""".getBytes());
}
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"));
@@ -176,14 +190,18 @@ 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");
"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 PrivateArea("area-1", PrivateArea.Family.PIECE_PRINCIPALE,
PrivateArea.Subtype.LIVING_ROOM, "Séjour")), List.of(), List.of());
}
private static LeaseFinalization finalization() {
return new LeaseFinalization("finalization-1", LANDLORD_ID, LEASE_ID, 1, "FR-MEUBLE-2025-01",
LocalDate.of(2026, 7, 19), "signed-storage", "signed.pdf", "signed-hash", "subject-1", NOW,
List.of(new LeaseFinalization.LegalOverride("SECURITY_DEPOSIT_ABOVE_TWO_MONTHS", "1", "subject-1",
NOW)));
NOW)), true, List.of(new LeaseFinalization.InventoryCorrection("correction-1", null,
"area-1", "Chaise", 0, "Absente")), true);
}
private static Lease activeLease() {
@@ -193,7 +211,7 @@ class LeaseFinalizationWebTest {
Lease lease = Lease.draft(LEASE_ID, LANDLORD_ID, apartmentId, List.of(TENANT_ID), List.of(), period, terms);
lease.activate(new LeaseSnapshot(
new LeaseSnapshot.Landlord(LANDLORD_ID, "Camille", "Martin", "camille@example.com",
"+33600000000", "1 rue A"), new LeaseSnapshot.Apartment(apartmentId, "Studio", "2 rue B"),
"+33600000000", "1 rue A"), new LeaseSnapshot.Apartment(apartmentId, "Studio", "2 rue B", fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile.empty(), java.util.List.of()),
List.of(new LeaseSnapshot.Tenant(TENANT_ID, "Alex", "Bernard", "alex@example.com", "", "3 rue C")),
List.of(), lease.period(), terms));
return lease;
@@ -0,0 +1,41 @@
package fr.nessar.lmnporganizer.rental.adapter.out.filesystem;
import fr.nessar.lmnporganizer.rental.adapter.in.config.RentalStorageProperties;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
class FileSystemPropertyConditionAttachmentStorageTest {
@TempDir
Path temporaryDirectory;
@Test
void recoversAnInterruptedDeletionAndFinishesACommittedOne() throws Exception {
RentalStorageProperties properties = new RentalStorageProperties(temporaryDirectory.resolve("attachments"));
Path root = properties.attachments().resolve("condition-reports");
FileSystemPropertyConditionAttachmentStorage storage =
new FileSystemPropertyConditionAttachmentStorage(properties);
String key = storage.store(new LeaseId("lease-a"), "photo-a", "webp", new byte[]{1, 2, 3});
storage.stageDeletion(key);
storage = new FileSystemPropertyConditionAttachmentStorage(properties);
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);
assertFalse(Files.exists(root.resolve(key)));
assertFalse(Files.exists(deletion));
}
}
@@ -0,0 +1,178 @@
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.junit.jupiter.api.Test;
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.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ImageMagickPropertyConditionPhotoNormalizerTest {
private static final byte[] WEBP = {'R', 'I', 'F', 'F', 4, 0, 0, 0, 'W', 'E', 'B', 'P'};
@Test
void normalizesAContentDetectedImageWithTheExpectedCommand() {
List<List<String>> commands = new ArrayList<>();
var normalizer = new ImageMagickPropertyConditionPhotoNormalizer(properties(1024), (command, directory) -> {
commands.add(command);
if (command.contains("-auto-orient")) {
write(Path.of(command.getLast().substring("webp:".length())), WEBP);
return success("");
}
if (command.contains("-verbose")) {
return success("Image:\n Format: WEBP\n Orientation: Undefined\n");
}
if (command.getLast().endsWith("normalized.webp")) {
return success("WEBP|1920|960|Undefined\n");
}
return success("JPEG|2400|1200|TopLeft\n");
});
var result = normalizer.normalize(new byte[]{1, 2, 3});
assertArrayEquals(WEBP, result.content());
assertEquals(1920, result.width());
assertEquals(960, result.height());
List<String> conversion = commands.stream().filter(command -> command.contains("-auto-orient"))
.findFirst().orElseThrow();
assertTrue(conversion.contains("1920x1080>"));
assertTrue(conversion.contains("-strip"));
assertTrue(conversion.contains("75"));
assertTrue(conversion.stream().noneMatch(argument -> argument.equals("sh") || argument.equals("-c")));
}
@Test
void choosesTheFrameAfterApplyingEachExifOrientation() {
for (String orientation : List.of("TopLeft", "TopRight", "BottomRight", "BottomLeft")) {
assertEquals("1920x1080>", ImageMagickPropertyConditionPhotoNormalizer.resizeFrame(
new ImageMagickPropertyConditionPhotoNormalizer.ImageDetails(
"JPEG", 2400, 1200, orientation)));
}
for (String orientation : List.of("LeftTop", "RightTop", "RightBottom", "LeftBottom")) {
assertEquals("1080x1920>", ImageMagickPropertyConditionPhotoNormalizer.resizeFrame(
new ImageMagickPropertyConditionPhotoNormalizer.ImageDetails(
"JPEG", 2400, 1200, orientation)));
}
}
@Test
void rejectsEmptyOversizedUnsupportedAnimatedAndInvalidImages() {
var neverCalled = new AtomicInteger();
var normalizer = new ImageMagickPropertyConditionPhotoNormalizer(properties(2), (command, directory) -> {
neverCalled.incrementAndGet();
return success("");
});
assertThrows(PropertyConditionPhotoNormalizationFailure.class, () -> normalizer.normalize(null));
assertThrows(PropertyConditionPhotoNormalizationFailure.class, () -> normalizer.normalize(new byte[0]));
assertThrows(PropertyConditionPhotoNormalizationFailure.class, () -> normalizer.normalize(new byte[3]));
assertEquals(0, neverCalled.get());
assertThrows(PropertyConditionPhotoNormalizationFailure.class,
() -> ImageMagickPropertyConditionPhotoNormalizer.parseDetails("GIF|10|10|Undefined\n"
+ "GIF|10|10|Undefined\n"));
assertThrows(PropertyConditionPhotoNormalizationFailure.class,
() -> ImageMagickPropertyConditionPhotoNormalizer.parseDetails("invalid"));
assertThrows(PropertyConditionPhotoNormalizationFailure.class,
() -> ImageMagickPropertyConditionPhotoNormalizer.parseDetails("JPEG|wide|10|Undefined"));
var unsupported = normalizer(properties(1024), "GIF|10|10|Undefined\n", "WEBP|10|10|Undefined\n", WEBP);
assertThrows(PropertyConditionPhotoNormalizationFailure.class,
() -> unsupported.normalize(new byte[]{1}));
var tooWide = normalizer(properties(1024), "JPEG|12001|10|Undefined\n",
"WEBP|10|10|Undefined\n", WEBP);
assertThrows(PropertyConditionPhotoNormalizationFailure.class,
() -> tooWide.normalize(new byte[]{1}));
}
@Test
void rejectsConversionFailureInvalidWebpUpscalingAndResidualMetadata() {
var conversionFailure = new ImageMagickPropertyConditionPhotoNormalizer(properties(1024),
(command, directory) -> command.contains("-auto-orient")
? new ImageMagickPropertyConditionPhotoNormalizer.CommandResult(1, "", "decoder failed")
: success("JPEG|640|480|Undefined\n"));
assertThrows(PropertyConditionPhotoNormalizationFailure.class,
() -> conversionFailure.normalize(new byte[]{1}));
var invalidMagic = normalizer(properties(1024), "PNG|640|480|Undefined\n",
"WEBP|640|480|Undefined\n", new byte[12]);
assertThrows(PropertyConditionPhotoNormalizationFailure.class,
() -> invalidMagic.normalize(new byte[]{1}));
var upscale = normalizer(properties(1024), "JPEG|640|480|Undefined\n",
"WEBP|800|600|Undefined\n", WEBP);
assertThrows(PropertyConditionPhotoNormalizationFailure.class,
() -> upscale.normalize(new byte[]{1}));
var metadata = normalizer(properties(1024), "HEIC|640|480|Undefined\n",
"WEBP|640|480|Undefined\n", WEBP, "Profile-exif: 12 bytes");
assertThrows(PropertyConditionPhotoNormalizationFailure.class,
() -> metadata.normalize(new byte[]{1}));
}
@Test
void stopsAnExternalProcessAtTheApplicationTimeout() throws IOException {
Path directory = Files.createTempDirectory("photo-normalizer-timeout-");
try {
var result = ImageMagickPropertyConditionPhotoNormalizer.run(
List.of("/bin/sh", "-c", "printf late; sleep 1"), directory, Duration.ofMillis(10));
throw new AssertionError("Expected timeout, got " + result);
} catch (PropertyConditionPhotoNormalizationFailure failure) {
assertTrue(failure.getMessage().contains("expiré"));
} finally {
try (var paths = Files.walk(directory)) {
for (Path path : paths.sorted(java.util.Comparator.reverseOrder()).toList()) {
Files.deleteIfExists(path);
}
}
}
}
private static ImageMagickPropertyConditionPhotoNormalizer normalizer(
PropertyConditionPhotoProperties properties, String original, String result, byte[] content) {
return normalizer(properties, original, result, content, "Format: WEBP\nOrientation: Undefined");
}
private static ImageMagickPropertyConditionPhotoNormalizer normalizer(
PropertyConditionPhotoProperties properties, String original, String result, byte[] content,
String verbose) {
return new ImageMagickPropertyConditionPhotoNormalizer(properties, (command, directory) -> {
if (command.contains("-auto-orient")) {
write(Path.of(command.getLast().substring("webp:".length())), content);
return success("");
}
if (command.contains("-verbose")) {
return success(verbose);
}
return success(command.getLast().endsWith("normalized.webp") ? result : original);
});
}
private static PropertyConditionPhotoProperties properties(long maxBytes) {
return new PropertyConditionPhotoProperties("magick", DataSize.ofBytes(maxBytes), 12_000, 12_000,
DataSize.ofMegabytes(192), DataSize.ofMegabytes(384), DataSize.ofMegabytes(768), 2,
Duration.ofSeconds(20));
}
private static ImageMagickPropertyConditionPhotoNormalizer.CommandResult success(String output) {
return new ImageMagickPropertyConditionPhotoNormalizer.CommandResult(0, output, "");
}
private static void write(Path path, byte[] content) {
try {
Files.write(path, content);
} catch (IOException failure) {
throw new IllegalStateException(failure);
}
}
}
@@ -11,6 +11,7 @@ import fr.nessar.lmnporganizer.rental.domain.apartment.Apartment;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile;
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentDocumentType;
import fr.nessar.lmnporganizer.rental.domain.apartment.PrivateArea;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import java.math.BigDecimal;
import java.time.Instant;
@@ -51,7 +52,7 @@ class MongoApartmentRepositoryTest {
apartment.address(),
apartment.purchasePrice(),
apartment.description(),
4);
apartment.legalProfile(), java.util.Set.of(), apartment.privateAreas(), List.of(), 4);
assertThat(document.version()).isNull();
assertThat(mapped.version()).isEqualTo(-1);
@@ -71,7 +72,7 @@ class MongoApartmentRepositoryTest {
Instant.parse("2026-07-19T12:00:00Z"));
Apartment source = Apartment.reconstitute(apartment().id(), LANDLORD_ID, apartment().name(),
apartment().address(), apartment().purchasePrice(), apartment().description(),
apartment().legalProfile(), java.util.Set.of(), List.of(apartmentDocument), 4);
apartment().legalProfile(), java.util.Set.of(), apartment().privateAreas(), List.of(apartmentDocument), 4);
fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentDocument stored =
MongoApartmentRepository.toDomain(MongoApartmentRepository.toDocument(source)).documents().getFirst();
@@ -79,24 +80,12 @@ class MongoApartmentRepositoryTest {
assertThat(stored).isEqualTo(apartmentDocument);
}
@Test
void defaultsMissingLegacyLegalProfiles() {
ApartmentDocument legacy = new ApartmentDocument(APARTMENT_ID, LANDLORD_ID.value(), "Garden flat",
"1 Main Street", new BigDecimal("175000.25"), "Ground floor", null, null, List.of(), 2L);
Apartment restored = MongoApartmentRepository.toDomain(legacy);
assertThat(restored.legalProfile()).isEqualTo(ApartmentLegalProfile.empty());
assertThat(restored.documents()).isEmpty();
assertThat(restored.version()).isEqualTo(2);
}
@Test
void readsLegacyEnergyEstimatesWithoutAReferenceYear() {
Apartment legacyApartment = new Apartment(new ApartmentId(APARTMENT_ID), LANDLORD_ID, "Garden flat",
"1 Main Street", new BigDecimal("175000.25"), "Ground floor",
new ApartmentLegalProfile("tax-1", null, "", null, null, 0, "", "", "", "", "C",
new BigDecimal("750.00"), "Habitation", "", "", "", "75"));
new ApartmentLegalProfile("tax-1", null, "", null, null, "", "", "", "", "C",
new BigDecimal("750.00"), "Habitation", "", "", "", "75"), List.of());
when(mongoTemplate.findOne(any(Query.class), eq(ApartmentDocument.class)))
.thenReturn(MongoApartmentRepository.toDocument(legacyApartment));
MongoApartmentRepository repository = new MongoApartmentRepository(mongoTemplate);
@@ -131,7 +120,7 @@ class MongoApartmentRepositoryTest {
"1 Main Street",
new BigDecimal("175000.25"),
"Ground floor",
6);
apartment().legalProfile(), java.util.Set.of(), apartment().privateAreas(), List.of(), 6);
when(mongoTemplate.find(any(Query.class), eq(ApartmentDocument.class)))
.thenReturn(List.of(MongoApartmentRepository.toDocument(persisted)));
when(mongoTemplate.remove(any(Query.class), eq(ApartmentDocument.class)))
@@ -169,7 +158,9 @@ class MongoApartmentRepositoryTest {
"Ground floor",
new ApartmentLegalProfile("tax-1", ApartmentLegalProfile.BuildingType.APARTMENT_BUILDING,
"Copropriété", ApartmentLegalProfile.ConstructionPeriod.PERIOD_1949_1974,
new BigDecimal("31.50"), 2, "Cave", "Cuisine équipée", "Individuel", "Individuelle",
"C", new BigDecimal("750.00"), 2024, "Habitation", "Balcon", "Local vélos", "Fibre", "75"));
new BigDecimal("31.50"), "Cave", "Cuisine équipée", "Individuel", "Individuelle",
"C", new BigDecimal("750.00"), "Habitation", "Balcon", "Local vélos", "Fibre", "75"),
List.of(new PrivateArea("living-room", PrivateArea.Family.PIECE_PRINCIPALE,
PrivateArea.Subtype.LIVING_ROOM, "Séjour")));
}
}
Loaded 100 of 193 files, more files were not shown because too many files have changed in this diff. Show more