feat(rental): add inventory and condition reports (#7)
This commit is contained in:
1 parent
92d8a3e351
commit
e4be5cd05f
193 files changed
+9697
-483
No files matched your search
+53
-1
@@ -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"]
|
||||
+9
@@ -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 {
|
||||
}
|
||||
+42
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
-6
@@ -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
|
||||
|
||||
+25
-7
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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());
|
||||
}
|
||||
}
|
||||
+6
@@ -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)
|
||||
|
||||
+81
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
-3
@@ -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());
|
||||
|
||||
-5
@@ -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) {
|
||||
|
||||
+8
-1
@@ -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,
|
||||
|
||||
+2
-3
@@ -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) {
|
||||
}
|
||||
}
|
||||
+53
-3
@@ -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
-10
@@ -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(),
|
||||
|
||||
+56
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+175
@@ -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()); }
|
||||
}
|
||||
}
|
||||
+239
@@ -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 { }
|
||||
}
|
||||
+293
@@ -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) {
|
||||
}
|
||||
}
|
||||
+19
-4
@@ -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) {
|
||||
}
|
||||
+86
@@ -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) { }
|
||||
}
|
||||
+75
@@ -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) { }
|
||||
}
|
||||
+47
-9
@@ -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,
|
||||
|
||||
+27
-5
@@ -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) {
|
||||
}
|
||||
}
|
||||
+26
-8
@@ -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,
|
||||
|
||||
+109
@@ -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";
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-2
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-4
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+402
@@ -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) { }
|
||||
}
|
||||
+32
@@ -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;
|
||||
}
|
||||
}
|
||||
+299
@@ -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) { }
|
||||
}
|
||||
+85
@@ -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"));
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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()))
|
||||
|
||||
+73
-42
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+21
-17
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+106
-11
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+21
@@ -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 { }
|
||||
}
|
||||
+8
@@ -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) {
|
||||
}
|
||||
}
|
||||
+7
@@ -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);
|
||||
}
|
||||
+4
-15
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
@@ -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);
|
||||
}
|
||||
}
|
||||
+35
-30
@@ -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;
|
||||
}
|
||||
|
||||
+4
-8
@@ -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(
|
||||
|
||||
+56
@@ -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
|
||||
}
|
||||
}
|
||||
+19
@@ -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);
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.condition;
|
||||
|
||||
public enum AccessMeansType { CLE, BADGE, TELECOMMANDE, OTHER }
|
||||
+15
@@ -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.");
|
||||
}
|
||||
}
|
||||
+15
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -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();
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.condition;
|
||||
|
||||
public enum ConditionState { NEUF, BON_ETAT, ETAT_USAGE, DEGRADE, ABSENT, NON_APPLICABLE }
|
||||
+15
@@ -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.");
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.condition;
|
||||
|
||||
public sealed interface EntryPhotoDisposition permits MatchedEntryPhoto, MissingEntryPhoto {
|
||||
String entryPhotoId();
|
||||
}
|
||||
+31
@@ -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);
|
||||
}
|
||||
}
|
||||
+11
@@ -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();
|
||||
}
|
||||
}
|
||||
+26
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
@@ -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.");
|
||||
}
|
||||
}
|
||||
+11
@@ -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();
|
||||
}
|
||||
}
|
||||
+31
@@ -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 }
|
||||
+13
@@ -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.");
|
||||
}
|
||||
}
|
||||
+10
@@ -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.");
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.condition;
|
||||
|
||||
public enum ParticipantAttendance { PRESENT, REPRESENTED }
|
||||
+511
@@ -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; }
|
||||
}
|
||||
+17
@@ -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();
|
||||
}
|
||||
}
|
||||
+20
@@ -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();
|
||||
}
|
||||
}
|
||||
+10
@@ -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();
|
||||
}
|
||||
}
|
||||
+11
@@ -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);
|
||||
}
|
||||
+26
@@ -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();
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.condition;
|
||||
|
||||
public enum PropertyConditionReportStatus { DRAFT, OBSOLETE, FINALIZED }
|
||||
+37
@@ -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();
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.condition;
|
||||
|
||||
public enum PropertyConditionReportType { ENTRY, EXIT }
|
||||
+27
@@ -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();
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.condition;
|
||||
|
||||
public enum ReportPartyRole { LANDLORD, TENANT }
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.condition;
|
||||
|
||||
public sealed interface SupplementAudit permits OnTimeSupplement, LateSupplementAddition { }
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.condition;
|
||||
|
||||
public enum ZoneEquipment { NONE, CLIMATISATION, OTHER }
|
||||
+13
@@ -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.");
|
||||
}
|
||||
}
|
||||
+25
@@ -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;
|
||||
}
|
||||
}
|
||||
+121
@@ -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; }
|
||||
}
|
||||
+10
@@ -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();
|
||||
}
|
||||
}
|
||||
+11
@@ -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);
|
||||
}
|
||||
+27
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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();
|
||||
}
|
||||
}
|
||||
+27
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.furniture;
|
||||
|
||||
public enum FurniturePresence {
|
||||
PRESENT,
|
||||
ABSENT
|
||||
}
|
||||
+17
-9
@@ -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) {
|
||||
|
||||
+42
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
+8
-5
@@ -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>
|
||||
+31
@@ -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);
|
||||
}
|
||||
}
|
||||
+16
-20
@@ -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()));
|
||||
|
||||
+97
@@ -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"));
|
||||
}
|
||||
}
|
||||
+8
-6
@@ -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;
|
||||
|
||||
+30
-12
@@ -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;
|
||||
|
||||
+41
@@ -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));
|
||||
}
|
||||
}
|
||||
+178
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-19
@@ -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
Reference in new issue
Block a user