From df776d820071de866a1dae1aa8595cf6c516d451 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Tue, 16 Jun 2026 19:12:14 +0200 Subject: [PATCH] fix(#112): dates et places AM en reprise (resetForReprise) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrige l’ombre des paramètres dateOfBirth, agreementDate et placesAvailable dans resetForReprise ; renforce le parsing reprise-dossier et ajoute un test. Co-authored-by: Cursor --- frontend/lib/models/am_registration_data.dart | 6 +- frontend/lib/models/reprise_dossier.dart | 80 ++++++++++++++--- .../auth/am_register_step2_screen.dart | 90 ++++++++++--------- frontend/lib/services/auth_service.dart | 5 +- frontend/lib/utils/reprise_mapper.dart | 39 +++++++- .../professional_info_form_screen.dart | 70 ++++++++++----- frontend/test/reprise_am_parse_test.dart | 52 +++++++++++ 7 files changed, 257 insertions(+), 85 deletions(-) create mode 100644 frontend/test/reprise_am_parse_test.dart diff --git a/frontend/lib/models/am_registration_data.dart b/frontend/lib/models/am_registration_data.dart index 08ecc60..1de1aef 100644 --- a/frontend/lib/models/am_registration_data.dart +++ b/frontend/lib/models/am_registration_data.dart @@ -141,14 +141,14 @@ class AmRegistrationData extends ChangeNotifier { photoBytes = null; photoFilename = null; photoConsent = consentementPhoto; - dateOfBirth = dateOfBirth; + this.dateOfBirth = dateOfBirth; this.birthCity = birthCity; this.birthCountry = birthCountry; this.nir = nir; this.agrementNumber = agrementNumber; - agreementDate = agreementDate; + this.agreementDate = agreementDate; this.capacity = capacity; - placesAvailable = placesAvailable; + this.placesAvailable = placesAvailable; this.presentationText = presentationText; cguAccepted = false; notifyListeners(); diff --git a/frontend/lib/models/reprise_dossier.dart b/frontend/lib/models/reprise_dossier.dart index 3d416f0..fe425ca 100644 --- a/frontend/lib/models/reprise_dossier.dart +++ b/frontend/lib/models/reprise_dossier.dart @@ -64,7 +64,22 @@ class RepriseDossier { bool get isParent => role == 'parent'; bool get isAm => role == 'assistante_maternelle'; + static Map? _nestedMap(dynamic value) { + if (value is Map) return Map.from(value); + return null; + } + + static dynamic _firstNonNull(List values) { + for (final v in values) { + if (v != null) return v; + } + return null; + } + factory RepriseDossier.fromJson(Map json) { + final nestedUser = _nestedMap(json['user']); + final nestedDossier = _nestedMap(json['dossier']); + final parentsRaw = json['parents']; final parentsList = parentsRaw is List ? parentsRaw @@ -100,19 +115,64 @@ class RepriseDossier { texteMotivation: (json['texte_motivation'] ?? json['presentation_dossier']) ?.toString(), consentementPhoto: RepriseMapper.optionalBool(json['consentement_photo']) || - RepriseMapper.optionalBool(json['consent_photo']), - dateNaissance: RepriseMapper.optionalDateString(json['date_naissance']), - lieuNaissanceVille: json['lieu_naissance_ville']?.toString(), - lieuNaissancePays: json['lieu_naissance_pays']?.toString(), - numeroAgrement: (json['numero_agrement'] ?? json['numero_agrement_am']) - ?.toString(), - nir: json['nir']?.toString(), - dateAgrement: RepriseMapper.optionalDateString(json['date_agrement']), + RepriseMapper.optionalBool(json['consent_photo']) || + RepriseMapper.optionalBool(nestedUser?['consentement_photo']), + dateNaissance: RepriseMapper.optionalDateString( + _firstNonNull([ + json['date_naissance'], + json['dateNaissance'], + nestedUser?['date_naissance'], + nestedUser?['dateNaissance'], + ]), + ), + lieuNaissanceVille: _firstNonNull([ + json['lieu_naissance_ville'], + json['lieuNaissanceVille'], + nestedUser?['lieu_naissance_ville'], + nestedUser?['lieuNaissanceVille'], + ])?.toString(), + lieuNaissancePays: _firstNonNull([ + json['lieu_naissance_pays'], + json['lieuNaissancePays'], + nestedUser?['lieu_naissance_pays'], + nestedUser?['lieuNaissancePays'], + ])?.toString(), + numeroAgrement: _firstNonNull([ + json['numero_agrement'], + json['numero_agrement_am'], + json['numeroAgrement'], + nestedDossier?['numero_agrement'], + nestedDossier?['numeroAgrement'], + ])?.toString(), + nir: _firstNonNull([ + json['nir'], + nestedDossier?['nir'], + ])?.toString(), + dateAgrement: RepriseMapper.optionalDateString( + _firstNonNull([ + json['date_agrement'], + json['dateAgrement'], + nestedDossier?['date_agrement'], + nestedDossier?['dateAgrement'], + ]), + ), nbMaxEnfants: RepriseMapper.optionalInt( - json['nb_max_enfants'] ?? json['capacite_accueil'], + _firstNonNull([ + json['nb_max_enfants'], + json['capacite_accueil'], + json['nbMaxEnfants'], + nestedDossier?['nb_max_enfants'], + nestedDossier?['capacite_accueil'], + ]), ), placeDisponible: RepriseMapper.optionalInt( - json['place_disponible'] ?? json['places_disponibles'], + _firstNonNull([ + json['place_disponible'], + json['places_disponibles'], + json['placesDisponibles'], + nestedDossier?['place_disponible'], + nestedDossier?['places_disponibles'], + ]), ), biographie: (json['biographie'] ?? json['presentation'])?.toString(), ); diff --git a/frontend/lib/screens/auth/am_register_step2_screen.dart b/frontend/lib/screens/auth/am_register_step2_screen.dart index 8a7e3e4..6f50782 100644 --- a/frontend/lib/screens/auth/am_register_step2_screen.dart +++ b/frontend/lib/screens/auth/am_register_step2_screen.dart @@ -11,51 +11,53 @@ class AmRegisterStep2Screen extends StatelessWidget { @override Widget build(BuildContext context) { - final registrationData = Provider.of(context, listen: false); - - final initialData = ProfessionalInfoData( - photoPath: registrationData.photoPath, - photoBytes: registrationData.photoBytes, - photoFilename: registrationData.photoFilename, - photoConsent: registrationData.photoConsent, - dateOfBirth: registrationData.dateOfBirth, - birthCity: registrationData.birthCity, - birthCountry: registrationData.birthCountry, - nir: registrationData.nir, - agrementNumber: registrationData.agrementNumber, - agreementDate: registrationData.agreementDate, - capacity: registrationData.capacity, - placesAvailable: registrationData.placesAvailable, - ); - - return ProfessionalInfoFormScreen( - key: ValueKey( - 'am-pro-${registrationData.dateOfBirth?.millisecondsSinceEpoch}' - '-${registrationData.agreementDate?.millisecondsSinceEpoch}' - '-${registrationData.placesAvailable}' - '-${registrationData.nir}', - ), - stepText: 'Étape 2/4', - title: 'Vos informations professionnelles', - cardColor: CardColorHorizontal.green, - initialData: initialData, - previousRoute: '/am-register-step1', - onSubmit: (data) { - registrationData.updateProfessionalInfo( - photoPath: data.photoPath, - photoBytes: data.photoBytes, - photoFilename: data.photoFilename, - photoConsent: data.photoConsent, - dateOfBirth: data.dateOfBirth, - birthCity: data.birthCity, - birthCountry: data.birthCountry, - nir: data.nir, - agrementNumber: data.agrementNumber, - agreementDate: data.agreementDate, - capacity: data.capacity, - placesAvailable: data.placesAvailable, + return Consumer( + builder: (context, registrationData, _) { + final initialData = ProfessionalInfoData( + photoPath: registrationData.photoPath, + photoBytes: registrationData.photoBytes, + photoFilename: registrationData.photoFilename, + photoConsent: registrationData.photoConsent, + dateOfBirth: registrationData.dateOfBirth, + birthCity: registrationData.birthCity, + birthCountry: registrationData.birthCountry, + nir: registrationData.nir, + agrementNumber: registrationData.agrementNumber, + agreementDate: registrationData.agreementDate, + capacity: registrationData.capacity, + placesAvailable: registrationData.placesAvailable, + ); + + return ProfessionalInfoFormScreen( + key: ValueKey( + 'am-pro-${registrationData.dateOfBirth?.millisecondsSinceEpoch}' + '-${registrationData.agreementDate?.millisecondsSinceEpoch}' + '-${registrationData.placesAvailable}' + '-${registrationData.nir}', + ), + stepText: 'Étape 2/4', + title: 'Vos informations professionnelles', + cardColor: CardColorHorizontal.green, + initialData: initialData, + previousRoute: '/am-register-step1', + onSubmit: (data) { + registrationData.updateProfessionalInfo( + photoPath: data.photoPath, + photoBytes: data.photoBytes, + photoFilename: data.photoFilename, + photoConsent: data.photoConsent, + dateOfBirth: data.dateOfBirth, + birthCity: data.birthCity, + birthCountry: data.birthCountry, + nir: data.nir, + agrementNumber: data.agrementNumber, + agreementDate: data.agreementDate, + capacity: data.capacity, + placesAvailable: data.placesAvailable, + ); + context.go('/am-register-step3'); + }, ); - context.go('/am-register-step3'); }, ); } diff --git a/frontend/lib/services/auth_service.dart b/frontend/lib/services/auth_service.dart index 76c8f81..1871e07 100644 --- a/frontend/lib/services/auth_service.dart +++ b/frontend/lib/services/auth_service.dart @@ -287,7 +287,10 @@ class AuthService { if (decoded is! Map) { throw Exception('Réponse invalide du serveur.'); } - return RepriseDossier.fromJson(decoded); + final payload = decoded['data'] is Map + ? Map.from(decoded['data'] as Map) + : decoded; + return RepriseDossier.fromJson(payload); } /// Modale login : numéro + e-mail → token reprise. POST /auth/reprise-identify. #112. diff --git a/frontend/lib/utils/reprise_mapper.dart b/frontend/lib/utils/reprise_mapper.dart index 6691b7d..53c285b 100644 --- a/frontend/lib/utils/reprise_mapper.dart +++ b/frontend/lib/utils/reprise_mapper.dart @@ -108,6 +108,15 @@ class RepriseMapper { static DateTime? parseIsoDate(String? raw) { if (raw == null || raw.trim().isEmpty) return null; final s = raw.trim(); + final iso = RegExp(r'^(\d{4})-(\d{2})-(\d{2})'); + final isoMatch = iso.firstMatch(s); + if (isoMatch != null) { + return DateTime( + int.parse(isoMatch.group(1)!), + int.parse(isoMatch.group(2)!), + int.parse(isoMatch.group(3)!), + ); + } try { return DateTime.parse(s); } catch (_) { @@ -128,10 +137,34 @@ class RepriseMapper { if (value == null) return null; if (value is String) { final s = value.trim(); - return s.isEmpty ? null : s; + return s.isEmpty || s == 'null' ? null : s; } - if (value is DateTime) return value.toIso8601String(); - return null; + if (value is DateTime) { + return '${value.year.toString().padLeft(4, '0')}-' + '${value.month.toString().padLeft(2, '0')}-' + '${value.day.toString().padLeft(2, '0')}'; + } + if (value is num) { + final ms = value.abs() > 9999999999 + ? value.toInt() + : value.toInt() * 1000; + final dt = DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true); + return '${dt.year.toString().padLeft(4, '0')}-' + '${dt.month.toString().padLeft(2, '0')}-' + '${dt.day.toString().padLeft(2, '0')}'; + } + if (value is Map) { + final y = value['year']; + final m = value['month'] ?? value['monthValue']; + final d = value['day'] ?? value['dayOfMonth']; + if (y is num && m is num && d is num) { + return '${y.toInt().toString().padLeft(4, '0')}-' + '${m.toInt().toString().padLeft(2, '0')}-' + '${d.toInt().toString().padLeft(2, '0')}'; + } + } + final s = value.toString().trim(); + return s.isEmpty || s == 'null' ? null : s; } static int? optionalInt(dynamic value) { diff --git a/frontend/lib/widgets/professional_info_form_screen.dart b/frontend/lib/widgets/professional_info_form_screen.dart index 6225cd1..9a21bd0 100644 --- a/frontend/lib/widgets/professional_info_form_screen.dart +++ b/frontend/lib/widgets/professional_info_form_screen.dart @@ -134,30 +134,7 @@ class _ProfessionalInfoFormScreenState extends State @override void initState() { super.initState(); - - final data = widget.initialData; - if (data != null) { - _selectedDate = data.dateOfBirth; - _dateOfBirthController.text = data.dateOfBirth != null - ? DateFormat('dd/MM/yyyy').format(data.dateOfBirth!) - : ''; - _birthCityController.text = data.birthCity; - _birthCountryController.text = data.birthCountry; - final nirRaw = nirToRaw(data.nir); - _nirController.text = nirRaw.length == 15 ? formatNir(nirRaw) : data.nir; - _agrementController.text = data.agrementNumber; - _selectedAgreementDate = data.agreementDate; - _agreementDateController.text = data.agreementDate != null - ? DateFormat('dd/MM/yyyy').format(data.agreementDate!) - : ''; - _capacityController.text = data.capacity?.toString() ?? ''; - _placesAvailableController.text = data.placesAvailable?.toString() ?? ''; - _photoPathFramework = data.photoPath; - _photoFile = data.photoFile; - _photoBytes = data.photoBytes; - _photoFilename = data.photoFilename; - _photoConsent = data.photoConsent; - } + _applyInitialData(widget.initialData); if (widget.mode == DisplayMode.editable) { _birthCityFocus = FocusNode(); @@ -168,6 +145,51 @@ class _ProfessionalInfoFormScreenState extends State } } + @override + void didUpdateWidget(covariant ProfessionalInfoFormScreen oldWidget) { + super.didUpdateWidget(oldWidget); + final next = widget.initialData; + final prev = oldWidget.initialData; + if (next == null) return; + if (prev == null || + prev.dateOfBirth != next.dateOfBirth || + prev.agreementDate != next.agreementDate || + prev.placesAvailable != next.placesAvailable || + prev.capacity != next.capacity || + prev.nir != next.nir || + prev.birthCity != next.birthCity || + prev.birthCountry != next.birthCountry || + prev.agrementNumber != next.agrementNumber || + prev.photoPath != next.photoPath || + prev.photoConsent != next.photoConsent) { + _applyInitialData(next); + } + } + + void _applyInitialData(ProfessionalInfoData? data) { + if (data == null) return; + _selectedDate = data.dateOfBirth; + _dateOfBirthController.text = data.dateOfBirth != null + ? DateFormat('dd/MM/yyyy').format(data.dateOfBirth!) + : ''; + _birthCityController.text = data.birthCity; + _birthCountryController.text = data.birthCountry; + final nirRaw = nirToRaw(data.nir); + _nirController.text = nirRaw.length == 15 ? formatNir(nirRaw) : data.nir; + _agrementController.text = data.agrementNumber; + _selectedAgreementDate = data.agreementDate; + _agreementDateController.text = data.agreementDate != null + ? DateFormat('dd/MM/yyyy').format(data.agreementDate!) + : ''; + _capacityController.text = data.capacity?.toString() ?? ''; + _placesAvailableController.text = data.placesAvailable?.toString() ?? ''; + _photoPathFramework = data.photoPath; + _photoFile = data.photoFile; + _photoBytes = data.photoBytes; + _photoFilename = data.photoFilename; + _photoConsent = data.photoConsent; + } + void _onBirthCityFocusChange() { if (_birthCityFocus == null || _birthCityFocus!.hasFocus) return; _applyPlaceNameFormat(_birthCityController); diff --git a/frontend/test/reprise_am_parse_test.dart b/frontend/test/reprise_am_parse_test.dart new file mode 100644 index 0000000..3fcf621 --- /dev/null +++ b/frontend/test/reprise_am_parse_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:p_tits_pas/models/am_registration_data.dart'; +import 'package:p_tits_pas/models/reprise_dossier.dart'; +import 'package:p_tits_pas/utils/reprise_mapper.dart'; + +void main() { + test('RepriseDossier parse champs AM plats', () { + final d = RepriseDossier.fromJson({ + 'id': 'am1', + 'email': 'marie@test.fr', + 'role': 'assistante_maternelle', + 'date_naissance': '1980-06-08', + 'date_agrement': '2019-09-01', + 'place_disponible': 2, + 'nb_max_enfants': 4, + 'lieu_naissance_ville': 'Ajaccio', + 'nir': '280062A00100191', + 'numero_agrement': 'AGR-2019-095001', + }); + + expect(d.dateNaissance, '1980-06-08'); + expect(d.dateAgrement, '2019-09-01'); + expect(d.placeDisponible, 2); + + final data = AmRegistrationData(); + RepriseMapper.applyAmDossier(data, d); + expect(data.dateOfBirth, DateTime(1980, 6, 8)); + expect(data.agreementDate, DateTime(2019, 9, 1)); + expect(data.placesAvailable, 2); + }); + + test('RepriseDossier parse champs AM imbriqués + wrapper data', () { + final d = RepriseDossier.fromJson({ + 'id': 'am1', + 'email': 'marie@test.fr', + 'role': 'assistante_maternelle', + 'nir': '280062A00100191', + 'numero_agrement': 'AGR-2019-095001', + 'nb_max_enfants': 4, + 'lieu_naissance_ville': 'Ajaccio', + 'user': {'date_naissance': '1980-06-08T00:00:00.000Z'}, + 'dossier': { + 'date_agrement': '2019-09-01', + 'places_disponibles': 2, + }, + }); + + expect(d.dateNaissance, isNotNull); + expect(d.dateAgrement, '2019-09-01'); + expect(d.placeDisponible, 2); + }); +}