Commit 1edf3267 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(accounting): manage the chart of accounts from the screen — move, delete, reclassify

The chart could only be created and edited. Restructuring it — the thing that
actually gets asked for — meant a developer.

Adds move / delete / promote-demote with the guards a ledger needs, each refusing
with the specific reason rather than failing later at month end:

MOVE
- Refuses a move under the account's own descendant, which would detach the
  subtree from the root and make the tree query loop.
- Refuses a parent of a different account_type — that would file an asset under
  liabilities and quietly corrupt the balance sheet.
- Refuses a non-header parent.
- Carries the whole subtree and recomputes every level beneath.

DELETE
- An account with posted history is ARCHIVED, never deleted: removing it would
  leave old journal lines pointing at a name that no longer exists. The button
  relabels itself to "أرشفة" and says why.
- Refuses while children exist, or while anything still points at it — posting
  rules, tax profiles, voucher types, vouchers, bank accounts, treasuries — and
  lists what, so the blocker is actionable.
- System accounts can be deactivated, not removed.

PROMOTE / DEMOTE
- Refuses to promote an account that already carries movement; a header takes no
  entries, so its balance would be stranded.
- Refuses to demote one with children; entries would post at a summary level and
  double-count up the tree.

The management panel loads the usage check before offering anything, so a button
that is going to be refused is disabled with the reason instead of being offered
and failing.

