fix(rental): complete condition report invariants (#7)

This commit is contained in:
nessar authored and Nessar committed 2026-08-17 14:18:16 +02:00
1 parent 62d0f171f4
commit 5d2d85aa8c
23 files changed
+652 -85

No files matched your search

@@ -150,16 +150,31 @@ export class ConditionReportEditor {
)
)
return 'Renseignez l’adresse de correspondance de chaque locataire.';
if (
this.type() === 'EXIT' &&
this.entryPhotos().some((photo) => {
if (this.type() === 'EXIT') {
const usedExitPhotoIds = new Set<string>();
for (const entryPhoto of this.entryPhotos()) {
const disposition = report.entryPhotoDispositions.find(
(item) => item.entryPhotoId === photo.reference,
(item) => item.entryPhotoId === entryPhoto.reference,
);
return !disposition?.exitPhotoId && !disposition?.absenceReason?.trim();
})
)
return 'Chaque photo d’entrée exige une photo de sortie ou un motif d’absence.';
if (!disposition?.exitPhotoId) {
if (!disposition?.absenceReason?.trim()) {
return 'Chaque photo d’entrée exige une photo de sortie ou un motif d’absence.';
}
continue;
}
const exitPhoto = report.photos.find(
(photo) => photo.reference === disposition.exitPhotoId,
);
if (
!exitPhoto ||
exitPhoto.zoneId !== entryPhoto.zoneId ||
usedExitPhotoIds.has(exitPhoto.reference)
) {
return 'Chaque photo d’entrée doit être liée à une photo de sortie unique de la même zone.';
}
usedExitPhotoIds.add(exitPhoto.reference);
}
}
return '';
}
@@ -72,7 +72,7 @@
(change)="setEntryPhotoDisposition(entryPhoto.reference, 'exitPhotoId', $event)"
>
<option value="">Aucune</option>
@for (exitPhoto of report().photos; track exitPhoto.reference) {
@for (exitPhoto of exitPhotosFor(entryPhoto); track exitPhoto.reference) {
<option [value]="exitPhoto.reference">{{ exitPhoto.reference }}</option>
}
</select>
@@ -105,8 +105,8 @@ export class ConditionReportEvidenceSection implements OnDestroy {
.subscribe({
next: () => {
this.retakePhoto(zoneId);
this.successOccurred.emit('Photo ajoutée.');
this.reloadRequested.emit();
this.successOccurred.emit('Photo ajoutée.');
},
error: (error) =>
this.errorOccurred.emit(
@@ -143,6 +143,22 @@ export class ConditionReportEvidenceSection implements OnDestroy {
);
}
exitPhotosFor(entryPhoto: ConditionPhoto): ConditionPhoto[] {
const report = this.report();
const current = this.disposition(entryPhoto.reference).exitPhotoId;
const used = new Set(
report.entryPhotoDispositions
.filter((item) => item.entryPhotoId !== entryPhoto.reference)
.map((item) => item.exitPhotoId)
.filter((reference): reference is string => Boolean(reference)),
);
return report.photos.filter(
(photo) =>
photo.zoneId === entryPhoto.zoneId &&
(photo.reference === current || !used.has(photo.reference)),
);
}
photoUrl(type: ConditionReportType, reference: string): string {
return this.reports.photoUrl(this.leaseId(), type, reference);
}
@@ -8,16 +8,19 @@
</div>
</header>
@if (loading()) {
@if (loading() && !report()) {
<p role="status">Chargement de l’état des lieux…</p>
} @else if (error() && !report()) {
<p role="alert" class="operation-error">{{ error() }}</p>
<button type="button" (click)="load()">Réessayer</button>
} @else if (report(); as value) {
@if (loading()) {
<p role="status">Mise à jour de l’état des lieux…</p>
}
@if (error()) {
<p role="alert" class="operation-error">{{ error() }}</p>
}
@if (success()) {
@if (success() && !error()) {
<p role="status">{{ success() }}</p>
}
@if (value.leaseRevisionNumber !== null) {
@@ -47,9 +50,22 @@
<section>
<h2>Révision et finalisation</h2>
@if (value.status === 'DRAFT') {
<button type="button" [disabled]="revisionPending() || dirty()" (click)="createRevision()">
{{ revisionPending() ? 'Génération…' : 'Générer et télécharger la révision PDF' }}
<button
type="button"
[disabled]="revisionPending() || revisionDownloadPending() || dirty()"
(click)="createRevision()"
>
{{
revisionPending()
? 'Génération…'
: revisionDownloadPending()
? 'Téléchargement…'
: 'Générer et télécharger la révision PDF'
}}
</button>
@if (revisionDownloadError()) {
<p role="alert" class="operation-error">{{ revisionDownloadError() }}</p>
}
<label>
Scan signé PDF
<input type="file" accept="application/pdf,.pdf" (change)="selectSignedScan($event)" />
@@ -90,7 +106,12 @@
</button>
} @else if (value.status === 'FINALIZED') {
<p>État des lieux finalisé.</p>
<button type="button" (click)="downloadBundle()">Télécharger le dossier ZIP</button>
<button type="button" [disabled]="bundleDownloadPending()" (click)="downloadBundle()">
{{ bundleDownloadPending() ? 'Téléchargement…' : 'Télécharger le dossier ZIP' }}
</button>
@if (bundleDownloadError()) {
<p role="alert" class="operation-error">{{ bundleDownloadError() }}</p>
}
<h3>Attestations de remise</h3>
@for (participant of value.participants; track participant.partyId) {
@if (hasAttested(participant.partyId)) {
@@ -1,6 +1,6 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap } from '@angular/router';
import { of, throwError } from 'rxjs';
import { of, Subject, throwError } from 'rxjs';
import { vi } from 'vitest';
import {
ConditionPhoto,
@@ -332,6 +332,20 @@ describe('ConditionReportEditor', () => {
});
editor.save();
expect(errors.at(-1)).toContain('photo d’entrée');
fixture.componentRef.setInput('entryPhotos', [
...valid.photos,
{ ...valid.photos[0]!, reference: 'photo-2' },
]);
fixture.componentRef.setInput('report', {
...valid,
forwardingAddresses: [{ tenantPartyId: 'tenant-1', address: '12 rue A' }],
entryPhotoDispositions: [
{ entryPhotoId: 'photo-1', exitPhotoId: 'photo-1' },
{ entryPhotoId: 'photo-2', exitPhotoId: 'photo-1' },
],
});
editor.save();
expect(errors.at(-1)).toContain('unique');
fixture.componentRef.setInput('type', 'ENTRY');
fixture.componentRef.setInput('entryPhotos', []);
fixture.componentRef.setInput('report', valid);
@@ -530,6 +544,24 @@ describe('Condition-report sections', () => {
entryPhotoId: 'entry-photo',
exitPhotoId: 'exit-photo',
});
fixture.componentRef.setInput('report', {
...report,
photos: [
{ ...supplementPhoto, reference: 'exit-photo', zoneId: 'kitchen' },
{ ...supplementPhoto, reference: 'used-photo', zoneId: 'kitchen' },
],
entryPhotoDispositions: [
{ entryPhotoId: 'entry-photo', exitPhotoId: 'exit-photo' },
{ entryPhotoId: 'other-entry-photo', exitPhotoId: 'used-photo' },
],
});
expect(
section.exitPhotosFor({
...supplementPhoto,
reference: 'entry-photo',
zoneId: 'kitchen',
}),
).toEqual([expect.objectContaining({ reference: 'exit-photo' })]);
expect(service.uploadPhoto).toHaveBeenCalledWith('lease-1', 'EXIT', file, 'kitchen');
expect(service.deletePhoto).toHaveBeenCalledWith('lease-1', 'EXIT', 'photo-1', 1);
expect(section.photoUrl('ENTRY', 'entry-photo')).toBe('/photo.webp');
@@ -543,6 +575,38 @@ describe('Condition-report sections', () => {
});
describe('ConditionReportPage', () => {
it('preserves the established report while it refreshes', () => {
const refresh = new Subject<PropertyConditionReport>();
const service = {
getReport: vi.fn().mockReturnValueOnce(of(report)).mockReturnValue(refresh),
};
TestBed.configureTestingModule({
providers: [
{
provide: ActivatedRoute,
useValue: {
snapshot: { paramMap: convertToParamMap({ leaseId: 'lease-1', type: 'ENTRY' }) },
},
},
{ provide: ConditionReportService, useValue: service },
{
provide: FileDownloadService,
useValue: { download: vi.fn(), isPending: vi.fn(() => false), error: vi.fn(() => '') },
},
],
});
const fixture = TestBed.createComponent(ConditionReportPage);
fixture.detectChanges();
fixture.componentInstance.load();
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Révision de bail source 3');
expect(fixture.nativeElement.textContent).toContain('Mise à jour de l’état des lieux');
refresh.next({ ...report, version: 2 });
refresh.complete();
});
it('refreshes the revision version, requires attestation, and renders every comparison section', () => {
const refreshed = { ...report, version: 2 };
const comparison: ConditionReportComparison = {
@@ -642,7 +706,11 @@ describe('ConditionReportPage', () => {
getComparison: vi.fn().mockReturnValue(of(comparison)),
photoUrl: vi.fn().mockReturnValue('/photo.webp'),
};
const downloads = { download: vi.fn() };
const downloads = {
download: vi.fn(),
isPending: vi.fn(() => false),
error: vi.fn(() => ''),
};
TestBed.configureTestingModule({
providers: [
{
@@ -730,7 +798,11 @@ describe('ConditionReportPage', () => {
appendSupplement: vi.fn().mockReturnValue(of(exitReport)),
photoUrl: vi.fn().mockReturnValue('/photo.webp'),
};
const downloads = { download: vi.fn() };
const downloads = {
download: vi.fn(),
isPending: vi.fn((key: string) => key.includes('bundle')),
error: vi.fn((key: string) => (key.includes('bundle') ? 'Téléchargement impossible.' : '')),
};
TestBed.configureTestingModule({
providers: [
{
@@ -765,6 +837,9 @@ describe('ConditionReportPage', () => {
page.dirty.set(false);
page.finalize();
page.downloadBundle();
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Téléchargement…');
expect(fixture.nativeElement.textContent).toContain('Téléchargement impossible.');
page.loadComparison();
page.attestDelivery('tenant-2');
page.changeReport({ ...exitReport, occurredOn: '2026-07-23' });
@@ -838,7 +913,10 @@ describe('ConditionReportPage', () => {
},
},
{ provide: ConditionReportService, useValue: service },
{ provide: FileDownloadService, useValue: { download: vi.fn() } },
{
provide: FileDownloadService,
useValue: { download: vi.fn(), isPending: vi.fn(() => false), error: vi.fn(() => '') },
},
],
});
const fixture = TestBed.createComponent(ConditionReportPage);
@@ -850,5 +928,9 @@ describe('ConditionReportPage', () => {
expect(page.loading()).toBe(false);
expect(page.comparisonPending()).toBe(false);
expect(page.error()).toContain('Impossible de charger la comparaison');
page.report.set(report);
page.success.set('Ancienne réussite');
fixture.detectChanges();
expect(fixture.nativeElement.textContent).not.toContain('Ancienne réussite');
});
});
@@ -93,7 +93,7 @@ export class ConditionReportPage {
load(): void {
this.loading.set(true);
this.error.set('');
this.clearNotices();
this.reports
.getReport(this.leaseId, this.type)
.pipe(rxFinalize(() => this.loading.set(false)))
@@ -117,7 +117,7 @@ export class ConditionReportPage {
save(request: SaveConditionReportRequest): void {
if (this.saving()) return;
this.saving.set(true);
this.error.set('');
this.clearNotices();
this.reports
.saveReport(this.leaseId, this.type, request)
.pipe(rxFinalize(() => this.saving.set(false)))
@@ -135,6 +135,7 @@ export class ConditionReportPage {
createRevision(): void {
const report = this.report();
if (!report || this.revisionPending()) return;
this.clearNotices();
if (this.dirty()) {
this.error.set('Enregistrez le brouillon avant de générer sa révision.');
return;
@@ -151,8 +152,8 @@ export class ConditionReportPage {
`etat-des-lieux-${this.type.toLowerCase()}-revision-${revision.number}.pdf`,
'Impossible de télécharger la révision PDF.',
);
this.success.set(`Révision ${revision.number} générée.`);
this.load();
this.success.set(`Révision ${revision.number} générée.`);
},
error: (error) =>
this.error.set(httpErrorMessage(error, 'Impossible de générer la révision PDF.')),
@@ -210,13 +211,9 @@ export class ConditionReportPage {
finalize(): void {
const report = this.report();
const scan = this.signedScan();
if (
!report ||
!scan ||
!this.finalizationAttested() ||
!this.allPartiesSigned() ||
this.finalizing()
) {
if (this.finalizing()) return;
this.clearNotices();
if (!report || !scan || !this.finalizationAttested() || !this.allPartiesSigned()) {
if (!scan || !this.finalizationAttested() || !this.allPartiesSigned()) {
this.error.set('Téléversez le scan PDF et confirmez les deux attestations.');
}
@@ -255,6 +252,7 @@ export class ConditionReportPage {
loadComparison(): void {
if (this.comparisonPending()) return;
this.clearNotices();
this.comparisonPending.set(true);
this.reports
.getComparison(this.leaseId)
@@ -269,6 +267,7 @@ export class ConditionReportPage {
attestDelivery(partyId: string): void {
const report = this.report();
if (!report || this.busyAttestationPartyId()) return;
this.clearNotices();
this.busyAttestationPartyId.set(partyId);
this.reports
.attestDelivery(this.leaseId, this.type, report.version, partyId)
@@ -312,6 +311,8 @@ export class ConditionReportPage {
}
appendSupplement(): void {
if (this.supplementPending()) return;
this.clearNotices();
const signedPdf = this.supplementSignedPdf();
const requestedOn = this.supplementRequestedOn().trim();
const acceptedAdditions = this.supplementAcceptedAdditions().trim();
@@ -321,9 +322,7 @@ export class ConditionReportPage {
!requestedOn ||
!acceptedAdditions ||
(this.supplementType() === 'HEATING' && !heatingPeriodStartedOn) ||
(this.supplementIsLate() &&
(!this.lateWarningConfirmed() || !this.supplementReason().trim())) ||
this.supplementPending()
(this.supplementIsLate() && (!this.lateWarningConfirmed() || !this.supplementReason().trim()))
) {
if (!signedPdf || !requestedOn || !acceptedAdditions) {
this.error.set('Renseignez la date, les ajouts acceptés et le PDF signé du complément.');
@@ -374,6 +373,27 @@ export class ConditionReportPage {
return this.reports.photoUrl(this.leaseId, type, reference);
}
revisionDownloadPending(): boolean {
return this.downloads.isPending(`condition-report-revision-${this.leaseId}-${this.type}`);
}
revisionDownloadError(): string {
return this.downloads.error(`condition-report-revision-${this.leaseId}-${this.type}`);
}
bundleDownloadPending(): boolean {
return this.downloads.isPending(`condition-report-bundle-${this.leaseId}-${this.type}`);
}
bundleDownloadError(): string {
return this.downloads.error(`condition-report-bundle-${this.leaseId}-${this.type}`);
}
private clearNotices(): void {
this.error.set('');
this.success.set('');
}
private value(event: Event): string {
const target = event.target;
return target instanceof HTMLInputElement ||