Align furniture inventory validation (#52)

This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent 66fa95dabd
commit 82293a322d
8 files changed
+712 -227

No files matched your search

@@ -10,13 +10,11 @@ import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryWarning
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureLine;
import fr.nessar.lmnporganizer.rental.domain.furniture.FurniturePresence;
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 java.util.List;
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.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
@@ -47,34 +45,62 @@ Response get(@AuthenticationPrincipal OidcUser principal, @PathVariable String l
Response replace(
@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId,
@Valid @RequestBody Request request) {
@RequestBody Request request) {
var landlordId = landlord.id(principal);
var id = new LeaseId(leaseId);
var privateAreas = inventories.privateAreas(landlordId, id);
List<FieldError> errors = FurnitureInventoryRequestValidator.validate(request, privateAreas);
if (!errors.isEmpty()) {
throw new InventoryValidationException(errors);
}
return Response.from(
inventories.replace(
landlordId,
id,
request.version(),
request.lines().stream().map(LineRequest::toDomain).toList()),
inventories.privateAreas(landlordId, id));
privateAreas);
}
record Request(@NotNull @Min(0) Long version, @NotNull @Valid List<LineRequest> lines) {}
@ExceptionHandler(InventoryValidationException.class)
ResponseEntity<ValidationError> invalidInventory(InventoryValidationException exception) {
return ResponseEntity.badRequest()
.body(new ValidationError("validation_failed", exception.errors()));
}
}
record Request(Long version, List<LineRequest> lines) {}
record LineRequest(
@NotBlank String id,
String id,
FurnitureCategory category,
FurniturePresence presence,
@NotNull FurnitureEquipmentKind equipmentKind,
@NotBlank String privateAreaId,
@NotBlank String designation,
@Min(0) int quantity,
FurnitureEquipmentKind equipmentKind,
String privateAreaId,
String designation,
Integer quantity,
String description) {
FurnitureLine toDomain() {
FurnitureLine toDomain() {
return new FurnitureLine(
id, category, presence, equipmentKind, privateAreaId, designation, quantity, description);
}
}
}
final class InventoryValidationException extends RuntimeException {
private final List<FieldError> errors;
InventoryValidationException(List<FieldError> errors) {
this.errors = List.copyOf(errors);
}
List<FieldError> errors() {
return errors;
}
}
record ValidationError(String code, List<FieldError> errors) {}
record FieldError(String field, String code) {}
record LineResponse(
String id,
@@ -85,7 +111,7 @@ record LineResponse(
String designation,
int quantity,
String description) {
static LineResponse from(FurnitureLine line) {
static LineResponse from(FurnitureLine line) {
return new LineResponse(
line.id(),
line.category(),
@@ -95,13 +121,13 @@ record LineResponse(
line.designation(),
line.quantity(),
line.description());
}
}
}
record PrivateAreaResponse(String id, String label) {
static PrivateAreaResponse from(PrivateArea area) {
static PrivateAreaResponse from(PrivateArea area) {
return new PrivateAreaResponse(area.id(), area.label());
}
}
}
record Response(
@@ -115,7 +141,7 @@ record Response(
boolean readyForLeaseDocument,
boolean frozen,
long version) {
static Response from(FurnitureInventory inventory, List<PrivateArea> privateAreas) {
static Response from(FurnitureInventory inventory, List<PrivateArea> privateAreas) {
return new Response(
inventory.id().value(),
inventory.templateVersion(),
@@ -127,6 +153,5 @@ record Response(
inventory.isReadyForLeaseDocument(),
inventory.frozen(),
inventory.version());
}
}
}
@@ -0,0 +1,115 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.lease;
import fr.nessar.lmnporganizer.rental.domain.apartment.PrivateArea;
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureCategory;
import fr.nessar.lmnporganizer.rental.domain.furniture.FurniturePresence;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
final class FurnitureInventoryRequestValidator {
private FurnitureInventoryRequestValidator() {}
static List<FieldError> validate(Request request, List<PrivateArea> privateAreas) {
List<FieldError> errors = new ArrayList<>();
if (request == null) {
errors.add(new FieldError("request", "not_null"));
return errors;
}
if (request.version() == null) {
errors.add(new FieldError("version", "not_null"));
} else if (request.version() < 0) {
errors.add(new FieldError("version", "min"));
}
if (request.lines() == null) {
errors.add(new FieldError("lines", "not_null"));
return errors;
}
Set<String> privateAreaIds = new HashSet<>();
for (PrivateArea privateArea : privateAreas) {
privateAreaIds.add(privateArea.id());
}
Map<String, Integer> lineIds = new HashMap<>();
Map<FurnitureCategory, Integer> categories = new HashMap<>();
for (int index = 0; index < request.lines().size(); index++) {
LineRequest line = request.lines().get(index);
if (line == null) {
errors.add(new FieldError(field(index, "line"), "not_null"));
continue;
}
validate(errors, privateAreaIds, line, index);
duplicate(errors, lineIds, trimmed(line.id()), index, "id");
if (line.category() != null) {
duplicate(errors, categories, line.category(), index, "category");
}
}
return errors;
}
private static void validate(
List<FieldError> errors, Set<String> privateAreaIds, LineRequest line, int index) {
if (blank(line.id())) errors.add(new FieldError(field(index, "id"), "not_blank"));
if (line.equipmentKind() == null) {
errors.add(new FieldError(field(index, "equipmentKind"), "not_null"));
}
if (blank(line.privateAreaId())) {
errors.add(new FieldError(field(index, "privateAreaId"), "not_blank"));
} else if (!privateAreaIds.contains(line.privateAreaId().trim())) {
errors.add(new FieldError(field(index, "privateAreaId"), "unknown_private_area"));
}
if (blank(line.designation()))
errors.add(new FieldError(field(index, "designation"), "not_blank"));
if (line.quantity() == null) {
errors.add(new FieldError(field(index, "quantity"), "not_null"));
} else if (line.quantity() < 0) {
errors.add(new FieldError(field(index, "quantity"), "min"));
}
if (line.category() == null && line.presence() != null) {
errors.add(new FieldError(field(index, "presence"), "invalid_presence"));
}
if (line.category() != null && line.equipmentKind() != null) {
if (line.equipmentKind() != line.category().equipmentKind()) {
errors.add(new FieldError(field(index, "equipmentKind"), "invalid_equipment_kind"));
}
if (line.presence() == null && line.quantity() != null && line.quantity() != 0) {
errors.add(new FieldError(field(index, "quantity"), "undeclared_quantity"));
}
}
if (line.presence() == FurniturePresence.PRESENT
&& line.quantity() != null
&& line.quantity() == 0) {
errors.add(new FieldError(field(index, "quantity"), "present_quantity"));
}
if (line.presence() == FurniturePresence.ABSENT
&& line.quantity() != null
&& line.quantity() != 0) {
errors.add(new FieldError(field(index, "quantity"), "absent_quantity"));
}
}
private static <T> void duplicate(
List<FieldError> errors, Map<T, Integer> values, T value, int index, String name) {
if (value == null) return;
Integer first = values.putIfAbsent(value, index);
if (first != null) {
errors.add(new FieldError(field(first, name), "duplicate"));
errors.add(new FieldError(field(index, name), "duplicate"));
}
}
private static boolean blank(String value) {
return value == null || value.isBlank();
}
private static String trimmed(String value) {
return blank(value) ? null : value.trim();
}
private static String field(int index, String name) {
return "lines[" + index + "]." + name;
}
}
@@ -1,5 +1,6 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.lease;
import static org.hamcrest.Matchers.hasItems;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
@@ -84,13 +85,21 @@ void getInitializesAndExposesUndeclaredLegalCategories() throws Exception {
@Test
void replaceUsesTheClientVersionAndRejectsRequestsWithoutIt() throws Exception {
when(inventories.replace(eq(LANDLORD_ID), eq(LEASE_ID), eq(0L), any())).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(
put("/api/leases/lease-1/inventory")
.contentType(MediaType.APPLICATION_JSON)
.content(
"{\"version\":0,\"lines\":[{\"id\":\"line-1\",\"equipmentKind\":\"NONE\",\"privateAreaId\":\"area-1\",\"designation\":\"Lampe\",\"quantity\":1}]}")
"{\"version\":0,\"lines\":[{\"id\":\"line-1\",\"category\":\"BEDDING\",\"equipmentKind\":\"NONE\",\"privateAreaId\":\"area-1\",\"designation\":\"Literie\",\"quantity\":0}]}")
.with(landlord())
.with(csrf()))
.andExpect(status().isOk());
@@ -106,6 +115,49 @@ void replaceUsesTheClientVersionAndRejectsRequestsWithoutIt() throws Exception {
.andExpect(status().isBadRequest());
}
@Test
void returnsEveryIndexedInventoryLineErrorTogether() throws Exception {
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(
put("/api/leases/lease-1/inventory")
.contentType(MediaType.APPLICATION_JSON)
.content(
"""
{"version":-1,"lines":[
{"id":"","category":"BEDDING","presence":"PRESENT","equipmentKind":"OTHER","privateAreaId":"","designation":"","quantity":0},
{"id":"","category":"BEDDING","presence":"ABSENT","equipmentKind":"NONE","privateAreaId":"unknown","designation":"Lit","quantity":1},
{"id":"free","presence":"PRESENT","equipmentKind":"NONE","privateAreaId":"area-1","designation":"Lampe","quantity":1}
]}
""")
.with(landlord())
.with(csrf()))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("validation_failed"))
.andExpect(jsonPath("$.errors.length()").value(12))
.andExpect(
jsonPath("$.errors[*].field")
.value(
hasItems(
"version",
"lines[0].privateAreaId",
"lines[0].designation",
"lines[0].quantity",
"lines[1].privateAreaId",
"lines[1].quantity",
"lines[2].presence",
"lines[0].category",
"lines[1].category")));
}
private static FurnitureInventory inventory() {
return FurnitureInventory.prefilled(
new fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryId("inventory-1"),
@@ -0,0 +1,67 @@
import { FurnitureInventoryLine } from '../../services/furniture-inventory.service';
import { FieldValidationError } from '../../services/http-error-message';
export function inventoryLineErrors(
lines: FurnitureInventoryLine[],
privateAreaIds: Set<string>,
): FieldValidationError[] {
return lines.flatMap((line, index) => {
const errors: FieldValidationError[] = [];
if (!privateAreaIds.has(line.privateAreaId)) {
errors.push(
fieldError(
index,
'privateAreaId',
line.privateAreaId ? 'unknown_private_area' : 'not_blank',
),
);
}
if (!line.designation.trim()) errors.push(fieldError(index, 'designation', 'not_blank'));
if (!Number.isInteger(line.quantity) || line.quantity < 0) {
errors.push(fieldError(index, 'quantity', 'min'));
}
if (line.category === null && line.presence !== null) {
errors.push(fieldError(index, 'presence', 'invalid_presence'));
}
if (line.category && line.presence === null && line.quantity !== 0) {
errors.push(fieldError(index, 'quantity', 'undeclared_quantity'));
}
if (line.category && line.presence === 'PRESENT' && line.quantity === 0) {
errors.push(fieldError(index, 'quantity', 'present_quantity'));
}
if (line.category && line.presence === 'ABSENT' && line.quantity !== 0) {
errors.push(fieldError(index, 'quantity', 'absent_quantity'));
}
return errors;
});
}
export function inventoryLineGuidance(code: string, field: string): string {
if (field === 'privateAreaId') {
return code === 'unknown_private_area'
? 'Choisissez un espace privatif du logement.'
: 'Renseignez l’espace privatif de cette ligne.';
}
if (field === 'designation') return 'Renseignez la désignation de cette ligne.';
if (field === 'presence') {
return 'Une ligne libre ne peut pas déclarer une présence réglementaire.';
}
if (field === 'category') return 'Chaque catégorie réglementaire ne peut figurer qu’une fois.';
if (field === 'equipmentKind') {
return 'La classification de cette catégorie réglementaire ne peut pas être modifiée.';
}
if (code === 'present_quantity') {
return 'Un élément réglementaire présent doit avoir une quantité positive.';
}
if (code === 'absent_quantity') {
return 'Un élément réglementaire absent doit avoir une quantité nulle.';
}
if (code === 'undeclared_quantity') {
return 'La quantité doit être nulle tant que l’élément n’est pas déclaré.';
}
return 'Saisissez une quantité entière positive ou nulle.';
}
function fieldError(index: number, field: string, code: string): FieldValidationError {
return { field: `lines[${index}].${field}`, code };
}
@@ -14,3 +14,6 @@
max-width: 32rem;
width: 100%;
}
.field-error {
margin: 0.25rem 0 0;
}
@@ -11,180 +11,296 @@
</div>
</header>
@if (loading()) {
@if (loading() && !inventory().id) {
<p role="status">Chargement de l’inventaire…</p>
} @else {
@if (error()) {
<p role="alert">{{ error() }}</p>
@if (loading()) {
<p role="status">Actualisation de l’inventaire…</p>
}
@if (success()) {
<p role="status">{{ success() }}</p>
@if (loadError()) {
<p role="alert">{{ loadError() }}</p>
<button type="button" (click)="load()">Réessayer</button>
}
@if (inventory().frozen) {
<p role="status">Cet inventaire est figé.</p>
}
<p>
@if (inventory().readyForLeaseDocument) {
L’inventaire est prêt pour la génération du bail.
} @else {
Des éléments réglementaires doivent encore être déclarés.
@if (inventory().id) {
@if (saveError()) {
<p role="alert">{{ saveError() }}</p>
}
</p>
@if (inventory().legalWarnings.length) {
<section aria-labelledby="inventory-warnings-heading">
<h2 id="inventory-warnings-heading">Avertissements juridiques</h2>
<ul>
@for (warning of inventory().legalWarnings; track warning.code) {
<li>
{{ warningLabel(warning.category) }} est déclaré absent (règle
{{ warning.ruleVersion }}).
</li>
}
</ul>
</section>
}
<form (submit)="$event.preventDefault(); save()">
<fieldset [disabled]="inventory().frozen || saving()">
<section aria-labelledby="mandatory-furniture-heading">
<h2 id="mandatory-furniture-heading">Catalogue réglementaire</h2>
@for (line of inventory().lines; track line.id) {
@if (line.category) {
<fieldset class="inventory-line">
<legend>{{ line.designation || line.category }}</legend>
<p>{{ line.category }}</p>
<label>
<span>Présence</span>
<select
required
[value]="line.presence ?? ''"
(change)="updatePresence(line.id, $event)"
>
<option value="">À déclarer</option>
<option value="PRESENT">Présent</option>
<option value="ABSENT">Absent</option>
</select>
</label>
<label>
<span>Zone</span>
<select
required
[value]="line.privateAreaId"
(change)="updateText(line.id, 'privateAreaId', $event)"
>
<option value="">Choisir un espace</option>
@for (area of inventory().privateAreas; track area.id) {
<option [value]="area.id">{{ area.label }}</option>
}
</select>
</label>
<label>
<span>Désignation</span>
<input
required
[value]="line.designation"
(input)="updateText(line.id, 'designation', $event)"
/>
</label>
<label>
<span>Quantité</span>
<input
required
type="number"
[min]="line.presence === 'PRESENT' ? 1 : 0"
step="1"
[value]="line.quantity"
(input)="updateQuantity(line.id, $event)"
/>
</label>
<label>
<span>Description</span>
<textarea
[value]="line.description"
(input)="updateText(line.id, 'description', $event)"
></textarea>
</label>
@if (line.equipmentKind !== 'NONE') {
<p>Une zone dédiée sera créée dans l’état des lieux.</p>
}
</fieldset>
}
}
</section>
<section aria-labelledby="free-furniture-heading">
<h2 id="free-furniture-heading">Éléments complémentaires</h2>
@for (line of inventory().lines; track line.id) {
@if (line.category === null) {
<fieldset class="inventory-line">
<legend>{{ line.designation || 'Ligne libre' }}</legend>
<label>
<span>Zone</span>
<select
required
[value]="line.privateAreaId"
(change)="updateText(line.id, 'privateAreaId', $event)"
>
<option value="">Choisir un espace</option>
@for (area of inventory().privateAreas; track area.id) {
<option [value]="area.id">{{ area.label }}</option>
}
</select>
</label>
<label>
<span>Désignation</span>
<input
required
[value]="line.designation"
(input)="updateText(line.id, 'designation', $event)"
/>
</label>
<label>
<span>Quantité</span>
<input
required
type="number"
min="0"
step="1"
[value]="line.quantity"
(input)="updateQuantity(line.id, $event)"
/>
</label>
<label>
<span>Description</span>
<textarea
[value]="line.description"
(input)="updateText(line.id, 'description', $event)"
></textarea>
</label>
<label>
<span>Suivi dans l’état des lieux</span>
<select
[value]="line.equipmentKind"
(change)="updateEquipmentKind(line.id, $event)"
>
<option value="NONE">Aucune zone dédiée</option>
<option value="CLIMATISATION">Climatisation</option>
<option value="OTHER">Autre équipement</option>
</select>
</label>
<button type="button" (click)="removeLine(line.id)">
Supprimer cette ligne
</button>
</fieldset>
}
}
<button type="button" (click)="addLine()">Ajouter une ligne libre</button>
</section>
</fieldset>
@if (!inventory().frozen) {
<button type="submit" [disabled]="saving()">
{{ saving() ? 'Enregistrement…' : 'Enregistrer l’inventaire' }}
</button>
@if (success()) {
<p role="status">{{ success() }}</p>
}
</form>
@if (inventory().frozen) {
<p role="status">Cet inventaire est figé.</p>
}
<p>
@if (inventory().readyForLeaseDocument) {
L’inventaire est prêt pour la génération du bail.
} @else {
Des éléments réglementaires doivent encore être déclarés.
}
</p>
@if (inventory().legalWarnings.length) {
<section aria-labelledby="inventory-warnings-heading">
<h2 id="inventory-warnings-heading">Avertissements juridiques</h2>
<ul>
@for (warning of inventory().legalWarnings; track warning.code) {
<li>
{{ warningLabel(warning.category) }} est déclaré absent (règle
{{ warning.ruleVersion }}).
</li>
}
</ul>
</section>
}
<form novalidate (submit)="$event.preventDefault(); save()">
<fieldset [disabled]="inventory().frozen || saving()">
<section aria-labelledby="mandatory-furniture-heading">
<h2 id="mandatory-furniture-heading">Catalogue réglementaire</h2>
@for (line of inventory().lines; track line.id) {
@if (line.category) {
<fieldset class="inventory-line">
<legend>{{ line.designation || line.category }}</legend>
<p>{{ line.category }}</p>
@if (lineError(line, 'category')) {
<p class="field-error">{{ lineError(line, 'category') }}</p>
}
<label>
<span>Présence</span>
<select
[id]="controlId(line, 'presence')"
required
[value]="line.presence ?? ''"
(change)="updatePresence(line.id, $event)"
[attr.aria-describedby]="
lineError(line, 'presence')
? controlId(line, 'presence') + '-error'
: null
"
>
<option value="">À déclarer</option>
<option value="PRESENT">Présent</option>
<option value="ABSENT">Absent</option>
</select>
@if (lineError(line, 'presence')) {
<p
[id]="controlId(line, 'presence') + '-error'"
class="field-error"
>
{{ lineError(line, 'presence') }}
</p>
}
</label>
<label>
<span>Espace privatif</span>
<select
[id]="controlId(line, 'privateAreaId')"
required
[value]="line.privateAreaId"
(change)="updateText(line.id, 'privateAreaId', $event)"
[attr.aria-describedby]="
lineError(line, 'privateAreaId')
? controlId(line, 'privateAreaId') + '-error'
: null
"
>
<option value="">Choisir un espace</option>
@for (area of inventory().privateAreas; track area.id) {
<option [value]="area.id">{{ area.label }}</option>
}
</select>
@if (lineError(line, 'privateAreaId')) {
<p
[id]="controlId(line, 'privateAreaId') + '-error'"
class="field-error"
>
{{ lineError(line, 'privateAreaId') }}
</p>
}
</label>
<label>
<span>Désignation</span>
<input
[id]="controlId(line, 'designation')"
required
[value]="line.designation"
(input)="updateText(line.id, 'designation', $event)"
[attr.aria-describedby]="
lineError(line, 'designation')
? controlId(line, 'designation') + '-error'
: null
"
/>
@if (lineError(line, 'designation')) {
<p
[id]="controlId(line, 'designation') + '-error'"
class="field-error"
>
{{ lineError(line, 'designation') }}
</p>
}
</label>
<label>
<span>Quantité</span>
<input
[id]="controlId(line, 'quantity')"
required
type="number"
[min]="line.presence === 'PRESENT' ? 1 : 0"
step="1"
[value]="line.quantity"
(input)="updateQuantity(line.id, $event)"
[attr.aria-describedby]="
lineError(line, 'quantity')
? controlId(line, 'quantity') + '-error'
: null
"
/>
@if (lineError(line, 'quantity')) {
<p
[id]="controlId(line, 'quantity') + '-error'"
class="field-error"
>
{{ lineError(line, 'quantity') }}
</p>
}
</label>
<label>
<span>Description</span>
<textarea
[value]="line.description"
(input)="updateText(line.id, 'description', $event)"
></textarea>
</label>
@if (line.equipmentKind !== 'NONE') {
<p>Une zone dédiée sera créée dans l’état des lieux.</p>
}
</fieldset>
}
}
</section>
<section aria-labelledby="free-furniture-heading">
<h2 id="free-furniture-heading">Éléments complémentaires</h2>
@for (line of inventory().lines; track line.id) {
@if (line.category === null) {
<fieldset class="inventory-line">
<legend>{{ line.designation || 'Ligne libre' }}</legend>
@if (lineError(line, 'category')) {
<p class="field-error">{{ lineError(line, 'category') }}</p>
}
@if (lineError(line, 'presence')) {
<p class="field-error">{{ lineError(line, 'presence') }}</p>
}
<label>
<span>Espace privatif</span>
<select
[id]="controlId(line, 'privateAreaId')"
required
[value]="line.privateAreaId"
(change)="updateText(line.id, 'privateAreaId', $event)"
[attr.aria-describedby]="
lineError(line, 'privateAreaId')
? controlId(line, 'privateAreaId') + '-error'
: null
"
>
<option value="">Choisir un espace</option>
@for (area of inventory().privateAreas; track area.id) {
<option [value]="area.id">{{ area.label }}</option>
}
</select>
@if (lineError(line, 'privateAreaId')) {
<p
[id]="controlId(line, 'privateAreaId') + '-error'"
class="field-error"
>
{{ lineError(line, 'privateAreaId') }}
</p>
}
</label>
<label>
<span>Désignation</span>
<input
[id]="controlId(line, 'designation')"
required
[value]="line.designation"
(input)="updateText(line.id, 'designation', $event)"
[attr.aria-describedby]="
lineError(line, 'designation')
? controlId(line, 'designation') + '-error'
: null
"
/>
@if (lineError(line, 'designation')) {
<p
[id]="controlId(line, 'designation') + '-error'"
class="field-error"
>
{{ lineError(line, 'designation') }}
</p>
}
</label>
<label>
<span>Quantité</span>
<input
[id]="controlId(line, 'quantity')"
required
type="number"
min="0"
step="1"
[value]="line.quantity"
(input)="updateQuantity(line.id, $event)"
[attr.aria-describedby]="
lineError(line, 'quantity')
? controlId(line, 'quantity') + '-error'
: null
"
/>
@if (lineError(line, 'quantity')) {
<p
[id]="controlId(line, 'quantity') + '-error'"
class="field-error"
>
{{ lineError(line, 'quantity') }}
</p>
}
</label>
<label>
<span>Description</span>
<textarea
[value]="line.description"
(input)="updateText(line.id, 'description', $event)"
></textarea>
</label>
<label>
<span>Suivi dans l’état des lieux</span>
<select
[value]="line.equipmentKind"
(change)="updateEquipmentKind(line.id, $event)"
>
<option value="NONE">Aucune zone dédiée</option>
<option value="CLIMATISATION">Climatisation</option>
<option value="OTHER">Autre équipement</option>
</select>
</label>
<button type="button" (click)="removeLine(line.id)">
Supprimer cette ligne
</button>
</fieldset>
}
}
<button type="button" (click)="addLine()">Ajouter une ligne libre</button>
</section>
</fieldset>
@if (!inventory().frozen) {
<button type="submit" [disabled]="saving()">
{{ saving() ? 'Enregistrement…' : 'Enregistrer l’inventaire' }}
</button>
}
</form>
}
}
</section>
@@ -70,7 +70,7 @@ describe('InventoryPage', () => {
});
});
it('blocks an undeclared legal category with an actionable error', () => {
it('saves an undeclared legal category with its zero quantity', () => {
const { fixture, service } = createPage({
...inventory,
lines: [{ ...inventory.lines[0], presence: null, quantity: 0 }],
@@ -78,11 +78,50 @@ describe('InventoryPage', () => {
readyForLeaseDocument: false,
});
fixture.componentInstance.save();
expect(service.saveInventory).toHaveBeenCalledWith('lease-1', {
version: 3,
lines: [{ ...inventory.lines[0], presence: null, quantity: 0 }],
});
});
it('reveals every local line error before saving', async () => {
const { fixture, service } = createPage({
...inventory,
lines: [
{
...inventory.lines[0],
privateAreaId: 'unknown',
designation: '',
presence: 'PRESENT',
quantity: 0,
},
{
id: 'free-1',
category: null,
presence: 'PRESENT',
equipmentKind: 'NONE',
privateAreaId: 'bedroom-1',
designation: 'Lampe',
quantity: 1,
description: '',
},
],
});
fixture.componentInstance.save();
fixture.detectChanges();
await Promise.resolve();
expect(service.saveInventory).not.toHaveBeenCalled();
expect(fixture.nativeElement.textContent).toContain('Déclarez la présence ou l’absence');
expect(fixture.componentInstance.fieldErrors()).toEqual([
{ field: 'lines[0].privateAreaId', code: 'unknown_private_area' },
{ field: 'lines[0].designation', code: 'not_blank' },
{ field: 'lines[0].quantity', code: 'present_quantity' },
{ field: 'lines[1].presence', code: 'invalid_presence' },
]);
expect(document.activeElement?.id).toBe('inventory-bed-1-privateAreaId');
});
it('updates editable facts, normalizes legal presence, and protects frozen inventory', () => {
@@ -127,7 +166,7 @@ describe('InventoryPage', () => {
page.removeLine('bed-1');
page.save();
expect(page.inventory().lines).toHaveLength(1);
expect(page.error()).toContain('figé');
expect(page.saveError()).toContain('figé');
});
it('shows load and save failures without replacing the current inventory', () => {
@@ -153,9 +192,40 @@ describe('InventoryPage', () => {
const page = fixture.componentInstance;
page.save();
expect(page.error()).toContain('Impossible d’enregistrer');
expect(page.saveError()).toContain('Impossible d’enregistrer');
page.load();
expect(page.error()).toContain('Impossible de charger');
expect(page.loadError()).toContain('Impossible de charger');
expect(page.inventory()).toEqual(inventory);
});
it('reveals all server line errors, focuses the first, and clears corrections independently', async () => {
const { fixture, service } = createPage();
service.saveInventory.mockReturnValue(
throwError(() => ({
status: 400,
error: {
code: 'validation_failed',
errors: [
{ field: 'lines[0].privateAreaId', code: 'not_blank' },
{ field: 'lines[0].designation', code: 'not_blank' },
],
},
})),
);
fixture.componentInstance.save();
fixture.detectChanges();
await Promise.resolve();
expect(fixture.nativeElement.textContent).toContain('Renseignez l’espace privatif');
expect(fixture.nativeElement.textContent).toContain('Renseignez la désignation');
expect(document.activeElement?.id).toBe('inventory-bed-1-privateAreaId');
const designation = document.createElement('input');
designation.value = 'Lit double';
fixture.componentInstance.updateText('bed-1', 'designation', eventFor(designation));
expect(fixture.componentInstance.fieldErrors()).toEqual([
{ field: 'lines[0].privateAreaId', code: 'not_blank' },
]);
});
});
@@ -7,7 +7,12 @@ import {
FurnitureInventoryService,
FurniturePresence,
} from '../../services/furniture-inventory.service';
import { httpErrorMessage } from '../../services/http-error-message';
import {
FieldValidationError,
httpErrorMessage,
saveFailure,
} from '../../services/http-error-message';
import { inventoryLineErrors, inventoryLineGuidance } from './inventory-line-validation';
const emptyInventory: FurnitureInventory = {
id: '',
@@ -35,7 +40,9 @@ export class InventoryPage {
readonly inventory = signal<FurnitureInventory>(emptyInventory);
readonly loading = signal(true);
readonly saving = signal(false);
readonly error = signal('');
readonly loadError = signal('');
readonly saveError = signal('');
readonly fieldErrors = signal<FieldValidationError[]>([]);
readonly success = signal('');
constructor() {
@@ -44,15 +51,14 @@ export class InventoryPage {
load(): void {
this.loading.set(true);
this.error.set('');
this.success.set('');
this.loadError.set('');
this.inventories
.getInventory(this.leaseId)
.pipe(rxFinalize(() => this.loading.set(false)))
.subscribe({
next: (inventory) => this.inventory.set(inventory),
error: (error) =>
this.error.set(
this.loadError.set(
httpErrorMessage(error, 'Impossible de charger l’inventaire du mobilier.'),
),
});
@@ -70,6 +76,7 @@ export class InventoryPage {
? event.target.value
: '';
this.updateLine(id, { [field]: value });
this.clearFieldError(id, field);
}
updateQuantity(id: string, event: Event): void {
@@ -79,6 +86,7 @@ export class InventoryPage {
? -1
: input.valueAsNumber;
this.updateLine(id, { quantity });
this.clearFieldError(id, 'quantity');
}
updatePresence(id: string, event: Event): void {
@@ -96,6 +104,7 @@ export class InventoryPage {
? 1
: line.quantity,
});
this.clearFieldError(id, 'presence', 'quantity');
}
addLine(): void {
@@ -130,6 +139,7 @@ export class InventoryPage {
const value = event.target instanceof HTMLSelectElement ? event.target.value : '';
if (value === 'NONE' || value === 'CLIMATISATION' || value === 'OTHER') {
this.updateLine(id, { equipmentKind: value });
this.clearFieldError(id, 'equipmentKind');
}
}
@@ -141,18 +151,25 @@ export class InventoryPage {
}
save(): void {
if (this.saving()) return;
const inventory = this.inventory();
if (inventory.frozen) {
this.error.set('Cet inventaire est figé et ne peut plus être modifié.');
this.saveError.set('Cet inventaire est figé et ne peut plus être modifié.');
return;
}
const validationError = this.validationError(inventory.lines);
if (validationError) {
this.error.set(validationError);
const errors = inventoryLineErrors(
inventory.lines,
new Set(inventory.privateAreas.map((privateArea) => privateArea.id)),
);
if (errors.length) {
this.saveError.set('Veuillez corriger les champs signalés.');
this.fieldErrors.set(errors);
this.focusFirstError(errors);
return;
}
this.saving.set(true);
this.error.set('');
this.saveError.set('');
this.fieldErrors.set([]);
this.success.set('');
this.inventories
.saveInventory(this.leaseId, { version: inventory.version, lines: inventory.lines })
@@ -162,13 +179,49 @@ export class InventoryPage {
this.inventory.set(saved);
this.success.set('Inventaire enregistré.');
},
error: (error) =>
this.error.set(
httpErrorMessage(error, 'Impossible d’enregistrer l’inventaire.'),
),
error: (error) => {
const failure = saveFailure(error, 'Impossible d’enregistrer l’inventaire.');
this.saveError.set(failure.message);
this.fieldErrors.set(failure.fieldErrors);
this.focusFirstError(failure.fieldErrors);
},
});
}
lineError(line: FurnitureInventoryLine, field: string): string | null {
const index = this.inventory().lines.indexOf(line);
const error = this.fieldErrors().find(
(candidate) => candidate.field === this.fieldName(index, field),
);
return error ? inventoryLineGuidance(error.code, field) : null;
}
controlId(line: FurnitureInventoryLine, field: string): string {
return `inventory-${line.id}-${field === 'category' ? 'presence' : field}`;
}
private clearFieldError(id: string, ...fields: string[]): void {
const index = this.inventory().lines.findIndex((line) => line.id === id);
this.fieldErrors.update((errors) =>
errors.filter(
(error) => !fields.some((field) => error.field === this.fieldName(index, field)),
),
);
if (!this.fieldErrors().length) this.saveError.set('');
}
private focusFirstError(errors: FieldValidationError[]): void {
queueMicrotask(() => {
const error = errors.find((candidate) => candidate.field.startsWith('lines['));
if (!error) return;
const close = error.field.indexOf('].');
const index = Number(error.field.slice('lines['.length, close));
const field = error.field.slice(close + 2);
const line = this.inventory().lines[index];
document.getElementById(line ? this.controlId(line, field) : '')?.focus();
});
}
private updateLine(id: string, changes: Partial<FurnitureInventoryLine>): void {
if (this.inventory().frozen || this.saving()) return;
this.inventory.update((inventory) => ({
@@ -177,23 +230,7 @@ export class InventoryPage {
}));
}
private validationError(lines: FurnitureInventoryLine[]): string | null {
for (const line of lines) {
if (!line.privateAreaId.trim()) return 'Renseignez la zone de chaque ligne.';
if (!line.designation.trim()) return 'Renseignez la désignation de chaque ligne.';
if (!Number.isInteger(line.quantity) || line.quantity < 0) {
return 'Saisissez une quantité entière positive ou nulle.';
}
if (line.category && line.presence === null) {
return 'Déclarez la présence ou l’absence de chaque élément réglementaire.';
}
if (line.category && line.presence === 'PRESENT' && line.quantity === 0) {
return 'Un élément réglementaire présent doit avoir une quantité positive.';
}
if (line.category && line.presence === 'ABSENT' && line.quantity !== 0) {
return 'Un élément réglementaire absent doit avoir une quantité nulle.';
}
}
return null;
private fieldName(index: number, field: string): string {
return `lines[${index}].${field}`;
}
}