Commit 1c8ee16d authored by Fares's avatar Fares

feat(transfers): complete child separation with dependents, fix effective date logic

- Fix effective date: use min(max(work_date, graduation_date), date_turned_25) instead of min(work_date, date_turned_25)
- Make graduation_date mandatory when child is employed
- Annual subscription now includes all family members (member + spouses + children + temps)
- Add dependents section to form: user specifies counts and details (name, national_id) for each person joining new membership
- TransferProcessor creates dependent records (spouses, children, temporary_members) from notes JSON on completion
- Migration adds target_spouses_count, target_children_count, target_temps_count columns
- Updated architecture map
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 5a5aba46
...@@ -113,6 +113,9 @@ class TransferController extends Controller ...@@ -113,6 +113,9 @@ class TransferController extends Controller
if (!$workDate) { if (!$workDate) {
return $this->redirect("/transfers/create/{$memberId}")->withError('يجب إدخال تاريخ مباشرة العمل عند اختيار موظف'); return $this->redirect("/transfers/create/{$memberId}")->withError('يجب إدخال تاريخ مباشرة العمل عند اختيار موظف');
} }
if (!$graduationDate) {
return $this->redirect("/transfers/create/{$memberId}")->withError('يجب إدخال تاريخ التخرج عند اختيار موظف');
}
} }
$date25Override = trim($request->post('date_turned_25', '')) ?: null; $date25Override = trim($request->post('date_turned_25', '')) ?: null;
...@@ -125,12 +128,23 @@ class TransferController extends Controller ...@@ -125,12 +128,23 @@ class TransferController extends Controller
$child['date_of_birth'], $child['date_of_birth'],
$isEmployed, $isEmployed,
$workDate, $workDate,
$date25Override $date25Override,
$graduationDate
); );
$dateTurned25 = $dates['date_turned_25']; $dateTurned25 = $dates['date_turned_25'];
$effectiveTransferDate = $dates['effective_transfer_date']; $effectiveTransferDate = $dates['effective_transfer_date'];
} }
// Read dependent counts for new membership (child_separation)
$depSpouses = 0;
$depChildren = 0;
$depTemps = 0;
if ($transferType === 'child_separation') {
$depSpouses = (int) $request->post('dep_spouses_count', 0);
$depChildren = (int) $request->post('dep_children_count', 0);
$depTemps = (int) $request->post('dep_temps_count', 0);
}
// Calculate fees // Calculate fees
$qualCode = null; $qualCode = null;
...@@ -141,7 +155,10 @@ class TransferController extends Controller ...@@ -141,7 +155,10 @@ class TransferController extends Controller
(bool) $isEmployed, (bool) $isEmployed,
$workDate, $workDate,
$effectiveTransferDate, $effectiveTransferDate,
$qualCode $qualCode,
$depSpouses,
$depChildren,
$depTemps
); );
} else { } else {
$feeCalc = SeparationFeeCalculator::calculate((int) $memberId, $childId, $qualCode); $feeCalc = SeparationFeeCalculator::calculate((int) $memberId, $childId, $qualCode);
...@@ -203,12 +220,21 @@ class TransferController extends Controller ...@@ -203,12 +220,21 @@ class TransferController extends Controller
$employee = App::getInstance()->currentEmployee(); $employee = App::getInstance()->currentEmployee();
// Build notes JSON with recipient data if full_transfer // Build notes JSON
$notesJson = null; $notesJson = null;
if ($transferType === 'full_transfer' && $recipientData) { if ($transferType === 'full_transfer' && $recipientData) {
$notesPayload = ['recipient_data' => $recipientData]; $notesPayload = ['recipient_data' => $recipientData];
if ($notes) $notesPayload['user_notes'] = $notes; if ($notes) $notesPayload['user_notes'] = $notes;
$notesJson = json_encode($notesPayload, JSON_UNESCAPED_UNICODE); $notesJson = json_encode($notesPayload, JSON_UNESCAPED_UNICODE);
} elseif ($transferType === 'child_separation' && ($depSpouses > 0 || $depChildren > 0 || $depTemps > 0)) {
$dependentsData = [
'spouses' => $request->post('dep_spouses', []),
'children' => $request->post('dep_children', []),
'temporary_members' => $request->post('dep_temps', []),
];
$notesPayload = ['dependents_data' => $dependentsData];
if ($notes) $notesPayload['user_notes'] = $notes;
$notesJson = json_encode($notesPayload, JSON_UNESCAPED_UNICODE);
} else { } else {
$notesJson = $notes ?: null; $notesJson = $notes ?: null;
} }
...@@ -238,6 +264,9 @@ class TransferController extends Controller ...@@ -238,6 +264,9 @@ class TransferController extends Controller
'work_date' => $workDate, 'work_date' => $workDate,
'date_turned_25' => $dateTurned25, 'date_turned_25' => $dateTurned25,
'effective_transfer_date' => $effectiveTransferDate, 'effective_transfer_date' => $effectiveTransferDate,
'target_spouses_count' => $depSpouses > 0 ? $depSpouses : null,
'target_children_count' => $depChildren > 0 ? $depChildren : null,
'target_temps_count' => $depTemps > 0 ? $depTemps : null,
]); ]);
if (FormBridge::exists('TRANSFER_SEPARATION')) { if (FormBridge::exists('TRANSFER_SEPARATION')) {
...@@ -417,20 +446,28 @@ class TransferController extends Controller ...@@ -417,20 +446,28 @@ class TransferController extends Controller
$isEmployedPost = $request->post('is_employed', ''); $isEmployedPost = $request->post('is_employed', '');
$isEmployed = $isEmployedPost !== '' ? (bool)(int)$isEmployedPost : false; $isEmployed = $isEmployedPost !== '' ? (bool)(int)$isEmployedPost : false;
$workDate = trim($request->post('work_date', '')) ?: null; $workDate = trim($request->post('work_date', '')) ?: null;
$graduationDate = trim($request->post('graduation_date', '')) ?: null;
$date25Override = trim($request->post('date_turned_25', '')) ?: null; $date25Override = trim($request->post('date_turned_25', '')) ?: null;
$depSpouses = (int) $request->post('dep_spouses_count', 0);
$depChildren = (int) $request->post('dep_children_count', 0);
$depTemps = (int) $request->post('dep_temps_count', 0);
if ($child && !empty($child['date_of_birth'])) { if ($child && !empty($child['date_of_birth'])) {
$dates = SeparationFeeCalculator::computeChildSeparationDates( $dates = SeparationFeeCalculator::computeChildSeparationDates(
$child['date_of_birth'], $child['date_of_birth'],
$isEmployed, $isEmployed,
$isEmployed ? $workDate : null, $isEmployed ? $workDate : null,
$date25Override $date25Override,
$isEmployed ? $graduationDate : null
); );
$result = SeparationFeeCalculator::calculateForChildSeparation( $result = SeparationFeeCalculator::calculateForChildSeparation(
$memberId, $childId, $isEmployed, $memberId, $childId, $isEmployed,
$isEmployed ? $workDate : null, $isEmployed ? $workDate : null,
$dates['effective_transfer_date'], $dates['effective_transfer_date'],
$qualCode $qualCode,
$depSpouses,
$depChildren,
$depTemps
); );
$result['date_turned_25'] = $dates['date_turned_25']; $result['date_turned_25'] = $dates['date_turned_25'];
$result['effective_transfer_date'] = $dates['effective_transfer_date']; $result['effective_transfer_date'] = $dates['effective_transfer_date'];
......
...@@ -28,6 +28,7 @@ class TransferRequest extends Model ...@@ -28,6 +28,7 @@ class TransferRequest extends Model
'board_decision_notes', 'approved_by', 'approved_at', 'board_decision_notes', 'approved_by', 'approved_at',
'completed_at', 'status', 'notes', 'completed_at', 'status', 'notes',
'is_employed', 'graduation_date', 'work_date', 'date_turned_25', 'effective_transfer_date', 'is_employed', 'graduation_date', 'work_date', 'date_turned_25', 'effective_transfer_date',
'target_spouses_count', 'target_children_count', 'target_temps_count',
]; ];
public static function getForMember(int $memberId): array public static function getForMember(int $memberId): array
......
...@@ -142,29 +142,34 @@ final class SeparationFeeCalculator ...@@ -142,29 +142,34 @@ final class SeparationFeeCalculator
/** /**
* Calculate the effective transfer date for فصل أبناء: * Calculate the effective transfer date for فصل أبناء:
* - If employed: effective = min(work_date, date_turned_25) * - If employed: effective = min(max(work_date, graduation_date), date_turned_25)
* - If not employed (or work_date is null): effective = date_turned_25 * Both work AND graduation must be completed; the later of the two is when
* both conditions are met. Then compare with date_turned_25 (take earlier).
* - If not employed: effective = date_turned_25
* *
* date_turned_25 defaults to DOB + 25 years but may be overridden by user. * @param string $dobString Child's date_of_birth (YYYY-MM-DD)
*
* @param string $dobString Child's date_of_birth (YYYY-MM-DD)
* @param bool $isEmployed * @param bool $isEmployed
* @param string|null $workDate YYYY-MM-DD, required when employed * @param string|null $workDate YYYY-MM-DD, required when employed
* @param string|null $date25Override User-supplied override for date_turned_25 * @param string|null $date25Override User-supplied override for date_turned_25
* @param string|null $graduationDate YYYY-MM-DD, required when employed
* @return array{date_turned_25: string, effective_transfer_date: string} * @return array{date_turned_25: string, effective_transfer_date: string}
*/ */
public static function computeChildSeparationDates( public static function computeChildSeparationDates(
string $dobString, string $dobString,
bool $isEmployed, bool $isEmployed,
?string $workDate, ?string $workDate,
?string $date25Override = null ?string $date25Override = null,
?string $graduationDate = null
): array { ): array {
$dob = new \DateTime(substr($dobString, 0, 10)); $dob = new \DateTime(substr($dobString, 0, 10));
$dob->modify('+25 years'); $dob->modify('+25 years');
$dateTurned25 = $date25Override ?? $dob->format('Y-m-d'); $dateTurned25 = $date25Override ?? $dob->format('Y-m-d');
if ($isEmployed && $workDate) { if ($isEmployed && $workDate && $graduationDate) {
$effective = min($dateTurned25, $workDate); $conditionMetDate = max($workDate, $graduationDate);
$effective = min($conditionMetDate, $dateTurned25);
} elseif ($isEmployed && $workDate) {
$effective = min($workDate, $dateTurned25);
} else { } else {
$effective = $dateTurned25; $effective = $dateTurned25;
} }
...@@ -185,7 +190,10 @@ final class SeparationFeeCalculator ...@@ -185,7 +190,10 @@ final class SeparationFeeCalculator
bool $isEmployed, bool $isEmployed,
?string $workDate, ?string $workDate,
string $effectiveTransferDate, string $effectiveTransferDate,
?string $qualificationCode = null ?string $qualificationCode = null,
int $depSpouses = 0,
int $depChildren = 0,
int $depTemps = 0
): array { ): array {
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$member = $db->selectOne("SELECT * FROM members WHERE id = ?", [$sourceMemberId]); $member = $db->selectOne("SELECT * FROM members WHERE id = ?", [$sourceMemberId]);
...@@ -242,10 +250,19 @@ final class SeparationFeeCalculator ...@@ -242,10 +250,19 @@ final class SeparationFeeCalculator
$activatedAt = $member['activated_at'] ?? $member['created_at'] ?? date('Y-m-d'); $activatedAt = $member['activated_at'] ?? $member['created_at'] ?? date('Y-m-d');
$annualSubCovered = self::isCurrentYearSubscriptionCovered($activatedAt); $annualSubCovered = self::isCurrentYearSubscriptionCovered($activatedAt);
$annualSub = ServicePrice::getPrice('SVC_ANNUAL_MEMBER', '492.00');
$memberSub = ServicePrice::getPrice('SVC_ANNUAL_MEMBER', '492.00');
$spouseSub = ServicePrice::getPrice('SVC_ANNUAL_SPOUSE', '492.00');
$childSub = ServicePrice::getPrice('SVC_ANNUAL_CHILD', '222.00');
$tempSub = ServicePrice::getPrice('SVC_ANNUAL_TEMP', '222.00');
$devFeeData = RuleEngine::get('DEVELOPMENT_FEE'); $devFeeData = RuleEngine::get('DEVELOPMENT_FEE');
$devFee = $devFeeData['amount'] ?? '35.00'; $devFee = $devFeeData['amount'] ?? '35.00';
$annualSubscriptionFee = $annualSubCovered ? '0.00' : bcadd($annualSub, $devFee, 2);
$familyTotal = $memberSub;
$familyTotal = bcadd($familyTotal, bcmul($spouseSub, (string) $depSpouses, 2), 2);
$familyTotal = bcadd($familyTotal, bcmul($childSub, (string) $depChildren, 2), 2);
$familyTotal = bcadd($familyTotal, bcmul($tempSub, (string) $depTemps, 2), 2);
$annualSubscriptionFee = $annualSubCovered ? '0.00' : bcadd($familyTotal, $devFee, 2);
$totalFee = bcadd(bcadd($separationFee, $formFee, 2), $annualSubscriptionFee, 2); $totalFee = bcadd(bcadd($separationFee, $formFee, 2), $annualSubscriptionFee, 2);
...@@ -266,6 +283,9 @@ final class SeparationFeeCalculator ...@@ -266,6 +283,9 @@ final class SeparationFeeCalculator
'effective_transfer_date' => $effectiveTransferDate, 'effective_transfer_date' => $effectiveTransferDate,
'is_employed' => $isEmployed, 'is_employed' => $isEmployed,
'work_date' => $workDate, 'work_date' => $workDate,
'dep_spouses' => $depSpouses,
'dep_children' => $depChildren,
'dep_temps' => $depTemps,
]; ];
} }
......
...@@ -163,6 +163,60 @@ final class TransferProcessor ...@@ -163,6 +163,60 @@ final class TransferProcessor
], '`id` = ?', [(int) $request['spouse_id']]); ], '`id` = ?', [(int) $request['spouse_id']]);
} }
// Child separation: create dependents specified on the form
if ($request['transfer_type'] === 'child_separation' && !empty($request['notes'])) {
$notesDecoded = json_decode($request['notes'], true) ?: [];
$depsData = $notesDecoded['dependents_data'] ?? [];
if (!empty($depsData['spouses'])) {
foreach ($depsData['spouses'] as $sp) {
if (empty($sp['full_name_ar'])) continue;
$db->insert('spouses', [
'member_id' => $newMemberId,
'full_name_ar' => $sp['full_name_ar'],
'national_id' => $sp['national_id'] ?? null,
'status' => 'active',
'is_archived' => 0,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
}
if (!empty($depsData['children'])) {
foreach ($depsData['children'] as $ch) {
if (empty($ch['full_name_ar'])) continue;
$db->insert('children', [
'member_id' => $newMemberId,
'full_name_ar' => $ch['full_name_ar'],
'national_id' => $ch['national_id'] ?? null,
'gender' => $ch['gender'] ?? 'male',
'classification' => 'active',
'status' => 'active',
'is_archived' => 0,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
}
$tempsArr = $depsData['temps'] ?? $depsData['temporary_members'] ?? [];
if (!empty($tempsArr)) {
foreach ($tempsArr as $tm) {
if (empty($tm['full_name_ar'])) continue;
$db->insert('temporary_members', [
'member_id' => $newMemberId,
'full_name_ar' => $tm['full_name_ar'],
'national_id' => $tm['national_id'] ?? null,
'status' => 'active',
'is_archived' => 0,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
}
}
// Full membership transfer: move all dependents to new member // Full membership transfer: move all dependents to new member
if ($isFullTransfer) { if ($isFullTransfer) {
$db->query("UPDATE spouses SET member_id = ?, updated_at = NOW() WHERE member_id = ? AND is_archived = 0", [$newMemberId, (int) $sourceMember['id']]); $db->query("UPDATE spouses SET member_id = ?, updated_at = NOW() WHERE member_id = ? AND is_archived = 0", [$newMemberId, (int) $sourceMember['id']]);
......
This diff is collapsed.
<?php
declare(strict_types=1);
use App\Core\Database;
return function (Database $db): void {
$cols = [
'target_spouses_count' => "ALTER TABLE transfer_requests ADD COLUMN target_spouses_count INT UNSIGNED NULL DEFAULT NULL AFTER target_companions_count",
'target_children_count' => "ALTER TABLE transfer_requests ADD COLUMN target_children_count INT UNSIGNED NULL DEFAULT NULL AFTER target_spouses_count",
'target_temps_count' => "ALTER TABLE transfer_requests ADD COLUMN target_temps_count INT UNSIGNED NULL DEFAULT NULL AFTER target_children_count",
];
foreach ($cols as $col => $sql) {
$exists = $db->selectOne(
"SELECT 1 FROM information_schema.COLUMNS WHERE table_schema = DATABASE() AND table_name = 'transfer_requests' AND column_name = ?",
[$col]
);
if (!$exists) {
$db->raw($sql);
}
}
};
# Transfers Module — Architecture Map # Transfers Module — Architecture Map
> **Last updated:** 2026-06-10 > **Last updated:** 2026-07-21
> **Status:** Living document — incrementally updated as new information is discovered > **Status:** Living document — incrementally updated as new information is discovered
--- ---
...@@ -83,7 +83,15 @@ app/Modules/Transfers/ ...@@ -83,7 +83,15 @@ app/Modules/Transfers/
| approved_at | timestamp | YES | | | | | approved_at | timestamp | YES | | | |
| completed_at | timestamp | YES | | | | | completed_at | timestamp | YES | | | |
| status | varchar(50) | NO | MUL | requested | requested/approved/fee_paid/completed/rejected | | status | varchar(50) | NO | MUL | requested | requested/approved/fee_paid/completed/rejected |
| notes | text | YES | | | JSON (full_transfer stores recipient_data here) | | is_employed | tinyint(1) | YES | | | Whether child is employed (child_separation) |
| graduation_date | date | YES | | | Graduation date (required when employed) |
| work_date | date | YES | | | Employment start date |
| date_turned_25 | date | YES | | | Calculated: child's DOB + 25 years |
| effective_transfer_date | date | YES | | | Computed: min(max(work,grad), date25) |
| target_spouses_count | int unsigned | YES | | | Dependents joining new membership (spouses) |
| target_children_count | int unsigned | YES | | | Dependents joining new membership (children) |
| target_temps_count | int unsigned | YES | | | Dependents joining new membership (temps) |
| notes | text | YES | | | JSON: full_transfer stores recipient_data; child_separation stores dependents_data |
| created_at | timestamp | NO | | CURRENT_TIMESTAMP | | | created_at | timestamp | NO | | CURRENT_TIMESTAMP | |
| updated_at | timestamp | NO | | auto-update | | | updated_at | timestamp | NO | | auto-update | |
| created_by | bigint unsigned | YES | | | FK to employees | | created_by | bigint unsigned | YES | | | FK to employees |
...@@ -110,16 +118,23 @@ requested → approved → fee_paid → completed ...@@ -110,16 +118,23 @@ requested → approved → fee_paid → completed
## 5. Core Business Flows ## 5. Core Business Flows
### 5.1 Child Separation Flow ### 5.1 Child Separation Flow (فصل أبناء)
``` ```
1. GET /transfers/create/{memberId} 1. GET /transfers/create/{memberId}
- Show active children and spouses for selection - Show active children (filtered >= 25 for child_separation) and spouses
- Show employment status, graduation_date, work_date fields
- Show dependents section (spouses/children/temps joining new membership)
2. POST /transfers/store/{memberId} 2. POST /transfers/store/{memberId}
- Validate: transfer_type, child_id required for child types - Validate: transfer_type, child_id, is_employed
- Age check: child must be >= CHILD_MANDATORY_SEPARATION_AGE (25) - If employed: graduation_date AND work_date required
- Calculate fees via SeparationFeeCalculator::calculate() - Compute effective_transfer_date = min(max(work_date, graduation_date), date_turned_25)
- Read dep_spouses_count, dep_children_count, dep_temps_count
- Calculate fees via SeparationFeeCalculator::calculateForChildSeparation()
(annual subscription includes member + specified dependents)
- Collect dependent details (name, national_id) from form arrays
- Store in notes JSON as dependents_data
- Create TransferRequest record (status='requested') - Create TransferRequest record (status='requested')
- Submit to FormBridge (TRANSFER_SEPARATION form) - Submit to FormBridge (TRANSFER_SEPARATION form)
- Dispatch: transfer.requested - Dispatch: transfer.requested
...@@ -139,9 +154,10 @@ requested → approved → fee_paid → completed ...@@ -139,9 +154,10 @@ requested → approved → fee_paid → completed
a. Take archive snapshot of source member a. Take archive snapshot of source member
b. Archive source member (membership_number=NULL, status='transferred', is_archived=1) b. Archive source member (membership_number=NULL, status='transferred', is_archived=1)
c. Create new member with SAME membership_number c. Create new member with SAME membership_number
d. Mark child as 'separated' / move dependents for full_transfer d. Mark child as 'separated'
e. Record number chain via ArchiveService e. Create dependents from notes.dependents_data (spouses, children, temps)
f. Status → 'completed' f. Record number chain via ArchiveService
g. Status → 'completed'
- Dispatch: transfer.completed - Dispatch: transfer.completed
``` ```
...@@ -166,7 +182,11 @@ Separation Fee = Current Membership Value × Fee Percentage (by years) ...@@ -166,7 +182,11 @@ Separation Fee = Current Membership Value × Fee Percentage (by years)
Year 6+: SEPARATION_FEE_YEAR_6_PLUS Year 6+: SEPARATION_FEE_YEAR_6_PLUS
Form Fee = FORM_TRANSFER_FEE rule (default 570 EGP) Form Fee = FORM_TRANSFER_FEE rule (default 570 EGP)
Annual Subscription = SVC_ANNUAL_MEMBER + DEVELOPMENT_FEE (default 492 + 35 = 527 EGP) Annual Subscription (child_separation with dependents):
= SVC_ANNUAL_MEMBER + (spouses × SVC_ANNUAL_SPOUSE) + (children × SVC_ANNUAL_CHILD)
+ (temps × SVC_ANNUAL_TEMP) + DEVELOPMENT_FEE (once per family)
If activated after July 1 of current FY → 0 (already covered by membership fee)
Annual Subscription (legacy/other types) = SVC_ANNUAL_MEMBER + DEVELOPMENT_FEE (492 + 35 = 527 EGP)
Companion Surcharge: if new owner brings MORE dependents than source had: Companion Surcharge: if new owner brings MORE dependents than source had:
- Extra spouses: tiered (SPOUSE_2ND_FEE, SPOUSE_3RD_FEE, SPOUSE_4TH_FEE) - Extra spouses: tiered (SPOUSE_2ND_FEE, SPOUSE_3RD_FEE, SPOUSE_4TH_FEE)
...@@ -259,6 +279,9 @@ Companion Surcharge: if new owner brings MORE dependents than source had: ...@@ -259,6 +279,9 @@ Companion Surcharge: if new owner brings MORE dependents than source had:
| Service Catalog Codes | Purpose | | Service Catalog Codes | Purpose |
|----------------------|---------| |----------------------|---------|
| SVC_ANNUAL_MEMBER | Annual member subscription base (492) | | SVC_ANNUAL_MEMBER | Annual member subscription base (492) |
| SVC_ANNUAL_SPOUSE | Annual spouse subscription (492) |
| SVC_ANNUAL_CHILD | Annual child subscription (222) |
| SVC_ANNUAL_TEMP | Annual temporary member subscription (222) |
| SVC_ADDITION_FORM | Addition form fee fallback (570) | | SVC_ADDITION_FORM | Addition form fee fallback (570) |
--- ---
......
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