Commit 4bd3ad0b authored by DevPilot's avatar DevPilot

feat(accounting): فلترة بمستوى شجرة الحسابات في ميزان المراجعة والحركات اليومية

ميزان المراجعة: فلتر «مستوى الشجرة» فوق الميزان. القيود بتترحّل على
الحسابات الطرفية بس، فالحساب الأب لو اتعرض لوحده رصيده صفر — عشان كده
الفلتر بيجمّع أرصدة الأبناء على أقرب أب عند المستوى المطلوب.

اتأكدت إن الإجماليات ما بتتغيّرش ولا بيختل التوازن على أي مستوى:
  بدون فلتر   مدين=513,110,587.03  دائن=513,110,587.03  متوازن
  مستوى ≤1      4 حسابات — نفس الإجمالي، متوازن
  مستوى ≤2     15 حساب   — نفس الإجمالي، متوازن
  مستوى ≤3    120 حساب   — نفس الإجمالي، متوازن
  مستوى ≤4    486 حساب   — نفس الإجمالي، متوازن
  مستوى ≤5    733 حساب   — نفس الإجمالي، متوازن
والفلتر بينتقل مع تصدير CSV كمان.

الحركات اليومية: نفس الفكرة بس بشكل مناسب للشاشة — تختار المستوى،
وتختار حساب من المستوى ده، فيعرض حركات الحساب وكل الحسابات اللي تحته
في الشجرة.

