Commit 4a24c7a3 authored by Mahmoud Aglan's avatar Mahmoud Aglan

FuckTest

parent a3fd26ba
......@@ -213,6 +213,14 @@ final class App
return $this->db;
}
/**
* Set the database instance (used by CLI seeds that need App context).
*/
public function setDb(Database $db): void
{
$this->db = $db;
}
public function session(): Session
{
return $this->session;
......
......@@ -12,6 +12,7 @@ use App\Modules\Accounting\Models\AccountPayable;
use App\Modules\Accounting\Models\AccountReceivable;
use App\Modules\Accounting\Services\LedgerService;
use App\Modules\Accounting\Services\FinancialReportService;
use App\Modules\Accounting\Services\GLSyncService;
use App\Modules\Payments\Services\PaymentService;
class ReportController extends Controller
......@@ -133,6 +134,32 @@ class ReportController extends Controller
];
}
// Super admin check for sync button
$isSuperAdmin = false;
$employee = App::getInstance()->currentEmployee();
if ($employee) {
$isSuperAdmin = (bool) $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]
);
}
// Count unsynced payments (payments without journal entries)
$unsyncedCount = 0;
if ($isSuperAdmin) {
$unsyncedRow = $db->selectOne(
"SELECT COUNT(*) as cnt FROM payments p
WHERE p.is_voided = 0 AND p.amount > 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'
)"
);
$unsyncedCount = (int) ($unsyncedRow['cnt'] ?? 0);
}
return $this->view('Accounting/Views/dashboard/index', [
'fiscal_year' => $currentFY ? $currentFY->toArray() : null,
'monthly_totals' => $monthlyTotals,
......@@ -141,6 +168,8 @@ class ReportController extends Controller
'draft_count' => (int) ($draftCount['cnt'] ?? 0),
'payment_stats' => $paymentStats,
'recent_payments' => $formattedPayments,
'is_super_admin' => $isSuperAdmin,
'unsynced_count' => $unsyncedCount,
]);
}
......@@ -578,4 +607,59 @@ class ReportController extends Controller
],
]);
}
/**
* Sync all existing financial data into the General Ledger.
* Super-admin only. POST action from dashboard.
*/
public function syncGL(): Response
{
$this->authorize('accounting.fiscal_year.manage');
// Extra guard: super admin only
$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 $this->redirect('/accounting');
}
}
$result = GLSyncService::syncAll();
$total = $result['payments'] + $result['fines'] + $result['installments']
+ $result['sales'] + $result['payroll'] + $result['refunds'] + $result['rentals'];
$alerts = [];
if ($total > 0) {
$details = [];
if ($result['payments'] > 0) $details[] = $result['payments'] . ' مدفوعات';
if ($result['fines'] > 0) $details[] = $result['fines'] . ' غرامات';
if ($result['installments'] > 0) $details[] = $result['installments'] . ' أقساط';
if ($result['sales'] > 0) $details[] = $result['sales'] . ' مبيعات';
if ($result['payroll'] > 0) $details[] = $result['payroll'] . ' رواتب';
if ($result['refunds'] > 0) $details[] = $result['refunds'] . ' مرتجعات';
if ($result['rentals'] > 0) $details[] = $result['rentals'] . ' تأمينات إيجار';
$alerts[] = ['type' => 'success', 'message' => 'تم مزامنة ' . $total . ' قيد محاسبي (' . implode('، ', $details) . ')'];
} else {
$alerts[] = ['type' => 'success', 'message' => 'جميع البيانات المالية متزامنة بالفعل — لا توجد قيود جديدة'];
}
if ($result['errors'] > 0) {
$alerts[] = ['type' => 'error', 'message' => 'فشل ' . $result['errors'] . ' عملية — راجع سجل الأخطاء'];
}
$session->flash('_alerts', $alerts);
return $this->redirect('/accounting');
}
}
......@@ -56,6 +56,9 @@ return [
['POST', '/accounting/period-closing/close-month', 'Accounting\Controllers\PeriodClosingController@closeMonth', ['auth'], 'accounting.period.close'],
['POST', '/accounting/period-closing/reopen-month', 'Accounting\Controllers\PeriodClosingController@reopenMonth', ['auth'], 'accounting.period.reopen'],
// ── GL Sync (super-admin) ───────────────────────────────
['POST', '/accounting/sync-gl', 'Accounting\Controllers\ReportController@syncGL', ['auth'], 'accounting.fiscal_year.manage'],
// ── Reports ──────────────────────────────────────────────
['GET', '/accounting/reports/trial-balance', 'Accounting\Controllers\ReportController@trialBalance', ['auth'], 'accounting.reports.trial_balance'],
['GET', '/accounting/reports/general-ledger', 'Accounting\Controllers\ReportController@generalLedger', ['auth'], 'accounting.reports.general_ledger'],
......
This diff is collapsed.
......@@ -52,6 +52,22 @@
</div>
</div>
<?php if (!empty($is_super_admin) && $unsynced_count > 0): ?>
<!-- Sync Warning Banner -->
<div class="card" style="padding:15px 20px;margin-bottom:15px;background:#FEF3C7;border:1px solid #F59E0B;">
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;">
<div>
<strong style="color:#92400E;">يوجد <?= $unsynced_count ?> مدفوعات غير مسجلة في الدفتر العام</strong>
<p style="margin:3px 0 0;font-size:13px;color:#78350F;">التقارير المحاسبية (قائمة الدخل، الميزانية، ميزان المراجعة) لن تعكس البيانات الحقيقية حتى يتم المزامنة</p>
</div>
<form method="POST" action="/accounting/sync-gl" style="display:inline;">
<?= csrf_field() ?>
<button type="submit" class="btn btn-primary" style="white-space:nowrap;" onclick="return confirm('هل تريد مزامنة جميع البيانات المالية مع الدفتر العام؟ قد تستغرق العملية بضع ثوانٍ.')">مزامنة الدفتر العام</button>
</form>
</div>
</div>
<?php endif; ?>
<!-- Quick Actions -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;margin-bottom:25px;">
<div class="card" style="padding:20px;">
......@@ -63,6 +79,12 @@
<a href="/accounting/reports/balance-sheet" 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>
<?php if (!empty($is_super_admin)): ?>
<form method="POST" action="/accounting/sync-gl" style="display:inline;">
<?= csrf_field() ?>
<button type="submit" class="btn" style="background:#7C3AED;color:#fff;border:none;" onclick="return confirm('هل تريد مزامنة جميع البيانات المالية مع الدفتر العام؟')">مزامنة GL</button>
</form>
<?php endif; ?>
</div>
</div>
<div class="card" style="padding:20px;">
......
......@@ -91,6 +91,39 @@ MenuRegistry::register('accounting', [
],
]);
// ────────────────────────────────────────────────────────────
// Accounting Module — Auto-Create Fiscal Year
// ────────────────────────────────────────────────────────────
// Without an open fiscal year, ALL auto-posted journal entries silently fail.
// This ensures a fiscal year always exists for the current calendar year.
(function (): void {
try {
$db = \App\Core\App::getInstance()->db();
$year = (int) date('Y');
$startDate = $year . '-01-01';
$endDate = $year . '-12-31';
$existing = $db->selectOne(
"SELECT id FROM fiscal_years WHERE start_date <= ? AND end_date >= ? AND is_archived = 0",
[$startDate, $endDate]
);
if (!$existing) {
$db->insert('fiscal_years', [
'name_ar' => 'السنة المالية ' . $year,
'name_en' => 'Fiscal Year ' . $year,
'start_date' => $startDate,
'end_date' => $endDate,
'status' => 'open',
'is_current' => 1,
'is_archived' => 0,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
} catch (\Throwable $e) {
// Silently ignore — table might not exist yet during initial migration
}
})();
// ────────────────────────────────────────────────────────────
// Accounting Module — Event Listeners (Auto-Posting)
// ────────────────────────────────────────────────────────────
......
......@@ -61,7 +61,14 @@ $dbConfig = [
'collation' => $config['database']['collation'] ?? 'utf8mb4_unicode_ci',
];
$db = new \App\Core\Database($dbConfig);
$db = new \App\Core\Database(
(string) $dbConfig['host'],
(int) $dbConfig['port'],
(string) $dbConfig['name'],
(string) $dbConfig['user'],
(string) $dbConfig['pass'],
(string) $dbConfig['charset']
);
$command = $argv[1] ?? 'help';
$arg2 = $argv[2] ?? null;
......
<?php
declare(strict_types=1);
use App\Core\Database;
/**
* Ensure open fiscal years exist for ALL years that have financial data.
*
* Without a fiscal year, ALL auto-posted journal entries silently fail
* because JournalService::createEntry() requires FiscalYear::findByDate().
*
* This seed:
* 1. Scans payments, fines, sales, payroll for the earliest and latest dates
* 2. Creates a fiscal year for every calendar year in that range (+ current year)
* 3. Ensures exactly the current-year fiscal year is marked is_current = 1
*
* SAFE TO RE-RUN: Skips years that already have a fiscal year.
*
* Run: php cli.php seed
*/
return function (Database $db): void {
$now = date('Y-m-d H:i:s');
$currentYear = (int) date('Y');
// Find the range of years with financial data
$minMaxQueries = [
"SELECT MIN(payment_date) as min_date, MAX(payment_date) as max_date FROM payments WHERE is_voided = 0",
];
// Optional tables — only query if they exist
$optionalTables = [
'fines' => "SELECT MIN(imposed_date) as min_date, MAX(imposed_date) as max_date FROM fines WHERE is_archived = 0",
'sales' => "SELECT MIN(sale_date) as min_date, MAX(sale_date) as max_date FROM sales WHERE status = 'completed'",
'hr_payroll_runs' => "SELECT MIN(payment_date) as min_date, MAX(payment_date) as max_date FROM hr_payroll_runs WHERE status = 'paid'",
'installment_plans' => "SELECT MIN(created_at) as min_date, MAX(created_at) as max_date FROM installment_plans WHERE is_archived = 0",
];
foreach ($optionalTables as $table => $query) {
$exists = $db->selectOne("SHOW TABLES LIKE ?", [$table]);
if ($exists) {
$minMaxQueries[] = $query;
}
}
$minYear = $currentYear;
$maxYear = $currentYear;
foreach ($minMaxQueries as $query) {
try {
$row = $db->selectOne($query);
if ($row && $row['min_date']) {
$y = (int) substr($row['min_date'], 0, 4);
if ($y > 2000 && $y < $minYear) {
$minYear = $y;
}
}
if ($row && $row['max_date']) {
$y = (int) substr($row['max_date'], 0, 4);
if ($y > 2000 && $y > $maxYear) {
$maxYear = $y;
}
}
} catch (\Throwable $e) {
// Table might not exist or query might fail — skip
continue;
}
}
echo " Fiscal year range needed: {$minYear}{$maxYear}\n";
// Clear stale is_current flags
$db->execute("UPDATE fiscal_years SET is_current = 0 WHERE is_current = 1");
$created = 0;
for ($year = $minYear; $year <= $maxYear; $year++) {
$startDate = $year . '-01-01';
$endDate = $year . '-12-31';
// Check if fiscal year already exists for this range
$existing = $db->selectOne(
"SELECT id, status FROM fiscal_years
WHERE start_date <= ? AND end_date >= ? AND is_archived = 0",
[$startDate, $endDate]
);
if ($existing) {
// Ensure it's open so the sync seed can post entries
if ($existing['status'] !== 'open') {
$db->update('fiscal_years', [
'status' => 'open',
'updated_at' => $now,
], '`id` = ?', [(int) $existing['id']]);
echo " Reopened fiscal year {$year} for sync\n";
}
// Mark current year as is_current
if ($year === $currentYear) {
$db->update('fiscal_years', [
'is_current' => 1,
'updated_at' => $now,
], '`id` = ?', [(int) $existing['id']]);
}
continue;
}
// Create new fiscal year
$db->insert('fiscal_years', [
'name_ar' => 'السنة المالية ' . $year,
'name_en' => 'Fiscal Year ' . $year,
'start_date' => $startDate,
'end_date' => $endDate,
'status' => 'open',
'is_current' => ($year === $currentYear) ? 1 : 0,
'is_archived' => 0,
'created_at' => $now,
'updated_at' => $now,
]);
$created++;
echo " Created fiscal year {$year}\n";
}
if ($created === 0) {
echo " All fiscal years already exist.\n";
} else {
echo " Created {$created} fiscal year(s).\n";
}
};
<?php
declare(strict_types=1);
use App\Core\App;
use App\Core\Database;
use App\Modules\Accounting\Services\GLSyncService;
/**
* Sync ALL existing financial data into the General Ledger.
*
* Delegates to GLSyncService which is also callable from the web UI
* (Accounting Dashboard → "مزامنة GL" button).
*
* SAFE TO RE-RUN: Idempotent — skips records that already have journal entries.
*
* PREREQUISITE: Phase_36_002_seed_fiscal_year should run first (but GLSyncService
* also creates fiscal years internally, so it's self-healing).
*
* Run: php cli.php seed
*/
return function (Database $db): void {
// AccountingIntegrationService and JournalService use App::getInstance()->db()
// internally. In CLI context, App is not booted, so we inject the db instance.
App::getInstance()->setDb($db);
echo " Running GL sync...\n";
$result = GLSyncService::syncAll();
$total = $result['payments'] + $result['fines'] + $result['installments']
+ $result['sales'] + $result['payroll'] + $result['refunds'] + $result['rentals'];
echo " Payments: {$result['payments']}\n";
echo " Fines: {$result['fines']}\n";
echo " Installments: {$result['installments']}\n";
echo " Sales COGS: {$result['sales']}\n";
echo " Payroll: {$result['payroll']}\n";
echo " Refunds: {$result['refunds']}\n";
echo " Rentals: {$result['rentals']}\n";
echo " ──────────────────\n";
echo " Total synced: {$total}\n";
echo " Errors: {$result['errors']}\n";
if ($result['errors'] > 0) {
echo "\n WARNING: Some entries failed. Check logs for details.\n";
}
};
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