Reference scanning tolerates a missing table or column, so a trimmed or older
install does not break the screen. Descendant walking is iterative and guarded
against a pre-existing cycle rather than recursing into a hang.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent b43c5268
...@@ -9,6 +9,7 @@ use App\Core\App; ...@@ -9,6 +9,7 @@ use App\Core\App;
use App\Core\Response; use App\Core\Response;
use App\Modules\Accounting\Models\Account; use App\Modules\Accounting\Models\Account;
use App\Modules\Accounting\Models\CostCenter; use App\Modules\Accounting\Models\CostCenter;
use App\Modules\Accounting\Services\ChartOfAccountsService;
class ChartOfAccountsController extends Controller class ChartOfAccountsController extends Controller
{ {
...@@ -163,4 +164,107 @@ class ChartOfAccountsController extends Controller ...@@ -163,4 +164,107 @@ class ChartOfAccountsController extends Controller
return $this->json($results); return $this->json($results);
} }
// ────────────────────────────────────────────────────────────
// Structural edits
// ────────────────────────────────────────────────────────────
/** Everything pointing at an account — shown before any destructive action. */
public function usage(Request $request, string $id): Response
{
$this->authorize('accounting.coa.view');
$db = App::getInstance()->db();
$acc = $db->selectOne(
"SELECT id, account_code, name_ar, is_header, is_active, is_system, is_archived
FROM chart_of_accounts WHERE id = ?",
[(int) $id]
);
if (!$acc) {
return $this->json(['success' => false, 'error' => 'الحساب غير موجود']);
}
$usage = ChartOfAccountsService::usage((int) $id);
// Say plainly what each action would do, so the screen never offers a button
// that is going to be refused.
$blockers = [];
if ($usage['children'] > 0) {
$blockers[] = 'له ' . $usage['children'] . ' حساب فرعي';
}
if ($usage['lines'] > 0) {
$blockers[] = 'عليه ' . $usage['lines'] . ' حركة مرحّلة برصيد ' . $usage['balance'];
}
foreach ($usage['refs'] as $r) {
$blockers[] = 'مستخدم في ' . $r;
}
return $this->json([
'success' => true,
'account' => $acc,
'usage' => $usage,
'blockers' => $blockers,
'can_delete' => $usage['children'] === 0 && empty($usage['refs']) && (int) ($acc['is_system'] ?? 0) === 0,
'will_archive'=> $usage['lines'] > 0,
]);
}
public function reparent(Request $request, string $id): Response
{
$this->authorize('accounting.coa.manage');
$newParent = $request->post('parent_id');
$newParent = ($newParent === null || $newParent === '' || (int) $newParent === 0)
? null
: (int) $newParent;
$result = ChartOfAccountsService::reparent((int) $id, $newParent);
return $result['success']
? $this->redirect('/accounting/chart-of-accounts')
->withSuccess('تم نقل الحساب و' . (($result['moved'] ?? 1) - 1) . ' حساب فرعي تحته')
: $this->redirect('/accounting/chart-of-accounts')->withError($result['error'] ?? 'فشل النقل');
}
public function destroy(Request $request, string $id): Response
{
$this->authorize('accounting.coa.manage');
$result = ChartOfAccountsService::remove((int) $id);
if (!$result['success']) {
return $this->redirect('/accounting/chart-of-accounts')->withError($result['error'] ?? 'تعذر الحذف');
}
return !empty($result['archived'])
? $this->redirect('/accounting/chart-of-accounts')
->withWarning('الحساب عليه حركات مرحّلة — تم أرشفته بدل حذفه حتى تظل القيود القديمة مقروءة')
: $this->redirect('/accounting/chart-of-accounts')->withSuccess('تم حذف الحساب');
}
public function toggleHeader(Request $request, string $id): Response
{
$this->authorize('accounting.coa.manage');
$makeHeader = (int) $request->post('is_header', 0) === 1;
$result = ChartOfAccountsService::setHeader((int) $id, $makeHeader);
return $result['success']
? $this->redirect('/accounting/chart-of-accounts')
->withSuccess($makeHeader ? 'تم تحويله لحساب رئيسي' : 'تم تحويله لحساب ترحيل')
: $this->redirect('/accounting/chart-of-accounts')->withError($result['error'] ?? 'تعذر التغيير');
}
/** Next free code under a parent, for the create form. */
public function nextCode(Request $request): Response
{
$this->authorize('accounting.coa.manage');
$parentId = (int) $request->get('parent_id', 0);
if ($parentId <= 0) {
return $this->json(['code' => null]);
}
return $this->json(['code' => ChartOfAccountsService::nextCode($parentId)]);
}
} }
...@@ -19,6 +19,11 @@ return [ ...@@ -19,6 +19,11 @@ return [
['GET', '/accounting/chart-of-accounts/{id:\d+}/edit', 'Accounting\Controllers\ChartOfAccountsController@edit', ['auth'], 'accounting.coa.manage'], ['GET', '/accounting/chart-of-accounts/{id:\d+}/edit', 'Accounting\Controllers\ChartOfAccountsController@edit', ['auth'], 'accounting.coa.manage'],
['POST', '/accounting/chart-of-accounts/{id:\d+}', 'Accounting\Controllers\ChartOfAccountsController@update', ['auth', 'csrf'], 'accounting.coa.manage'], ['POST', '/accounting/chart-of-accounts/{id:\d+}', 'Accounting\Controllers\ChartOfAccountsController@update', ['auth', 'csrf'], 'accounting.coa.manage'],
['GET', '/accounting/chart-of-accounts/search', 'Accounting\Controllers\ChartOfAccountsController@search', ['auth'], 'accounting.coa.view'], ['GET', '/accounting/chart-of-accounts/search', 'Accounting\Controllers\ChartOfAccountsController@search', ['auth'], 'accounting.coa.view'],
['GET', '/accounting/chart-of-accounts/next-code', 'Accounting\Controllers\ChartOfAccountsController@nextCode', ['auth'], 'accounting.coa.manage'],
['GET', '/accounting/chart-of-accounts/{id:\d+}/usage', 'Accounting\Controllers\ChartOfAccountsController@usage', ['auth'], 'accounting.coa.view'],
['POST', '/accounting/chart-of-accounts/{id:\d+}/reparent', 'Accounting\Controllers\ChartOfAccountsController@reparent', ['auth', 'csrf'], 'accounting.coa.manage'],
['POST', '/accounting/chart-of-accounts/{id:\d+}/delete', 'Accounting\Controllers\ChartOfAccountsController@destroy', ['auth', 'csrf'], 'accounting.coa.manage'],
['POST', '/accounting/chart-of-accounts/{id:\d+}/header', 'Accounting\Controllers\ChartOfAccountsController@toggleHeader', ['auth', 'csrf'], 'accounting.coa.manage'],
// ── Journal Types ──────────────────────────────────────── // ── Journal Types ────────────────────────────────────────
['GET', '/accounting/journal-types', 'Accounting\Controllers\JournalTypeController@index', ['auth'], 'accounting.journal_type.view'], ['GET', '/accounting/journal-types', 'Accounting\Controllers\JournalTypeController@index', ['auth'], 'accounting.journal_type.view'],
......
This diff is collapsed.
...@@ -67,7 +67,13 @@ ...@@ -67,7 +67,13 @@
<span style="flex:1;margin:0 15px;font-weight:<?= $weight ?>;"><?= e($acc['name_ar']) ?></span> <span style="flex:1;margin:0 15px;font-weight:<?= $weight ?>;"><?= e($acc['name_ar']) ?></span>
<span style="width:80px;font-size:12px;color:#6B7280;"><?= $typeLabel ?></span> <span style="width:80px;font-size:12px;color:#6B7280;"><?= $typeLabel ?></span>
<span style="width:100px;direction:ltr;text-align:left;font-size:13px;"><?= !$isHeader ? money($acc['current_balance'] ?? '0.00') : '' ?></span> <span style="width:100px;direction:ltr;text-align:left;font-size:13px;"><?= !$isHeader ? money($acc['current_balance'] ?? '0.00') : '' ?></span>
<?php if (can('accounting.coa.manage')): ?><a href="/accounting/chart-of-accounts/<?= (int)$acc['id'] ?>/edit" style="font-size:12px;color:#0D7377;">تعديل</a><?php endif; ?> <?php if (can('accounting.coa.manage')): ?>
<a href="/accounting/chart-of-accounts/<?= (int)$acc['id'] ?>/edit" style="font-size:12px;color:#0D7377;">تعديل</a>
<button type="button" class="coa-manage" data-id="<?= (int)$acc['id'] ?>"
data-code="<?= e($acc['account_code']) ?>" data-name="<?= e($acc['name_ar']) ?>"
data-header="<?= (int)$acc['is_header'] ?>"
style="background:none;border:none;cursor:pointer;color:#6B7280;font-size:12px;margin-inline-start:10px;">إدارة</button>
<?php endif; ?>
</div> </div>
<?php <?php
if (!empty($acc['children'])) { if (!empty($acc['children'])) {
...@@ -108,4 +114,126 @@ ...@@ -108,4 +114,126 @@
filter(); filter();
})(); })();
</script> </script>
<?php if (can('accounting.coa.manage')): ?>
<div id="coa-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:560px;width:100%;">
<div style="padding:14px 18px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;">
<div>
<h3 style="margin:0;font-size:15px;" id="cm-name"></h3>
<div style="font-size:11px;color:#9CA3AF;direction:ltr;text-align:right;" id="cm-code"></div>
</div>
<button type="button" id="cm-close" class="btn btn-sm btn-ghost">إغلاق</button>
</div>
<div style="padding:18px;">
<div id="cm-usage" style="font-size:12.5px;color:#4B5563;margin-bottom:16px;">جارٍ الفحص…</div>
<div style="border-top:1px solid #F3F4F6;padding-top:14px;margin-bottom:14px;">
<label class="form-label">نقل الحساب تحت حساب رئيسي آخر</label>
<form method="POST" id="cm-move-form" style="display:flex;gap:8px;">
<?= csrf_field() ?>
<select name="parent_id" id="cm-parent" class="form-select" style="flex:1;">
<option value="">— جذر الشجرة —</option>
<?php
$__headers = \App\Core\App::getInstance()->db()->select(
"SELECT id, account_code, name_ar, account_type FROM chart_of_accounts
WHERE is_header = 1 AND is_archived = 0 AND is_active = 1 ORDER BY account_code"
);
foreach ($__headers as $h): ?>
<option value="<?= (int)$h['id'] ?>" data-type="<?= e($h['account_type']) ?>">
<?= e($h['account_code'] . ' — ' . $h['name_ar']) ?>
</option>
<?php endforeach; ?>
</select>
<button type="submit" class="btn btn-secondary">نقل</button>
</form>
<div class="form-help">النقل بيحرّك الحسابات المتفرعة معاه، وبيرفض لو النوع مختلف أو لو هيعمل دائرة.</div>
</div>
<div style="border-top:1px solid #F3F4F6;padding-top:14px;margin-bottom:14px;display:flex;gap:8px;align-items:center;">
<form method="POST" id="cm-header-form" style="display:inline;">
<?= csrf_field() ?>
<input type="hidden" name="is_header" id="cm-header-val">
<button type="submit" class="btn btn-outline btn-sm" id="cm-header-btn"></button>
</form>
<span style="font-size:11.5px;color:#6B7280;" id="cm-header-help"></span>
</div>
<div style="border-top:1px solid #F3F4F6;padding-top:14px;">
<form method="POST" id="cm-delete-form" onsubmit="return confirm('متأكد؟');">
<?= csrf_field() ?>
<button type="submit" class="btn btn-outline btn-sm" id="cm-delete-btn" style="color:#DC2626;">حذف الحساب</button>
</form>
<div class="form-help" id="cm-delete-help"></div>
</div>
</div>
</div>
</div>
<script>
(function () {
var modal = document.getElementById('coa-modal');
if (!modal) return;
document.querySelectorAll('.coa-manage').forEach(function (b) {
b.addEventListener('click', function () {
var id = b.dataset.id;
document.getElementById('cm-name').textContent = b.dataset.name;
document.getElementById('cm-code').textContent = b.dataset.code;
document.getElementById('cm-move-form').action = '/accounting/chart-of-accounts/' + id + '/reparent';
document.getElementById('cm-header-form').action = '/accounting/chart-of-accounts/' + id + '/header';
document.getElementById('cm-delete-form').action = '/accounting/chart-of-accounts/' + id + '/delete';
var isHeader = b.dataset.header === '1';
document.getElementById('cm-header-val').value = isHeader ? '0' : '1';
document.getElementById('cm-header-btn').textContent = isHeader ? 'حوّله لحساب ترحيل' : 'حوّله لحساب رئيسي';
document.getElementById('cm-header-help').textContent = isHeader
? 'الحساب الرئيسي لا يقبل القيود.'
: 'الحساب الرئيسي تجميعي فقط — لن يقبل قيودًا بعد التحويل.';
var box = document.getElementById('cm-usage');
box.textContent = 'جارٍ الفحص…';
modal.style.display = 'flex';
fetch('/accounting/chart-of-accounts/' + id + '/usage')
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.success) { box.textContent = d.error || 'تعذر الفحص'; return; }
var h = '';
if (!d.blockers.length) {
h = '<div style="background:#ECFDF5;border-radius:6px;padding:10px;color:#065F46;">حساب فارغ — يمكن حذفه أو نقله بحرية.</div>';
} else {
h = '<div style="background:#FFFBEB;border-radius:6px;padding:10px;color:#92400E;"><strong>مرتبط بـ:</strong><ul style="margin:6px 0 0;padding-inline-start:18px;">';
d.blockers.forEach(function (x) { h += '<li>' + x + '</li>'; });
h += '</ul></div>';
}
box.innerHTML = h;
var delBtn = document.getElementById('cm-delete-btn');
var delHelp = document.getElementById('cm-delete-help');
if (!d.can_delete) {
delBtn.disabled = true;
delBtn.style.opacity = '.5';
delHelp.textContent = 'الحذف غير متاح — عالج الارتباطات أعلاه أولًا.';
} else if (d.will_archive) {
delBtn.disabled = false;
delBtn.style.opacity = '1';
delBtn.textContent = 'أرشفة الحساب';
delHelp.textContent = 'عليه حركات مرحّلة، فسيُؤرشف بدل حذفه حتى تظل القيود القديمة مقروءة.';
} else {
delBtn.disabled = false;
delBtn.style.opacity = '1';
delBtn.textContent = 'حذف الحساب';
delHelp.textContent = 'الحساب فارغ — سيُحذف نهائيًا.';
}
});
});
});
document.getElementById('cm-close').addEventListener('click', function () { modal.style.display = 'none'; });
modal.addEventListener('click', function (e) { if (e.target === modal) modal.style.display = 'none'; });
})();
</script>
<?php endif; ?>
<?php $__template->endSection(); ?> <?php $__template->endSection(); ?>
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