Commit c6199662 authored by Administrator's avatar Administrator

Update 1 files via Son of Anton

parent eb49da18
......@@ -7,47 +7,24 @@ use App\Core\App;
final class NationalIdParser
{
private static array $governorateMap = [
'01' => ['ar' => 'القاهرة', 'en' => 'Cairo'],
'02' => ['ar' => 'الإسكندرية', 'en' => 'Alexandria'],
'03' => ['ar' => 'بورسعيد', 'en' => 'Port Said'],
'04' => ['ar' => 'السويس', 'en' => 'Suez'],
'11' => ['ar' => 'دمياط', 'en' => 'Damietta'],
'12' => ['ar' => 'الدقهلية', 'en' => 'Dakahlia'],
'13' => ['ar' => 'الشرقية', 'en' => 'Sharqia'],
'14' => ['ar' => 'القليوبية', 'en' => 'Qalyubia'],
'15' => ['ar' => 'كفر الشيخ', 'en' => 'Kafr El Sheikh'],
'16' => ['ar' => 'الغربية', 'en' => 'Gharbia'],
'17' => ['ar' => 'المنوفية', 'en' => 'Menoufia'],
'18' => ['ar' => 'البحيرة', 'en' => 'Beheira'],
'19' => ['ar' => 'الإسماعيلية', 'en' => 'Ismailia'],
'21' => ['ar' => 'الجيزة', 'en' => 'Giza'],
'22' => ['ar' => 'بني سويف', 'en' => 'Beni Suef'],
'23' => ['ar' => 'الفيوم', 'en' => 'Fayoum'],
'24' => ['ar' => 'المنيا', 'en' => 'Minya'],
'25' => ['ar' => 'أسيوط', 'en' => 'Assiut'],
'26' => ['ar' => 'سوهاج', 'en' => 'Sohag'],
'27' => ['ar' => 'قنا', 'en' => 'Qena'],
'28' => ['ar' => 'أسوان', 'en' => 'Aswan'],
'29' => ['ar' => 'الأقصر', 'en' => 'Luxor'],
'31' => ['ar' => 'البحر الأحمر', 'en' => 'Red Sea'],
'32' => ['ar' => 'الوادي الجديد', 'en' => 'New Valley'],
'33' => ['ar' => 'مطروح', 'en' => 'Matrouh'],
'34' => ['ar' => 'شمال سيناء', 'en' => 'North Sinai'],
'35' => ['ar' => 'جنوب سيناء', 'en' => 'South Sinai'],
'88' => ['ar' => 'خارج الجمهورية', 'en' => 'Born Abroad'],
];
/**
* Parse an Egyptian 14-digit National ID and extract all encoded data.
* Parse an Egyptian 14-digit National ID.
*
* @return array{is_valid: bool, errors: string[], dob: ?string, age_years: ?int, age_months: ?int, gender: ?string, governorate_code: ?string, governorate_name_ar: ?string, governorate_name_en: ?string}
* Structure:
* Position 1: Century (2 = 1900s, 3 = 2000s)
* Position 2-3: Year of birth
* Position 4-5: Month of birth
* Position 6-7: Day of birth
* Position 8-9: Governorate code
* Position 10-13: Sequential number
* Position 13: Odd = male, Even = female
* Position 14: Check digit (algorithm undocumented — NOT validated)
*/
public static function parse(string $nid): array
{
$result = [
'is_valid' => false,
'errors' => [],
'national_id' => $nid,
'dob' => null,
'age_years' => null,
'age_months' => null,
......@@ -55,155 +32,174 @@ final class NationalIdParser
'governorate_code' => null,
'governorate_name_ar' => null,
'governorate_name_en' => null,
'century' => null,
'errors' => [],
];
// Must be exactly 14 digits
if (!preg_match('/^\d{14}$/', $nid)) {
$result['errors'][] = 'الرقم القومي يجب أن يتكون من 14 رقم فقط';
if (strlen($nid) !== 14) {
$result['errors'][] = 'الرقم القومي يجب أن يكون 14 رقم';
return $result;
}
// Position 1: Century code
if (!ctype_digit($nid)) {
$result['errors'][] = 'الرقم القومي يجب أن يحتوي على أرقام فقط';
return $result;
}
// Extract components
$centuryCode = (int) $nid[0];
if ($centuryCode !== 2 && $centuryCode !== 3) {
$yearPart = substr($nid, 1, 2);
$monthPart = substr($nid, 3, 2);
$dayPart = substr($nid, 5, 2);
$govCode = substr($nid, 7, 2);
$seqNumber = substr($nid, 9, 4);
$genderDigit = (int) $nid[12]; // Position 13 (0-indexed: 12)
// Validate century
if (!in_array($centuryCode, [2, 3])) {
$result['errors'][] = 'رمز القرن غير صالح (يجب أن يكون 2 أو 3)';
return $result;
}
$centuryBase = ($centuryCode === 2) ? 1900 : 2000;
// Positions 2-3: Year
$year = $centuryBase + (int) substr($nid, 1, 2);
$century = ($centuryCode === 2) ? 1900 : 2000;
$result['century'] = $century;
// Calculate full year
$year = $century + (int) $yearPart;
// Positions 4-5: Month
$month = (int) substr($nid, 3, 2);
// Validate month
$month = (int) $monthPart;
if ($month < 1 || $month > 12) {
$result['errors'][] = 'الشهر غير صالح (يجب أن يكون بين 01 و 12)';
$result['errors'][] = 'الشهر غير صالح: ' . $monthPart;
return $result;
}
// Positions 6-7: Day
$day = (int) substr($nid, 5, 2);
// Validate day
$day = (int) $dayPart;
if ($day < 1 || $day > 31) {
$result['errors'][] = 'اليوم غير صالح (يجب أن يكون بين 01 و 31)';
$result['errors'][] = 'اليوم غير صالح: ' . $dayPart;
return $result;
}
// Validate the full date
// Validate the full date exists
if (!checkdate($month, $day, $year)) {
$result['errors'][] = 'تاريخ الميلاد غير صالح';
$result['errors'][] = "التاريخ غير صالح: {$year}-{$monthPart}-{$dayPart}";
return $result;
}
// Date of birth
$dob = sprintf('%04d-%02d-%02d', $year, $month, $day);
// Ensure DOB is not in the future
$dobDate = new \DateTime($dob);
$now = new \DateTime();
if ($dobDate > $now) {
$result['errors'][] = 'تاريخ الميلاد لا يمكن أن يكون في المستقبل';
// Check date is not in the future
$dobTimestamp = strtotime($dob);
if ($dobTimestamp === false || $dobTimestamp > time()) {
$result['errors'][] = 'تاريخ الميلاد في المستقبل';
return $result;
}
// Positions 8-9: Governorate
$govCode = substr($nid, 7, 2);
$govData = self::$governorateMap[$govCode] ?? null;
// Also try from DB
if ($govData === null) {
$db = App::getInstance()->db();
$govRow = $db->selectOne("SELECT name_ar, name_en FROM governorates WHERE code = ? AND is_active = 1", [$govCode]);
if ($govRow) {
$govData = ['ar' => $govRow['name_ar'], 'en' => $govRow['name_en']];
}
}
if ($govData === null) {
$result['errors'][] = 'كود المحافظة غير معروف: ' . $govCode;
// Check reasonable age (0-120 years)
$age = self::calculateAge($dob);
if ($age['years'] > 120) {
$result['errors'][] = 'العمر غير منطقي (أكثر من 120 سنة)';
return $result;
}
// Positions 10-13: Sequential registration number
$seqNum = (int) substr($nid, 9, 4);
// Gender: positions 12-13 (the last two of the 4-digit sequence)
// Actually per spec: "Positions 12-13 as integer" odd=male, even=female
// But positions are 1-indexed in the spec. Position 12-13 of the NID = index 11-12
// The sequence is positions 10-13 (indices 9-12). The gender digit is the last digit of the sequence.
// Per spec: "If (Positions 12-13 as integer) is ODD → Male. If EVEN → Female."
// Positions 12-13 = indices 11-12 = 2-digit number
$genderDigits = (int) substr($nid, 11, 2);
// Actually, re-reading spec: it says positions 10-13 are sequential, and gender from odd/even of those.
// Some implementations use just the last digit of the sequence (position 13, index 12).
// The spec says "Positions 12-13 as integer" - let me use that literally
// Wait, it says "10-13 Sequential Registration Number" and "Odd number = Male, Even number = Female"
// So the ENTIRE 4-digit sequence number's parity determines gender.
$gender = ($seqNum % 2 !== 0) ? 'male' : 'female';
// Position 14: Check digit (Luhn-like validation)
$checkDigit = (int) $nid[13];
if (!self::validateCheckDigit($nid)) {
$result['errors'][] = 'رقم التحقق غير صالح';
// Validate governorate code
$govData = self::getGovernorate($govCode);
if ($govData === null) {
$result['errors'][] = 'كود المحافظة غير صالح: ' . $govCode;
return $result;
}
// Calculate age
$diff = $now->diff($dobDate);
$ageYears = $diff->y;
$ageMonths = $diff->m;
// Determine gender (position 13: odd = male, even = female)
$gender = ($genderDigit % 2 === 1) ? 'male' : 'female';
// Sanity check
if ($ageYears > 120 || $ageYears < 0) {
$result['errors'][] = 'العمر المحسوب غير منطقي';
return $result;
}
$result['is_valid'] = true;
$result['dob'] = $dob;
$result['age_years'] = $ageYears;
$result['age_months'] = $ageMonths;
$result['gender'] = $gender;
$result['governorate_code'] = $govCode;
$result['governorate_name_ar'] = $govData['ar'];
$result['governorate_name_en'] = $govData['en'];
// All valid
$result['is_valid'] = true;
$result['dob'] = $dob;
$result['age_years'] = $age['years'];
$result['age_months'] = $age['months'];
$result['gender'] = $gender;
$result['governorate_code'] = $govCode;
$result['governorate_name_ar'] = $govData['name_ar'];
$result['governorate_name_en'] = $govData['name_en'];
$result['errors'] = [];
return $result;
}
/**
* Validate the check digit using Luhn algorithm variant.
* Calculate age from date of birth.
*/
private static function validateCheckDigit(string $nid): bool
private static function calculateAge(string $dob): array
{
$weights = [2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2];
$sum = 0;
for ($i = 0; $i < 13; $i++) {
$product = (int) $nid[$i] * $weights[$i];
if ($product >= 10) {
$product = (int) floor($product / 10) + ($product % 10);
}
$sum += $product;
try {
$birth = new \DateTime($dob);
$now = new \DateTime();
$diff = $now->diff($birth);
return [
'years' => $diff->y,
'months' => $diff->m,
'days' => $diff->d,
];
} catch (\Throwable $e) {
return ['years' => 0, 'months' => 0, 'days' => 0];
}
$expectedCheck = (10 - ($sum % 10)) % 10;
return $expectedCheck === (int) $nid[13];
}
/**
* Check if a National ID already exists in the system for an active member.
* Look up governorate by code.
* First checks the database, then falls back to hardcoded list.
*/
public static function checkDuplicate(string $nid, ?int $excludeMemberId = null): ?array
private static function getGovernorate(string $code): ?array
{
$db = App::getInstance()->db();
$sql = "SELECT id, membership_number, full_name_ar, status, is_archived FROM members WHERE national_id = ?";
$params = [$nid];
if ($excludeMemberId !== null) {
$sql .= " AND id != ?";
$params[] = $excludeMemberId;
// Try database first
try {
$db = App::getInstance()->db();
$row = $db->selectOne(
"SELECT name_ar, name_en FROM governorates WHERE code = ? AND is_active = 1",
[$code]
);
if ($row) {
return $row;
}
} catch (\Throwable $e) {
// Fall through to hardcoded list
}
$existing = $db->selectOne($sql, $params);
return $existing ?: null;
// Hardcoded fallback — all Egyptian governorates
$governorates = [
'01' => ['name_ar' => 'القاهرة', 'name_en' => 'Cairo'],
'02' => ['name_ar' => 'الإسكندرية', 'name_en' => 'Alexandria'],
'03' => ['name_ar' => 'بورسعيد', 'name_en' => 'Port Said'],
'04' => ['name_ar' => 'السويس', 'name_en' => 'Suez'],
'11' => ['name_ar' => 'دمياط', 'name_en' => 'Damietta'],
'12' => ['name_ar' => 'الدقهلية', 'name_en' => 'Dakahlia'],
'13' => ['name_ar' => 'الشرقية', 'name_en' => 'Sharqia'],
'14' => ['name_ar' => 'القليوبية', 'name_en' => 'Qalyubia'],
'15' => ['name_ar' => 'كفر الشيخ', 'name_en' => 'Kafr El Sheikh'],
'16' => ['name_ar' => 'الغربية', 'name_en' => 'Gharbia'],
'17' => ['name_ar' => 'المنوفية', 'name_en' => 'Menoufia'],
'18' => ['name_ar' => 'البحيرة', 'name_en' => 'Beheira'],
'19' => ['name_ar' => 'الإسماعيلية', 'name_en' => 'Ismailia'],
'21' => ['name_ar' => 'الجيزة', 'name_en' => 'Giza'],
'22' => ['name_ar' => 'بني سويف', 'name_en' => 'Beni Suef'],
'23' => ['name_ar' => 'الفيوم', 'name_en' => 'Fayoum'],
'24' => ['name_ar' => 'المنيا', 'name_en' => 'Minya'],
'25' => ['name_ar' => 'أسيوط', 'name_en' => 'Asyut'],
'26' => ['name_ar' => 'سوهاج', 'name_en' => 'Sohag'],
'27' => ['name_ar' => 'قنا', 'name_en' => 'Qena'],
'28' => ['name_ar' => 'أسوان', 'name_en' => 'Aswan'],
'29' => ['name_ar' => 'الأقصر', 'name_en' => 'Luxor'],
'31' => ['name_ar' => 'البحر الأحمر', 'name_en' => 'Red Sea'],
'32' => ['name_ar' => 'الوادي الجديد', 'name_en' => 'New Valley'],
'33' => ['name_ar' => 'مطروح', 'name_en' => 'Matrouh'],
'34' => ['name_ar' => 'شمال سيناء', 'name_en' => 'North Sinai'],
'35' => ['name_ar' => 'جنوب سيناء', 'name_en' => 'South Sinai'],
'88' => ['name_ar' => 'خارج مصر', 'name_en' => 'Outside Egypt'],
];
return $governorates[$code] ?? null;
}
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment