Commit d1cdb878 authored by DevPilot's avatar DevPilot

feat(accounting): فحص جاهزية قبل إقفال الشهر

الإقفال كان بيتحقق من حاجة واحدة (القيود المسودة) وبعدين يقفل. أسوأ حاجة
في الإقفال إن المحاسب يقفل ويطلّع القوائم وبعدين يكتشف ناقص ويضطر يفتح تاني.

الشاشة دلوقتي بتعمل سبع فحوصات قبل الإقفال، كل واحد بلينك يوصّل للشاشة
اللي تحلّه: القيود المسودة، توازن الميزان، الشهر السابق، العمليات غير
المقيّدة، الفلوس الواقفة في حسابات وسيطة، الشيكات المستحقة، والمطابقة
البنكية.

المسودة وعدم التوازن موانع بتوقف الإقفال — الباقي تنبيهات والقرار للمحاسب.
والفحص بيتعاد في الكنترولر مش بس في الشاشة عشان حد ما يعديه بـ POST مباشر.
parent 7d6c6ada
......@@ -9,6 +9,7 @@ use App\Core\App;
use App\Core\Response;
use App\Modules\Accounting\Models\FiscalYear;
use App\Modules\Accounting\Services\PeriodClosingService;
use App\Modules\Accounting\Services\PeriodCloseReadinessService;
class PeriodClosingController extends Controller
{
......@@ -26,6 +27,18 @@ class PeriodClosingController extends Controller
$periods = PeriodClosingService::getPeriodSummary($fiscalYearId);
}
// الشهر اللي المحاسب بيجهّز لإقفاله — بشكل افتراضي أقدم شهر مفتوح
$target = (string) $request->get('period', '');
if ($target === '') {
foreach ($periods as $p) {
if (($p['status'] ?? '') !== 'closed') { $target = (string) $p['period']; break; }
}
}
$readiness = ($fiscalYearId > 0 && $target !== '')
? PeriodCloseReadinessService::check($fiscalYearId, $target)
: null;
$fiscalYears = FiscalYear::query()
->where('is_archived', '=', 0)
->orderBy('start_date', 'DESC')
......@@ -36,6 +49,8 @@ class PeriodClosingController extends Controller
'fiscal_year' => $fiscalYear ? $fiscalYear->toArray() : null,
'fiscal_years' => $fiscalYears,
'fiscal_year_id' => $fiscalYearId,
'readiness' => $readiness,
'target_period' => $target,
]);
}
......@@ -52,6 +67,17 @@ class PeriodClosingController extends Controller
return $this->redirect('/accounting/period-closing');
}
// الفحص بيتعاد هنا مش بس في الشاشة — عشان حد ما يعديش الموانع
// بـ POST مباشر من غير ما يفتح الشاشة.
$readiness = PeriodCloseReadinessService::check($fiscalYearId, $period);
if (!$readiness['can_close']) {
$blockers = array_filter($readiness['checks'], fn($c) => $c['status'] === PeriodCloseReadinessService::BLOCK);
$reasons = implode(' — ', array_column($blockers, 'detail'));
$session = App::getInstance()->session();
$session->flash('_alerts', [['type' => 'error', 'message' => 'مش ممكن الإقفال: ' . $reasons]]);
return $this->redirect('/accounting/period-closing?fiscal_year_id=' . $fiscalYearId . '&period=' . $period);
}
$result = PeriodClosingService::closeMonth($fiscalYearId, $period);
$session = App::getInstance()->session();
......
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services;
use App\Core\App;
/**
* فحص الجاهزية قبل إقفال الشهر.
*
* إقفال الفترة مش زرار، ده قرار. قبل ما المحاسب يقفل شهر لازم يكون متأكد
* إن مفيش حاجة ناقصة هتضطره يفتح الشهر تاني بعد ما يطلّع القوائم — وده
* أسوأ حاجة ممكن تحصل في الإقفال.
*
* الفحص بيرد على الأسئلة اللي المراجع بيسألها فعلًا:
* • في قيود مسودة لسه ما اترحّلتش؟
* • الميزان متوازن؟ (مدين = دائن)
* • في عمليات تشغيلية اتعملت وما وصلتش الدفتر؟
* • في فلوس واقفة في حسابات وسيطة من غير ما تتقفل؟
* • الشهر اللي قبله مقفول؟ (ما ينفعش تقفل يونيو ومايو مفتوح)
* • في شيكات استحقّت وما اتعملش عليها إجراء؟
* • المطابقة البنكية اتعملت للفترة؟
*
* كل فحص بيرجّع: عدّى / تحذير / مانع، ولينك يوصّل للشاشة اللي تحلّه.
* المانع بس هو اللي بيوقف الإقفال — الباقي بينبّه وبيسيب القرار للمحاسب.
*/
final class PeriodCloseReadinessService
{
public const PASS = 'pass';
public const WARN = 'warn';
public const BLOCK = 'block';
/**
* @return array{checks:array, can_close:bool, blockers:int, warnings:int}
*/
public static function check(int $fiscalYearId, string $period): array
{
$from = $period . '-01';
$to = date('Y-m-t', strtotime($from));
$checks = [];
foreach (
[
'drafts' => fn() => self::draftEntries($fiscalYearId, $from, $to),
'balanced' => fn() => self::trialBalanced($fiscalYearId, $from, $to),
'prior_period' => fn() => self::priorPeriodClosed($fiscalYearId, $period),
'gaps' => fn() => self::operationalGaps(),
'parked' => fn() => self::parkedMoney(),
'instruments' => fn() => self::overdueInstruments($to),
'bank_recon' => fn() => self::bankReconciled($from, $to),
] as $key => $fn
) {
try {
$c = $fn();
} catch (\Throwable $e) {
// فحص ما ينفعش يتعمل ما يوقفش الإقفال — بس بنقولها
$c = [
'label' => $key,
'status' => self::WARN,
'detail' => 'تعذّر إجراء الفحص: ' . $e->getMessage(),
'url' => null,
];
}
$c['key'] = $key;
$checks[] = $c;
}
$blockers = count(array_filter($checks, fn($c) => $c['status'] === self::BLOCK));
$warnings = count(array_filter($checks, fn($c) => $c['status'] === self::WARN));
return [
'checks' => $checks,
'can_close' => $blockers === 0,
'blockers' => $blockers,
'warnings' => $warnings,
];
}
// ── الفحوصات ─────────────────────────────────────────────────
private static function draftEntries(int $fyId, string $from, string $to): array
{
$n = (int) (App::getInstance()->db()->selectOne(
"SELECT COUNT(*) AS c FROM journal_entries
WHERE fiscal_year_id = ? AND entry_date BETWEEN ? AND ?
AND status = 'draft' AND is_archived = 0",
[$fyId, $from, $to]
)['c'] ?? 0);
return [
'label' => 'القيود المسودة',
'status' => $n > 0 ? self::BLOCK : self::PASS,
'detail' => $n > 0
? "في {$n} قيد مسودة في الفترة. لازم يترحّلوا أو يتحذفوا قبل الإقفال."
: 'كل قيود الفترة مرحّلة.',
'url' => $n > 0 ? '/accounting/journal-entries?status=draft' : null,
];
}
private static function trialBalanced(int $fyId, string $from, string $to): array
{
$row = App::getInstance()->db()->selectOne(
"SELECT COALESCE(SUM(jel.debit), 0) AS d, COALESCE(SUM(jel.credit), 0) AS c
FROM journal_entry_lines jel
JOIN journal_entries je ON je.id = jel.journal_entry_id
WHERE je.fiscal_year_id = ? AND je.entry_date BETWEEN ? AND ?
AND je.status = 'posted' AND je.is_archived = 0",
[$fyId, $from, $to]
);
$diff = bcsub((string) ($row['d'] ?? '0'), (string) ($row['c'] ?? '0'), 2);
$ok = bccomp($diff, '0.00', 2) === 0;
return [
'label' => 'توازن الميزان',
'status' => $ok ? self::PASS : self::BLOCK,
'detail' => $ok
? 'مجموع المدين يساوي مجموع الدائن (' . money($row['d'] ?? 0) . ').'
: 'الميزان مش متوازن — الفرق ' . money($diff) . '. ده معناه قيد ناقص طرف.',
'url' => '/accounting/reports/trial-balance',
];
}
private static function priorPeriodClosed(int $fyId, string $period): array
{
$prev = date('Y-m', strtotime($period . '-01 -1 month'));
$row = App::getInstance()->db()->selectOne(
"SELECT status FROM period_closings WHERE fiscal_year_id = ? AND period = ?",
[$fyId, $prev]
);
// الشهر الأول في السنة المالية مالوش شهر قبله جوه نفس السنة
$exists = App::getInstance()->db()->selectOne(
"SELECT COUNT(*) AS c FROM journal_entries
WHERE fiscal_year_id = ? AND DATE_FORMAT(entry_date, '%Y-%m') = ?",
[$fyId, $prev]
);
if ((int) ($exists['c'] ?? 0) === 0) {
return [
'label' => 'الشهر السابق',
'status' => self::PASS,
'detail' => 'مفيش حركة في ' . $prev . '، فمفيش حاجة مستنية إقفال.',
'url' => null,
];
}
$closed = ($row['status'] ?? '') === 'closed';
return [
'label' => 'الشهر السابق',
'status' => $closed ? self::PASS : self::WARN,
'detail' => $closed
? "شهر {$prev} مقفول."
: "شهر {$prev} لسه مفتوح. الأصول إن الشهور تتقفل بالترتيب.",
'url' => '/accounting/period-closing',
];
}
private static function operationalGaps(): array
{
$gaps = \App\Modules\Accounting\Services\Revenue\GapToolService::gaps();
// الفجوة = اللي المفروض يتسجّل (projected) ناقص اللي اتسجّل فعلًا
$open = [];
$total = '0.00';
foreach ($gaps as $g) {
if (empty($g['available']) || (int) ($g['units'] ?? 0) <= 0) {
continue;
}
$gap = bcsub((string) ($g['projected'] ?? '0'), (string) ($g['recorded'] ?? '0'), 2);
if (bccomp($gap, '0.00', 2) > 0) {
$open[] = $g;
$total = bcadd($total, $gap, 2);
}
}
return [
'label' => 'العمليات غير المقيّدة',
'status' => count($open) > 0 ? self::WARN : self::PASS,
'detail' => count($open) > 0
? 'في ' . count($open) . ' مصدر إيراد بفجوة إجماليها ' . money($total)
. ' — عمليات حصلت وما وصلتش الدفتر.'
: 'كل العمليات التشغيلية وصلت الدفتر.',
'url' => '/accounting/gaps',
];
}
private static function parkedMoney(): array
{
$row = App::getInstance()->db()->selectOne(
"SELECT COUNT(*) AS c, COALESCE(SUM(h.amount), 0) AS total
FROM posting_chain_hops h
WHERE h.relieved_account_id IS NULL
AND h.parked_account_id IS NOT NULL"
);
$n = (int) ($row['c'] ?? 0);
return [
'label' => 'فلوس في حسابات وسيطة',
'status' => $n > 0 ? self::WARN : self::PASS,
'detail' => $n > 0
? "في {$n} حركة بإجمالي " . money($row['total'])
. ' واقفة في حسابات وسيطة ولسه ما اتقفلتش.'
: 'مفيش فلوس واقفة في حسابات وسيطة.',
'url' => '/accounting/posting-chains/parked',
];
}
private static function overdueInstruments(string $to): array
{
$row = App::getInstance()->db()->selectOne(
"SELECT COUNT(*) AS c, COALESCE(SUM(amount), 0) AS total
FROM negotiable_instruments
WHERE is_archived = 0 AND due_date <= ?
AND status IN ('in_hand','deposited','under_collection','ready','delivered','pending_clearance')",
[$to]
);
$n = (int) ($row['c'] ?? 0);
return [
'label' => 'شيكات استحقّت',
'status' => $n > 0 ? self::WARN : self::PASS,
'detail' => $n > 0
? "في {$n} شيك استحقّ بإجمالي " . money($row['total'])
. ' ولسه ما اتعملش عليه إجراء (تحصيل أو صرف أو ارتداد).'
: 'مفيش شيكات مستحقة معلّقة.',
'url' => '/accounting/instruments/register',
];
}
private static function bankReconciled(string $from, string $to): array
{
$db = App::getInstance()->db();
$banks = (int) ($db->selectOne(
"SELECT COUNT(*) AS c FROM bank_accounts WHERE is_active = 1 AND is_archived = 0"
)['c'] ?? 0);
if ($banks === 0) {
return [
'label' => 'المطابقة البنكية',
'status' => self::PASS,
'detail' => 'مفيش حسابات بنكية نشطة.',
'url' => null,
];
}
$done = (int) ($db->selectOne(
"SELECT COUNT(DISTINCT bank_account_id) AS c
FROM bank_reconciliations
WHERE is_archived = 0 AND status = 'approved'
AND statement_date BETWEEN ? AND ?",
[$from, $to]
)['c'] ?? 0);
$missing = $banks - $done;
return [
'label' => 'المطابقة البنكية',
'status' => $missing > 0 ? self::WARN : self::PASS,
'detail' => $missing > 0
? "{$done} من {$banks} حساب بنكي اتعملّه مطابقة معتمدة عن الفترة."
: 'كل الحسابات البنكية اتطابقت عن الفترة.',
'url' => '/accounting/bank-reconciliation',
];
}
}
......@@ -20,6 +20,58 @@
</div>
</div>
<?php if ($fiscal_year && !empty($readiness)): ?>
<!-- جاهزية الإقفال -->
<?php
use App\Modules\Accounting\Services\PeriodCloseReadinessService as RD;
$ok = $readiness['can_close'];
$styles = [
RD::PASS => ['#ECFDF5', '#A7F3D0', '#047857', '✓'],
RD::WARN => ['#FFFBEB', '#FDE68A', '#92400E', '!'],
RD::BLOCK => ['#FEF2F2', '#FECACA', '#B91C1C', '✕'],
];
?>
<div class="card" style="margin-bottom:18px;border-top:3px solid <?= $ok ? '#059669' : '#DC2626' ?>;">
<div style="padding:14px 18px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px;">
<div>
<strong style="font-size:15px;">جاهزية إقفال شهر <?= e($target_period) ?></strong>
<p style="margin:4px 0 0;color:#6B7280;font-size:12.5px;">
الفحوصات دي بتتعمل قبل الإقفال عشان ما تضطرش تفتح الشهر تاني بعد ما تطلّع القوائم.
</p>
</div>
<div style="text-align:left;">
<?php if ($ok): ?>
<span style="background:#ECFDF5;color:#047857;padding:6px 14px;border-radius:10px;font-size:13px;font-weight:700;">
جاهز للإقفال<?= $readiness['warnings'] > 0 ? ' — مع ' . (int) $readiness['warnings'] . ' تنبيه' : '' ?>
</span>
<?php else: ?>
<span style="background:#FEF2F2;color:#B91C1C;padding:6px 14px;border-radius:10px;font-size:13px;font-weight:700;">
<?= (int) $readiness['blockers'] ?> مانع لازم يتحل الأول
</span>
<?php endif; ?>
</div>
</div>
<div style="padding:12px 16px;display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:10px;">
<?php foreach ($readiness['checks'] as $c): ?>
<?php [$bg, $bd, $fg, $icon] = $styles[$c['status']] ?? $styles[RD::WARN]; ?>
<div style="border:1px solid <?= $bd ?>;background:<?= $bg ?>;border-radius:7px;padding:10px 12px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
<span style="width:19px;height:19px;border-radius:50%;background:<?= $fg ?>;color:#fff;
display:inline-flex;align-items:center;justify-content:center;
font-size:12px;font-weight:700;flex-shrink:0;"><?= $icon ?></span>
<strong style="font-size:13px;color:<?= $fg ?>;"><?= e($c['label']) ?></strong>
</div>
<div style="font-size:12.5px;color:#374151;line-height:1.7;"><?= e($c['detail']) ?></div>
<?php if (!empty($c['url']) && $c['status'] !== RD::PASS): ?>
<a href="<?= e($c['url']) ?>" style="font-size:12px;display:inline-block;margin-top:6px;">افتح الشاشة ›</a>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php if ($fiscal_year): ?>
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
......
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