Commit 69f09d80 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(installments): add cheque auto-generator with live validation

- ChequeService: add nextChequeNumber() — sequential from last numeric cheque in DB
- ChequeController: index() passes planTotal, uploadedTotal, nextChequeNum; storeBatch() validates total coverage + dedup + activates member; store() validates total coverage on final cheque
- Routes: add POST /installments/{planId}/cheques/batch
- cheques.php: full rewrite — KPI row, auto-generator panel (JS generates N editable rows from amount+bank+start-date), live total validation bar, submit disabled until total covered, editable-row table with per-row delete, existing cheques table with coverage status, single-upload form retained
Co-Authored-By: 's avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent e93d166b
......@@ -43,6 +43,10 @@ class ChequeController extends Controller
$remainingCount = max(0, $requiredCount - $uploadedCount);
$allSubmitted = $remainingCount === 0;
// Total amount that cheques must cover = total_with_interest (remaining after down payment)
$uploadedTotal = array_sum(array_column($cheques, 'cheque_amount'));
$planTotal = (float) ($plan['total_with_interest'] ?? 0);
return $this->view('Installments.Views.cheques', [
'plan' => $plan,
'cheques' => $cheques,
......@@ -50,8 +54,115 @@ class ChequeController extends Controller
'uploadedCount' => $uploadedCount,
'remainingCount' => $remainingCount,
'allSubmitted' => $allSubmitted,
'uploadedTotal' => $uploadedTotal,
'planTotal' => $planTotal,
'nextChequeNum' => ChequeService::nextChequeNumber(),
]);
}
/**
* Batch-save multiple cheques at once (no scan files required at this stage).
* Validates that the total cheque amount >= plan's total_with_interest.
*/
public function storeBatch(Request $request, string $planId): Response
{
$this->authorize('installment.create_plan');
$db = App::getInstance()->db();
$plan = $db->selectOne(
"SELECT id, member_id, number_of_months, total_with_interest, monthly_payment
FROM installment_plans WHERE id = ? AND status = 'active'",
[(int) $planId]
);
if (!$plan) {
return $this->redirect('/installments')->withError('خطة التقسيط غير موجودة');
}
$cheques = $request->post('cheques', []);
$planTotal = (float) $plan['total_with_interest'];
if (!is_array($cheques) || empty($cheques)) {
return $this->redirect("/installments/{$planId}/cheques")->withError('لم يتم إدخال أي شيكات');
}
// Validate each row and compute grand total
$errors = [];
$totalEntered = 0.0;
foreach ($cheques as $idx => $chq) {
$n = $idx + 1;
$num = trim((string) ($chq['cheque_number'] ?? ''));
$bnk = trim((string) ($chq['bank_name'] ?? ''));
$amt = (float) ($chq['cheque_amount'] ?? 0);
$dt = trim((string) ($chq['cheque_date'] ?? ''));
if ($num === '') $errors[] = "الشيك #{$n}: رقم الشيك مطلوب";
if ($bnk === '') $errors[] = "الشيك #{$n}: اسم البنك مطلوب";
if ($amt <= 0) $errors[] = "الشيك #{$n}: المبلغ يجب أن يكون أكبر من صفر";
if ($dt === '') $errors[] = "الشيك #{$n}: التاريخ مطلوب";
$totalEntered += $amt;
}
if (empty($errors) && $totalEntered < $planTotal - 0.009) {
$errors[] = sprintf(
'إجمالي قيمة الشيكات (%.2f ج.م) أقل من إجمالي التقسيط (%.2f ج.م) — يجب أن تغطي الشيكات المبلغ كاملاً',
$totalEntered,
$planTotal
);
}
if (!empty($errors)) {
$session = App::getInstance()->session();
$session->flash('_alerts', array_map(fn($m) => ['type' => 'error', 'message' => $m], $errors));
return $this->redirect("/installments/{$planId}/cheques");
}
// Check for duplicate cheque numbers within the batch and against DB
$batchNums = array_filter(array_map(fn($c) => trim((string) ($c['cheque_number'] ?? '')), $cheques));
if (count(array_unique($batchNums)) !== count($batchNums)) {
return $this->redirect("/installments/{$planId}/cheques")
->withError('توجد أرقام شيكات مكررة في القائمة — يجب أن يكون كل رقم فريداً');
}
$employeeId = App::getInstance()->session()->get('employee_id');
$ts = date('Y-m-d H:i:s');
$db->beginTransaction();
try {
foreach ($cheques as $chq) {
$db->insert('installment_cheques', [
'installment_plan_id' => (int) $planId,
'cheque_number' => trim((string) ($chq['cheque_number'] ?? '')),
'bank_name' => trim((string) ($chq['bank_name'] ?? '')),
'cheque_date' => trim((string) ($chq['cheque_date'] ?? '')),
'cheque_amount' => (float) ($chq['cheque_amount'] ?? 0),
'scan_path' => '',
'scan_original_name' => null,
'uploaded_by' => $employeeId ? (int) $employeeId : null,
'notes' => trim((string) ($chq['notes'] ?? '')) ?: null,
'created_at' => $ts,
'updated_at' => $ts,
]);
}
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
return $this->redirect("/installments/{$planId}/cheques")->withError('خطأ أثناء الحفظ: ' . $e->getMessage());
}
// Check if count threshold reached → activate member
if (ChequeService::allChequesSubmitted((int) $planId)) {
$result = ChequeService::activateMemberAfterCheques((int) $planId);
if ($result['success'] && !($result['already_active'] ?? false)) {
return $this->redirect("/installments/{$planId}/cheques")
->withSuccess('تم حفظ الشيكات وتفعيل العضوية — رقم العضوية: ' . ($result['membership_number'] ?? ''));
}
}
return $this->redirect("/installments/{$planId}/cheques")
->withSuccess('تم حفظ ' . count($cheques) . ' شيكات بنجاح — الإجمالي: ' . number_format($totalEntered, 2) . ' ج.م');
}
public function store(Request $request, string $planId): Response
{
......@@ -60,7 +171,7 @@ class ChequeController extends Controller
$db = App::getInstance()->db();
$plan = $db->selectOne(
"SELECT id, member_id, number_of_months FROM installment_plans WHERE id = ? AND status = 'active'",
"SELECT id, member_id, number_of_months, total_with_interest FROM installment_plans WHERE id = ? AND status = 'active'",
[(int) $planId]
);
......@@ -118,6 +229,23 @@ class ChequeController extends Controller
}
}
// Total-coverage validation: sum of existing + this cheque must cover plan total
if (empty($errors)) {
$existingTotal = (float) ($db->selectOne(
"SELECT COALESCE(SUM(cheque_amount), 0) as s FROM installment_cheques WHERE installment_plan_id = ?",
[(int) $planId]
)['s'] ?? 0);
$newTotal = $existingTotal + (float) $chequeAmount;
$isLastBatch = ($currentCount + 1) >= (int) $plan['number_of_months'];
if ($isLastBatch && $newTotal < (float) $plan['total_with_interest'] - 0.009) {
$errors[] = sprintf(
'إجمالي قيمة الشيكات بعد الإضافة (%.2f ج.م) أقل من إجمالي التقسيط (%.2f ج.م)',
$newTotal,
(float) $plan['total_with_interest']
);
}
}
if (!empty($errors)) {
$session = App::getInstance()->session();
$alerts = array_map(fn($msg) => ['type' => 'error', 'message' => $msg], $errors);
......
......@@ -12,4 +12,5 @@ return [
// Cheque uploads
['GET', '/installments/{planId}/cheques', 'Installments\Controllers\ChequeController@index', ['auth'], 'installment.view'],
['POST', '/installments/{planId}/cheques', 'Installments\Controllers\ChequeController@store', ['auth', 'csrf'], 'installment.create_plan'],
['POST', '/installments/{planId}/cheques/batch', 'Installments\Controllers\ChequeController@storeBatch', ['auth', 'csrf'], 'installment.create_plan'],
];
\ No newline at end of file
......@@ -10,6 +10,22 @@ use App\Core\EventBus;
final class ChequeService
{
/**
* Return the next sequential cheque number by inspecting all existing cheque_number values
* that are purely numeric. Falls back to 1 if none exist.
*/
public static function nextChequeNumber(): int
{
$db = App::getInstance()->db();
$row = $db->selectOne(
"SELECT MAX(CAST(cheque_number AS UNSIGNED)) AS max_num
FROM installment_cheques
WHERE cheque_number REGEXP '^[0-9]+$'",
[]
);
return (int) ($row['max_num'] ?? 0) + 1;
}
/**
* Check if all required cheques have been uploaded for an installment plan.
*/
......
This diff is collapsed.
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