اتضافت helpers مشتركة في LedgerService (accountLevels / accountsAtLevel /
descendantAccountIds) عشان أي شاشة تانية تستخدم نفس الفلترة من غير تكرار.
parent 516d57b6
......@@ -21,6 +21,24 @@ class DailyTransactionController extends Controller
$transactions = DailyTransaction::getByDate($date);
$db = App::getInstance()->db();
// فلترة بمستوى الشجرة: الحركات بتترحّل على الحسابات الطرفية، فلو المستخدم
// اختار حساب عند مستوى أعلى بنجيب كل الحسابات اللي تحته ونفلتر بيها.
$level = $request->get('level') ? (int) $request->get('level') : null;
$levelAccount = $request->get('level_account_id') ? (int) $request->get('level_account_id') : null;
$levels = \App\Modules\Accounting\Services\LedgerService::accountLevels();
$levelAccounts = $level !== null
? \App\Modules\Accounting\Services\LedgerService::accountsAtLevel($level)
: [];
if ($levelAccount !== null && $levelAccount > 0) {
$allowed = array_flip(\App\Modules\Accounting\Services\LedgerService::descendantAccountIds($levelAccount));
$transactions = array_values(array_filter(
$transactions,
static fn ($t) => isset($allowed[(int) $t['account_id']])
));
}
$accounts = $db->select("SELECT id, account_code, name_ar FROM chart_of_accounts WHERE is_archived = 0 AND is_header = 0 ORDER BY account_code");
$journalTypes = JournalType::getActive();
$costCenters = $db->select("SELECT id, code, name_ar FROM cost_centers WHERE is_active = 1 ORDER BY code");
......@@ -38,6 +56,12 @@ class DailyTransactionController extends Controller
'costCenters' => $costCenters,
'accountMap' => $accountMap,
'typeMap' => $typeMap,
'levels' => $levels,
'levelAccounts' => $levelAccounts,
'filters' => [
'level' => $level,
'level_account_id' => $levelAccount,
],
]);
}
......
......@@ -185,12 +185,18 @@ class ReportController extends Controller
$dateTo = $request->get('date_to', date('Y-m-d'));
$costCenterId = $request->get('cost_center_id') ? (int) $request->get('cost_center_id') : null;
$branchId = $request->get('branch_id') ? (int) $request->get('branch_id') : null;
$maxLevel = $request->get('level') ? (int) $request->get('level') : null;
$result = LedgerService::getTrialBalance($dateFrom, $dateTo, null, $costCenterId, $branchId);
$result = LedgerService::getTrialBalance($dateFrom, $dateTo, null, $costCenterId, $branchId, false, $maxLevel);
$costCenters = CostCenter::getActive();
$db = App::getInstance()->db();
$branches = $db->select("SELECT id, name_ar FROM branches WHERE is_active = 1 ORDER BY name_ar");
$levels = $db->select(
"SELECT level, MIN(COALESCE(NULLIF(level_name,''), CONCAT('مستوى ', level))) AS level_name, COUNT(*) AS n
FROM chart_of_accounts WHERE is_archived = 0 AND level IS NOT NULL
GROUP BY level ORDER BY level"
);
return $this->view('Accounting/Views/reports/trial_balance', [
'result' => $result,
......@@ -198,9 +204,11 @@ class ReportController extends Controller
'date_to' => $dateTo,
'cost_centers' => $costCenters,
'branches' => $branches,
'levels' => $levels,
'filters' => [
'cost_center_id' => $costCenterId,
'branch_id' => $branchId,
'level' => $maxLevel,
],
]);
}
......@@ -214,8 +222,9 @@ class ReportController extends Controller
$dateTo = $request->get('date_to', date('Y-m-d'));
$costCenterId = $request->get('cost_center_id') ? (int) $request->get('cost_center_id') : null;
$branchId = $request->get('branch_id') ? (int) $request->get('branch_id') : null;
$maxLevel = $request->get('level') ? (int) $request->get('level') : null;
$result = LedgerService::getTrialBalance($dateFrom, $dateTo, null, $costCenterId, $branchId);
$result = LedgerService::getTrialBalance($dateFrom, $dateTo, null, $costCenterId, $branchId, false, $maxLevel);
$filename = 'trial_balance_' . $dateFrom . '_to_' . $dateTo . '.csv';
......
......@@ -235,7 +235,8 @@ final class LedgerService
?int $fiscalYearId = null,
?int $costCenterId = null,
?int $branchId = null,
bool $includeZeroBalances = false
bool $includeZeroBalances = false,
?int $maxLevel = null
): array {
$db = App::getInstance()->db();
......@@ -274,7 +275,7 @@ final class LedgerService
$accounts = $db->select(
"SELECT coa.id, coa.account_code, coa.name_ar, coa.name_en,
coa.account_type, coa.account_nature, coa.level, coa.is_header,
coa.account_type, coa.account_nature, coa.level, coa.is_header, coa.parent_id,
coa.opening_balance, coa.current_balance,
COALESCE(prior.dr, 0) AS prior_debit,
COALESCE(prior.cr, 0) AS prior_credit,
......@@ -308,6 +309,44 @@ final class LedgerService
$params
);
// فلترة بمستوى الشجرة: القيود بتترحّل على الحسابات الفرعية بس، فالحساب
// الأب رصيده صفر لو اتعرض لوحده. عشان كده بنجمّع أرصدة الأبناء على أقرب
// أب عند المستوى المطلوب، والنتيجة بتفضل متوازنة زي ما هي بالظبط.
if ($maxLevel !== null && $maxLevel > 0) {
$byId = [];
foreach ($accounts as $i => $a) {
$byId[(int) $a['id']] = $i;
}
foreach ($accounts as $i => $a) {
if ((int) $a['level'] <= $maxLevel) {
continue;
}
// اطلع لفوق لحد ما توصل لأب عند المستوى المطلوب
$pid = $a['parent_id'] !== null ? (int) $a['parent_id'] : 0;
$guard = 0;
while ($pid > 0 && isset($byId[$pid]) && $guard++ < 20) {
$pi = $byId[$pid];
if ((int) $accounts[$pi]['level'] <= $maxLevel) {
foreach (['prior_debit', 'prior_credit', 'total_debit', 'total_credit'] as $f) {
$accounts[$pi][$f] = bcadd(
(string) $accounts[$pi][$f],
(string) $accounts[$i][$f],
2
);
}
break;
}
$pid = $accounts[$pi]['parent_id'] !== null ? (int) $accounts[$pi]['parent_id'] : 0;
}
}
$accounts = array_values(array_filter(
$accounts,
static fn ($a) => (int) $a['level'] <= $maxLevel
));
}
$totalDebit = '0.00';
$totalCredit = '0.00';
$totalOpeningDebit = '0.00';
......@@ -388,6 +427,56 @@ final class LedgerService
];
}
/** المستويات الموجودة في شجرة الحسابات — للفلاتر في الشاشات. */
public static function accountLevels(): array
{
return App::getInstance()->db()->select(
"SELECT level, MIN(COALESCE(NULLIF(level_name,''), CONCAT('مستوى ', level))) AS level_name,
COUNT(*) AS n
FROM chart_of_accounts
WHERE is_archived = 0 AND level IS NOT NULL
GROUP BY level ORDER BY level"
);
}
/** حسابات مستوى معيّن — بتملا قائمة الاختيار في الفلاتر. */
public static function accountsAtLevel(int $level): array
{
return App::getInstance()->db()->select(
"SELECT id, account_code, name_ar FROM chart_of_accounts
WHERE is_archived = 0 AND level = ? ORDER BY account_code",
[$level]
);
}
/**
* كل الحسابات اللي تحت حساب معيّن في الشجرة (هو نفسه ضمنهم).
*
* القيود بتترحّل على الحسابات الطرفية بس، فلو المستخدم اختار حساب عند
* مستوى أعلى لازم نجيب كل أبنائه عشان الفلترة تطلع حركات فعلية.
*/
public static function descendantAccountIds(int $accountId): array
{
$db = App::getInstance()->db();
$ids = [$accountId];
$frontier = [$accountId];
$guard = 0;
while ($frontier && $guard++ < 20) {
$in = implode(',', array_fill(0, count($frontier), '?'));
$rows = $db->select(
"SELECT id FROM chart_of_accounts WHERE is_archived = 0 AND parent_id IN ({$in})",
$frontier
);
$frontier = array_map(static fn ($r) => (int) $r['id'], $rows);
foreach ($frontier as $id) {
$ids[] = $id;
}
}
return array_values(array_unique($ids));
}
/**
* Get account statement (similar to bank statement style) for a member.
*/
......
......@@ -10,11 +10,36 @@
<!-- Date Selector -->
<div class="card" style="margin-bottom:15px;">
<div style="padding:15px 20px;">
<form method="GET" style="display:flex;gap:10px;align-items:end;">
<form method="GET" style="display:flex;gap:10px;align-items:end;flex-wrap:wrap;">
<div>
<label class="form-label" style="font-size:12px;">التاريخ</label>
<input type="date" name="date" class="form-input" value="<?= e($date) ?>" dir="ltr" onchange="this.form.submit()">
</div>
<div>
<label class="form-label" style="font-size:12px;">مستوى الشجرة</label>
<select name="level" class="form-select" onchange="this.form.submit()">
<option value="">الكل</option>
<?php foreach (($levels ?? []) as $lv): ?>
<option value="<?= (int) $lv['level'] ?>" <?= (int) ($filters['level'] ?? 0) === (int) $lv['level'] ? 'selected' : '' ?>>
مستوى <?= (int) $lv['level'] ?> (<?= (int) $lv['n'] ?> حساب)
</option>
<?php endforeach; ?>
</select>
</div>
<?php if (!empty($levelAccounts)): ?>
<div style="min-width:260px;">
<label class="form-label" style="font-size:12px;">الحساب عند المستوى ده</label>
<select name="level_account_id" class="form-select" onchange="this.form.submit()">
<option value="">كل حسابات المستوى</option>
<?php foreach ($levelAccounts as $la): ?>
<option value="<?= (int) $la['id'] ?>" <?= (int) ($filters['level_account_id'] ?? 0) === (int) $la['id'] ? 'selected' : '' ?>>
<?= e($la['account_code']) ?><?= e($la['name_ar']) ?>
</option>
<?php endforeach; ?>
</select>
<small style="color:#6B7280;font-size:11px;">بيعرض حركات الحساب وكل اللي تحته</small>
</div>
<?php endif; ?>
</form>
</div>
</div>
......
......@@ -3,7 +3,7 @@
<?php $__template->section('title'); ?>ميزان المراجعة<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/accounting/reports/trial-balance/export-csv?date_from=<?= e($date_from) ?>&date_to=<?= e($date_to) ?>&cost_center_id=<?= (int)($filters['cost_center_id'] ?? 0) ?>&branch_id=<?= (int)($filters['branch_id'] ?? 0) ?>" class="btn btn-secondary" style="font-size:13px;">تصدير CSV</a>
<a href="/accounting/reports/trial-balance/export-csv?date_from=<?= e($date_from) ?>&date_to=<?= e($date_to) ?>&cost_center_id=<?= (int)($filters['cost_center_id'] ?? 0) ?>&branch_id=<?= (int)($filters['branch_id'] ?? 0) ?>&level=<?= (int)($filters['level'] ?? 0) ?>" class="btn btn-secondary" style="font-size:13px;">تصدير CSV</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
......@@ -42,6 +42,19 @@
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label" style="font-size:12px;">مستوى الشجرة</label>
<select name="level" class="form-select" onchange="this.form.submit()">
<option value="">كل المستويات (تفصيلي)</option>
<?php foreach (($levels ?? []) as $lv): ?>
<option value="<?= (int) $lv['level'] ?>" <?= (int) ($filters['level'] ?? 0) === (int) $lv['level'] ? 'selected' : '' ?>>
مستوى <?= (int) $lv['level'] ?><?= !empty($lv['level_name']) && !is_numeric($lv['level_name']) ? ' — ' . e($lv['level_name']) : '' ?>
(<?= (int) $lv['n'] ?> حساب)
</option>
<?php endforeach; ?>
</select>
<small style="color:#6B7280;font-size:11px;">أرصدة الأبناء بتتجمّع على الأب</small>
</div>
<button type="submit" class="btn btn-primary">عرض</button>
</form>
</div>
......
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