Commit b43c5268 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(accounting): payment and receipt vouchers, and fix the instrument seed

VOUCHERS (سندات الصرف والقبض)
Pay an expense without thinking in debits and credits. The clerk says "صرف ٥٬٠٠٠
دعاية نقدي" — picks a type, the cash account, and what it was for — and the double
entry is derived and previewed live before saving.

  outflow (صرف)   Dr each expense line   Cr cash / bank
  inflow  (قبض)   Dr cash / bank         Cr each revenue line

Voucher TYPES are rows, not code: a club adds "سند صرف كهرباء" with its account
pre-selected from the screen. Seeded with general, advertising, maintenance,
utilities, bank, and two receipt types.

Handled deliberately:
- Input VAT splits out per line, so a supplier invoice with 14% recoverable tax
  records the expense net and the tax in its own asset account without the clerk
  doing the arithmetic. Inclusive and exclusive are both correct.
- Every account is checked postable before saving; a header or inactive account is
  named in the error rather than failing at post time.
- A line pointing at the cash account itself is refused — the entry would cancel
  to nothing.
- Posting is idempotent: a voucher that already carries a journal entry is refused.
- Cancelling a POSTED voucher reverses its entry rather than deleting it; a posted
  entry is answered with an opposite entry, never erased.
- Voucher numbers retry on collision so two clerks saving at once cannot take the
  same number.
- A type in use deactivates instead of deleting, so its vouchers keep their type.
- Approval is optional per type and blocks posting until granted.

INSTRUMENT SEED FIX
Phase_104_003 died on a duplicate key and never recorded: its existence check
filtered on is_header = 0, so it missed 230602 أوراق الدفع قصيرة الأجل — which
exists as a header — and tried to insert it. Existence is now checked by code
alone, and notes payable hangs at 23060201 underneath it.

