Commit 7b060cb6 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(installments): smart cheque generator continues from existing cheques

- ChequeService: add nextChequeNumberForPlan(planId) — returns next number
  scoped to the specific plan, not globally across all plans
- ChequeController::index(): pass nextChequeNumForPlan to view
- ChequeController::storeBatch(): load existing cheques before validation;
  check against existing numbers for duplicates; guard against exceeding
  requiredCount; coverage check uses existingTotal + batchTotal; only
  enforce full-coverage on the final batch
- cheques.php JS: generator uses REMAINING_COUNT (not amount-math) for count,
  NEXT_NUM_FOR_PLAN for sequence start — correctly continues from cheque 6
  if 5 already exist; preview shows "تكملة من #N" context note
- cheques.php UI: yellow info banner when existing cheques present, showing
  count, remaining, and starting cheque number
Co-Authored-By: 's avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent 0bcd5891
...@@ -48,15 +48,16 @@ class ChequeController extends Controller ...@@ -48,15 +48,16 @@ class ChequeController extends Controller
$planTotal = (float) ($plan['total_with_interest'] ?? 0); $planTotal = (float) ($plan['total_with_interest'] ?? 0);
return $this->view('Installments.Views.cheques', [ return $this->view('Installments.Views.cheques', [
'plan' => $plan, 'plan' => $plan,
'cheques' => $cheques, 'cheques' => $cheques,
'requiredCount' => $requiredCount, 'requiredCount' => $requiredCount,
'uploadedCount' => $uploadedCount, 'uploadedCount' => $uploadedCount,
'remainingCount' => $remainingCount, 'remainingCount' => $remainingCount,
'allSubmitted' => $allSubmitted, 'allSubmitted' => $allSubmitted,
'uploadedTotal' => $uploadedTotal, 'uploadedTotal' => $uploadedTotal,
'planTotal' => $planTotal, 'planTotal' => $planTotal,
'nextChequeNum' => ChequeService::nextChequeNumber(), 'nextChequeNum' => ChequeService::nextChequeNumber(),
'nextChequeNumForPlan'=> ChequeService::nextChequeNumberForPlan((int) $planId),
]); ]);
} }
...@@ -86,7 +87,30 @@ class ChequeController extends Controller ...@@ -86,7 +87,30 @@ class ChequeController extends Controller
return $this->redirect("/installments/{$planId}/cheques")->withError('لم يتم إدخال أي شيكات'); return $this->redirect("/installments/{$planId}/cheques")->withError('لم يتم إدخال أي شيكات');
} }
// Validate each row and compute grand total // Load existing cheques for this plan (to prevent duplicates and compute remaining)
$existingCheques = $db->select(
"SELECT cheque_number, cheque_amount FROM installment_cheques WHERE installment_plan_id = ?",
[(int) $planId]
);
$existingNums = array_map(fn($c) => trim((string) $c['cheque_number']), $existingCheques);
$existingTotal = array_sum(array_column($existingCheques, 'cheque_amount'));
$existingCount = count($existingCheques);
$requiredCount = (int) $plan['number_of_months'];
// Guard: no more cheques needed
if ($existingCount >= $requiredCount) {
return $this->redirect("/installments/{$planId}/cheques")
->withError('تم تسليم جميع الشيكات المطلوبة بالفعل — لا يمكن إضافة المزيد');
}
// Guard: batch would exceed required count
if ($existingCount + count($cheques) > $requiredCount) {
$allowed = $requiredCount - $existingCount;
return $this->redirect("/installments/{$planId}/cheques")
->withError("عدد الشيكات المدخلة ({$allowed} مسموح به) يتجاوز العدد المتبقي — المسموح: {$allowed} شيك");
}
// Validate each row and compute batch total
$errors = []; $errors = [];
$totalEntered = 0.0; $totalEntered = 0.0;
foreach ($cheques as $idx => $chq) { foreach ($cheques as $idx => $chq) {
...@@ -101,12 +125,22 @@ class ChequeController extends Controller ...@@ -101,12 +125,22 @@ class ChequeController extends Controller
if ($amt <= 0) $errors[] = "الشيك #{$n}: المبلغ يجب أن يكون أكبر من صفر"; if ($amt <= 0) $errors[] = "الشيك #{$n}: المبلغ يجب أن يكون أكبر من صفر";
if ($dt === '') $errors[] = "الشيك #{$n}: التاريخ مطلوب"; if ($dt === '') $errors[] = "الشيك #{$n}: التاريخ مطلوب";
// Check against existing cheque numbers in DB
if ($num !== '' && in_array($num, $existingNums, true)) {
$errors[] = "الشيك #{$n}: رقم الشيك \"{$num}\" موجود مسبقاً في هذه الخطة";
}
$totalEntered += $amt; $totalEntered += $amt;
} }
if (empty($errors) && $totalEntered < $planTotal - 0.009) { // Only enforce coverage check if this batch completes all required cheques
$isLastBatch = ($existingCount + count($cheques)) >= $requiredCount;
$grandTotal = $existingTotal + $totalEntered;
if (empty($errors) && $isLastBatch && $grandTotal < $planTotal - 0.009) {
$errors[] = sprintf( $errors[] = sprintf(
'إجمالي قيمة الشيكات (%.2f ج.م) أقل من إجمالي التقسيط (%.2f ج.م) — يجب أن تغطي الشيكات المبلغ كاملاً', 'إجمالي قيمة جميع الشيكات (%.2f ج.م = موجود %.2f + جديد %.2f) أقل من إجمالي التقسيط (%.2f ج.م)',
$grandTotal,
$existingTotal,
$totalEntered, $totalEntered,
$planTotal $planTotal
); );
...@@ -118,7 +152,7 @@ class ChequeController extends Controller ...@@ -118,7 +152,7 @@ class ChequeController extends Controller
return $this->redirect("/installments/{$planId}/cheques"); return $this->redirect("/installments/{$planId}/cheques");
} }
// Check for duplicate cheque numbers within the batch and against DB // Check for duplicate cheque numbers within the batch itself
$batchNums = array_filter(array_map(fn($c) => trim((string) ($c['cheque_number'] ?? '')), $cheques)); $batchNums = array_filter(array_map(fn($c) => trim((string) ($c['cheque_number'] ?? '')), $cheques));
if (count(array_unique($batchNums)) !== count($batchNums)) { if (count(array_unique($batchNums)) !== count($batchNums)) {
return $this->redirect("/installments/{$planId}/cheques") return $this->redirect("/installments/{$planId}/cheques")
......
...@@ -11,8 +11,8 @@ use App\Core\EventBus; ...@@ -11,8 +11,8 @@ use App\Core\EventBus;
final class ChequeService final class ChequeService
{ {
/** /**
* Return the next sequential cheque number by inspecting all existing cheque_number values * Return the next sequential cheque number across ALL plans (global).
* that are purely numeric. Falls back to 1 if none exist. * Used for single-upload suggestions only.
*/ */
public static function nextChequeNumber(): int public static function nextChequeNumber(): int
{ {
...@@ -26,6 +26,22 @@ final class ChequeService ...@@ -26,6 +26,22 @@ final class ChequeService
return (int) ($row['max_num'] ?? 0) + 1; return (int) ($row['max_num'] ?? 0) + 1;
} }
/**
* Return the next sequential cheque number within a specific plan.
* Auto-generator uses this to continue from where the last cheque left off.
*/
public static function nextChequeNumberForPlan(int $planId): int
{
$db = App::getInstance()->db();
$row = $db->selectOne(
"SELECT MAX(CAST(cheque_number AS UNSIGNED)) AS max_num
FROM installment_cheques
WHERE installment_plan_id = ? AND cheque_number REGEXP '^[0-9]+$'",
[$planId]
);
return (int) ($row['max_num'] ?? 0) + 1;
}
/** /**
* Check if all required cheques have been uploaded for an installment plan. * Check if all required cheques have been uploaded for an installment plan.
*/ */
......
...@@ -70,6 +70,14 @@ $coveragePct = $planTotal > 0 ? min(100, round($uploadedTotal / $planTotal * 10 ...@@ -70,6 +70,14 @@ $coveragePct = $planTotal > 0 ? min(100, round($uploadedTotal / $planTotal * 10
<span style="margin-right:auto;font-size:12px;color:#6B7280;">اكتب قيمة الشيك والبنك → الجهاز يولّد العدد المطلوب تلقائياً</span> <span style="margin-right:auto;font-size:12px;color:#6B7280;">اكتب قيمة الشيك والبنك → الجهاز يولّد العدد المطلوب تلقائياً</span>
</div> </div>
<div style="padding:20px;"> <div style="padding:20px;">
<?php if ($uploadedCount > 0): ?>
<div style="padding:10px 14px;background:#FEF9C3;border:1px solid #FCD34D;border-radius:6px;font-size:13px;margin-bottom:14px;">
<strong style="color:#92400E;">⚠️ يوجد <?= $uploadedCount ?> شيك مسجل مسبقاً</strong>
— سيتم توليد الشيكات المتبقية فقط (<strong><?= $remainingCount ?></strong> شيك)
بدءاً من رقم <strong><?= $nextChequeNumForPlan ?? ($uploadedCount + 1) ?></strong>
— لن تُعاد الشيكات الموجودة ولن تُنشأ سجلات مكررة.
</div>
<?php endif; ?>
<!-- Generator inputs --> <!-- Generator inputs -->
<div style="display:grid;grid-template-columns:1fr 1fr 1fr auto;gap:12px;align-items:end;margin-bottom:14px;"> <div style="display:grid;grid-template-columns:1fr 1fr 1fr auto;gap:12px;align-items:end;margin-bottom:14px;">
<div> <div>
...@@ -267,10 +275,13 @@ $coveragePct = $planTotal > 0 ? min(100, round($uploadedTotal / $planTotal * 10 ...@@ -267,10 +275,13 @@ $coveragePct = $planTotal > 0 ? min(100, round($uploadedTotal / $planTotal * 10
<script> <script>
(function () { (function () {
// ── Constants injected from PHP ────────────────────────────────────────── // ── Constants injected from PHP ──────────────────────────────────────────
var PLAN_TOTAL = <?= json_encode(round($planTotal, 2)) ?>; var PLAN_TOTAL = <?= json_encode(round($planTotal, 2)) ?>;
var ALREADY_UPLOADED = <?= json_encode(round($uploadedTotal, 2)) ?>; var ALREADY_UPLOADED = <?= json_encode(round($uploadedTotal, 2)) ?>;
var NEXT_NUM = <?= json_encode($nextNum) ?>; var NEXT_NUM = <?= json_encode($nextNum) ?>; // global (single-upload)
var MONTHLY = <?= json_encode(round($monthlyPay, 2)) ?>; var NEXT_NUM_FOR_PLAN = <?= json_encode($nextChequeNumForPlan ?? ($uploadedCount + 1)) ?>; // plan-specific
var REMAINING_COUNT = <?= json_encode((int) $remainingCount) ?>; // cheques still needed
var ALREADY_COUNT = <?= json_encode((int) $uploadedCount) ?>; // cheques already saved
var MONTHLY = <?= json_encode(round($monthlyPay, 2)) ?>;
var rows = []; // Array of row objects currently in the generated table var rows = []; // Array of row objects currently in the generated table
...@@ -286,44 +297,50 @@ $coveragePct = $planTotal > 0 ? min(100, round($uploadedTotal / $planTotal * 10 ...@@ -286,44 +297,50 @@ $coveragePct = $planTotal > 0 ? min(100, round($uploadedTotal / $planTotal * 10
// ── Preview how many cheques will be generated ───────────────────────── // ── Preview how many cheques will be generated ─────────────────────────
window.genPreview = function () { window.genPreview = function () {
var amt = parseFloat(document.getElementById('gen_amount').value) || 0; var amt = parseFloat(document.getElementById('gen_amount').value) || 0;
var bank = document.getElementById('gen_bank').value.trim(); var box = document.getElementById('genPreviewBox');
var box = document.getElementById('genPreviewBox'); if (!amt || amt <= 0 || REMAINING_COUNT <= 0) { box.style.display='none'; return; }
if (!amt || amt <= 0) { box.style.display='none'; return; }
// Count is driven by REMAINING_COUNT (cheques still needed), not by amount math
var needed = Math.ceil((PLAN_TOTAL - ALREADY_UPLOADED) / amt); var needed = REMAINING_COUNT;
if (needed <= 0) needed = 1; var endNum = NEXT_NUM_FOR_PLAN + needed - 1;
var total = needed * amt; var lastAmt = Math.round((PLAN_TOTAL - ALREADY_UPLOADED - amt * (needed - 1)) * 100) / 100;
var endNum = NEXT_NUM + needed - 1; var total = amt * (needed - 1) + lastAmt;
document.getElementById('genCount').textContent = needed; document.getElementById('genCount').textContent = needed;
document.getElementById('genAmtDisplay').textContent = fmt(amt); document.getElementById('genAmtDisplay').textContent = fmt(amt);
document.getElementById('genTotalDisplay').textContent = fmt(total); document.getElementById('genTotalDisplay').textContent = fmt(total);
document.getElementById('genNumRange').textContent = NEXT_NUM + ' → ' + endNum; document.getElementById('genNumRange').textContent =
(ALREADY_COUNT > 0)
? 'تكملة من #' + NEXT_NUM_FOR_PLAN + ' ← #' + endNum + ' (الموجود: ' + ALREADY_COUNT + ' شيك)'
: '#' + NEXT_NUM_FOR_PLAN + ' ← #' + endNum;
box.style.display = ''; box.style.display = '';
}; };
// ── Generate the editable rows ───────────────────────────────────────── // ── Generate the editable rows ─────────────────────────────────────────
window.generateCheques = function () { window.generateCheques = function () {
var amt = parseFloat(document.getElementById('gen_amount').value) || 0; var amt = parseFloat(document.getElementById('gen_amount').value) || 0;
var bank = document.getElementById('gen_bank').value.trim(); var bank = document.getElementById('gen_bank').value.trim();
var startDate = document.getElementById('gen_start_date').value; var startDate = document.getElementById('gen_start_date').value;
if (!amt || amt <= 0) { alert('أدخل قيمة الشيك أولاً'); return; } if (!amt || amt <= 0) { alert('أدخل قيمة الشيك أولاً'); return; }
if (!bank) { alert('أدخل اسم البنك أولاً'); return; } if (!bank) { alert('أدخل اسم البنك أولاً'); return; }
if (!startDate) { alert('أدخل تاريخ أول شيك'); return; } if (!startDate) { alert('أدخل تاريخ أول شيك'); return; }
if (REMAINING_COUNT <= 0) { alert('تم تسليم جميع الشيكات المطلوبة بالفعل'); return; }
var needed = Math.ceil((PLAN_TOTAL - ALREADY_UPLOADED) / amt); var needed = REMAINING_COUNT;
if (needed <= 0) needed = 1; var remaining = PLAN_TOTAL - ALREADY_UPLOADED;
rows = []; rows = [];
for (var i = 0; i < needed; i++) { for (var i = 0; i < needed; i++) {
var isLast = (i === needed - 1);
rows.push({ rows.push({
cheque_number: String(NEXT_NUM + i), cheque_number: String(NEXT_NUM_FOR_PLAN + i),
bank_name: bank, bank_name: bank,
cheque_date: addMonths(startDate, i), cheque_date: addMonths(startDate, i),
cheque_amount: (i === needed - 1) // Last cheque absorbs any rounding difference
? Math.round((PLAN_TOTAL - ALREADY_UPLOADED - amt * (needed - 1)) * 100) / 100 cheque_amount: isLast
? Math.round((remaining - amt * (needed - 1)) * 100) / 100
: amt, : amt,
notes: '', notes: '',
}); });
......
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