fix(#112): dates et places AM en reprise (resetForReprise)

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 <cursoragent@cursor.com>
This commit is contained in:
MARTIN Julien 2026-06-16 19:12:14 +02:00
parent c26ed00374
commit df776d8200
7 changed files with 257 additions and 85 deletions

View File

@ -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();

View File

@ -64,7 +64,22 @@ class RepriseDossier {
bool get isParent => role == 'parent';
bool get isAm => role == 'assistante_maternelle';
static Map<String, dynamic>? _nestedMap(dynamic value) {
if (value is Map) return Map<String, dynamic>.from(value);
return null;
}
static dynamic _firstNonNull(List<dynamic> values) {
for (final v in values) {
if (v != null) return v;
}
return null;
}
factory RepriseDossier.fromJson(Map<String, dynamic> 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(),
);

View File

@ -11,8 +11,8 @@ class AmRegisterStep2Screen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final registrationData = Provider.of<AmRegistrationData>(context, listen: false);
return Consumer<AmRegistrationData>(
builder: (context, registrationData, _) {
final initialData = ProfessionalInfoData(
photoPath: registrationData.photoPath,
photoBytes: registrationData.photoBytes,
@ -58,5 +58,7 @@ class AmRegisterStep2Screen extends StatelessWidget {
context.go('/am-register-step3');
},
);
},
);
}
}

View File

@ -287,7 +287,10 @@ class AuthService {
if (decoded is! Map<String, dynamic>) {
throw Exception('Réponse invalide du serveur.');
}
return RepriseDossier.fromJson(decoded);
final payload = decoded['data'] is Map
? Map<String, dynamic>.from(decoded['data'] as Map)
: decoded;
return RepriseDossier.fromJson(payload);
}
/// Modale login : numéro + e-mail token reprise. POST /auth/reprise-identify. #112.

View File

@ -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) {

View File

@ -134,9 +134,40 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
@override
void initState() {
super.initState();
_applyInitialData(widget.initialData);
final data = widget.initialData;
if (data != null) {
if (widget.mode == DisplayMode.editable) {
_birthCityFocus = FocusNode();
_birthCountryFocus = FocusNode();
_nirFocus = FocusNode();
_birthCityFocus!.addListener(_onBirthCityFocusChange);
_birthCountryFocus!.addListener(_onBirthCountryFocusChange);
}
}
@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!)
@ -159,15 +190,6 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
_photoConsent = data.photoConsent;
}
if (widget.mode == DisplayMode.editable) {
_birthCityFocus = FocusNode();
_birthCountryFocus = FocusNode();
_nirFocus = FocusNode();
_birthCityFocus!.addListener(_onBirthCityFocusChange);
_birthCountryFocus!.addListener(_onBirthCountryFocusChange);
}
}
void _onBirthCityFocusChange() {
if (_birthCityFocus == null || _birthCityFocus!.hasFocus) return;
_applyPlaceNameFormat(_birthCityController);

View File

@ -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);
});
}