Account codes for the seeded voucher types were read off the live chart rather
than guessed: 3303/3304/3305 are all headers, and 3305 is stationery, not
advertising. They now point at 330702 دعاية و إعلان, 33061 صيانة مباني, and
330401 كهرباء.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 2780509a
This diff is collapsed.
...@@ -171,6 +171,18 @@ return [ ...@@ -171,6 +171,18 @@ return [
['POST', '/accounting/billing/sources/{id:\d+}', 'Accounting\Controllers\BillingController@saveSource', ['auth', 'csrf'], 'accounting.billing.manage'], ['POST', '/accounting/billing/sources/{id:\d+}', 'Accounting\Controllers\BillingController@saveSource', ['auth', 'csrf'], 'accounting.billing.manage'],
['POST', '/accounting/billing/sources/{id:\d+}/delete', 'Accounting\Controllers\BillingController@deleteSource', ['auth', 'csrf'], 'accounting.billing.manage'], ['POST', '/accounting/billing/sources/{id:\d+}/delete', 'Accounting\Controllers\BillingController@deleteSource', ['auth', 'csrf'], 'accounting.billing.manage'],
// ── Vouchers (payment / receipt) ────────────────────────
['GET', '/accounting/vouchers', 'Accounting\\Controllers\\VoucherController@index', ['auth'], 'accounting.voucher.view'],
['GET', '/accounting/vouchers/create', 'Accounting\\Controllers\\VoucherController@create', ['auth'], 'accounting.voucher.create'],
['POST', '/accounting/vouchers', 'Accounting\\Controllers\\VoucherController@store', ['auth', 'csrf'], 'accounting.voucher.create'],
['GET', '/accounting/vouchers/types', 'Accounting\\Controllers\\VoucherController@types', ['auth'], 'accounting.voucher.manage'],
['POST', '/accounting/vouchers/types/{id:\\d+}', 'Accounting\\Controllers\\VoucherController@saveType', ['auth', 'csrf'], 'accounting.voucher.manage'],
['POST', '/accounting/vouchers/types/{id:\\d+}/delete', 'Accounting\\Controllers\\VoucherController@deleteType', ['auth', 'csrf'], 'accounting.voucher.manage'],
['GET', '/accounting/vouchers/{id:\\d+}', 'Accounting\\Controllers\\VoucherController@show', ['auth'], 'accounting.voucher.view'],
['POST', '/accounting/vouchers/{id:\\d+}/post', 'Accounting\\Controllers\\VoucherController@post', ['auth', 'csrf'], 'accounting.voucher.post'],
['POST', '/accounting/vouchers/{id:\\d+}/approve', 'Accounting\\Controllers\\VoucherController@approve', ['auth', 'csrf'], 'accounting.voucher.approve'],
['POST', '/accounting/vouchers/{id:\\d+}/cancel', 'Accounting\\Controllers\\VoucherController@cancel', ['auth', 'csrf'], 'accounting.voucher.post'],
// ── Letters of Guarantee ──────────────────────────────── // ── Letters of Guarantee ────────────────────────────────
['GET', '/accounting/guarantees', 'Accounting\Controllers\LetterOfGuaranteeController@index', ['auth'], 'accounting.guarantee.view'], ['GET', '/accounting/guarantees', 'Accounting\Controllers\LetterOfGuaranteeController@index', ['auth'], 'accounting.guarantee.view'],
['GET', '/accounting/guarantees/create', 'Accounting\Controllers\LetterOfGuaranteeController@create', ['auth'], 'accounting.guarantee.manage'], ['GET', '/accounting/guarantees/create', 'Accounting\Controllers\LetterOfGuaranteeController@create', ['auth'], 'accounting.guarantee.manage'],
......
This diff is collapsed.
This diff is collapsed.
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>سندات الصرف والقبض<?php $__template->endSection(); ?>
<?php
$statusLabels = [
'draft' => ['مسودة', 'badge-neutral'],
'pending_approval' => ['بانتظار الاعتماد', 'badge-warning'],
'posted' => ['مُرحَّل', 'badge-success'],
'cancelled' => ['ملغي', 'badge-danger'],
];
?>
<?php $__template->section('content'); ?>
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:15px;margin-bottom:18px;flex-wrap:wrap;">
<div>
<h2 style="margin:0 0 4px;">سندات الصرف والقبض</h2>
<p style="margin:0;color:#6B7280;font-size:13px;max-width:640px;">
صرف مصروف أو تسجيل قبض من غير ما تفكّر في مدين ودائن — النظام بيبني القيد.
</p>
</div>
<div style="display:flex;gap:8px;">
<?php if (can('accounting.voucher.manage')): ?>
<a href="/accounting/vouchers/types" class="btn btn-outline">أنواع السندات</a>
<?php endif; ?>
<?php if (can('accounting.voucher.create')): ?>
<a href="/accounting/vouchers/create" class="btn btn-primary">+ سند جديد</a>
<?php endif; ?>
</div>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-bottom:18px;">
<?php foreach ($summary as $s): ?>
<?php [$lbl, $cls] = $statusLabels[$s['status']] ?? [$s['status'], 'badge-neutral']; ?>
<a href="/accounting/vouchers?status=<?= e($s['status']) ?>" style="text-decoration:none;">
<div class="card" style="padding:14px 16px;">
<div style="font-size:12px;color:#6B7280;margin-bottom:6px;"><?= e($lbl) ?></div>
<div style="font-size:20px;font-weight:700;"><?= money($s['total']) ?></div>
<div style="font-size:11px;color:#9CA3AF;"><?= number_format((int) $s['n']) ?> سند</div>
</div>
</a>
<?php endforeach; ?>
</div>
<div class="card" style="padding:14px 16px;margin-bottom:15px;">
<form method="GET" action="/accounting/vouchers" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div style="min-width:180px;">
<label class="form-label">النوع</label>
<select name="type" class="form-select">
<option value="0">الكل</option>
<?php foreach ($types as $t): ?>
<option value="<?= (int) $t['id'] ?>" <?= $typeId === (int) $t['id'] ? 'selected' : '' ?>><?= e($t['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div style="min-width:170px;">
<label class="form-label">الحالة</label>
<select name="status" class="form-select">
<option value="">الكل</option>
<?php foreach ($statusLabels as $k => $v): ?>
<option value="<?= e($k) ?>" <?= $status === $k ? 'selected' : '' ?>><?= e($v[0]) ?></option>
<?php endforeach; ?>
</select>
</div>
<div><button type="submit" class="btn btn-primary">تصفية</button></div>
</form>
</div>
<div class="card">
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead>
<tr>
<th>رقم السند</th><th>النوع</th><th>التاريخ</th><th>البيان</th>
<th>المستفيد</th><th>المبلغ</th><th>الحالة</th><th></th>
</tr>
</thead>
<tbody>
<?php foreach ($vouchers as $v): ?>
<?php [$lbl, $cls] = $statusLabels[$v['status']] ?? [$v['status'], 'badge-neutral']; ?>
<tr>
<td style="direction:ltr;text-align:right;font-weight:600;"><?= e($v['voucher_number']) ?></td>
<td style="font-size:12px;">
<?= e($v['type_name']) ?>
<div style="font-size:10px;color:#9CA3AF;"><?= $v['direction'] === 'outflow' ? 'صرف' : 'قبض' ?></div>
</td>
<td style="font-size:12px;color:#6B7280;"><?= e($v['voucher_date']) ?></td>
<td style="font-size:12px;"><?= e(mb_substr((string) $v['description_ar'], 0, 50)) ?></td>
<td style="font-size:12px;"><?= e((string) ($v['beneficiary_name'] ?? '—')) ?></td>
<td style="font-weight:600;"><?= money($v['total_amount']) ?></td>
<td><span class="badge <?= $cls ?>"><?= e($lbl) ?></span></td>
<td><a href="/accounting/vouchers/<?= (int) $v['id'] ?>" class="btn btn-sm btn-outline">عرض</a></td>
</tr>
<?php endforeach; ?>
<?php if (empty($vouchers)): ?>
<tr><td colspan="8" style="text-align:center;color:#6B7280;padding:30px;">لا توجد سندات</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>سند <?= e($voucher['voucher_number']) ?><?php $__template->endSection(); ?>
<?php
$statusLabels = [
'draft' => ['مسودة','badge-neutral'], 'pending_approval' => ['بانتظار الاعتماد','badge-warning'],
'posted' => ['مُرحَّل','badge-success'], 'cancelled' => ['ملغي','badge-danger'],
];
[$lbl, $cls] = $statusLabels[$voucher['status']] ?? [$voucher['status'],'badge-neutral'];
$isOut = $voucher['direction'] === 'outflow';
?>
<?php $__template->section('content'); ?>
<div style="margin-bottom:16px;">
<a href="/accounting/vouchers" style="color:#6B7280;font-size:13px;text-decoration:none;">→ رجوع إلى السندات</a>
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap;margin-top:6px;">
<div>
<h2 style="margin:0 0 4px;"><?= e($voucher['type_name']) ?><span style="direction:ltr;"><?= e($voucher['voucher_number']) ?></span></h2>
<div style="font-size:13px;color:#6B7280;"><?= e($voucher['description_ar']) ?></div>
</div>
<div style="text-align:left;">
<span class="badge <?= $cls ?>" style="font-size:12px;"><?= e($lbl) ?></span>
<div style="font-size:24px;font-weight:700;margin-top:6px;color:<?= $isOut ? '#B45309' : '#059669' ?>;"><?= money($voucher['total_amount']) ?></div>
</div>
</div>
</div>
<?php if (!empty($voucher['cancel_reason'])): ?>
<div class="card" style="margin-bottom:14px;border-right:3px solid #DC2626;padding:12px 18px;">
<strong style="color:#991B1B;font-size:13px;">ملغي:</strong>
<span style="font-size:13px;"><?= e($voucher['cancel_reason']) ?></span>
</div>
<?php endif; ?>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:14px;margin-bottom:14px;">
<div class="card">
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;"><h3 style="margin:0;font-size:14px;">البيانات</h3></div>
<div style="padding:14px 18px;font-size:13px;line-height:2;">
<div><span style="color:#6B7280;">التاريخ:</span> <?= e($voucher['voucher_date']) ?></div>
<div><span style="color:#6B7280;"><?= $isOut ? 'المدفوع له' : 'الدافع' ?>:</span> <?= e((string)($voucher['beneficiary_name'] ?? '—')) ?></div>
<div><span style="color:#6B7280;">طريقة الدفع:</span> <?= e($voucher['payment_method']) ?></div>
<div><span style="color:#6B7280;"><?= $isOut ? 'صُرف من' : 'حُصّل في' ?>:</span>
<span style="direction:ltr;"><?= e((string)$voucher['counter_code']) ?></span> <?= e((string)$voucher['counter_name']) ?></div>
<?php if (!empty($voucher['check_number'])): ?>
<div><span style="color:#6B7280;">شيك رقم:</span> <span style="direction:ltr;"><?= e($voucher['check_number']) ?></span><?= e((string)$voucher['check_bank']) ?></div>
<?php endif; ?>
<?php if (!empty($voucher['reference'])): ?>
<div><span style="color:#6B7280;">المرجع:</span> <span style="direction:ltr;"><?= e($voucher['reference']) ?></span></div>
<?php endif; ?>
<div><span style="color:#6B7280;">أنشأه:</span> <?= e((string)($voucher['created_by_name'] ?? '—')) ?></div>
</div>
</div>
<div class="card">
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;"><h3 style="margin:0;font-size:14px;">البنود</h3></div>
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead><tr><th>الحساب</th><th>البيان</th><th>المبلغ</th></tr></thead>
<tbody>
<?php foreach ($lines as $l): ?>
<tr>
<td style="font-size:12px;"><span style="direction:ltr;color:#6B7280;"><?= e($l['account_code']) ?></span> <?= e($l['account_name']) ?>
<?php if (!empty($l['tax_name'])): ?><div style="font-size:10px;color:#92400E;"><?= e($l['tax_name']) ?></div><?php endif; ?>
</td>
<td style="font-size:12px;"><?= e((string)($l['description_ar'] ?? '—')) ?></td>
<td style="font-weight:600;"><?= money($l['amount']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
<?php if (!empty($entryLines)): ?>
<div class="card" style="margin-bottom:14px;">
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;">
<h3 style="margin:0;font-size:14px;">القيد المُرحَّل</h3>
<a href="/accounting/journal-entries/<?= (int) $voucher['journal_entry_id'] ?>" class="btn btn-sm btn-ghost">فتح القيد</a>
</div>
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead><tr><th>الحساب</th><th>البيان</th><th>مدين</th><th>دائن</th></tr></thead>
<tbody>
<?php $td='0.00'; $tc='0.00'; foreach ($entryLines as $e): $td=bcadd($td,(string)$e['debit'],2); $tc=bcadd($tc,(string)$e['credit'],2); ?>
<tr>
<td style="font-size:12px;"><span style="direction:ltr;color:#6B7280;"><?= e($e['account_code']) ?></span> <?= e($e['account_name']) ?></td>
<td style="font-size:12px;"><?= e((string)($e['description_ar'] ?? '')) ?></td>
<td style="font-weight:600;"><?= bccomp((string)$e['debit'],'0.00',2)>0 ? money($e['debit']) : '' ?></td>
<td style="font-weight:600;color:#A22A2A;"><?= bccomp((string)$e['credit'],'0.00',2)>0 ? money($e['credit']) : '' ?></td>
</tr>
<?php endforeach; ?>
<tr style="background:#F9FAFB;font-weight:700;">
<td colspan="2">الإجمالي</td><td><?= money($td) ?></td><td><?= money($tc) ?></td>
</tr>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<?php if ($voucher['status'] === 'pending_approval' && can('accounting.voucher.approve')): ?>
<form method="POST" action="/accounting/vouchers/<?= (int)$voucher['id'] ?>/approve"><?= csrf_field() ?>
<button type="submit" class="btn btn-primary">اعتماد السند</button></form>
<?php endif; ?>
<?php if ($voucher['status'] === 'draft' && can('accounting.voucher.post')): ?>
<form method="POST" action="/accounting/vouchers/<?= (int)$voucher['id'] ?>/post"><?= csrf_field() ?>
<button type="submit" class="btn btn-primary">ترحيل إلى الدفاتر</button></form>
<?php endif; ?>
<?php if ($voucher['status'] !== 'cancelled' && can('accounting.voucher.post')): ?>
<form method="POST" action="/accounting/vouchers/<?= (int)$voucher['id'] ?>/cancel"
onsubmit="var r=prompt('سبب الإلغاء؟'); if(!r) return false; this.reason.value=r; return true;">
<?= csrf_field() ?><input type="hidden" name="reason">
<button type="submit" class="btn btn-outline" style="color:#DC2626;">إلغاء السند<?= $voucher['status']==='posted' ? ' وعكس قيده' : '' ?></button>
</form>
<?php endif; ?>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>أنواع السندات<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div style="margin-bottom:16px;">
<a href="/accounting/vouchers" style="color:#6B7280;font-size:13px;text-decoration:none;">→ رجوع إلى السندات</a>
<h2 style="margin:6px 0 4px;">أنواع السندات</h2>
<p style="margin:0;color:#6B7280;font-size:13px;max-width:700px;">
عرّف نوع سند لكل مصروف متكرر — دعاية، صيانة، كهرباء — واختار له حسابه الافتراضي،
فبيبقى الصرف بضغطة. <strong>ما بيحتاجش مبرمج.</strong>
</p>
</div>
<div class="card" style="margin-bottom:16px;">
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;"><h3 style="margin:0;font-size:14px;">إضافة نوع</h3></div>
<div style="padding:16px 18px;">
<form method="POST" action="/accounting/vouchers/types/0">
<?= csrf_field() ?>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:14px;">
<div><label class="form-label">الكود <span style="color:#DC2626;">*</span></label>
<input type="text" name="code" class="form-input" required dir="ltr" placeholder="PV_ADS" style="text-transform:uppercase;"></div>
<div><label class="form-label">الاسم <span style="color:#DC2626;">*</span></label>
<input type="text" name="name_ar" class="form-input" required placeholder="سند صرف دعاية"></div>
<div><label class="form-label">الاتجاه</label>
<select name="direction" class="form-select">
<option value="outflow">صرف — فلوس خرجت</option>
<option value="inflow">قبض — فلوس دخلت</option>
</select></div>
<div><label class="form-label">بادئة الترقيم</label>
<input type="text" name="number_prefix" class="form-input" dir="ltr" value="PV" maxlength="10"></div>
<div><label class="form-label">يحتاج اعتماد؟</label>
<select name="requires_approval" class="form-select">
<option value="0">لا</option><option value="1">نعم</option>
</select></div>
<div><label class="form-label">ترتيب العرض</label>
<input type="number" name="sort_order" class="form-input" dir="ltr" value="100"></div>
</div>
<div style="margin-top:14px;"><button type="submit" class="btn btn-primary">حفظ النوع</button></div>
</form>
</div>
</div>
<div class="card">
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead><tr><th>الكود</th><th>الاسم</th><th>الاتجاه</th><th>الحساب الافتراضي</th><th>الاستخدام</th><th>الحالة</th><th></th></tr></thead>
<tbody>
<?php foreach ($types as $t): ?>
<tr>
<td style="direction:ltr;text-align:right;font-weight:600;"><?= e($t['code']) ?></td>
<td><?= e($t['name_ar']) ?></td>
<td><?= $t['direction']==='outflow' ? 'صرف' : 'قبض' ?></td>
<td style="font-size:12px;">
<?php if (!empty($t['line_code'])): ?>
<span style="direction:ltr;color:#6B7280;"><?= e($t['line_code']) ?></span> <?= e($t['line_name']) ?>
<?php else: ?><span style="color:#9CA3AF;"></span><?php endif; ?>
</td>
<td><?= number_format((int)$t['usage_count']) ?> سند</td>
<td><span class="badge <?= (int)$t['is_active'] ? 'badge-success' : 'badge-neutral' ?>"><?= (int)$t['is_active'] ? 'نشط' : 'موقوف' ?></span></td>
<td>
<form method="POST" action="/accounting/vouchers/types/<?= (int)$t['id'] ?>/delete" style="display:inline;"
onsubmit="return confirm('حذف أو إيقاف النوع؟');">
<?= csrf_field() ?><button type="submit" class="btn btn-sm btn-ghost" style="color:#DC2626;">حذف</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php $__template->endSection(); ?>
...@@ -107,6 +107,13 @@ PermissionRegistry::register('accounting', [ ...@@ -107,6 +107,13 @@ PermissionRegistry::register('accounting', [
'accounting.revenue_mapping.view' => ['ar' => 'عرض توزيع الإيرادات', 'en' => 'View Revenue Mapping'], 'accounting.revenue_mapping.view' => ['ar' => 'عرض توزيع الإيرادات', 'en' => 'View Revenue Mapping'],
'accounting.revenue_mapping.manage' => ['ar' => 'إدارة توزيع الإيرادات', 'en' => 'Manage Revenue Mapping'], 'accounting.revenue_mapping.manage' => ['ar' => 'إدارة توزيع الإيرادات', 'en' => 'Manage Revenue Mapping'],
// Vouchers
'accounting.voucher.view' => ['ar' => 'عرض السندات', 'en' => 'View Vouchers'],
'accounting.voucher.create' => ['ar' => 'إنشاء سند', 'en' => 'Create Voucher'],
'accounting.voucher.post' => ['ar' => 'ترحيل وإلغاء السندات', 'en' => 'Post/Cancel Vouchers'],
'accounting.voucher.approve' => ['ar' => 'اعتماد السندات', 'en' => 'Approve Vouchers'],
'accounting.voucher.manage' => ['ar' => 'إدارة أنواع السندات', 'en' => 'Manage Voucher Types'],
// Billing (universal collection) // Billing (universal collection)
'accounting.billing.view' => ['ar' => 'عرض المطالبات', 'en' => 'View Billing'], 'accounting.billing.view' => ['ar' => 'عرض المطالبات', 'en' => 'View Billing'],
'accounting.billing.collect' => ['ar' => 'تحصيل المطالبات', 'en' => 'Collect Billing'], 'accounting.billing.collect' => ['ar' => 'تحصيل المطالبات', 'en' => 'Collect Billing'],
...@@ -131,6 +138,7 @@ MenuRegistry::register('accounting', [ ...@@ -131,6 +138,7 @@ MenuRegistry::register('accounting', [
['label_ar' => 'توزيع الإيرادات', 'label_en' => 'Revenue Mapping', 'route' => '/accounting/revenue-mapping', 'permission' => 'accounting.revenue_mapping.view', 'order' => 2], ['label_ar' => 'توزيع الإيرادات', 'label_en' => 'Revenue Mapping', 'route' => '/accounting/revenue-mapping', 'permission' => 'accounting.revenue_mapping.view', 'order' => 2],
['label_ar' => 'مركز التوصيل', 'label_en' => 'Connection Centre', 'route' => '/accounting/revenue-mapping/connections', 'permission' => 'accounting.revenue_mapping.view', 'order' => 2], ['label_ar' => 'مركز التوصيل', 'label_en' => 'Connection Centre', 'route' => '/accounting/revenue-mapping/connections', 'permission' => 'accounting.revenue_mapping.view', 'order' => 2],
['label_ar' => 'المطالبات والتحصيل', 'label_en' => 'Billing & Collection', 'route' => '/accounting/billing', 'permission' => 'accounting.billing.view', 'order' => 2], ['label_ar' => 'المطالبات والتحصيل', 'label_en' => 'Billing & Collection', 'route' => '/accounting/billing', 'permission' => 'accounting.billing.view', 'order' => 2],
['label_ar' => 'سندات الصرف والقبض', 'label_en' => 'Vouchers', 'route' => '/accounting/vouchers', 'permission' => 'accounting.voucher.view', 'order' => 3],
['label_ar' => 'الإيراد المؤجل', 'label_en' => 'Deferred Revenue', 'route' => '/accounting/revenue-mapping/recognition', 'permission' => 'accounting.revenue_mapping.view', 'order' => 2], ['label_ar' => 'الإيراد المؤجل', 'label_en' => 'Deferred Revenue', 'route' => '/accounting/revenue-mapping/recognition', 'permission' => 'accounting.revenue_mapping.view', 'order' => 2],
['label_ar' => 'قيود اليومية', 'label_en' => 'Journal Entries', 'route' => '/accounting/journal-entries', 'permission' => 'accounting.journal.view', 'order' => 3], ['label_ar' => 'قيود اليومية', 'label_en' => 'Journal Entries', 'route' => '/accounting/journal-entries', 'permission' => 'accounting.journal.view', 'order' => 3],
['label_ar' => 'أنواع اليومية', 'label_en' => 'Journal Types', 'route' => '/accounting/journal-types', 'permission' => 'accounting.journal_type.view', 'order' => 4], ['label_ar' => 'أنواع اليومية', 'label_en' => 'Journal Types', 'route' => '/accounting/journal-types', 'permission' => 'accounting.journal_type.view', 'order' => 4],
......
<?php
declare(strict_types=1);
/**
* سندات الصرف والقبض — payment and receipt vouchers.
*
* The manual journal entry screen can already express any transaction, but it asks
* the user to think in debits and credits. A clerk paying an advertising invoice
* should be able to say "صرف ٥٬٠٠٠ دعاية نقدي" and have the entry built for them.
*
* A voucher is that: pick a type, pick where the money came from or went to, list
* what it was for, post. The double entry is derived, never typed.
*
* Voucher TYPES are configurable, so a club can add "سند صرف صيانة" with the
* maintenance account pre-selected without touching code.
*/
return function (\App\Core\Database $db): void {
$db->raw("
CREATE TABLE IF NOT EXISTS `voucher_types` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
`code` VARCHAR(40) NOT NULL,
`name_ar` VARCHAR(200) NOT NULL,
`name_en` VARCHAR(200) NULL,
`direction` ENUM('outflow','inflow') NOT NULL DEFAULT 'outflow'
COMMENT 'outflow = صرف (money leaves), inflow = قبض (money arrives)',
`number_prefix` VARCHAR(10) NOT NULL DEFAULT 'PV',
`default_counter_account_id` BIGINT UNSIGNED NULL
COMMENT 'cash or bank side; null = chosen per voucher',
`default_line_account_id` BIGINT UNSIGNED NULL
COMMENT 'the expense or revenue account this type usually hits',
`requires_approval` TINYINT(1) NOT NULL DEFAULT 0,
`requires_supplier` TINYINT(1) NOT NULL DEFAULT 0,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`is_system` TINYINT(1) NOT NULL DEFAULT 0,
`sort_order` SMALLINT UNSIGNED NOT NULL DEFAULT 100,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `uq_voucher_type_code` (`code`),
INDEX `idx_voucher_type_active` (`is_active`, `sort_order`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
$db->raw("
CREATE TABLE IF NOT EXISTS `vouchers` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
`voucher_number` VARCHAR(30) NOT NULL,
`voucher_type_id` BIGINT UNSIGNED NOT NULL,
`direction` ENUM('outflow','inflow') NOT NULL,
`voucher_date` DATE NOT NULL,
`counter_account_id` BIGINT UNSIGNED NOT NULL COMMENT 'the cash / bank side',
`payment_method` VARCHAR(30) NOT NULL DEFAULT 'cash',
`total_amount` DECIMAL(18,2) NOT NULL DEFAULT 0.00,
`beneficiary_name` VARCHAR(300) NULL COMMENT 'who was paid, or who paid',
`supplier_id` BIGINT UNSIGNED NULL,
`member_id` BIGINT UNSIGNED NULL,
`employee_id` BIGINT UNSIGNED NULL,
`check_number` VARCHAR(50) NULL,
`check_bank` VARCHAR(100) NULL,
`check_date` DATE NULL,
`reference` VARCHAR(100) NULL,
`description_ar` VARCHAR(500) NOT NULL,
`notes` TEXT NULL,
`attachment_path` VARCHAR(500) NULL,
`status` ENUM('draft','pending_approval','posted','cancelled') NOT NULL DEFAULT 'draft',
`journal_entry_id` BIGINT UNSIGNED NULL,
`reversal_entry_id` BIGINT UNSIGNED NULL,
`approved_by` BIGINT UNSIGNED NULL,
`approved_at` DATETIME NULL,
`posted_by` BIGINT UNSIGNED NULL,
`posted_at` DATETIME NULL,
`cancelled_by` BIGINT UNSIGNED NULL,
`cancelled_at` DATETIME NULL,
`cancel_reason` VARCHAR(300) NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`created_by` BIGINT UNSIGNED NULL,
UNIQUE KEY `uq_voucher_number` (`voucher_number`),
INDEX `idx_voucher_status` (`status`, `voucher_date`),
INDEX `idx_voucher_type` (`voucher_type_id`, `status`),
CONSTRAINT `fk_voucher_type` FOREIGN KEY (`voucher_type_id`)
REFERENCES `voucher_types`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
$db->raw("
CREATE TABLE IF NOT EXISTS `voucher_lines` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
`voucher_id` BIGINT UNSIGNED NOT NULL,
`line_number` SMALLINT UNSIGNED NOT NULL DEFAULT 1,
`account_id` BIGINT UNSIGNED NOT NULL,
`amount` DECIMAL(18,2) NOT NULL,
`description_ar` VARCHAR(300) NULL,
`cost_center_id` BIGINT UNSIGNED NULL,
`branch_id` BIGINT UNSIGNED NULL,
`tax_profile_id` BIGINT UNSIGNED NULL COMMENT 'input VAT on this line, if any',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_voucher_line` (`voucher_id`, `line_number`),
CONSTRAINT `fk_voucher_line_voucher` FOREIGN KEY (`voucher_id`)
REFERENCES `vouchers`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
};
...@@ -35,9 +35,18 @@ return function (\App\Core\Database $db): void { ...@@ -35,9 +35,18 @@ return function (\App\Core\Database $db): void {
$ensureLeaf = function (string $code, string $nameAr, string $nameEn, string $parentCode) $ensureLeaf = function (string $code, string $nameAr, string $nameEn, string $parentCode)
use ($db, $now, $accId): ?int { use ($db, $now, $accId): ?int {
$existing = $accId($code); // Check existence by CODE ALONE. Filtering on is_header here would miss an
if ($existing !== null) { // account that exists as a header and then try to insert it — which is
return $existing; // exactly how this failed on 230602 أوراق الدفع قصيرة الأجل.
$any = $db->selectOne(
"SELECT id, is_header, is_active FROM chart_of_accounts WHERE account_code = ?",
[$code]
);
if ($any) {
// It exists but cannot be posted to; the caller must pick another.
return ((int) $any['is_header'] === 0 && (int) $any['is_active'] === 1)
? (int) $any['id']
: null;
} }
$parent = $db->selectOne( $parent = $db->selectOne(
...@@ -78,7 +87,8 @@ return function (\App\Core\Database $db): void { ...@@ -78,7 +87,8 @@ return function (\App\Core\Database $db): void {
$bounceFeeRev = $accId('410511'); // مصاريف رفض شيكات — already exists $bounceFeeRev = $accId('410511'); // مصاريف رفض شيكات — already exists
$bankAccount = $accId('12060201'); // البنك الأهلي $bankAccount = $accId('12060201'); // البنك الأهلي
$cashAccount = $accId('12060101'); // الصندوق بالجنيه المصري $cashAccount = $accId('12060101'); // الصندوق بالجنيه المصري
$notesPayable = $ensureLeaf('230602', 'أوراق دفع — موردون', 'Notes Payable — Suppliers', '2306'); // 230602 أوراق الدفع قصيرة الأجل already exists as a header — the leaf goes under it.
$notesPayable = $ensureLeaf('23060201', 'أوراق دفع — موردون', 'Notes Payable — Suppliers', '230602');
// Endorsement hands the cheque to a creditor; default to trade payables. // Endorsement hands the cheque to a creditor; default to trade payables.
$endorsement = $accId('230601002') ?? $notesPayable; $endorsement = $accId('230601002') ?? $notesPayable;
......
<?php
declare(strict_types=1);
/**
* Starter voucher types, including the ones a finance review actually asks about.
*
* Each is just a row — the club adds "سند صرف كهرباء" from the screen the same way.
* The default expense account is attached where the chart already has a sensible
* one, and left null otherwise so the clerk picks it rather than posting somewhere
* wrong by default.
*/
return function (\App\Core\Database $db): void {
$now = date('Y-m-d H:i:s');
$accId = function (?string $code) use ($db): ?int {
if ($code === null) { return null; }
$r = $db->selectOne(
"SELECT id FROM chart_of_accounts
WHERE account_code = ? AND is_archived = 0 AND is_header = 0 AND is_active = 1",
[$code]
);
return $r ? (int) $r['id'] : null;
};
$cash = $accId('12060101'); // الصندوق بالجنيه المصري
$bank = $accId('12060201'); // البنك
// [code, name, direction, prefix, counter, default line account]
$types = [
['PV_GENERAL', 'سند صرف عام', 'outflow', 'PV', $cash, null],
// Codes verified as postable leaves on the live chart — 3303/3304/3305 are
// headers and 3305 is stationery, not advertising.
['PV_ADS', 'سند صرف دعاية وإعلان', 'outflow', 'PV', $cash, '330702'],
['PV_MAINT', 'سند صرف صيانة', 'outflow', 'PV', $cash, '33061'],
['PV_UTILITIES', 'سند صرف كهرباء ومياه', 'outflow', 'PV', $cash, '330401'],
['PV_BANK', 'سند صرف بنكي', 'outflow', 'BP', $bank, null],
['RV_GENERAL', 'سند قبض عام', 'inflow', 'RV', $cash, null],
['RV_BANK', 'سند قبض بنكي', 'inflow', 'BR', $bank, null],
];
foreach ($types as [$code, $name, $direction, $prefix, $counter, $lineCode]) {
if ($db->selectOne("SELECT id FROM voucher_types WHERE code = ?", [$code])) {
continue;
}
$db->insert('voucher_types', [
'code' => $code,
'name_ar' => $name,
'direction' => $direction,
'number_prefix' => $prefix,
'default_counter_account_id' => $counter,
'default_line_account_id' => $accId($lineCode),
'requires_approval' => 0,
'requires_supplier' => 0,
'is_active' => 1,
'is_system' => 1,
'sort_order' => 100,
'created_at' => $now,
'updated_at' => $now,
]);
}
};
<?php
declare(strict_types=1);
/**
* Voucher permissions follow the journal-entry permissions, since a voucher is a
* friendlier way of writing the same entry.
*/
return function (\App\Core\Database $db): void {
$grants = [
'accounting.journal.view' => ['accounting.voucher.view'],
'accounting.journal.create' => ['accounting.voucher.view', 'accounting.voucher.create'],
'accounting.journal.post' => ['accounting.voucher.post', 'accounting.voucher.approve'],
'accounting.coa.manage' => ['accounting.voucher.manage'],
];
foreach ($grants as $sourceKey => $newKeys) {
foreach ($db->select("SELECT DISTINCT role_id FROM role_permissions WHERE permission_key = ?", [$sourceKey]) as $row) {
$roleId = (int) $row['role_id'];
foreach ($newKeys as $key) {
if ($db->selectOne("SELECT id FROM role_permissions WHERE role_id = ? AND permission_key = ?", [$roleId, $key])) {
continue;
}
$db->insert('role_permissions', [
'role_id' => $roleId,
'permission_key' => $key,
'granted_at' => date('Y-m-d H:i:s'),
]);
}
}
}
};
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