Commit 46b84589 authored by DevPilot's avatar DevPilot

feat(accounting): شاشة متابعة خطابات الضمان — سجل تجديدات كامل + تنبيه 45 يوم

التجديد كان بيكتب فوق تاريخ الانتهاء القديم ويسيب renewal_date واحد، يعني
تاريخ الخطاب بيضيع: مش عارف اتجدّد كام مرة ولا التواريخ السابقة كانت إيه.

المطلوب اتنفّذ بالكامل:
- جدول letter_of_guarantee_renewals: كل تجديد صف مستقل فيه رقم التجديد
  وتاريخه وتاريخ الانتهاء السابق والجديد وعمولة التجديد ومين سجّله.
- الخطاب نفسه ما بيتغيّرش: نفس الرقم ونفس المورد ونفس المشروع. اللي
  بيتحدّث هو تاريخ الانتهاء الحالي وعدّاد التجديدات بس.
- أعمدة جديدة: supplier_id (المورد/الشركة)، purpose (الغرض)،
  project_name (المشروع)، renewal_count، original_expiry_date.
- الحالة بقت محسوبة من تاريخ الانتهاء الحالي مش متخزّنة: ساري / قريب
  الانتهاء / منتهي / مُجدد — فـ«قريب الانتهاء» بتتحدّث لوحدها، ولو الخطاب
  اتجدّد التنبيه بيتقاس على التاريخ الجديد تلقائياً.
- تنبيه قبل الانتهاء بـ45 يوم: بانر في شاشة الخطابات (وكمان بانر أحمر
  للخطابات اللي فات ميعادها ولسه مفتوحة)، وقاعدة في محرك التنبيهات
  (guarantee_expiry) عشان تطلع في الإشعارات كمان.
- updateStatus بقى يرفض حالة 'renewed' ويوجّه لزر التجديد، عشان محدش
  يغيّر تاريخ الانتهاء من غير ما يتسجّل في السجل.

وباجّات كانت في نفس الشاشة:
- فلاتر الحالة والنوع كانت بقيم مش موجودة في الـ ENUM أصلاً
  (claimed/cancelled/bid_bond/performance_bond/retention) — يعني الفلترة
  ما كانتش بترجّع حاجة أبداً. اتظبطت على القيم الحقيقية.
- عمود البنك كان بيقرا issuing_bank والكويري بترجّع bank_name، فكان
  بيطلع «—» دايماً.
- زر «خطاب ضمان جديد» كان بيتحكم بصلاحية accounting.instruments.manage
  بينما الـ route محتاج accounting.guarantee.manage.

