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 ...@@ -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. */ /** Re-scan the ERP for chargeable things that have no stream yet. */
public function sync(): Response public function sync(): Response
{ {
...@@ -724,6 +824,129 @@ class RevenueMappingController extends Controller ...@@ -724,6 +824,129 @@ class RevenueMappingController extends Controller
return $this->redirect('/accounting/revenue-mapping')->withSuccess($msg); 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. */ /** Account picker used by the rule builder. */
public function searchAccounts(Request $request): Response public function searchAccounts(Request $request): Response
{ {
......
...@@ -152,7 +152,10 @@ return [ ...@@ -152,7 +152,10 @@ return [
['POST', '/accounting/revenue-mapping/tax-profiles/{id:\d+}', 'Accounting\Controllers\RevenueMappingController@updateTaxProfile', ['auth', 'csrf'], 'accounting.revenue_mapping.manage'], ['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'], ['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'], ['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/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/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'], ['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'], ['GET', '/accounting/revenue-mapping/{id:\d+}/edit', 'Accounting\Controllers\RevenueMappingController@edit', ['auth'], 'accounting.revenue_mapping.view'],
......
This diff is collapsed.
...@@ -230,6 +230,44 @@ ...@@ -230,6 +230,44 @@
</div> </div>
</form> </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 ══════════ --> <!-- ══════════ Line template ══════════ -->
<template id="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;"> <div class="rule-line" style="border:1px solid #E5E7EB;border-radius:8px;padding:12px;margin-bottom:10px;background:#fff;">
...@@ -258,7 +296,10 @@ ...@@ -258,7 +296,10 @@
</div> </div>
<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="text" class="form-input acct-search f-acct-search" placeholder="ابحث بالكود أو الاسم">
<input type="hidden" class="f-account-id"> <input type="hidden" class="f-account-id">
<div class="acct-results"></div> <div class="acct-results"></div>
...@@ -421,6 +462,12 @@ ...@@ -421,6 +462,12 @@
[value, el.querySelector('.f-desc'), el.querySelector('.f-base'), el.querySelector('.f-months')] [value, el.querySelector('.f-desc'), el.querySelector('.f-base'), el.querySelector('.f-months')]
.forEach(function (i) { if (i) i.addEventListener('input', sync); }); .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('.remove-line').addEventListener('click', function () { el.remove(); renumber(); sync(); });
el.querySelector('.move-up').addEventListener('click', function () { el.querySelector('.move-up').addEventListener('click', function () {
if (el.previousElementSibling) { container.insertBefore(el, el.previousElementSibling); renumber(); sync(); } if (el.previousElementSibling) { container.insertBefore(el, el.previousElementSibling); renumber(); sync(); }
...@@ -615,6 +662,73 @@ ...@@ -615,6 +662,73 @@
simOut.innerHTML = html; 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 ──────────────────────────────────────────────────── // ── Boot ────────────────────────────────────────────────────
document.getElementById('add-line').addEventListener('click', function () { addLine(null); sync(); }); document.getElementById('add-line').addEventListener('click', function () { addLine(null); sync(); });
simAmount.addEventListener('input', sync); simAmount.addEventListener('input', sync);
......
...@@ -124,6 +124,7 @@ MenuRegistry::register('accounting', [ ...@@ -124,6 +124,7 @@ MenuRegistry::register('accounting', [
['label_ar' => 'لوحة التحكم', 'label_en' => 'Dashboard', 'route' => '/accounting', 'permission' => 'accounting.reports.view', 'order' => 1], ['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' => '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' => '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' => '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);
/**
* 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`)");
}
};
This diff is collapsed.
<?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