Merge develop into master (squash): ticket #102 NIR harmonisation

- Backend: DTO NIR 15 car 2A/2B, validation format+clé, warning cohérence
- BDD: nir_chiffre NOT NULL, migration pour bases existantes
- Seeds: 02 nir_chiffre, 03 Marie 2A / Fatima 99
- Frontend: nir_utils, nir_text_field, formulaire pro, mock inscription AM

Made-with: Cursor
This commit is contained in:
2026-02-26 13:55:42 +01:00
parent b1a80f85c9
commit ca98821b3e
12 changed files with 355 additions and 38 deletions
+113
View File
@@ -0,0 +1,113 @@
import 'package:flutter/services.dart';
/// Utilitaires NIR (Numéro d'Inscription au Répertoire) INSEE, 15 caractères.
/// Corse : 2A (2A) et 2B (2B) au lieu de 19/20. Clé de contrôle : 97 - (NIR13 mod 97).
/// Normalise le NIR : 15 caractères, sans espaces ni séparateurs. Corse conservée (2A/2B).
String normalizeNir(String input) {
if (input.isEmpty) return '';
final cleaned = input.replaceAll(' ', '').replaceAll('-', '').replaceAll('/', '').toUpperCase();
final buf = StringBuffer();
int i = 0;
while (i < cleaned.length && buf.length < 15) {
final c = cleaned[i];
if (buf.length < 5) {
if (c.compareTo('0') >= 0 && c.compareTo('9') <= 0) buf.write(c);
i++;
} else if (buf.length == 5) {
if (c == '2' && i + 1 < cleaned.length && (cleaned[i + 1] == 'A' || cleaned[i + 1] == 'B')) {
buf.write('2');
buf.write(cleaned[i + 1]);
i += 2;
} else if ((c == 'A' || c == 'B')) {
buf.write('2');
buf.write(c);
i++;
} else if (c.compareTo('0') >= 0 && c.compareTo('9') <= 0) {
buf.write(c);
if (i + 1 < cleaned.length && cleaned[i + 1].compareTo('0') >= 0 && cleaned[i + 1].compareTo('9') <= 0) {
buf.write(cleaned[i + 1]);
i += 2;
} else {
i++;
}
} else {
i++;
}
} else {
if (c.compareTo('0') >= 0 && c.compareTo('9') <= 0) buf.write(c);
i++;
}
}
return buf.toString().length > 15 ? buf.toString().substring(0, 15) : buf.toString();
}
/// Retourne la chaîne brute à 15 caractères (chiffres + 2A ou 2B).
String nirToRaw(String normalized) {
String s = normalized.replaceAll(' ', '').replaceAll('-', '').replaceAll('/', '');
if (s.length > 15) s = s.substring(0, 15);
return s;
}
/// Formate pour affichage : 1 12 34 56 789 012-34 ou 1 12 34 2A 789 012-34
String formatNir(String raw) {
final r = nirToRaw(raw);
if (r.length < 15) return r;
final dept = r.substring(5, 7);
final isCorsica = dept == '2A' || dept == '2B';
if (isCorsica) {
return '${r.substring(0, 5)} ${r.substring(5, 7)} ${r.substring(7, 10)} ${r.substring(10, 13)}-${r.substring(13, 15)}';
}
return '${r.substring(0, 1)} ${r.substring(1, 3)} ${r.substring(3, 5)} ${r.substring(5, 7)} ${r.substring(7, 10)} ${r.substring(10, 13)}-${r.substring(13, 15)}';
}
/// Vérifie le format : 15 caractères, structure 1+2+2+2+3+3+2, département 2A/2B autorisé.
bool _isFormatValid(String raw) {
if (raw.length != 15) return false;
final dept = raw.substring(5, 7);
final restDigits = raw.substring(0, 5) + (dept == '2A' ? '19' : dept == '2B' ? '18' : dept) + raw.substring(7, 15);
if (!RegExp(r'^[12]\d{12}\d{2}$').hasMatch(restDigits)) return false;
return RegExp(r'^[12]\d{4}(?:\d{2}|2A|2B)\d{6}$').hasMatch(raw);
}
/// Calcule la clé de contrôle (97 - (NIR13 mod 97)). Pour 2A→19, 2B→18.
int _controlKey(String raw13) {
String n = raw13;
if (raw13.length >= 7 && (raw13.substring(5, 7) == '2A' || raw13.substring(5, 7) == '2B')) {
n = raw13.substring(0, 5) + (raw13.substring(5, 7) == '2A' ? '19' : '18') + raw13.substring(7);
}
final big = int.tryParse(n);
if (big == null) return -1;
return 97 - (big % 97);
}
/// Valide le NIR (format + clé). Retourne null si valide, message d'erreur sinon.
String? validateNir(String? value) {
if (value == null || value.isEmpty) return 'NIR requis';
final raw = nirToRaw(value).toUpperCase();
if (raw.length != 15) return 'Le NIR doit contenir 15 caractères (chiffres, ou 2A/2B pour la Corse)';
if (!_isFormatValid(raw)) return 'Format NIR invalide (ex. 1 12 34 56 789 012-34 ou 2A pour la Corse)';
final key = _controlKey(raw.substring(0, 13));
final keyStr = key >= 0 && key <= 99 ? key.toString().padLeft(2, '0') : '';
final expectedKey = raw.substring(13, 15);
if (key < 0 || keyStr != expectedKey) return 'Clé de contrôle NIR invalide';
return null;
}
/// Formateur de saisie : affiche le NIR formaté (1 12 34 56 789 012-34) et limite à 15 caractères utiles.
class NirInputFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final raw = normalizeNir(newValue.text);
if (raw.isEmpty) return newValue;
final formatted = formatNir(raw);
final offset = formatted.length;
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: offset),
);
}
}