Commit ac28a438 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(accounting): connection centre — every money path, what it needs, and split-anything

Answers the question the finance review will actually ask: "we know it is not
connected — how do we connect it, from inside the system, now?"

Three parts.

1. مركز التوصيل (/accounting/revenue-mapping/connections)
   Every money path in the ERP in one list, split by WHAT IT NEEDS rather than by
   severity, because that decides who can close it:

     - تُوصَّل الآن من الشاشة — the module already fires an event carrying the
       amount, so mapping the accounts is the whole fix. Has a button.
     - تحتاج تعديل برمجي — the module writes the money to its own table and fires
       nothing. Mapping would change nothing, so there is deliberately NO button
       and the row says exactly what is missing. A button here would be a lie.
     - موصولة — with the current split shown inline and a rewire button.

   Each row reads the amount sitting in that module's own table live, so every gap
   is a number instead of an adjective.

2. Rewire and split anything, including already-connected paths
   The connected list shows each current split and offers "قسّم على حسابات" when a
   path still posts to a single account. Any line can be a percentage, a flat
   amount, or the remainder — so "30% of the 150,000 to this fund, 10% to that one,
   the rest to membership revenue" is three lines and a save. Saving takes a new
   version with an effective date; posted entries never move.

3. Create the destination account without leaving the screen
   A fund that does not exist yet used to mean leaving for the chart of accounts
   and losing the room. "+ حساب جديد" creates the leaf under a chosen header,
   takes the next free code, inherits type and nature, and drops straight into the
   line. Refuses to hang a child off a posting account, which would strand its
   balance.

Also seeds the club fund accounts a distribution rule needs to point at — sports
support, member welfare, martyrs stamp (already priced at 5 EGP in the service
catalogue with nowhere to post it), federation share, facilities development.
They are liabilities, not revenue: money earmarked for a fund is held on that
fund's behalf, and posting it to revenue would overstate income.

