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
$planTotal = (float) ($plan['total_with_interest'] ?? 0);
return $this->view('Installments.Views.cheques', [
'plan' => $plan,
'cheques' => $cheques,
'requiredCount' => $requiredCount,
'uploadedCount' => $uploadedCount,
'remainingCount' => $remainingCount,
'allSubmitted' => $allSubmitted,
'uploadedTotal' => $uploadedTotal,
'planTotal' => $planTotal,
'nextChequeNum' => ChequeService::nextChequeNumber(),
'plan' => $plan,
'cheques' => $cheques,
'requiredCount' => $requiredCount,
'uploadedCount' => $uploadedCount,
'remainingCount' => $remainingCount,
'allSubmitted' => $allSubmitted,
'uploadedTotal' => $uploadedTotal,
'planTotal' => $planTotal,
'nextChequeNum' => ChequeService::nextChequeNumber(),
'nextChequeNumForPlan'=> ChequeService::nextChequeNumberForPlan((int) $planId),
]);
}
......@@ -86,7 +87,30 @@ class ChequeController extends Controller
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 = [];
$totalEntered = 0.0;
foreach ($cheques as $idx => $chq) {
......@@ -101,12 +125,22 @@ class ChequeController extends Controller
if ($amt <= 0) $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;
}
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(
'إجمالي قيمة الشيكات (%.2f ج.م) أقل من إجمالي التقسيط (%.2f ج.م) — يجب أن تغطي الشيكات المبلغ كاملاً',
'إجمالي قيمة جميع الشيكات (%.2f ج.م = موجود %.2f + جديد %.2f) أقل من إجمالي التقسيط (%.2f ج.م)',
$grandTotal,
$existingTotal,
$totalEntered,
$planTotal
);
......@@ -118,7 +152,7 @@ class ChequeController extends Controller
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));
if (count(array_unique($batchNums)) !== count($batchNums)) {
return $this->redirect("/installments/{$planId}/cheques")
......
......@@ -11,8 +11,8 @@ 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.
* Return the next sequential cheque number across ALL plans (global).
* Used for single-upload suggestions only.
*/
public static function nextChequeNumber(): int
{
......@@ -26,6 +26,22 @@ final class ChequeService
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.
*/
......
......@@ -70,6 +70,14 @@ $coveragePct = $planTotal > 0 ? min(100, round($uploadedTotal / $planTotal * 10
<span style="margin-right:auto;font-size:12px;color:#6B7280;">اكتب قيمة الشيك والبنك → الجهاز يولّد العدد المطلوب تلقائياً</span>
</div>
<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 -->
<div style="display:grid;grid-template-columns:1fr 1fr 1fr auto;gap:12px;align-items:end;margin-bottom:14px;">
<div>
......@@ -267,10 +275,13 @@ $coveragePct = $planTotal > 0 ? min(100, round($uploadedTotal / $planTotal * 10
<script>
(function () {
// ── Constants injected from PHP ──────────────────────────────────────────
var PLAN_TOTAL = <?= json_encode(round($planTotal, 2)) ?>;
var ALREADY_UPLOADED = <?= json_encode(round($uploadedTotal, 2)) ?>;
var NEXT_NUM = <?= json_encode($nextNum) ?>;
var MONTHLY = <?= json_encode(round($monthlyPay, 2)) ?>;
var PLAN_TOTAL = <?= json_encode(round($planTotal, 2)) ?>;
var ALREADY_UPLOADED = <?= json_encode(round($uploadedTotal, 2)) ?>;
var NEXT_NUM = <?= json_encode($nextNum) ?>; // global (single-upload)
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
......@@ -286,44 +297,50 @@ $coveragePct = $planTotal > 0 ? min(100, round($uploadedTotal / $planTotal * 10
// ── Preview how many cheques will be generated ─────────────────────────
window.genPreview = function () {
var amt = parseFloat(document.getElementById('gen_amount').value) || 0;
var bank = document.getElementById('gen_bank').value.trim();
var box = document.getElementById('genPreviewBox');
if (!amt || amt <= 0) { box.style.display='none'; return; }
var needed = Math.ceil((PLAN_TOTAL - ALREADY_UPLOADED) / amt);
if (needed <= 0) needed = 1;
var total = needed * amt;
var endNum = NEXT_NUM + needed - 1;
document.getElementById('genCount').textContent = needed;
document.getElementById('genAmtDisplay').textContent = fmt(amt);
var amt = parseFloat(document.getElementById('gen_amount').value) || 0;
var box = document.getElementById('genPreviewBox');
if (!amt || amt <= 0 || REMAINING_COUNT <= 0) { box.style.display='none'; return; }
// Count is driven by REMAINING_COUNT (cheques still needed), not by amount math
var needed = REMAINING_COUNT;
var endNum = NEXT_NUM_FOR_PLAN + needed - 1;
var lastAmt = Math.round((PLAN_TOTAL - ALREADY_UPLOADED - amt * (needed - 1)) * 100) / 100;
var total = amt * (needed - 1) + lastAmt;
document.getElementById('genCount').textContent = needed;
document.getElementById('genAmtDisplay').textContent = fmt(amt);
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 = '';
};
// ── Generate the editable rows ─────────────────────────────────────────
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 startDate = document.getElementById('gen_start_date').value;
if (!amt || amt <= 0) { alert('أدخل قيمة الشيك أولاً'); return; }
if (!bank) { alert('أدخل اسم البنك أولاً'); return; }
if (!startDate) { alert('أدخل تاريخ أول شيك'); return; }
if (REMAINING_COUNT <= 0) { alert('تم تسليم جميع الشيكات المطلوبة بالفعل'); return; }
var needed = Math.ceil((PLAN_TOTAL - ALREADY_UPLOADED) / amt);
if (needed <= 0) needed = 1;
var needed = REMAINING_COUNT;
var remaining = PLAN_TOTAL - ALREADY_UPLOADED;
rows = [];
for (var i = 0; i < needed; i++) {
var isLast = (i === needed - 1);
rows.push({
cheque_number: String(NEXT_NUM + i),
cheque_number: String(NEXT_NUM_FOR_PLAN + i),
bank_name: bank,
cheque_date: addMonths(startDate, i),
cheque_amount: (i === needed - 1)
? Math.round((PLAN_TOTAL - ALREADY_UPLOADED - amt * (needed - 1)) * 100) / 100
// Last cheque absorbs any rounding difference
cheque_amount: isLast
? Math.round((remaining - amt * (needed - 1)) * 100) / 100
: amt,
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