Commit 5c5a5d2c authored by DevPilot's avatar DevPilot

feat(accounting): add GL sync preview before posting unsynced records

Lets a super admin review every unposted payment/fine/installment/sale/
payroll/refund/rental-deposit row before committing the sync, instead of
posting blind from the dashboard warning banner.
parent 26670221
...@@ -558,6 +558,36 @@ class ReportController extends Controller ...@@ -558,6 +558,36 @@ class ReportController extends Controller
])); ]));
} }
/**
* Preview what the GL sync would post, without writing anything.
* Super-admin only. GET action from dashboard, linked before the actual sync.
*/
public function syncGLPreview(): Response
{
$this->authorize('accounting.fiscal_year.manage');
if (!$this->requireSuperAdminForSync()) {
return $this->redirect('/accounting');
}
$preview = GLSyncService::previewAll();
$labels = [
'payments' => 'مدفوعات',
'fines' => 'غرامات',
'installments' => 'أقساط',
'sales' => 'مبيعات (تكلفة البضاعة المباعة)',
'payroll' => 'رواتب',
'refunds' => 'مرتجعات مبيعات',
'rentals' => 'تأمينات إيجار',
];
return $this->view('Accounting/Views/dashboard/gl_sync_preview', [
'preview' => $preview,
'labels' => $labels,
]);
}
/** /**
* Sync all existing financial data into the General Ledger. * Sync all existing financial data into the General Ledger.
* Super-admin only. POST action from dashboard. * Super-admin only. POST action from dashboard.
...@@ -566,21 +596,8 @@ class ReportController extends Controller ...@@ -566,21 +596,8 @@ class ReportController extends Controller
{ {
$this->authorize('accounting.fiscal_year.manage'); $this->authorize('accounting.fiscal_year.manage');
// Extra guard: super admin only if (!$this->requireSuperAdminForSync()) {
$db = App::getInstance()->db(); return $this->redirect('/accounting');
$employee = App::getInstance()->currentEmployee();
$session = App::getInstance()->session();
if ($employee) {
$isSuperAdmin = $db->selectOne(
"SELECT 1 FROM employee_roles er JOIN roles r ON r.id = er.role_id
WHERE er.employee_id = ? AND r.role_code = 'super_admin' AND er.is_active = 1 LIMIT 1",
[(int) $employee->id]
);
if (!$isSuperAdmin) {
$session->flash('_alerts', [['type' => 'error', 'message' => 'هذا الإجراء متاح فقط للمدير العام']]);
return $this->redirect('/accounting');
}
} }
$result = GLSyncService::syncAll(); $result = GLSyncService::syncAll();
...@@ -609,7 +626,32 @@ class ReportController extends Controller ...@@ -609,7 +626,32 @@ class ReportController extends Controller
$alerts[] = ['type' => 'error', 'message' => 'فشل ' . $result['errors'] . ' عملية — راجع سجل الأخطاء']; $alerts[] = ['type' => 'error', 'message' => 'فشل ' . $result['errors'] . ' عملية — راجع سجل الأخطاء'];
} }
$session->flash('_alerts', $alerts); App::getInstance()->session()->flash('_alerts', $alerts);
return $this->redirect('/accounting'); return $this->redirect('/accounting');
} }
/**
* Super-admin guard shared by the GL sync preview and sync actions.
* Flashes an error and returns false when the current employee isn't super admin.
*/
private function requireSuperAdminForSync(): bool
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$session = App::getInstance()->session();
if ($employee) {
$isSuperAdmin = $db->selectOne(
"SELECT 1 FROM employee_roles er JOIN roles r ON r.id = er.role_id
WHERE er.employee_id = ? AND r.role_code = 'super_admin' AND er.is_active = 1 LIMIT 1",
[(int) $employee->id]
);
if (!$isSuperAdmin) {
$session->flash('_alerts', [['type' => 'error', 'message' => 'هذا الإجراء متاح فقط للمدير العام']]);
return false;
}
}
return true;
}
} }
...@@ -121,6 +121,7 @@ return [ ...@@ -121,6 +121,7 @@ return [
['POST', '/accounting/opening-entries/snapshot', 'Accounting\Controllers\OpeningEntryController@snapshot', ['auth', 'csrf'], 'accounting.opening_entry.manage'], ['POST', '/accounting/opening-entries/snapshot', 'Accounting\Controllers\OpeningEntryController@snapshot', ['auth', 'csrf'], 'accounting.opening_entry.manage'],
// ── GL Sync (super-admin) ─────────────────────────────── // ── GL Sync (super-admin) ───────────────────────────────
['GET', '/accounting/sync-gl/preview', 'Accounting\Controllers\ReportController@syncGLPreview', ['auth'], 'accounting.fiscal_year.manage'],
['POST', '/accounting/sync-gl', 'Accounting\Controllers\ReportController@syncGL', ['auth', 'csrf'], 'accounting.fiscal_year.manage'], ['POST', '/accounting/sync-gl', 'Accounting\Controllers\ReportController@syncGL', ['auth', 'csrf'], 'accounting.fiscal_year.manage'],
// ── Reports ────────────────────────────────────────────── // ── Reports ──────────────────────────────────────────────
......
...@@ -92,6 +92,161 @@ final class GLSyncService ...@@ -92,6 +92,161 @@ final class GLSyncService
return $result; return $result;
} }
/**
* Preview what syncAll() would post, without writing anything.
* Returns the raw unposted rows per source, plus counts/totals, for review before sync.
*
* @return array{
* payments: array, fines: array, installments: array, sales: array,
* payroll: array, refunds: array, rentals: array, total_count: int
* }
*/
public static function previewAll(): array
{
$db = App::getInstance()->db();
$preview = [
'payments' => self::previewPayments($db),
'fines' => self::previewFines($db),
'installments' => self::previewInstallments($db),
'sales' => self::previewSales($db),
'payroll' => self::previewPayroll($db),
'refunds' => self::previewRefunds($db),
'rentals' => self::previewRentalDeposits($db),
];
$preview['total_count'] = array_sum(array_map('count', $preview));
return $preview;
}
private static function previewPayments($db): array
{
return $db->select(
"SELECT p.id, p.payment_date, p.amount, p.payment_method, p.payment_type,
m.full_name_ar AS party_name
FROM payments p
LEFT JOIN members m ON m.id = p.member_id
WHERE p.is_voided = 0
AND NOT EXISTS (
SELECT 1 FROM journal_entries je
WHERE je.reference_type = 'payment' AND je.reference_id = p.id
AND je.is_archived = 0 AND je.status != 'reversed'
)
ORDER BY p.payment_date ASC, p.id ASC"
);
}
private static function previewFines($db): array
{
if (!$db->selectOne("SHOW TABLES LIKE 'fines'")) return [];
return $db->select(
"SELECT f.id, f.created_at AS payment_date, f.amount, f.status,
m.full_name_ar AS party_name
FROM fines f
LEFT JOIN members m ON m.id = f.member_id
WHERE f.penalty_type = 'fine' AND f.amount > 0
AND NOT EXISTS (
SELECT 1 FROM journal_entries je
WHERE je.reference_type = 'fine' AND je.reference_id = f.id
AND je.is_archived = 0 AND je.status != 'reversed'
)
ORDER BY f.created_at ASC, f.id ASC"
);
}
private static function previewInstallments($db): array
{
if (!$db->selectOne("SHOW TABLES LIKE 'installment_plans'")) return [];
return $db->select(
"SELECT ip.id, ip.created_at AS payment_date, ip.total_amount AS amount,
m.full_name_ar AS party_name
FROM installment_plans ip
LEFT JOIN members m ON m.id = ip.member_id
WHERE ip.total_amount > 0
AND NOT EXISTS (
SELECT 1 FROM journal_entries je
WHERE je.reference_type = 'installment_plan' AND je.reference_id = ip.id
AND je.is_archived = 0 AND je.status != 'reversed'
)
ORDER BY ip.created_at ASC, ip.id ASC"
);
}
private static function previewSales($db): array
{
if (!$db->selectOne("SHOW TABLES LIKE 'sales'")) return [];
return $db->select(
"SELECT s.id, s.sale_date AS payment_date, s.total_amount AS amount,
s.invoice_number AS party_name
FROM sales s
WHERE s.status = 'completed'
AND NOT EXISTS (
SELECT 1 FROM journal_entries je
WHERE je.reference_type = 'sale_cogs' AND je.reference_id = s.id
AND je.is_archived = 0 AND je.status != 'reversed'
)
ORDER BY s.sale_date ASC, s.id ASC"
);
}
private static function previewPayroll($db): array
{
if (!$db->selectOne("SHOW TABLES LIKE 'hr_payroll_runs'")) return [];
return $db->select(
"SELECT pr.id, pr.paid_at AS payment_date, pp.total_net AS amount,
pp.period_code AS party_name
FROM hr_payroll_runs pr
LEFT JOIN hr_payroll_periods pp ON pp.id = pr.period_id
WHERE pr.status = 'paid'
AND NOT EXISTS (
SELECT 1 FROM journal_entries je
WHERE je.reference_type = 'payroll' AND je.reference_id = pr.id
AND je.is_archived = 0 AND je.status != 'reversed'
)
ORDER BY pr.paid_at ASC, pr.id ASC"
);
}
private static function previewRefunds($db): array
{
if (!$db->selectOne("SHOW TABLES LIKE 'sale_refunds'")) return [];
return $db->select(
"SELECT r.id, r.refund_amount AS amount, r.refund_number AS party_name
FROM sale_refunds r
WHERE r.refund_amount > 0
AND NOT EXISTS (
SELECT 1 FROM journal_entries je
WHERE je.reference_type = 'sale_refund' AND je.reference_id = r.id
AND je.is_archived = 0 AND je.status != 'reversed'
)
ORDER BY r.id ASC"
);
}
private static function previewRentalDeposits($db): array
{
if (!$db->selectOne("SHOW TABLES LIKE 'rental_contracts'")) return [];
return $db->select(
"SELECT rc.id, CONCAT('عقد #', rc.id) AS party_name, p.amount, p.payment_date
FROM rental_contracts rc
LEFT JOIN payments p ON p.id = rc.deposit_payment_id
WHERE rc.deposit_payment_id IS NOT NULL AND rc.is_archived = 0
AND NOT EXISTS (
SELECT 1 FROM journal_entries je
WHERE je.reference_type = 'rental_deposit' AND je.reference_id = rc.id
AND je.is_archived = 0 AND je.status != 'reversed'
)
ORDER BY rc.id ASC"
);
}
/** /**
* Ensure fiscal years exist for all years that have financial data. * Ensure fiscal years exist for all years that have financial data.
*/ */
......
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>معاينة مزامنة الدفتر العام<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div style="margin-bottom:20px;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px;">
<div>
<h2 style="margin:0;">معاينة مزامنة الدفتر العام</h2>
<p style="color:#6B7280;margin:5px 0 0;">هذه القيود لم تُرحّل بعد. راجعها ثم اضغط "تأكيد المزامنة" لترحيلها فعليًا إلى الدفتر العام.</p>
</div>
<div style="display:flex;gap:8px;">
<a href="/accounting" class="btn btn-outline">رجوع</a>
<?php if ($preview['total_count'] > 0): ?>
<form method="POST" action="/accounting/sync-gl" style="display:inline;">
<?= csrf_field() ?>
<button type="submit" class="btn btn-primary" onclick="return confirm('هل تريد ترحيل <?= (int) $preview['total_count'] ?> عملية إلى الدفتر العام؟')">تأكيد المزامنة (<?= (int) $preview['total_count'] ?>)</button>
</form>
<?php endif; ?>
</div>
</div>
<?php if ($preview['total_count'] === 0): ?>
<div class="card" style="padding:30px;text-align:center;color:#059669;">
جميع البيانات المالية متزامنة بالفعل — لا توجد قيود جديدة للترحيل.
</div>
<?php endif; ?>
<?php foreach ($labels as $key => $label): ?>
<?php $rows = $preview[$key] ?? []; ?>
<?php if (empty($rows)) continue; ?>
<div class="card" style="padding:20px;margin-bottom:20px;">
<h3 style="margin:0 0 12px;"><?= e($label) ?> <span style="color:#6B7280;font-size:14px;">(<?= count($rows) ?>)</span></h3>
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead>
<tr>
<th>#</th>
<th>التاريخ</th>
<th>الجهة / المرجع</th>
<th>المبلغ</th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $row): ?>
<tr>
<td style="font-family:monospace;font-size:12px;"><?= (int) $row['id'] ?></td>
<td><?= e($row['payment_date'] ?? '—') ?></td>
<td><?= e($row['party_name'] ?? '—') ?></td>
<td style="font-weight:600;direction:ltr;text-align:left;"><?= money($row['amount'] ?? '0.00') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endforeach; ?>
<?php $__template->endSection(); ?>
...@@ -60,10 +60,7 @@ ...@@ -60,10 +60,7 @@
<strong style="color:#92400E;">يوجد <?= $unsynced_count ?> مدفوعات غير مسجلة في الدفتر العام</strong> <strong style="color:#92400E;">يوجد <?= $unsynced_count ?> مدفوعات غير مسجلة في الدفتر العام</strong>
<p style="margin:3px 0 0;font-size:13px;color:#78350F;">التقارير المحاسبية (قائمة الدخل، الميزانية، ميزان المراجعة) لن تعكس البيانات الحقيقية حتى يتم المزامنة</p> <p style="margin:3px 0 0;font-size:13px;color:#78350F;">التقارير المحاسبية (قائمة الدخل، الميزانية، ميزان المراجعة) لن تعكس البيانات الحقيقية حتى يتم المزامنة</p>
</div> </div>
<form method="POST" action="/accounting/sync-gl" style="display:inline;"> <a href="/accounting/sync-gl/preview" class="btn btn-primary" style="white-space:nowrap;">معاينة ومزامنة الدفتر العام</a>
<?= csrf_field() ?>
<button type="submit" class="btn btn-primary" style="white-space:nowrap;" onclick="return confirm('هل تريد مزامنة جميع البيانات المالية مع الدفتر العام؟ قد تستغرق العملية بضع ثوانٍ.')">مزامنة الدفتر العام</button>
</form>
</div> </div>
</div> </div>
<?php endif; ?> <?php endif; ?>
...@@ -80,10 +77,7 @@ ...@@ -80,10 +77,7 @@
<a href="/accounting/reports/treasury" class="btn btn-outline">الخزينة والمدفوعات</a> <a href="/accounting/reports/treasury" class="btn btn-outline">الخزينة والمدفوعات</a>
<a href="/accounting/reports/revenue-analysis" class="btn btn-outline">تحليل الإيرادات</a> <a href="/accounting/reports/revenue-analysis" class="btn btn-outline">تحليل الإيرادات</a>
<?php if (!empty($is_super_admin)): ?> <?php if (!empty($is_super_admin)): ?>
<form method="POST" action="/accounting/sync-gl" style="display:inline;"> <a href="/accounting/sync-gl/preview" class="btn" style="background:#7C3AED;color:#fff;border:none;">معاينة مزامنة GL</a>
<?= csrf_field() ?>
<button type="submit" class="btn" style="background:#7C3AED;color:#fff;border:none;" onclick="return confirm('هل تريد مزامنة جميع البيانات المالية مع الدفتر العام؟')">مزامنة GL</button>
</form>
<?php endif; ?> <?php endif; ?>
</div> </div>
</div> </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