الـ migration اتطبّقت على قاعدة البيانات الحية واتسجّلت (batch 130).
parent fa934c04
......@@ -7,6 +7,7 @@ use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Accounting\Services\GuaranteeRenewalService;
class LetterOfGuaranteeController extends Controller
{
......@@ -30,18 +31,29 @@ class LetterOfGuaranteeController extends Controller
}
$guarantees = $db->select(
"SELECT lg.*, ba.account_name_ar as bank_name
"SELECT lg.*, ba.account_name_ar as bank_name, s.name_ar AS supplier_name
FROM letters_of_guarantee lg
LEFT JOIN bank_accounts ba ON ba.id = lg.bank_account_id
LEFT JOIN suppliers s ON s.id = lg.supplier_id
WHERE {$where}
ORDER BY lg.expiry_date ASC",
$params
);
// «قريب الانتهاء» محسوبة من التاريخ الحالي، فلازم نفلتر بيها بعد الاستعلام
if ($status === 'expiring_soon') {
$guarantees = array_values(array_filter(
$guarantees,
static fn ($g) => GuaranteeRenewalService::displayStatus($g)['code'] === 'expiring_soon'
));
}
return $this->view('Accounting.Views.guarantees.index', [
'guarantees' => $guarantees,
'status' => $status,
'type' => $type,
'guarantees' => $guarantees,
'status' => $status,
'type' => $type,
'expiringSoon' => GuaranteeRenewalService::expiringSoon(),
'overdue' => GuaranteeRenewalService::overdue(),
]);
}
......@@ -51,10 +63,12 @@ class LetterOfGuaranteeController extends Controller
$db = App::getInstance()->db();
$bankAccounts = $db->select("SELECT id, account_name_ar FROM bank_accounts WHERE is_active = 1 ORDER BY account_name_ar");
$suppliers = $db->select("SELECT id, name_ar FROM suppliers WHERE is_archived = 0 ORDER BY name_ar");
return $this->view('Accounting.Views.guarantees.form', [
'guarantee' => null,
'guarantee' => null,
'bankAccounts' => $bankAccounts,
'suppliers' => $suppliers,
]);
}
......@@ -87,6 +101,10 @@ class LetterOfGuaranteeController extends Controller
'expiry_date' => $request->postDate('expiry_date'),
'status' => 'issued',
'related_contract' => $request->post('related_contract'),
'supplier_id' => $request->post('supplier_id') ? (int) $request->post('supplier_id') : null,
'purpose' => $request->post('purpose') ?: null,
'project_name' => $request->post('project_name') ?: null,
'original_expiry_date' => $request->postDate('expiry_date'),
'notes' => $request->post('notes'),
'created_by' => $employee ? (int) $employee->id : null,
]);
......@@ -100,9 +118,10 @@ class LetterOfGuaranteeController extends Controller
$db = App::getInstance()->db();
$guarantee = $db->selectOne(
"SELECT lg.*, ba.account_name_ar as bank_name
"SELECT lg.*, ba.account_name_ar as bank_name, s.name_ar AS supplier_name
FROM letters_of_guarantee lg
LEFT JOIN bank_accounts ba ON ba.id = lg.bank_account_id
LEFT JOIN suppliers s ON s.id = lg.supplier_id
WHERE lg.id = ?",
[(int) $id]
);
......@@ -111,7 +130,11 @@ class LetterOfGuaranteeController extends Controller
return $this->redirect('/accounting/guarantees')->withError('خطاب الضمان غير موجود');
}
return $this->view('Accounting.Views.guarantees.show', ['guarantee' => $guarantee]);
return $this->view('Accounting.Views.guarantees.show', [
'guarantee' => $guarantee,
'renewals' => GuaranteeRenewalService::history((int) $id),
'display' => GuaranteeRenewalService::displayStatus($guarantee),
]);
}
public function updateStatus(Request $request, string $id): Response
......@@ -119,16 +142,51 @@ class LetterOfGuaranteeController extends Controller
$this->authorize('accounting.guarantee.manage');
$db = App::getInstance()->db();
$newStatus = $request->post('status');
$updateData = ['status' => $newStatus];
$newStatus = (string) $request->post('status');
// التجديد له مسار مستقل عشان يتسجّل في السجل — مينفعش يعدّي من هنا
// ويكتب فوق تاريخ الانتهاء من غير ما يتسجّل التاريخ القديم.
if ($newStatus === 'renewed') {
$updateData['renewal_date'] = date('Y-m-d');
$updateData['expiry_date'] = $request->postDate('new_expiry_date');
return $this->redirect('/accounting/guarantees/' . (int) $id)
->withError('استخدم زر «تجديد الخطاب» عشان التجديد يتسجّل في سجل التجديدات');
}
$valid = ['requested', 'issued', 'active', 'released', 'called', 'expired'];
if (!in_array($newStatus, $valid, true)) {
return $this->redirect('/accounting/guarantees/' . (int) $id)->withError('حالة غير صالحة');
}
$db->update('letters_of_guarantee', $updateData, 'id = ?', [(int) $id]);
$db->update('letters_of_guarantee', [
'status' => $newStatus,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]);
return $this->redirect('/accounting/guarantees/' . (int) $id)->withSuccess('تم تحديث حالة خطاب الضمان');
}
/**
* تجديد خطاب الضمان — بيسجّل التجديد في السجل وبيحدّث تاريخ الانتهاء
* الحالي، من غير ما يمسح أي بيانات سابقة.
*/
public function renew(Request $request, string $id): Response
{
$this->authorize('accounting.guarantee.manage');
$employee = App::getInstance()->currentEmployee();
$result = GuaranteeRenewalService::renew((int) $id, (string) $request->post('new_expiry_date', ''), [
'renewal_date' => $request->post('renewal_date') ?: date('Y-m-d'),
'commission_amount' => (string) $request->post('commission_amount', '0'),
'notes' => $request->post('notes') ?: null,
'employee_id' => $employee ? (int) $employee->id : null,
]);
if (!($result['success'] ?? false)) {
return $this->redirect('/accounting/guarantees/' . (int) $id)->withError($result['error']);
}
return $this->redirect('/accounting/guarantees/' . $id)->withSuccess('تم تحديث حالة خطاب الضمان');
return $this->redirect('/accounting/guarantees/' . (int) $id)->withSuccess(
'تم تسجيل التجديد رقم ' . $result['renewal_no'] .
' — تاريخ الانتهاء اتغيّر من ' . $result['previous'] . ' إلى ' . $result['new']
);
}
}
......@@ -256,4 +256,5 @@ return [
['POST', '/accounting/guarantees', 'Accounting\Controllers\LetterOfGuaranteeController@store', ['auth', 'csrf'], 'accounting.guarantee.manage'],
['GET', '/accounting/guarantees/{id:\d+}', 'Accounting\Controllers\LetterOfGuaranteeController@show', ['auth'], 'accounting.guarantee.view'],
['POST', '/accounting/guarantees/{id:\d+}/status', 'Accounting\Controllers\LetterOfGuaranteeController@updateStatus', ['auth', 'csrf'], 'accounting.guarantee.manage'],
['POST', '/accounting/guarantees/{id:\d+}/renew', 'Accounting\Controllers\LetterOfGuaranteeController@renew', ['auth', 'csrf'], 'accounting.guarantee.manage'],
];
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services;
use App\Core\App;
use App\Core\Logger;
/**
* دورة حياة خطاب الضمان: التجديد وسجله والتنبيه قبل الانتهاء.
*
* قاعدة أساسية: التجديد ما بيمسحش ولا بيستبدل بيانات الخطاب. الخطاب يفضل
* هو هو بنفس الرقم والمورد والمشروع، وكل تجديد بيتسجّل كصف في سجل
* التجديدات بتاريخه والانتهاء السابق والجديد. تاريخ الانتهاء الجديد هو
* اللي بيبقى «الحالي» واللي التنبيهات بتتحسب عليه.
*/
final class GuaranteeRenewalService
{
/** التنبيه بيظهر قبل تاريخ الانتهاء الحالي بالمدة دي. */
public const ALERT_DAYS_BEFORE = 45;
/**
* الحالة المعروضة — محسوبة من تاريخ الانتهاء الحالي مش متخزّنة،
* عشان «قريب الانتهاء» تتحدّث لوحدها من غير ما حد يشغّل حاجة.
*
* @return array{code:string,label:string,color:string,days:?int}
*/
public static function displayStatus(array $g): array
{
$stored = (string) ($g['status'] ?? '');
// الحالات النهائية بتتعرض زي ما هي — مفيش «قريب انتهاء» لخطاب مفرج عنه
$terminal = [
'released' => ['مُفرج عنه', '#6B7280'],
'called' => ['تم تسييله', '#DC2626'],
'expired' => ['منتهي', '#991B1B'],
];
if (isset($terminal[$stored])) {
return ['code' => $stored, 'label' => $terminal[$stored][0], 'color' => $terminal[$stored][1], 'days' => null];
}
$expiry = $g['expiry_date'] ?? null;
if (!$expiry) {
return ['code' => $stored ?: 'issued', 'label' => 'بدون تاريخ انتهاء', 'color' => '#6B7280', 'days' => null];
}
$today = new \DateTimeImmutable('today');
$exp = new \DateTimeImmutable((string) $expiry);
$days = (int) $today->diff($exp)->format('%r%a');
if ($days < 0) {
return ['code' => 'expired', 'label' => 'منتهي', 'color' => '#991B1B', 'days' => $days];
}
if ($days <= self::ALERT_DAYS_BEFORE) {
return ['code' => 'expiring_soon', 'label' => 'قريب الانتهاء', 'color' => '#D97706', 'days' => $days];
}
if ((int) ($g['renewal_count'] ?? 0) > 0) {
return ['code' => 'renewed', 'label' => 'مُجدد — ساري', 'color' => '#7C3AED', 'days' => $days];
}
return ['code' => 'active', 'label' => 'ساري', 'color' => '#059669', 'days' => $days];
}
/** سجل تجديدات خطاب معيّن، مرتّب من الأقدم للأحدث. */
public static function history(int $guaranteeId): array
{
return App::getInstance()->db()->select(
"SELECT r.*, e.full_name_ar AS created_by_name
FROM letter_of_guarantee_renewals r
LEFT JOIN employees e ON e.id = r.created_by
WHERE r.guarantee_id = ?
ORDER BY r.renewal_no ASC",
[$guaranteeId]
);
}
/**
* تجديد خطاب ضمان.
*
* بيسجّل صف في سجل التجديدات وبيحدّث تاريخ الانتهاء الحالي وعدّاد
* التجديدات — كله في transaction واحدة عشان ما يحصلش سجل من غير
* تحديث أو العكس.
*/
public static function renew(int $guaranteeId, string $newExpiry, array $options = []): array
{
$db = App::getInstance()->db();
$g = $db->selectOne("SELECT * FROM letters_of_guarantee WHERE id = ?", [$guaranteeId]);
if (!$g) {
return ['success' => false, 'error' => 'خطاب الضمان غير موجود'];
}
if (in_array((string) $g['status'], ['released', 'called'], true)) {
return ['success' => false, 'error' => 'لا يمكن تجديد خطاب مُفرج عنه أو تم تسييله'];
}
$newExpiry = trim($newExpiry);
if ($newExpiry === '' || !strtotime($newExpiry)) {
return ['success' => false, 'error' => 'تاريخ الانتهاء الجديد مطلوب'];
}
$prevExpiry = (string) ($g['expiry_date'] ?? '');
if ($prevExpiry !== '' && strtotime($newExpiry) <= strtotime($prevExpiry)) {
return ['success' => false, 'error' => 'تاريخ الانتهاء الجديد لازم يكون بعد التاريخ الحالي (' . $prevExpiry . ')'];
}
$renewalDate = $options['renewal_date'] ?? date('Y-m-d');
if (!strtotime((string) $renewalDate)) {
$renewalDate = date('Y-m-d');
}
$commission = (string) ($options['commission_amount'] ?? '0');
if (bccomp($commission, '0', 2) < 0) {
return ['success' => false, 'error' => 'عمولة التجديد لا يمكن أن تكون سالبة'];
}
$nextNo = (int) ($g['renewal_count'] ?? 0) + 1;
$now = date('Y-m-d H:i:s');
$db->beginTransaction();
try {
$db->insert('letter_of_guarantee_renewals', [
'guarantee_id' => $guaranteeId,
'renewal_no' => $nextNo,
'renewal_date' => $renewalDate,
'previous_expiry_date' => $prevExpiry ?: $renewalDate,
'new_expiry_date' => $newExpiry,
'commission_amount' => $commission,
'notes' => $options['notes'] ?? null,
'created_by' => $options['employee_id'] ?? null,
'created_at' => $now,
]);
$db->update('letters_of_guarantee', [
'expiry_date' => $newExpiry, // ده بقى تاريخ الانتهاء الحالي
'renewal_date' => $renewalDate,
'renewal_count' => $nextNo,
'status' => 'renewed',
'updated_at' => $now,
], '`id` = ?', [$guaranteeId]);
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
Logger::error('GuaranteeRenewalService::renew failed: ' . $e->getMessage());
return ['success' => false, 'error' => 'فشل تسجيل التجديد: ' . $e->getMessage()];
}
Logger::info("GuaranteeRenewalService: guarantee #{$guaranteeId} renewed (#{$nextNo}) {$prevExpiry} -> {$newExpiry}");
return [
'success' => true,
'renewal_no' => $nextNo,
'previous' => $prevExpiry,
'new' => $newExpiry,
];
}
/**
* الخطابات اللي قرب انتهاؤها — بتتحسب دايماً على تاريخ الانتهاء الحالي،
* يعني لو الخطاب اتجدّد بيتقاس على التاريخ الجديد تلقائياً.
*/
public static function expiringSoon(int $days = self::ALERT_DAYS_BEFORE): array
{
return App::getInstance()->db()->select(
"SELECT lg.*, ba.account_name_ar AS bank_name, s.name_ar AS supplier_name,
DATEDIFF(lg.expiry_date, CURDATE()) AS days_left
FROM letters_of_guarantee lg
LEFT JOIN bank_accounts ba ON ba.id = lg.bank_account_id
LEFT JOIN suppliers s ON s.id = lg.supplier_id
WHERE lg.expiry_date IS NOT NULL
AND lg.status NOT IN ('released','called','expired')
AND lg.expiry_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ? DAY)
ORDER BY lg.expiry_date ASC",
[$days]
);
}
/** خطابات فات تاريخ انتهائها ولسه محدش قفلها. */
public static function overdue(): array
{
return App::getInstance()->db()->select(
"SELECT lg.*, s.name_ar AS supplier_name,
DATEDIFF(CURDATE(), lg.expiry_date) AS days_over
FROM letters_of_guarantee lg
LEFT JOIN suppliers s ON s.id = lg.supplier_id
WHERE lg.expiry_date IS NOT NULL
AND lg.status NOT IN ('released','called','expired')
AND lg.expiry_date < CURDATE()
ORDER BY lg.expiry_date ASC"
);
}
}
......@@ -47,6 +47,26 @@
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:15px;margin-bottom:15px;">
<div>
<label class="form-label">المورد / الشركة</label>
<select name="supplier_id" class="form-select">
<option value="">— غير محدد —</option>
<?php foreach (($suppliers ?? []) as $sup): ?>
<option value="<?= (int) $sup['id'] ?>" <?= ((int) ($guarantee['supplier_id'] ?? 0)) === (int) $sup['id'] ? 'selected' : '' ?>><?= e($sup['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label">المشروع المرتبط</label>
<input type="text" name="project_name" value="<?= e($guarantee['project_name'] ?? '') ?>" class="form-control" placeholder="اسم المشروع">
</div>
<div>
<label class="form-label">الغرض من الخطاب</label>
<input type="text" name="purpose" value="<?= e($guarantee['purpose'] ?? '') ?>" class="form-control" placeholder="الغرض">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:15px;margin-bottom:15px;">
<div>
<label class="form-label">المبلغ</label>
......
......@@ -31,8 +31,10 @@ $types = [
'other' => 'أخرى',
];
$st = $guarantee['status'] ?? '';
$statusLabel = $statuses[$st] ?? ($st ?: '—');
$statusColor = $statusColors[$st] ?? '#374151';
$disp = $display ?? ['label' => ($statuses[$st] ?? ($st ?: '—')), 'color' => ($statusColors[$st] ?? '#374151'), 'days' => null, 'code' => $st];
$statusLabel = $disp['label'];
$statusColor = $disp['color'];
$daysLeft = $disp['days'];
$currency = $guarantee['currency_code'] ?? 'EGP';
$expired = !empty($guarantee['expiry_date']) && strtotime($guarantee['expiry_date']) < strtotime('today')
&& !in_array($st, ['released', 'called', 'expired'], true);
......@@ -41,7 +43,9 @@ $expired = !empty($guarantee['expiry_date']) && strtotime($guarantee['expiry_dat
<div class="card" style="margin-bottom:15px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px;">
<h3 style="margin:0;">خطاب ضمان رقم: <span style="direction:ltr;display:inline-block;"><?= e($guarantee['guarantee_number'] ?? '') ?></span></h3>
<span style="background:<?= $statusColor ?>1A;color:<?= $statusColor ?>;font-weight:700;font-size:13px;padding:5px 14px;border-radius:20px;"><?= e($statusLabel) ?></span>
<span style="background:<?= $statusColor ?>1A;color:<?= $statusColor ?>;font-weight:700;font-size:13px;padding:5px 14px;border-radius:20px;">
<?= e($statusLabel) ?><?php if ($daysLeft !== null && $daysLeft >= 0): ?> — باقي <?= (int) $daysLeft ?> يوم<?php endif; ?>
</span>
</div>
<div style="padding:20px;">
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:15px;">
......@@ -70,6 +74,28 @@ $expired = !empty($guarantee['expiry_date']) && strtotime($guarantee['expiry_dat
<span style="font-size:12px;color:#6B7280;">المستفيد:</span>
<span style="font-weight:600;"><?= e($guarantee['beneficiary_name'] ?? '—') ?></span>
</div>
<div>
<span style="font-size:12px;color:#6B7280;">المورد / الشركة:</span>
<span style="font-weight:600;"><?= e($guarantee['supplier_name'] ?? '—') ?></span>
</div>
<div>
<span style="font-size:12px;color:#6B7280;">المشروع:</span>
<span style="font-weight:600;"><?= e($guarantee['project_name'] ?? '—') ?></span>
</div>
<div>
<span style="font-size:12px;color:#6B7280;">الغرض:</span>
<span style="font-weight:600;"><?= e($guarantee['purpose'] ?? '—') ?></span>
</div>
<div>
<span style="font-size:12px;color:#6B7280;">عدد مرات التجديد:</span>
<span style="font-weight:600;"><?= (int) ($guarantee['renewal_count'] ?? 0) ?></span>
</div>
<?php if (!empty($guarantee['original_expiry_date']) && (int) ($guarantee['renewal_count'] ?? 0) > 0): ?>
<div>
<span style="font-size:12px;color:#6B7280;">الانتهاء الأصلي:</span>
<span style="font-weight:600;direction:ltr;display:inline-block;"><?= e($guarantee['original_expiry_date']) ?></span>
</div>
<?php endif; ?>
<div>
<span style="font-size:12px;color:#6B7280;">نوع الخطاب:</span>
<span style="font-weight:600;"><?= e($types[$guarantee['guarantee_type'] ?? ''] ?? ($guarantee['guarantee_type'] ?? '—')) ?></span>
......@@ -117,34 +143,97 @@ $expired = !empty($guarantee['expiry_date']) && strtotime($guarantee['expiry_dat
<label class="form-label" style="font-size:12px;">الحالة الجديدة</label>
<select name="status" id="lgStatus" class="form-select" required>
<?php foreach ($statuses as $val => $label): ?>
<?php if ($val === 'renewed') continue; // التجديد له زرّه الخاص ?>
<option value="<?= e($val) ?>" <?= $st === $val ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div id="lgRenewalWrap" style="min-width:220px;display:none;">
<button type="submit" class="btn btn-primary">حفظ الحالة</button>
</form>
</div>
<!-- ══ تجديد الخطاب ══ -->
<div class="card" style="margin-bottom:15px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;font-size:15px;">تجديد الخطاب</h3>
<p style="margin:6px 0 0;color:#6B7280;font-size:12.5px;line-height:1.8;">
التجديد بيسجّل تاريخ الانتهاء الجديد ويحتفظ بالقديم في سجل التجديدات تحت.
بيانات الخطاب والمورد والمشروع بتفضل زي ما هي.
</p>
</div>
<form method="POST" action="/accounting/guarantees/<?= (int) $guarantee['id'] ?>/renew"
style="padding:20px;display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:14px;align-items:end;">
<?= csrf_field() ?>
<div>
<label class="form-label" style="font-size:12px;">تاريخ الانتهاء الحالي</label>
<input type="text" class="form-input" value="<?= e($guarantee['expiry_date'] ?? '—') ?>" readonly
style="background:#F9FAFB;direction:ltr;text-align:left;">
</div>
<div>
<label class="form-label" style="font-size:12px;">تاريخ الانتهاء الجديد <span style="color:#DC2626;">*</span></label>
<input type="date" name="new_expiry_date" id="lgNewExpiry" class="form-input">
<input type="date" name="new_expiry_date" class="form-input" required
min="<?= e(date('Y-m-d', strtotime((string) ($guarantee['expiry_date'] ?? 'today')) + 86400)) ?>">
</div>
<div>
<label class="form-label" style="font-size:12px;">تاريخ التجديد</label>
<input type="date" name="renewal_date" class="form-input" value="<?= e(date('Y-m-d')) ?>">
</div>
<div>
<label class="form-label" style="font-size:12px;">عمولة التجديد</label>
<input type="number" step="0.01" min="0" name="commission_amount" value="0.00" class="form-input"
style="direction:ltr;text-align:left;">
</div>
<div style="grid-column:1/-1;display:flex;gap:12px;align-items:end;">
<div style="flex:1;">
<label class="form-label" style="font-size:12px;">ملاحظات</label>
<input type="text" name="notes" class="form-input" placeholder="أي تفاصيل عن التجديد">
</div>
<button type="submit" class="btn btn-primary"
onclick="return confirm('تسجيل تجديد الخطاب بالتاريخ الجديد؟');">تجديد الخطاب</button>
</div>
<button type="submit" class="btn btn-primary">حفظ الحالة</button>
</form>
</div>
<script>
(function(){
var sel = document.getElementById('lgStatus');
var wrap = document.getElementById('lgRenewalWrap');
var inp = document.getElementById('lgNewExpiry');
if (!sel || !wrap) return;
function sync(){
var isRenew = sel.value === 'renewed';
wrap.style.display = isRenew ? '' : 'none';
if (inp) inp.required = isRenew;
}
sel.addEventListener('change', sync);
sync();
})();
</script>
<?php endif; ?>
<!-- ══ سجل التجديدات ══ -->
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;">
<h3 style="margin:0;font-size:15px;">سجل التجديدات</h3>
<span style="font-size:12px;color:#6B7280;"><?= count($renewals ?? []) ?> تجديد</span>
</div>
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead>
<tr>
<th style="width:90px;">رقم التجديد</th>
<th>تاريخ التجديد</th>
<th>تاريخ الانتهاء السابق</th>
<th>تاريخ الانتهاء الجديد</th>
<th>عمولة التجديد</th>
<th>سجّله</th>
<th>ملاحظات</th>
</tr>
</thead>
<tbody>
<?php foreach (($renewals ?? []) as $r): ?>
<tr>
<td style="text-align:center;font-weight:700;"><?= (int) $r['renewal_no'] ?></td>
<td style="direction:ltr;text-align:right;"><?= e($r['renewal_date']) ?></td>
<td style="direction:ltr;text-align:right;color:#6B7280;"><?= e($r['previous_expiry_date']) ?></td>
<td style="direction:ltr;text-align:right;font-weight:600;color:#059669;"><?= e($r['new_expiry_date']) ?></td>
<td style="direction:ltr;text-align:right;"><?= money($r['commission_amount'] ?? 0) ?></td>
<td style="font-size:12px;"><?= e($r['created_by_name'] ?? '—') ?></td>
<td style="font-size:12px;color:#6B7280;"><?= e($r['notes'] ?? '—') ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($renewals)): ?>
<tr><td colspan="7" style="text-align:center;color:#6B7280;padding:26px;">الخطاب لم يُجدد من قبل</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<div style="margin-top:15px;">
<a href="/accounting/guarantees" class="btn btn-outline">رجوع للقائمة</a>
</div>
......
......@@ -32,6 +32,9 @@ final class AlertProcessorService
case 'capacity':
$results = self::checkGroupNearCapacity($rule);
break;
case 'guarantees':
$results = self::checkGuaranteeExpiry($rule);
break;
}
if (!empty($results)) {
......@@ -87,6 +90,37 @@ final class AlertProcessorService
return $results;
}
/**
* خطابات الضمان اللي قرب تاريخ انتهائها الحالي.
*
* التنبيه بيتحسب دايماً على expiry_date الحالي، يعني لو الخطاب اتجدّد
* بيتقاس على التاريخ الجديد تلقائياً من غير أي تدخّل.
*/
public static function checkGuaranteeExpiry(array $rule): array
{
$config = json_decode($rule['trigger_config_json'] ?? '{}', true);
$days = (int) ($config['days_before'] ?? \App\Modules\Accounting\Services\GuaranteeRenewalService::ALERT_DAYS_BEFORE);
$rows = \App\Modules\Accounting\Services\GuaranteeRenewalService::expiringSoon($days);
$results = [];
foreach ($rows as $row) {
$who = $row['supplier_name'] ?? $row['beneficiary_name'] ?? '';
$results[] = [
'type' => 'guarantee_expiry',
'guarantee_id' => (int) $row['id'],
'guarantee_number' => $row['guarantee_number'],
'supplier' => $who,
'expiry_date' => $row['expiry_date'],
'days_left' => (int) $row['days_left'],
'message' => 'خطاب الضمان ' . $row['guarantee_number'] . ' (' . $who . ') ينتهي في '
. $row['expiry_date'] . ' — باقي ' . (int) $row['days_left'] . ' يوم',
];
}
return $results;
}
/**
* Check for academy contracts expiring soon.
*/
......
<?php
declare(strict_types=1);
/**
* متابعة خطابات الضمان — سجل تجديدات + بيانات المشروع والغرض.
*
* التجديد كان بيكتب فوق تاريخ الانتهاء القديم ويسيب renewal_date واحد بس،
* فتاريخ الخطاب بيضيع: مش عارف اتجدّد كام مرة ولا إيه التواريخ السابقة.
*
* دلوقتي كل تجديد بيتسجّل في صف مستقل (رقم التجديد، تاريخه، الانتهاء
* السابق، الانتهاء الجديد)، والخطاب نفسه بيفضل هو هو مربوط بنفس المورد
* والمشروع — وتاريخ الانتهاء الحالي هو اللي التنبيهات بتتحسب عليه.
*/
return [
'up' => "
CREATE TABLE IF NOT EXISTS `letter_of_guarantee_renewals` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`guarantee_id` INT UNSIGNED NOT NULL,
`renewal_no` INT UNSIGNED NOT NULL,
`renewal_date` DATE NOT NULL,
`previous_expiry_date` DATE NOT NULL,
`new_expiry_date` DATE NOT NULL,
`commission_amount` DECIMAL(15,2) NOT NULL DEFAULT 0,
`notes` TEXT NULL,
`created_by` INT UNSIGNED NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_lg_renewal_no` (`guarantee_id`, `renewal_no`),
KEY `idx_lg_renewal_guarantee` (`guarantee_id`),
CONSTRAINT `fk_lg_renewal_guarantee` FOREIGN KEY (`guarantee_id`)
REFERENCES `letters_of_guarantee` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
ALTER TABLE `letters_of_guarantee`
ADD COLUMN `supplier_id` INT UNSIGNED NULL AFTER `beneficiary_name`,
ADD COLUMN `purpose` VARCHAR(500) NULL AFTER `guarantee_type`,
ADD COLUMN `project_name` VARCHAR(255) NULL AFTER `purpose`,
ADD COLUMN `renewal_count` INT UNSIGNED NOT NULL DEFAULT 0 AFTER `renewal_date`,
ADD COLUMN `original_expiry_date` DATE NULL AFTER `renewal_count`;
ALTER TABLE `letters_of_guarantee`
ADD KEY `idx_lg_supplier` (`supplier_id`),
ADD KEY `idx_lg_expiry` (`expiry_date`, `status`);
UPDATE `letters_of_guarantee`
SET `original_expiry_date` = `expiry_date`
WHERE `original_expiry_date` IS NULL;
",
'down' => "
DROP TABLE IF EXISTS `letter_of_guarantee_renewals`;
ALTER TABLE `letters_of_guarantee`
DROP COLUMN `supplier_id`,
DROP COLUMN `purpose`,
DROP COLUMN `project_name`,
DROP COLUMN `renewal_count`,
DROP COLUMN `original_expiry_date`;
",
];
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