Commit a5b513bb authored by Fares's avatar Fares

fix(transfers): resolve NOT NULL field errors during child separation completion

The TransferProcessor::execute() was failing with "Field 'date_of_birth'
doesn't have a default value" because:
1. Child's date_of_birth could be empty string (not null), bypassing ?? operator
2. Children table has no phone_mobile column, causing NOT NULL violation on members
3. Spouse creation was missing required fields (date_of_birth, marriage_date, join_date)
4. Temporary member creation was missing required fields (date_of_birth, gender, category)

Changes:
- Sanitize DOB: treat empty string and '0000-00-00' as null before NID fallback
- Add phone_mobile fallback chain: subject → source member → placeholder
- Spouse inserts now include all NOT NULL fields with sensible defaults
- Children inserts include child_order
- Temporary member inserts include category, date_of_birth, gender with NID parsing
- Death module: fix secondary spouse phone_mobile NULL issue
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 3e61798c
...@@ -757,7 +757,7 @@ class DeathController extends Controller ...@@ -757,7 +757,7 @@ class DeathController extends Controller
'full_name_ar' => $secSpouse['full_name_ar'], 'full_name_ar' => $secSpouse['full_name_ar'],
'full_name_en' => $secSpouse['full_name_en'] ?? null, 'full_name_en' => $secSpouse['full_name_en'] ?? null,
'national_id' => $secSpouse['national_id'], 'national_id' => $secSpouse['national_id'],
'date_of_birth' => $secSpouse['date_of_birth'] ?? '1900-01-01', 'date_of_birth' => !empty($secSpouse['date_of_birth']) ? $secSpouse['date_of_birth'] : '1900-01-01',
'gender' => $secSpouse['gender'] ?? 'female', 'gender' => $secSpouse['gender'] ?? 'female',
'nationality' => $secSpouse['nationality'] ?? 'مصري', 'nationality' => $secSpouse['nationality'] ?? 'مصري',
'branch_id' => (int) $member['branch_id'], 'branch_id' => (int) $member['branch_id'],
...@@ -766,7 +766,7 @@ class DeathController extends Controller ...@@ -766,7 +766,7 @@ class DeathController extends Controller
'status' => 'active', 'status' => 'active',
'activated_at' => date('Y-m-d H:i:s'), 'activated_at' => date('Y-m-d H:i:s'),
'activated_by_payment_id' => $deathPaymentId, 'activated_by_payment_id' => $deathPaymentId,
'phone_mobile' => $secSpouse['mobile'] ?? null, 'phone_mobile' => $secSpouse['mobile'] ?? $member['phone_mobile'] ?? '00000000000',
'membership_value' => $member['membership_value'], 'membership_value' => $member['membership_value'],
'transferred_from_death_id' => (int) $id, 'transferred_from_death_id' => (int) $id,
'deceased_member_name' => $member['full_name_ar'], 'deceased_member_name' => $member['full_name_ar'],
......
...@@ -111,22 +111,38 @@ final class TransferProcessor ...@@ -111,22 +111,38 @@ final class TransferProcessor
$dob = $recipientData['date_of_birth'] ?? $subjectData['date_of_birth'] ?? null; $dob = $recipientData['date_of_birth'] ?? $subjectData['date_of_birth'] ?? null;
$gender = $recipientData['gender'] ?? $subjectData['gender'] ?? null; $gender = $recipientData['gender'] ?? $subjectData['gender'] ?? null;
$nid = $recipientData['national_id'] ?? $subjectData['national_id'] ?? null; $nid = $recipientData['national_id'] ?? $subjectData['national_id'] ?? null;
if ((!$dob || !$gender) && $nid && strlen($nid) === 14) {
$parsed = NationalIdParser::parse($nid); // Sanitize: empty string or invalid placeholder → try NID parsing
if (!$dob || $dob === '0000-00-00' || $dob === '1900-01-01') {
$dob = null;
}
if (!$gender || $gender === '') {
$gender = null;
}
if ((!$dob || !$gender) && $nid && strlen((string) $nid) === 14) {
$parsed = NationalIdParser::parse((string) $nid);
if ($parsed['is_valid']) { if ($parsed['is_valid']) {
$dob = $dob ?: $parsed['dob']; $dob = $dob ?: $parsed['dob'];
$gender = $gender ?: $parsed['gender']; $gender = $gender ?: $parsed['gender'];
} }
} }
// phone_mobile: children/spouses may not have this — fall through to source member
$phoneMobile = $recipientData['phone_mobile']
?? $subjectData['phone_mobile']
?? $subjectData['mobile']
?? $sourceMember['phone_mobile']
?? '00000000000';
$newMemberData = [ $newMemberData = [
'full_name_ar' => $recipientData['full_name_ar'] ?? $subjectData['full_name_ar'] ?? $subjectName, 'full_name_ar' => $recipientData['full_name_ar'] ?? $subjectData['full_name_ar'] ?? $subjectName,
'full_name_en' => $recipientData['full_name_en'] ?? $subjectData['full_name_en'] ?? null, 'full_name_en' => $recipientData['full_name_en'] ?? $subjectData['full_name_en'] ?? null,
'national_id' => $nid, 'national_id' => $nid,
'passport_number' => $subjectData['passport_number'] ?? null, 'passport_number' => $subjectData['passport_number'] ?? null,
'id_type' => $subjectData['id_type'] ?? 'national_id', 'id_type' => $subjectData['id_type'] ?? 'national_id',
'date_of_birth' => $dob ?? '1900-01-01', 'date_of_birth' => $dob ?: '1900-01-01',
'gender' => $gender ?? 'male', 'gender' => $gender ?: 'male',
'nationality' => $recipientData['nationality'] ?? $subjectData['nationality'] ?? 'مصري', 'nationality' => $recipientData['nationality'] ?? $subjectData['nationality'] ?? 'مصري',
'branch_id' => (int) $sourceMember['branch_id'], 'branch_id' => (int) $sourceMember['branch_id'],
'membership_type' => 'working', 'membership_type' => 'working',
...@@ -135,7 +151,7 @@ final class TransferProcessor ...@@ -135,7 +151,7 @@ final class TransferProcessor
'activated_at' => date('Y-m-d H:i:s'), 'activated_at' => date('Y-m-d H:i:s'),
'activated_by_payment_id'=> $transferPaymentId, 'activated_by_payment_id'=> $transferPaymentId,
'qualification_id' => $recipientData['qualification_id'] ?? $subjectData['qualification_id'] ?? $sourceMember['qualification_id'], 'qualification_id' => $recipientData['qualification_id'] ?? $subjectData['qualification_id'] ?? $sourceMember['qualification_id'],
'phone_mobile' => $recipientData['phone_mobile'] ?? $subjectData['mobile'] ?? $subjectData['phone_mobile'] ?? $sourceMember['phone_mobile'], 'phone_mobile' => $phoneMobile,
'membership_value' => $request['new_membership_value'], 'membership_value' => $request['new_membership_value'],
'membership_number' => $sameNumber, 'membership_number' => $sameNumber,
'created_at' => date('Y-m-d H:i:s'), 'created_at' => date('Y-m-d H:i:s'),
...@@ -180,35 +196,48 @@ final class TransferProcessor ...@@ -180,35 +196,48 @@ final class TransferProcessor
if ($request['transfer_type'] === 'child_separation' && !empty($request['notes'])) { if ($request['transfer_type'] === 'child_separation' && !empty($request['notes'])) {
$notesDecoded = json_decode($request['notes'], true) ?: []; $notesDecoded = json_decode($request['notes'], true) ?: [];
$depsData = $notesDecoded['dependents_data'] ?? []; $depsData = $notesDecoded['dependents_data'] ?? [];
$today = date('Y-m-d');
$ts = date('Y-m-d H:i:s');
if (!empty($depsData['spouses'])) { if (!empty($depsData['spouses'])) {
$spOrder = 0;
foreach ($depsData['spouses'] as $sp) { foreach ($depsData['spouses'] as $sp) {
if (empty($sp['full_name_ar'])) continue; if (empty($sp['full_name_ar'])) continue;
$spNid = $sp['national_id'] ?? null; $spOrder++;
$spNid = !empty($sp['national_id']) ? (string) $sp['national_id'] : null;
$spDob = null; $spDob = null;
$spGender = 'female';
if ($spNid && strlen($spNid) === 14) { if ($spNid && strlen($spNid) === 14) {
$spParsed = NationalIdParser::parse($spNid); $spParsed = NationalIdParser::parse($spNid);
if ($spParsed['is_valid']) $spDob = $spParsed['dob']; if ($spParsed['is_valid']) {
$spDob = $spParsed['dob'];
$spGender = $spParsed['gender'] ?? 'female';
}
} }
$spouseRow = [ $db->insert('spouses', [
'member_id' => $newMemberId, 'member_id' => $newMemberId,
'spouse_order' => $spOrder,
'full_name_ar' => $sp['full_name_ar'], 'full_name_ar' => $sp['full_name_ar'],
'national_id' => $spNid, 'national_id' => $spNid,
'date_of_birth'=> $spDob ?: '1900-01-01',
'gender' => $spGender,
'marriage_date'=> $sp['marriage_date'] ?? $today,
'join_date' => $today,
'status' => 'active', 'status' => 'active',
'is_archived' => 0, 'is_archived' => 0,
'created_at' => date('Y-m-d H:i:s'), 'created_at' => $ts,
'updated_at' => date('Y-m-d H:i:s'), 'updated_at' => $ts,
]; ]);
if ($spDob) $spouseRow['date_of_birth'] = $spDob;
$db->insert('spouses', $spouseRow);
} }
} }
if (!empty($depsData['children'])) { if (!empty($depsData['children'])) {
$chOrder = 0;
foreach ($depsData['children'] as $ch) { foreach ($depsData['children'] as $ch) {
if (empty($ch['full_name_ar'])) continue; if (empty($ch['full_name_ar'])) continue;
$chNid = $ch['national_id'] ?? null; $chOrder++;
$chDob = $ch['date_of_birth'] ?? null; $chNid = !empty($ch['national_id']) ? (string) $ch['national_id'] : null;
$chDob = !empty($ch['date_of_birth']) ? $ch['date_of_birth'] : null;
$chGender = $ch['gender'] ?? 'male'; $chGender = $ch['gender'] ?? 'male';
if (!$chDob && $chNid && strlen($chNid) === 14) { if (!$chDob && $chNid && strlen($chNid) === 14) {
$chParsed = NationalIdParser::parse($chNid); $chParsed = NationalIdParser::parse($chNid);
...@@ -219,15 +248,16 @@ final class TransferProcessor ...@@ -219,15 +248,16 @@ final class TransferProcessor
} }
$db->insert('children', [ $db->insert('children', [
'member_id' => $newMemberId, 'member_id' => $newMemberId,
'child_order' => $chOrder,
'full_name_ar' => $ch['full_name_ar'], 'full_name_ar' => $ch['full_name_ar'],
'national_id' => $chNid, 'national_id' => $chNid,
'date_of_birth' => $chDob ?? '1900-01-01', 'date_of_birth' => $chDob ?: '1900-01-01',
'gender' => $chGender, 'gender' => $chGender,
'classification' => 'active', 'classification' => 'active',
'status' => 'active', 'status' => 'active',
'is_archived' => 0, 'is_archived' => 0,
'created_at' => date('Y-m-d H:i:s'), 'created_at' => $ts,
'updated_at' => date('Y-m-d H:i:s'), 'updated_at' => $ts,
]); ]);
} }
} }
...@@ -236,14 +266,27 @@ final class TransferProcessor ...@@ -236,14 +266,27 @@ final class TransferProcessor
if (!empty($tempsArr)) { if (!empty($tempsArr)) {
foreach ($tempsArr as $tm) { foreach ($tempsArr as $tm) {
if (empty($tm['full_name_ar'])) continue; if (empty($tm['full_name_ar'])) continue;
$tmNid = !empty($tm['national_id']) ? (string) $tm['national_id'] : null;
$tmDob = null;
$tmGender = 'male';
if ($tmNid && strlen($tmNid) === 14) {
$tmParsed = NationalIdParser::parse($tmNid);
if ($tmParsed['is_valid']) {
$tmDob = $tmParsed['dob'];
$tmGender = $tmParsed['gender'] ?? 'male';
}
}
$db->insert('temporary_members', [ $db->insert('temporary_members', [
'member_id' => $newMemberId, 'member_id' => $newMemberId,
'category' => $tm['category'] ?? 'relative',
'full_name_ar' => $tm['full_name_ar'], 'full_name_ar' => $tm['full_name_ar'],
'national_id' => $tm['national_id'] ?? null, 'national_id' => $tmNid,
'date_of_birth'=> $tmDob ?: '1900-01-01',
'gender' => $tmGender,
'status' => 'active', 'status' => 'active',
'is_archived' => 0, 'is_archived' => 0,
'created_at' => date('Y-m-d H:i:s'), 'created_at' => $ts,
'updated_at' => date('Y-m-d H:i:s'), 'updated_at' => $ts,
]); ]);
} }
} }
......
# Transfers Module — Architecture Map # Transfers Module — Architecture Map
> **Last updated:** 2026-07-21 > **Last updated:** 2026-07-21 (bug fix: date_of_birth / phone_mobile NOT NULL failures in TransferProcessor)
> **Status:** Living document — incrementally updated as new information is discovered > **Status:** Living document — incrementally updated as new information is discovered
--- ---
...@@ -309,12 +309,20 @@ Companion Surcharge: if new owner brings MORE dependents than source had: ...@@ -309,12 +309,20 @@ Companion Surcharge: if new owner brings MORE dependents than source had:
- Companion surcharge uses the SAME tiered logic as adding new spouses/children from scratch - Companion surcharge uses the SAME tiered logic as adding new spouses/children from scratch
- If pricing_configs are missing or expired: falls back to $0.00 membership value - If pricing_configs are missing or expired: falls back to $0.00 membership value
### 11.4 Full Transfer Recipient Data ### 11.4 Required NOT NULL Columns (Common Failure Point)
- `members.date_of_birth` is NOT NULL — children may have empty/0000-00-00 DOB; TransferProcessor sanitizes before insert
- `members.phone_mobile` is NOT NULL — children have no phone column; falls through to source member
- `members.gender` is NOT NULL — defaults to 'male' if not derivable from NID
- `spouses.date_of_birth`, `spouses.marriage_date`, `spouses.join_date` are NOT NULL — use '1900-01-01' / today fallbacks
- `children.date_of_birth`, `children.gender` are NOT NULL — use NID parsing or '1900-01-01'/'male' fallbacks
- `temporary_members.date_of_birth`, `temporary_members.gender`, `temporary_members.category` are NOT NULL — defaults applied
### 11.5 Full Transfer Recipient Data
- Stored as JSON in `notes` field — fragile if JSON is malformed - Stored as JSON in `notes` field — fragile if JSON is malformed
- NID auto-parsing overwrites DOB and gender from form input - NID auto-parsing overwrites DOB and gender from form input
- No duplicate NID check against existing members before completion - No duplicate NID check against existing members before completion
### 11.5 Dependent Migration on Full Transfer ### 11.6 Dependent Migration on Full Transfer
- ALL non-archived spouses, children, and temporary_members are moved to new member via UPDATE - ALL non-archived spouses, children, and temporary_members are moved to new member via UPDATE
- No validation that new member's qualifications match dependent requirements - No validation that new member's qualifications match dependent requirements
- If transfer fails after dependents are moved but before commit: rollback handles it (within transaction) - If transfer fails after dependents are moved but before commit: rollback handles it (within transaction)
......
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