And 27 previously invisible paths are now catalogued with an honest wiring_status,
a plain-Arabic note on what is missing, and a pointer to the table holding the
money — so the screen shows the whole picture instead of only the working parts.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent dbf2ca59
......@@ -709,6 +709,106 @@ class RevenueMappingController extends Controller
]);
}
/**
* مركز التوصيل — every money path in the ERP and exactly what it needs.
*
* The honest version of "what is not connected". Each row carries the amount
* currently sitting in the module's own table, read live, so the size of each
* gap is a number rather than an adjective — and the action offered matches
* what the path can actually accept: a mapping, or a code change.
*/
public function connections(Request $request): Response
{
$this->authorize('accounting.revenue_mapping.view');
$db = App::getInstance()->db();
$streams = $db->select(
"SELECT s.*,
r.id AS rule_id, r.stage AS rule_stage
FROM revenue_streams s
LEFT JOIN revenue_posting_rules r
ON r.stream_id = s.id
AND r.status = 'active'
AND r.effective_from <= CURDATE()
AND (r.effective_to IS NULL OR r.effective_to >= CURDATE())
WHERE s.is_active = 1
GROUP BY s.id
ORDER BY s.category, s.name_ar"
);
$connected = [];
$mappable = [];
$needsCode = [];
foreach ($streams as $s) {
// How much money is sitting in this module's own table right now?
$s['at_stake'] = null;
$s['row_count'] = null;
if (!empty($s['evidence_table'])) {
$table = preg_replace('/[^a-z0-9_]/i', '', (string) $s['evidence_table']);
$amount = $s['evidence_amount_column']
? preg_replace('/[^a-z0-9_]/i', '', (string) $s['evidence_amount_column'])
: null;
$where = $s['evidence_where'] ? ' WHERE ' . $s['evidence_where'] : '';
try {
$select = $amount
? "SELECT COUNT(*) AS n, COALESCE(SUM(`{$amount}`), 0) AS total FROM `{$table}`{$where}"
: "SELECT COUNT(*) AS n, NULL AS total FROM `{$table}`{$where}";
$row = $db->selectOne($select);
$s['row_count'] = (int) ($row['n'] ?? 0);
$s['at_stake'] = $row['total'] !== null ? (string) $row['total'] : null;
} catch (\Throwable $e) {
// A stale evidence pointer must not take the page down.
$s['row_count'] = null;
}
}
if (!empty($s['rule_id'])) {
// Show the current split inline so a rewire decision can be made
// from this screen without opening each stream first.
$s['lines'] = $db->select(
"SELECT l.allocation_method, l.percentage, l.fixed_amount, l.line_type,
coa.account_code, coa.name_ar AS account_name
FROM revenue_posting_rule_lines l
JOIN chart_of_accounts coa ON coa.id = l.account_id
WHERE l.rule_id = ? AND l.is_active = 1
ORDER BY l.sort_order",
[(int) $s['rule_id']]
);
$connected[] = $s;
} elseif (($s['wiring_status'] ?? 'dispatches') === 'needs_code') {
$needsCode[] = $s;
} else {
$mappable[] = $s;
}
}
// Biggest gaps first — that is the order anyone will want to fix them in.
$bySize = static function (array $a, array $b): int {
return bccomp((string) ($b['at_stake'] ?? '0'), (string) ($a['at_stake'] ?? '0'), 2);
};
usort($needsCode, $bySize);
usort($mappable, $bySize);
$totalAtStake = '0.00';
foreach (array_merge($needsCode, $mappable) as $s) {
if ($s['at_stake'] !== null) {
$totalAtStake = bcadd($totalAtStake, (string) $s['at_stake'], 2);
}
}
return $this->view('Accounting.Views.revenue_mapping.connections', [
'connected' => $connected,
'mappable' => $mappable,
'needsCode' => $needsCode,
'totalAtStake' => $totalAtStake,
'categories' => self::categories(),
'stageLabels' => RevenuePostingEngine::STAGE_LABELS,
]);
}
/** Re-scan the ERP for chargeable things that have no stream yet. */
public function sync(): Response
{
......@@ -724,6 +824,129 @@ class RevenueMappingController extends Controller
return $this->redirect('/accounting/revenue-mapping')->withSuccess($msg);
}
/**
* Create a posting account without leaving the mapping screen.
*
* The whole point of the screen is that finance can answer "that 150,000 splits
* 30% to this fund" while the meeting is still happening. If the fund account
* does not exist yet, sending them to the chart of accounts and back loses the
* room. This creates the leaf under a chosen parent and hands it straight back
* to the picker.
*
* A parent must already be a header — turning a posting account into a header
* mid-life strands its balance.
*/
public function createAccount(Request $request): Response
{
$this->authorize('accounting.revenue_mapping.manage');
$db = App::getInstance()->db();
$parentId = (int) $request->post('parent_id', 0);
$nameAr = trim((string) $request->post('name_ar', ''));
$nameEn = trim((string) $request->post('name_en', ''));
if ($parentId <= 0 || $nameAr === '') {
return $this->json(['success' => false, 'error' => 'اختر الحساب الرئيسي واكتب اسم الحساب']);
}
$parent = $db->selectOne(
"SELECT id, account_code, name_ar, account_type, account_nature, level, is_header
FROM chart_of_accounts WHERE id = ? AND is_archived = 0",
[$parentId]
);
if (!$parent) {
return $this->json(['success' => false, 'error' => 'الحساب الرئيسي غير موجود']);
}
if ((int) $parent['is_header'] !== 1) {
return $this->json([
'success' => false,
'error' => 'الحساب «' . $parent['name_ar'] . '» حساب فرعي عليه حركة — لا يصلح كحساب أب. اختر حسابًا رئيسيًا.',
]);
}
// Next free code under this parent, following the chart's own width rule:
// children extend the parent code by two digits (four at the top levels).
$width = \strlen((string) $parent['account_code']) <= 2 ? 2 : 2;
$like = $parent['account_code'] . str_repeat('_', $width);
$last = $db->selectOne(
"SELECT account_code FROM chart_of_accounts
WHERE account_code LIKE ? AND CHAR_LENGTH(account_code) = ?
ORDER BY account_code DESC LIMIT 1",
[$like, \strlen((string) $parent['account_code']) + $width]
);
if ($last) {
$next = (int) substr((string) $last['account_code'], -$width) + 1;
} else {
$next = 1;
}
if ($next > (10 ** $width) - 1) {
return $this->json(['success' => false, 'error' => 'لا توجد أكواد متاحة تحت هذا الحساب الرئيسي']);
}
$code = $parent['account_code'] . str_pad((string) $next, $width, '0', STR_PAD_LEFT);
if ($db->selectOne("SELECT id FROM chart_of_accounts WHERE account_code = ?", [$code])) {
return $this->json(['success' => false, 'error' => 'الكود ' . $code . ' مستخدم بالفعل']);
}
$employee = App::getInstance()->currentEmployee();
$id = $db->insert('chart_of_accounts', [
'account_code' => $code,
'name_ar' => $nameAr,
'name_en' => $nameEn !== '' ? $nameEn : $nameAr,
'account_type' => $parent['account_type'],
'account_nature' => $parent['account_nature'],
'parent_id' => (int) $parent['id'],
'level' => ((int) $parent['level']) + 1,
'level_name' => 'جزئي',
'is_header' => 0,
'is_active' => 1,
'is_system' => 0,
'currency' => 'EGP',
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
'created_by' => $employee ? (int) $employee->id : null,
]);
return $this->json([
'success' => true,
'account' => [
'id' => $id,
'account_code' => $code,
'name_ar' => $nameAr,
'parent' => $parent['account_code'] . ' — ' . $parent['name_ar'],
],
]);
}
/** Header accounts a new posting account can hang under. */
public function parentAccounts(Request $request): Response
{
$this->authorize('accounting.revenue_mapping.view');
$db = App::getInstance()->db();
$type = (string) $request->get('type', '');
$where = ['is_archived = 0', 'is_active = 1', 'is_header = 1'];
$params = [];
if ($type !== '') {
$where[] = 'account_type = ?';
$params[] = $type;
}
return $this->json([
'parents' => $db->select(
"SELECT id, account_code, name_ar, account_type
FROM chart_of_accounts
WHERE " . implode(' AND ', $where) . "
ORDER BY account_code",
$params
),
]);
}
/** Account picker used by the rule builder. */
public function searchAccounts(Request $request): Response
{
......
......@@ -152,7 +152,10 @@ return [
['POST', '/accounting/revenue-mapping/tax-profiles/{id:\d+}', 'Accounting\Controllers\RevenueMappingController@updateTaxProfile', ['auth', 'csrf'], 'accounting.revenue_mapping.manage'],
['GET', '/accounting/revenue-mapping/recognition', 'Accounting\Controllers\RevenueMappingController@recognition', ['auth'], 'accounting.revenue_mapping.view'],
['POST', '/accounting/revenue-mapping/recognition/run', 'Accounting\Controllers\RevenueMappingController@runRecognition', ['auth', 'csrf'], 'accounting.revenue_mapping.manage'],
['GET', '/accounting/revenue-mapping/connections', 'Accounting\Controllers\RevenueMappingController@connections', ['auth'], 'accounting.revenue_mapping.view'],
['GET', '/accounting/revenue-mapping/search-accounts', 'Accounting\Controllers\RevenueMappingController@searchAccounts', ['auth'], 'accounting.revenue_mapping.view'],
['GET', '/accounting/revenue-mapping/parent-accounts', 'Accounting\Controllers\RevenueMappingController@parentAccounts', ['auth'], 'accounting.revenue_mapping.view'],
['POST', '/accounting/revenue-mapping/create-account', 'Accounting\Controllers\RevenueMappingController@createAccount', ['auth', 'csrf'], 'accounting.revenue_mapping.manage'],
['POST', '/accounting/revenue-mapping/simulate', 'Accounting\Controllers\RevenueMappingController@simulate', ['auth', 'csrf'], 'accounting.revenue_mapping.view'],
['POST', '/accounting/revenue-mapping/sync', 'Accounting\Controllers\RevenueMappingController@sync', ['auth', 'csrf'], 'accounting.revenue_mapping.manage'],
['GET', '/accounting/revenue-mapping/{id:\d+}/edit', 'Accounting\Controllers\RevenueMappingController@edit', ['auth'], 'accounting.revenue_mapping.view'],
......
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>مركز التوصيل<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div style="margin-bottom:16px;">
<a href="/accounting/revenue-mapping" 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:760px;">
كل مسار بيحرّك فلوس في النظام، وحالته الحقيقية، والمبلغ الموجود فعلًا في جدول
الوحدة دلوقتي. المسارات مقسومة حسب <strong>اللي محتاجاه بالظبط</strong>
مش حسب خطورتها — عشان كل صف يبقى واضح مين يقدر يقفله.
</p>
</div>
<!-- Summary -->
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:12px;margin-bottom:20px;">
<div class="card" style="padding:14px 16px;border-right:3px solid #059669;">
<div style="font-size:12px;color:#6B7280;margin-bottom:6px;">موصولة بالدفاتر</div>
<div style="font-size:24px;font-weight:700;color:#059669;"><?= count($connected) ?></div>
</div>
<div class="card" style="padding:14px 16px;border-right:3px solid #D97706;">
<div style="font-size:12px;color:#6B7280;margin-bottom:6px;">تُوصَّل من الشاشة</div>
<div style="font-size:24px;font-weight:700;color:#D97706;"><?= count($mappable) ?></div>
<div style="font-size:11px;color:#9CA3AF;margin-top:2px;">ربط الحسابات وخلاص</div>
</div>
<div class="card" style="padding:14px 16px;border-right:3px solid #DC2626;">
<div style="font-size:12px;color:#6B7280;margin-bottom:6px;">تحتاج تعديل برمجي</div>
<div style="font-size:24px;font-weight:700;color:#DC2626;"><?= count($needsCode) ?></div>
<div style="font-size:11px;color:#9CA3AF;margin-top:2px;">الوحدة لا ترسل الحدث</div>
</div>
<div class="card" style="padding:14px 16px;">
<div style="font-size:12px;color:#6B7280;margin-bottom:6px;">المبلغ الموجود خارج الدفاتر</div>
<div style="font-size:22px;font-weight:700;color:#B45309;"><?= money($totalAtStake) ?></div>
</div>
</div>
<!-- ══════════ 1. Mappable now ══════════ -->
<div class="card" style="margin-bottom:18px;">
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;background:#FFFBEB;">
<h3 style="margin:0;font-size:14px;color:#92400E;">تُوصَّل الآن من الشاشة — بدون مبرمج</h3>
<div style="font-size:12px;color:#6B7280;margin-top:4px;">
الوحدة بترسل الحدث ومعاه المبلغ. اللي ناقص هو ربط الحسابات فقط، وده بيتعمل
من زرار «اربط الحسابات» وبيشتغل فورًا على أي حركة جاية.
</div>
</div>
<?php if (empty($mappable)): ?>
<div style="padding:26px;text-align:center;color:#059669;">كل المسارات الجاهزة للربط مربوطة بالفعل ✓</div>
<?php else: ?>
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead><tr><th style="width:30%;">المسار</th><th style="width:14%;">التصنيف</th><th style="width:18%;">المبلغ الحالي</th><th style="width:26%;">الناقص</th><th></th></tr></thead>
<tbody>
<?php foreach ($mappable as $s): ?>
<tr>
<td>
<div style="font-weight:600;"><?= e($s['name_ar']) ?></div>
<div style="font-size:11px;color:#9CA3AF;direction:ltr;text-align:right;"><?= e($s['stream_code']) ?></div>
</td>
<td style="font-size:12px;"><?= e($categories[$s['category']] ?? $s['category']) ?></td>
<td>
<?php if ($s['at_stake'] !== null): ?>
<div style="font-weight:600;"><?= money($s['at_stake']) ?></div>
<div style="font-size:11px;color:#6B7280;"><?= number_format((int) $s['row_count']) ?> سجل</div>
<?php elseif ($s['row_count'] !== null): ?>
<span style="font-size:12px;color:#6B7280;"><?= number_format((int) $s['row_count']) ?> سجل</span>
<?php else: ?>
<span style="color:#9CA3AF;"></span>
<?php endif; ?>
</td>
<td style="font-size:11.5px;color:#6B7280;"><?= e($s['wiring_note'] ?? 'ربط الحسابات') ?></td>
<td style="text-align:left;">
<?php if (can('accounting.revenue_mapping.manage')): ?>
<a href="/accounting/revenue-mapping/<?= (int) $s['id'] ?>/edit" class="btn btn-sm btn-primary">اربط الحسابات</a>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<!-- ══════════ 2. Needs code ══════════ -->
<div class="card" style="margin-bottom:18px;">
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;background:#FEF2F2;">
<h3 style="margin:0;font-size:14px;color:#991B1B;">تحتاج تعديل برمجي أولًا</h3>
<div style="font-size:12px;color:#6B7280;margin-top:4px;">
الوحدة بتسجّل الفلوس في جدولها الخاص ومش بترسل أي حدث. ربط الحسابات هنا
<strong>مش هيعمل حاجة</strong> لحد ما الوحدة تبعت الحدث — عشان كده مفيش زرار ربط،
وده مذكور صراحة بدل ما الشاشة تدّي إحساس كاذب إنها اتظبطت.
</div>
</div>
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead><tr><th style="width:26%;">المسار</th><th style="width:12%;">الوحدة</th><th style="width:18%;">المبلغ الحالي</th><th style="width:44%;">إيه اللي ناقص بالظبط</th></tr></thead>
<tbody>
<?php foreach ($needsCode as $s): ?>
<tr>
<td>
<div style="font-weight:600;"><?= e($s['name_ar']) ?></div>
<div style="font-size:11px;color:#9CA3AF;direction:ltr;text-align:right;"><?= e($s['stream_code']) ?></div>
</td>
<td style="font-size:12px;color:#6B7280;direction:ltr;text-align:right;"><?= e($s['source_module']) ?></td>
<td>
<?php if ($s['at_stake'] !== null && bccomp((string) $s['at_stake'], '0.00', 2) > 0): ?>
<div style="font-weight:600;color:#B45309;"><?= money($s['at_stake']) ?></div>
<div style="font-size:11px;color:#6B7280;"><?= number_format((int) $s['row_count']) ?> سجل</div>
<?php elseif ($s['row_count'] !== null && (int) $s['row_count'] > 0): ?>
<span style="font-size:12px;color:#6B7280;"><?= number_format((int) $s['row_count']) ?> سجل</span>
<?php else: ?>
<span style="color:#9CA3AF;font-size:12px;">لا توجد بيانات بعد</span>
<?php endif; ?>
</td>
<td style="font-size:11.5px;color:#4B5563;line-height:1.8;"><?= e($s['wiring_note'] ?? '') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<!-- ══════════ 3. Connected ══════════ -->
<div class="card">
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;background:#ECFDF5;">
<h3 style="margin:0;font-size:14px;color:#065F46;">موصولة — ويمكن إعادة توزيعها في أي وقت</h3>
<div style="font-size:12px;color:#6B7280;margin-top:4px;">
التوزيع الحالي ظاهر قدام كل مسار. أي مسار من دول تقدر تقسّمه على أكتر من حساب —
نسبة مئوية، أو مبلغ ثابت، والباقي — من زرار «عدّل التوزيع». التعديل بياخد إصدار
جديد بتاريخ سريان، والقيود القديمة ما بتتغيرش.
</div>
</div>
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead><tr><th style="width:24%;">المسار</th><th style="width:12%;">المرحلة</th><th style="width:46%;">التوزيع الحالي</th><th style="width:18%;"></th></tr></thead>
<tbody>
<?php foreach ($connected as $s): ?>
<?php $isSingle = count($s['lines'] ?? []) === 1; ?>
<tr>
<td>
<div style="font-weight:600;"><?= e($s['name_ar']) ?></div>
<div style="font-size:11px;color:#9CA3AF;direction:ltr;text-align:right;"><?= e($s['stream_code']) ?></div>
</td>
<td><span class="badge badge-success" style="font-size:10px;"><?= e($stageLabels[$s['rule_stage']] ?? $s['rule_stage']) ?></span></td>
<td>
<?php foreach (($s['lines'] ?? []) as $l): ?>
<div style="display:flex;align-items:center;gap:6px;font-size:12px;line-height:1.9;flex-wrap:wrap;">
<span style="min-width:58px;font-weight:600;color:<?= $l['allocation_method'] === 'remainder' ? '#374151' : '#2563EB' ?>;">
<?php if ($l['allocation_method'] === 'percentage'): ?>
<?= rtrim(rtrim(number_format((float) $l['percentage'], 2), '0'), '.') ?>%
<?php elseif ($l['allocation_method'] === 'fixed'): ?>
<?= number_format((float) $l['fixed_amount'], 2) ?>
<?php else: ?>
الكل
<?php endif; ?>
</span>
<span style="color:#9CA3AF;"></span>
<span style="direction:ltr;color:#6B7280;font-size:11px;"><?= e($l['account_code']) ?></span>
<span><?= e($l['account_name']) ?></span>
</div>
<?php endforeach; ?>
<?php if ($isSingle): ?>
<div style="font-size:11px;color:#9CA3AF;margin-top:3px;">حساب واحد — قابل للتقسيم</div>
<?php endif; ?>
</td>
<td style="text-align:left;">
<?php if (can('accounting.revenue_mapping.manage')): ?>
<a href="/accounting/revenue-mapping/<?= (int) $s['id'] ?>/edit?stage=<?= e($s['rule_stage']) ?>"
class="btn btn-sm <?= $isSingle ? 'btn-secondary' : 'btn-outline' ?>">
<?= $isSingle ? 'قسّم على حسابات' : 'عدّل التوزيع' ?>
</a>
<?php else: ?>
<a href="/accounting/revenue-mapping/<?= (int) $s['id'] ?>/edit" class="btn btn-sm btn-outline">عرض</a>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php $__template->endSection(); ?>
......@@ -230,6 +230,44 @@
</div>
</form>
<!-- ══════════ Create-account modal ══════════ -->
<div id="acct-modal" style="display:none;position:fixed;inset:0;background:rgba(15,23,42,.55);z-index:900;align-items:center;justify-content:center;padding:16px;">
<div class="card" style="max-width:520px;width:100%;">
<div style="padding:14px 18px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;">
<h3 style="margin:0;font-size:15px;">حساب جديد</h3>
<button type="button" id="acct-close" class="btn btn-sm btn-ghost">إغلاق</button>
</div>
<div style="padding:18px;">
<p style="margin:0 0 14px;font-size:12px;color:#6B7280;">
بيتعمل حساب فرعي تحت حساب رئيسي تختاره، وياخد الكود التالي المتاح تلقائيًا،
ويورث نوع وطبيعة الأب. بعد الحفظ بيتحدد في البند على طول.
</p>
<div style="display:flex;flex-direction:column;gap:12px;">
<div>
<label class="form-label">تحت أي حساب رئيسي؟ <span style="color:#DC2626;">*</span></label>
<select id="acct-parent" class="form-select">
<option value="">— اختر —</option>
</select>
<div class="form-help">الالتزامات للصناديق والأمانات، والإيرادات لبنود الإيراد.</div>
</div>
<div>
<label class="form-label">اسم الحساب (عربي) <span style="color:#DC2626;">*</span></label>
<input type="text" id="acct-name-ar" class="form-input" placeholder="مثال: صندوق دعم النشاط الرياضي">
</div>
<div>
<label class="form-label">الاسم (إنجليزي)</label>
<input type="text" id="acct-name-en" class="form-input" dir="ltr">
</div>
<div id="acct-error" style="display:none;background:#FEF2F2;border:1px solid #FECACA;border-radius:6px;padding:9px 11px;color:#991B1B;font-size:12px;"></div>
</div>
<div style="margin-top:16px;display:flex;gap:8px;">
<button type="button" id="acct-save" class="btn btn-primary">إنشاء وتحديد</button>
<button type="button" id="acct-cancel" class="btn btn-ghost">إلغاء</button>
</div>
</div>
</div>
</div>
<!-- ══════════ Line template ══════════ -->
<template id="line-template">
<div class="rule-line" style="border:1px solid #E5E7EB;border-radius:8px;padding:12px;margin-bottom:10px;background:#fff;">
......@@ -258,7 +296,10 @@
</div>
<div>
<label class="form-label" style="font-size:11px;">الحساب</label>
<label class="form-label" style="font-size:11px;display:flex;justify-content:space-between;align-items:center;">
<span>الحساب</span>
<button type="button" class="new-acct" style="background:none;border:none;padding:0;cursor:pointer;color:#1F5FA8;font-size:11px;text-decoration:underline;">+ حساب جديد</button>
</label>
<input type="text" class="form-input acct-search f-acct-search" placeholder="ابحث بالكود أو الاسم">
<input type="hidden" class="f-account-id">
<div class="acct-results"></div>
......@@ -421,6 +462,12 @@
[value, el.querySelector('.f-desc'), el.querySelector('.f-base'), el.querySelector('.f-months')]
.forEach(function (i) { if (i) i.addEventListener('input', sync); });
// "+ حساب جديد" — create the account without leaving the screen, then
// drop it straight into this line.
el.querySelector('.new-acct').addEventListener('click', function () {
openAccountModal(el.querySelector('.f-account-id'), el.querySelector('.f-acct-search'));
});
el.querySelector('.remove-line').addEventListener('click', function () { el.remove(); renumber(); sync(); });
el.querySelector('.move-up').addEventListener('click', function () {
if (el.previousElementSibling) { container.insertBefore(el, el.previousElementSibling); renumber(); sync(); }
......@@ -615,6 +662,73 @@
simOut.innerHTML = html;
}
// ── Create-account modal ────────────────────────────────────
var modal = document.getElementById('acct-modal');
var mParent = document.getElementById('acct-parent');
var mNameAr = document.getElementById('acct-name-ar');
var mNameEn = document.getElementById('acct-name-en');
var mError = document.getElementById('acct-error');
var pendingHidden = null, pendingInput = null, parentsLoaded = false;
function openAccountModal(hidden, input) {
pendingHidden = hidden;
pendingInput = input;
mError.style.display = 'none';
mNameAr.value = ''; mNameEn.value = '';
modal.style.display = 'flex';
mNameAr.focus();
if (parentsLoaded) return;
fetch('/accounting/revenue-mapping/parent-accounts')
.then(function (r) { return r.json(); })
.then(function (d) {
(d.parents || []).forEach(function (p) {
var o = document.createElement('option');
o.value = p.id;
o.textContent = p.account_code + ' — ' + p.name_ar;
mParent.appendChild(o);
});
parentsLoaded = true;
});
}
function closeAccountModal() { modal.style.display = 'none'; }
document.getElementById('acct-close').addEventListener('click', closeAccountModal);
document.getElementById('acct-cancel').addEventListener('click', closeAccountModal);
modal.addEventListener('click', function (e) { if (e.target === modal) closeAccountModal(); });
document.getElementById('acct-save').addEventListener('click', function () {
mError.style.display = 'none';
var body = new FormData();
body.append('parent_id', mParent.value);
body.append('name_ar', mNameAr.value);
body.append('name_en', mNameEn.value);
if (csrf) { body.append('_csrf_token', csrf.value); }
fetch('/accounting/revenue-mapping/create-account', {
method: 'POST', body: body,
headers: { 'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-TOKEN': csrf ? csrf.value : '' }
})
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.success) {
mError.textContent = d.error || 'تعذر إنشاء الحساب';
mError.style.display = 'block';
return;
}
if (pendingHidden && pendingInput) {
pendingHidden.value = d.account.id;
pendingInput.value = d.account.account_code + ' — ' + d.account.name_ar;
}
closeAccountModal();
sync();
})
.catch(function () {
mError.textContent = 'تعذر الاتصال بالخادم';
mError.style.display = 'block';
});
});
// ── Boot ────────────────────────────────────────────────────
document.getElementById('add-line').addEventListener('click', function () { addLine(null); sync(); });
simAmount.addEventListener('input', sync);
......
......@@ -124,6 +124,7 @@ MenuRegistry::register('accounting', [
['label_ar' => 'لوحة التحكم', 'label_en' => 'Dashboard', 'route' => '/accounting', 'permission' => 'accounting.reports.view', 'order' => 1],
['label_ar' => 'دليل الحسابات', 'label_en' => 'Chart of Accounts', 'route' => '/accounting/chart-of-accounts', 'permission' => 'accounting.coa.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' => '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 Types', 'route' => '/accounting/journal-types', 'permission' => 'accounting.journal_type.view', 'order' => 4],
......
<?php
declare(strict_types=1);
/**
* Record WHY a money path is not reaching the ledger.
*
* "Not connected" hides two very different situations, and the difference decides
* who can fix it:
*
* dispatches the module already fires an event carrying the amount, so mapping
* it on the screen is all that is needed — finance can do it live.
* needs_code the module records the money in its own table and fires nothing.
* A mapping alone changes nothing; a developer must emit the event
* first. Saying otherwise on a screen is a lie waiting to be found.
*
* Without this the connection screen would show a "map it" button next to paths
* where mapping cannot possibly work.
*/
return function (\App\Core\Database $db): void {
$cols = $db->select(
"SELECT column_name FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'revenue_streams'"
);
$have = array_map('strtolower', array_column($cols, 'column_name'));
if (!\in_array('wiring_status', $have, true)) {
$db->raw("
ALTER TABLE `revenue_streams`
ADD COLUMN `wiring_status`
ENUM('dispatches','needs_code','manual_only')
NOT NULL DEFAULT 'dispatches'
COMMENT 'dispatches = event exists, mapping is enough; needs_code = a developer must emit the event first'
AFTER `source_key`
");
}
if (!\in_array('wiring_note', $have, true)) {
$db->raw("
ALTER TABLE `revenue_streams`
ADD COLUMN `wiring_note` VARCHAR(500) NULL
COMMENT 'what exactly is missing, in plain Arabic, for the connection screen'
AFTER `wiring_status`
");
}
if (!\in_array('evidence_table', $have, true)) {
$db->raw("
ALTER TABLE `revenue_streams`
ADD COLUMN `evidence_table` VARCHAR(64) NULL
COMMENT 'table holding the money today, so the screen can show the amount at stake'
AFTER `wiring_note`
");
$db->raw("
ALTER TABLE `revenue_streams`
ADD COLUMN `evidence_amount_column` VARCHAR(64) NULL AFTER `evidence_table`
");
$db->raw("
ALTER TABLE `revenue_streams`
ADD COLUMN `evidence_where` VARCHAR(255) NULL AFTER `evidence_amount_column`
");
}
$idx = $db->select(
"SELECT index_name FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'revenue_streams'
AND index_name = 'idx_stream_wiring'"
);
if (empty($idx)) {
$db->raw("ALTER TABLE `revenue_streams` ADD INDEX `idx_stream_wiring` (`wiring_status`, `is_active`)");
}
};
<?php
declare(strict_types=1);
/**
* Register every money path that exists in the ERP but does not yet reach the
* ledger, so the connection screen shows the WHOLE picture instead of only the
* parts that already work.
*
* Nothing here changes any posting. These are catalogue entries: a name, where the
* money sits today, and an honest note on what is missing. A path marked
* needs_code cannot be fixed by mapping alone and the screen says so rather than
* offering a button that would do nothing.
*
* Sourced from a module-by-module audit of all 67 modules against the live schema.
*/
return function (\App\Core\Database $db): void {
$now = date('Y-m-d H:i:s');
// [stream_code, name_ar, module, category, wiring, note, table, amount_col, where]
$streams = [
// ── الأنشطة الرياضية ─────────────────────────────────────────────
['sa:hourly_booking', 'حجز ملعب بالساعة', 'sports_activity', 'facility', 'needs_code',
'شاشة الحجوزات وشاشة المرآة بتسعّر الحجز وبتسجّله في sa_bookings من غير ما تعمل طلب دفع. الكود عنده دالة تحصيل جاهزة (SaPaymentService::payBooking) بس مش موصولة بأي زرار.',
'sa_bookings', 'total_amount', "payment_status = 'unpaid'"],
['sa:monthly_subscription', 'اشتراك نشاط شهري', 'sports_activity', 'activity', 'needs_code',
'التوليد الشهري بيعمل مستحقات في sa_subscriptions من غير ما يضيفها لطابور التحصيل. بتتحصّل واحدة واحدة من شاشة الاشتراك.',
'sa_subscriptions', 'final_amount', "payment_status IN ('overdue','unpaid')"],
['sa:locker_rental', 'إيجار لوكر', 'sports_activity', 'facility', 'needs_code',
'الجدول فيه أعمدة الدفع والإيصال والواجهة بتعرض حالة «مدفوع»، لكن مفيش كود بيعمل الدفعة أصلًا.',
'sa_locker_rentals', 'amount', null],
['sa:player_card', 'كارت النشاط الرياضي (إصدار/تجديد)', 'sports_activity', 'activity', 'needs_code',
'مفيش واجهة للتجديد، ونوع الدفع مرفوض للاعبين غير الأعضاء.',
'sa_player_cards', null, null],
['sa:pool_zone_booking', 'حجز منطقة حمام السباحة', 'sports_activity', 'facility', 'needs_code',
'أكتر سطح استخدامًا في النظام وبيتسجّل من غير أي مسار تحصيل.',
'sa_pool_zone_bookings', null, null],
['sa:registration_form', 'استمارة تسجيل نشاط رياضي', 'sports_activity', 'academy', 'dispatches',
'بتتحصّل فعليًا — رسوم الاستمارة بس، من غير تسعير الاشتراك.',
'sa_registrations', null, null],
// ── الأكاديميات ──────────────────────────────────────────────────
['academy:enrollment', 'قيد لاعب في أكاديمية', 'academies', 'academy', 'needs_code',
'القيد هو بوابة الفوترة الشهرية، وبيتم من غير أي رسوم أو حدث.',
'academy_enrollments', null, null],
['academy:contract_deposit', 'تأمين عقد أكاديمية', 'academy_contracts', 'academy', 'needs_code',
'التأمين بيتعلّم «مدفوع» من قائمة منسدلة — مش دفعة. مفيش إيصال ولا قيد.',
'sa_academy_contracts', 'deposit_amount', null],
['academy:contract_rent', 'إيجار شهري لعقد أكاديمية', 'academy_contracts', 'academy', 'needs_code',
'الإيجار الشهري متسجّل على العقد وبيتعرض في الشاشة، ومفيش توليد فواتير خالص.',
'sa_academy_contracts', 'monthly_rent', null],
['academy:settlement', 'تسوية أكاديمية شهرية', 'academy_contracts', 'academy', 'needs_code',
'محرك التسويات بيقرا من جدول فاضي (academy_contracts) بينما العقود الحقيقية في sa_academy_contracts.',
'academy_settlements', 'net_amount', null],
// ── المرافق والحجوزات ────────────────────────────────────────────
['facility:reservation', 'حجز مرفق', 'reservations', 'facility', 'needs_code',
'الحجز بيحسب المبلغ وبيخزنه في reservations.total_amount ومفيش مسار تحصيل موجود أصلًا.',
'reservations', 'total_amount', 'payment_id IS NULL'],
['facility:private_match', 'مباراة خاصة (مقدم)', 'reservations', 'facility', 'needs_code',
'المقدم بيتحصّل على الكاونتر وبيتكتب في عمود deposit_paid بس.',
'private_match_bookings', 'deposit_paid', null],
['facility:pool_booking', 'حجز حمام السباحة', 'pool_management', 'facility', 'needs_code',
'كل حجز بيتعمل بصفر بالتصميم — unit_rate و total_amount ثابتين صفر في الكود.',
'pool_bookings', 'total_amount', null],
// ── الإيجارات ────────────────────────────────────────────────────
['rental:contract_deposit', 'تأمين عقد إيجار', 'rentals', 'rental', 'dispatches',
'الحدث موجود ومربوط، لكن التأمين مش بيمر على بوابة المدفوعات فمفيش إيصال.',
'rental_contracts', 'deposit_amount', null],
['rental:monthly_invoice', 'فاتورة إيجار شهرية', 'rentals', 'rental', 'needs_code',
'الفاتورة بتتولّد كمستحق في جدول الإيجارات من غير قيد استحقاق.',
'rental_invoices', 'total_amount', "status <> 'paid'"],
// ── البطولات ─────────────────────────────────────────────────────
['tournament:registration_fee', 'رسوم الاشتراك في بطولة', 'tournaments', 'activity', 'needs_code',
'كل الطبقة المحاسبية جاهزة (مستمع + حساب + قاعدة) بس مفيش حد بيرسل الحدث. التسجيل بيعمل مشارك من غير رسوم.',
'tournament_participants', null, null],
// ── المبيعات والمخزون ────────────────────────────────────────────
['inventory:goods_receipt', 'استلام بضاعة من أمر شراء', 'inventory', 'procurement', 'needs_code',
'المخزون بيدخل دفتر المخزون من غير أي قيد مقابل في الدفاتر.',
'goods_received_notes', 'total_value', null],
['inventory:stock_variance', 'تسوية فروق الجرد', 'inventory', 'procurement', 'needs_code',
'العجز والزيادة أثرهما مباشر على الأرباح ومفيش مسار للدفاتر.',
'stock_audits', null, null],
['inventory:depreciation', 'إهلاك الأصول الشهري', 'inventory', 'procurement', 'needs_code',
'الإهلاك بيتسجل في دفتر الأصول بس.',
'depreciation_entries', 'amount', null],
['inventory:asset_disposal', 'استبعاد أصل والتصرف فيه', 'inventory', 'procurement', 'needs_code',
'حصيلة بيع الأصل بتتكتب في عمود ومفيش قيد ولا ربح/خسارة استبعاد.',
'asset_register', null, null],
// ── الموارد البشرية ──────────────────────────────────────────────
['hr:loan_disbursement', 'صرف سلفة لموظف', 'hr', 'payroll', 'needs_code',
'فلوس بتخرج من غير أي أثر مالي — المفروض تعمل مديونية على الموظف.',
'hr_employee_loans', 'loan_amount', null],
['hr:end_of_service', 'مستحقات نهاية الخدمة', 'hr', 'payroll', 'needs_code',
'أكبر تدفق نقدي فردي في الموارد البشرية وبيتسجّل ماليًا في مكان.',
'hr_end_of_service', 'total_amount', null],
['hr:coach_payment', 'مستحقات المدربين', 'coaches', 'payroll', 'needs_code',
'حاسبة رواتب كاملة من غير أي شاشة أو مهمة بتشغّلها، والمستمع مربوط على اسم حدث مختلف عن المُرسِل.',
'coach_payments', 'net_amount', null],
// ── الاشتراكات والغرامات ─────────────────────────────────────────
['subscription:annual_accrual', 'استحقاق الاشتراك السنوي', 'subscriptions', 'subscription', 'needs_code',
'توليد الاشتراكات بيعمل مديونية على العضو من غير قيد استحقاق.',
'subscriptions', 'base_amount', "status IN ('pending','overdue')"],
['subscription:late_fee', 'غرامة تأخير الاشتراك', 'subscriptions', 'penalty', 'needs_code',
'الغرامة بتتكتب على الاشتراك نفسه ومش بتتحول لغرامة ولا بترسل حدث.',
'subscriptions', 'fine_amount', 'fine_amount > 0'],
['fine:waived', 'الإعفاء من غرامة', 'fines', 'penalty', 'needs_code',
'الإعفاء بيلغي المطالبة من غير قيد عكسي، فالمديونية بتفضل في الدفاتر.',
'fines', 'amount', null],
];
foreach ($streams as [$code, $nameAr, $module, $category, $wiring, $note, $table, $amountCol, $where]) {
// Only catalogue a path whose table actually exists in this database.
if ($table !== null) {
$exists = $db->selectOne(
"SELECT 1 AS ok FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = ?",
[$table]
);
if (!$exists) {
$table = null;
$amountCol = null;
$where = null;
}
}
$existing = $db->selectOne("SELECT id FROM revenue_streams WHERE stream_code = ?", [$code]);
$row = [
'wiring_status' => $wiring,
'wiring_note' => $note,
'evidence_table' => $table,
'evidence_amount_column' => $amountCol,
'evidence_where' => $where,
'updated_at' => $now,
];
if ($existing) {
$db->update('revenue_streams', $row, '`id` = ?', [(int) $existing['id']]);
continue;
}
$db->insert('revenue_streams', $row + [
'stream_code' => $code,
'name_ar' => $nameAr,
'source_module' => $module,
'category' => $category,
'is_system' => 1,
'is_active' => 1,
'created_at' => $now,
]);
}
// Everything already mapped and posting is, by definition, dispatching.
$db->raw("
UPDATE revenue_streams s
JOIN revenue_posting_rules r ON r.stream_id = s.id AND r.status = 'active'
SET s.wiring_status = 'dispatches'
WHERE s.wiring_status IS NULL OR s.wiring_status = ''
");
};
<?php
declare(strict_types=1);
/**
* Create the club fund accounts a distribution rule actually needs to point at.
*
* The single most likely request in a finance review is "no — 30% of the
* membership fee goes to this fund and 10% to that one". Answering it on the
* screen requires the funds to exist as postable accounts. The chart already had
* صندوق الجزاءات and صندوق الزمالة; these are the rest of the set an Egyptian
* sporting club normally carries.
*
* They are LIABILITIES, not revenue: money collected from a member and earmarked
* for a fund is held on that fund's behalf until it is spent or remitted. Posting
* it to a revenue account would overstate income and understate obligations.
*
* طابع الشهداء in particular is already priced in the service catalogue
* (SVC_MARTYRS_STAMP, 5 EGP) with nowhere to post it.
*
* Creating them commits nothing — no rule points at them until someone maps one.
*/
return function (\App\Core\Database $db): void {
$now = date('Y-m-d H:i:s');
$parent = $db->selectOne(
"SELECT id, level FROM chart_of_accounts WHERE account_code = '2308' AND is_archived = 0"
);
if (!$parent) {
return;
}
// Group header for the funds, so they sit together in the chart.
$group = $db->selectOne("SELECT id, level FROM chart_of_accounts WHERE account_code = '230821'");
if (!$group) {
$groupId = $db->insert('chart_of_accounts', [
'account_code' => '230821',
'name_ar' => 'صناديق النادي والأمانات المخصصة',
'name_en' => 'Club Funds and Earmarked Deposits',
'account_type' => 'liability',
'account_nature' => 'credit',
'parent_id' => (int) $parent['id'],
'level' => ((int) $parent['level']) + 1,
'level_name' => 'فرعي',
'is_header' => 1,
'is_active' => 1,
'is_system' => 1,
'currency' => 'EGP',
'created_at' => $now,
'updated_at' => $now,
]);
$groupLevel = ((int) $parent['level']) + 1;
} else {
$groupId = (int) $group['id'];
$groupLevel = (int) $group['level'];
}
$funds = [
['23082101', 'صندوق دعم النشاط الرياضي', 'Sports Activity Support Fund'],
['23082102', 'صندوق الرعاية الاجتماعية للأعضاء', 'Member Welfare Fund'],
['23082103', 'طابع الشهداء — محصّل لحساب الغير', 'Martyrs Stamp — Collected for Third Party'],
['23082104', 'حصة الاتحاد الرياضي', 'Sports Federation Share'],
['23082105', 'صندوق تطوير المنشآت', 'Facilities Development Fund'],
];
foreach ($funds as [$code, $nameAr, $nameEn]) {
if ($db->selectOne("SELECT id FROM chart_of_accounts WHERE account_code = ?", [$code])) {
continue;
}
$db->insert('chart_of_accounts', [
'account_code' => $code,
'name_ar' => $nameAr,
'name_en' => $nameEn,
'account_type' => 'liability',
'account_nature' => 'credit',
'parent_id' => $groupId,
'level' => $groupLevel + 1,
'level_name' => 'جزئي',
'is_header' => 0,
'is_active' => 1,
'is_system' => 1,
'currency' => 'EGP',
'description_ar' => 'صندوق مخصص — يُحصَّل من العضو ويُحتفظ به التزامًا حتى الصرف أو التوريد',
'created_at' => $now,
'updated_at' => $now,
]);
}
};
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