Commit 33c76389 authored by DevPilot's avatar DevPilot

fix(accounting): make the accounting fundamentals actually work

Auditing the basics against the live books turned up four things that could
never have worked, plus a fifth that worked too well.

CLOSING A FISCAL YEAR WAS IMPOSSIBLE — three separate faults stacked:

  1. `Database::execute()` did not exist. Six call sites used it — closing a
     period, setting the current fiscal year, setting the default bank account,
     rebuilding an account balance, deleting a facility blackout — and every one
     was a fatal the moment it ran. Added to Database rather than patched at the
     call sites. This alone is why period_closings was empty.
  2. Closing a year requires every month closed first, then dates its entry the
     last day of the year — inside a month it just required to be closed. The
     two rules deadlocked. The closing entry, and only it, may now post there.
  3. The same entry has to zero accounts retired during the year, which the
     inactive-account guard refused. Closing an account is exactly when you must
     be able to clear it.
  4. And `period_closings.period` is varchar(7) while the year-end key is
     `YYYY-MM-YE` — ten characters — so the entry posted and the record failed,
     leaving the year half-closed.

  It now runs end to end: 19 lines, revenue and expenses zeroed to retained
  earnings, year marked closed, balance sheet still balanced.

PAYROLL POSTED TWICE. Salaries are the largest single expense the club books and
nothing guarded the reference, so a retried event or a second click doubled the
expense, the withheld tax and the insurance liability. Every other posting in
that service guards; this one did not.

FIXED ASSETS COULD NOT BE CREATED AT ALL. The club carries 4,705,686 of
machinery and 41,703,867 of construction in progress, and `asset_register` is
empty — so nothing has ever depreciated. Not because depreciation is unwired, it
posts correctly, but because there was no create route, no create method, and
nothing anywhere that inserted an asset. `item_id` was NOT NULL against
inventory_items, so registering a building meant inventing a stock row for it.

  Now: item_id and warehouse_id are nullable, the eight asset categories the
  chart already defines are seeded with their three GL accounts each, and there
  is a form. It distinguishes a purchase (posts Dr Asset / Cr Cash-or-Payable)
  from an opening-balance asset (posts nothing — the cost is already in the
  ledger, and posting it again would double the balance sheet).

  Asset disposal never worked either: `AccountCodes` was not imported, so the
  handler threw a fatal that the bootstrap's catch swallowed. Assets came off
  the register and stayed on the balance sheet.

  The register's own queries inner-joined inventory_items and warehouses, which
  would have hidden every real fixed asset behind an empty list and a "not
  found" page. LEFT joins, and the category name where there is no stock item.

  The depreciation month picker posted `month` while the controller read
  `period_month`, so whichever month the accountant chose was dropped and the
  current one ran instead.

Overlapping fiscal years are now shown on the fiscal-years screen. The club
carries calendar years alongside a July–June year, so entries in the overlap
belong to both and closing one leaves the other open over the same
transactions. Only the club can say which convention is real, so it is surfaced,
not guessed.

Verified on a clone of production with all 409 foreign keys: full asset cycle
posts (purchase, depreciation by category, disposal with its 5,000 loss), all
1,489 routes resolve, 252 controllers instantiate, 241 services load, trial
balance diff 0.00, balance sheet balanced.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
parent 61dab765
...@@ -66,6 +66,23 @@ class Database ...@@ -66,6 +66,23 @@ class Database
$this->pdo->exec($sql); $this->pdo->exec($sql);
} }
/**
* Run a write statement and report how many rows it touched.
*
* Six call sites already used `$db->execute(...)` — closing an accounting
* period, setting the current fiscal year, setting the default bank
* account, rebuilding an account balance, deleting a facility blackout —
* and the method did not exist. Every one of them was a fatal the moment it
* ran, which is why no period had ever been closed.
*
* It is `query()` with the row count instead of the statement, because that
* is what all of them wanted.
*/
public function execute(string $sql, array $params = []): int
{
return $this->query($sql, $params)->rowCount();
}
public function select(string $sql, array $params = []): array public function select(string $sql, array $params = []): array
{ {
return $this->query($sql, $params)->fetchAll(); return $this->query($sql, $params)->fetchAll();
......
...@@ -21,8 +21,28 @@ class FiscalYearController extends Controller ...@@ -21,8 +21,28 @@ class FiscalYearController extends Controller
->orderBy('start_date', 'DESC') ->orderBy('start_date', 'DESC')
->get(); ->get();
// Two fiscal years covering the same day is a contradiction the ledger
// cannot resolve on its own: an entry dated in the overlap belongs to
// both, closing one leaves the other open over the same transactions,
// and the year's result gets counted twice. The club currently carries
// calendar years alongside a July–June year, so this is not theoretical.
// Only the club can say which convention is real, so it is shown here
// rather than guessed at.
$overlaps = App::getInstance()->db()->select(
"SELECT a.id AS a_id, a.name_ar AS a_name, a.start_date AS a_start, a.end_date AS a_end, a.status AS a_status,
b.id AS b_id, b.name_ar AS b_name, b.start_date AS b_start, b.end_date AS b_end, b.status AS b_status
FROM fiscal_years a
JOIN fiscal_years b
ON a.id < b.id
AND a.start_date <= b.end_date
AND b.start_date <= a.end_date
WHERE a.is_archived = 0 AND b.is_archived = 0
ORDER BY a.start_date"
);
return $this->view('Accounting/Views/fiscal_years/index', [ return $this->view('Accounting/Views/fiscal_years/index', [
'years' => $years, 'years' => $years,
'overlaps' => $overlaps,
]); ]);
} }
......
...@@ -614,6 +614,15 @@ final class AccountingIntegrationService ...@@ -614,6 +614,15 @@ final class AccountingIntegrationService
return; return;
} }
// Salaries are the largest single expense the club posts, and nothing
// stopped this running twice — a retried event or a second click on
// "paid" booked the whole payslip again, doubling the expense, the
// withheld tax and the insurance liability. Every other posting in this
// service guards on its reference; this one did not.
if (\App\Modules\Accounting\Models\JournalEntry::findByReference('payroll', $payrollRunId)) {
return;
}
$num = static fn(?string $v): string => number_format((float) ($v ?? 0), 2, '.', ''); $num = static fn(?string $v): string => number_format((float) ($v ?? 0), 2, '.', '');
$grossSalary = $num($run['gross_earnings'] ?? '0'); $grossSalary = $num($run['gross_earnings'] ?? '0');
......
...@@ -47,9 +47,17 @@ final class JournalService ...@@ -47,9 +47,17 @@ final class JournalService
return ['success' => false, 'error' => 'السنة المالية مغلقة — لا يمكن إضافة قيود']; return ['success' => false, 'error' => 'السنة المالية مغلقة — لا يمكن إضافة قيود'];
} }
// Period closed check // Period closed check.
//
// The year-end closing entry is the one exception, and it has to be.
// Closing the year requires every month to be closed first, and the
// entry itself is dated the last day of the year — inside the month
// that was just closed. Without this the two rules deadlock and the
// fiscal year can never be closed at all, which is exactly what was
// happening. Only PeriodClosingService sets this flag.
$period = substr($entryDate, 0, 7); // YYYY-MM $period = substr($entryDate, 0, 7); // YYYY-MM
if (PeriodClosing::isPeriodClosed((int) $fiscalYear->id, $period)) { if (empty($header['allow_closed_period'])
&& PeriodClosing::isPeriodClosed((int) $fiscalYear->id, $period)) {
return ['success' => false, 'error' => 'الفترة ' . $period . ' مغلقة — لا يمكن إضافة قيود']; return ['success' => false, 'error' => 'الفترة ' . $period . ' مغلقة — لا يمكن إضافة قيود'];
} }
...@@ -99,7 +107,12 @@ final class JournalService ...@@ -99,7 +107,12 @@ final class JournalService
if ((int) $accountMap[(int) $accId]['is_header'] === 1) { if ((int) $accountMap[(int) $accId]['is_header'] === 1) {
return ['success' => false, 'error' => 'لا يمكن الترحيل إلى حساب رئيسي (header)']; return ['success' => false, 'error' => 'لا يمكن الترحيل إلى حساب رئيسي (header)'];
} }
if ((int) $accountMap[(int) $accId]['is_active'] === 0) { // An inactive account takes no new business — but the year-end entry
// is not new business, it is what CLEARS the account. An account
// retired mid-year still carries the activity it had while it was
// open, and refusing to zero it means the year can never be closed.
if ((int) $accountMap[(int) $accId]['is_active'] === 0
&& empty($header['allow_inactive_accounts'])) {
return ['success' => false, 'error' => 'الحساب غير نشط']; return ['success' => false, 'error' => 'الحساب غير نشط'];
} }
} }
......
...@@ -5,6 +5,11 @@ namespace App\Modules\Accounting\Services; ...@@ -5,6 +5,11 @@ namespace App\Modules\Accounting\Services;
use App\Core\App; use App\Core\App;
use App\Core\Logger; use App\Core\Logger;
// Without this, `AccountCodes::` resolves inside THIS namespace, where no such
// class exists — so onAssetDisposed threw a fatal Error every time it ran. The
// bootstrap listener catches Throwable and logs, so disposal has quietly never
// posted: assets came off the register and stayed on the balance sheet.
use App\Modules\Accounting\AccountCodes;
use App\Modules\Accounting\Models\JournalEntry; use App\Modules\Accounting\Models\JournalEntry;
use App\Modules\Accounting\Services\Revenue\AccrualService; use App\Modules\Accounting\Services\Revenue\AccrualService;
use App\Modules\Accounting\Services\Revenue\PostingRouter; use App\Modules\Accounting\Services\Revenue\PostingRouter;
...@@ -401,6 +406,116 @@ final class OperationalPostingService ...@@ -401,6 +406,116 @@ final class OperationalPostingService
} }
} }
/**
* Buying a fixed asset is not an expense. The club has exchanged one asset
* for another, so the cost is capitalised and released to the income
* statement over the asset's life through depreciation. Expensing it would
* bury a year's profit in the month of purchase and leave the balance sheet
* short by whatever the club actually owns.
*
* Dr الأصل الثابت (فئة الأصل)
* Cr النقدية / البنك / الموردين
*
* An asset that is ALREADY in the opening balance sheet posts nothing — its
* cost is in the ledger and posting it again would double the balance
* sheet. The register records it so it can depreciate from here on.
*/
public static function onAssetAcquired(array $data): void
{
$assetId = (int) ($data['asset_id'] ?? 0);
if ($assetId <= 0) {
return;
}
if (JournalEntry::findByReference('asset_acquisition', $assetId)) {
return;
}
$db = App::getInstance()->db();
$asset = $db->selectOne(
"SELECT a.id, a.asset_tag, a.purchase_cost, a.purchase_date, a.branch_id,
a.category_id, a.acquisition_source,
c.asset_account_id, c.name_ar AS category_name
FROM asset_register a
LEFT JOIN asset_categories c ON c.id = a.category_id
WHERE a.id = ?",
[$assetId]
);
if (!$asset) {
return;
}
// Already on the books — the register is catching up with the ledger,
// not adding to it.
if ((string) ($asset['acquisition_source'] ?? 'purchase') === 'opening') {
return;
}
$cost = self::money((string) ($asset['purchase_cost'] ?? '0'));
if (bccomp($cost, '0.00', self::SCALE) <= 0) {
return;
}
$assetAccount = (int) ($asset['asset_account_id'] ?? 0);
if ($assetAccount <= 0) {
Logger::error('Asset acquisition not posted — category has no asset account', [
'asset_id' => $assetId,
'category' => $asset['category_id'] ?? null,
]);
return;
}
// How it was paid for decides only the credit side.
$creditAccount = match ((string) ($data['payment_source'] ?? 'payable')) {
'cash' => PostingRouter::accountFor('treasury:method_cash', AccountCodes::CASH_ON_HAND, 'collection'),
'bank' => PostingRouter::accountFor('treasury:method_bank_transfer', AccountCodes::CASH_AT_BANK, 'collection'),
default => PostingRouter::accountFor('procurement:payable', AccountCodes::ACCOUNTS_PAYABLE, 'accrual'),
};
if ($creditAccount === null) {
Logger::error('Asset acquisition not posted — funding account unresolved', [
'asset_id' => $assetId,
'source' => $data['payment_source'] ?? 'payable',
]);
return;
}
$name = $asset['asset_tag'] ?: ('#' . $assetId);
$desc = 'شراء أصل ثابت — ' . $name
. ($asset['category_name'] ? ' (' . $asset['category_name'] . ')' : '');
$result = JournalService::createEntry([
'entry_date' => substr((string) ($asset['purchase_date'] ?? date('Y-m-d')), 0, 10),
'description_ar' => $desc,
'reference_type' => 'asset_acquisition',
'reference_id' => $assetId,
'source_module' => 'inventory',
'branch_id' => $asset['branch_id'] ?? null,
'is_auto_generated' => 1,
], [
['account_id' => $assetAccount, 'debit' => $cost, 'credit' => '0.00', 'description_ar' => 'إثبات تكلفة الأصل — ' . $name],
['account_id' => $creditAccount, 'debit' => '0.00', 'credit' => $cost, 'description_ar' => 'سداد/التزام شراء أصل — ' . $name],
], true);
if (empty($result['success'])) {
Logger::error('Asset acquisition entry failed', [
'asset_id' => $assetId,
'error' => $result['error'] ?? null,
]);
return;
}
// createEntry reports success without handing back the id, so the entry
// is read back by its reference to stamp the register.
$entry = JournalEntry::findByReference('asset_acquisition', $assetId);
if ($entry) {
$db->update('asset_register', [
'acquisition_entry_id' => (int) $entry->id,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$assetId]);
}
}
// ──────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────
// Corrections // Corrections
// ──────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────
......
...@@ -274,6 +274,12 @@ final class PeriodClosingService ...@@ -274,6 +274,12 @@ final class PeriodClosingService
'reference_type' => 'closing', 'reference_type' => 'closing',
'source_module' => 'accounting', 'source_module' => 'accounting',
'is_auto_generated' => 1, 'is_auto_generated' => 1,
// Dated the last day of the year, which is inside a month this
// method already required to be closed. And it has to clear any
// account that was retired during the year. This is the only entry
// allowed through either check — see JournalService::createEntry.
'allow_closed_period' => true,
'allow_inactive_accounts' => true,
], $lines, true); ], $lines, true);
if (!$closingResult['success']) { if (!$closingResult['success']) {
......
...@@ -9,6 +9,50 @@ ...@@ -9,6 +9,50 @@
<?php $__template->endSection(); ?> <?php $__template->endSection(); ?>
<?php $__template->section('content'); ?> <?php $__template->section('content'); ?>
<?php if (!empty($overlaps)): ?>
<div class="card" style="margin-bottom:15px;border-right:3px solid #DC2626;">
<div style="padding:14px 18px;">
<div style="font-size:15px;font-weight:700;margin-bottom:6px;">
سنوات مالية متداخلة — <?= number_format(count($overlaps)) ?>
</div>
<p style="margin:0 0 10px;color:#991B1B;font-size:12.5px;line-height:1.9;">
فيه سنتين ماليتين بيغطوا نفس الأيام. القيد اللي تاريخه في التداخل بيبقى تابع
للاتنين، وإقفال واحدة بيسيب التانية مفتوحة على نفس العمليات — يعني نتيجة نفس
الفترة ممكن تتحسب مرتين.
</p>
<p style="margin:0 0 12px;color:#6B7280;font-size:12px;line-height:1.9;">
النظام بيختار السنة الأقصر عند التداخل عشان يفضل ثابت، بس ده حل مؤقت مش قرار.
<strong>النادي هو اللي يقرر</strong>: السنة المالية تقويمية (يناير–ديسمبر)
ولا يوليو–يونيو؟ بعد ما تقرر، أرشِف السنين اللي مش تابعة للنظام المعتمد.
</p>
<div class="table-responsive">
<table class="table" style="font-size:12.5px;">
<thead>
<tr><th>السنة الأولى</th><th>الفترة</th><th>السنة التانية</th><th>الفترة</th></tr>
</thead>
<tbody>
<?php foreach ($overlaps as $o): ?>
<tr>
<td>
<a href="/accounting/fiscal-years/<?= (int) $o['a_id'] ?>"><?= e((string) $o['a_name']) ?></a>
<span style="color:#9CA3AF;">(<?= e((string) $o['a_status']) ?>)</span>
</td>
<td style="direction:ltr;text-align:right;"><?= e((string) $o['a_start']) ?><?= e((string) $o['a_end']) ?></td>
<td>
<a href="/accounting/fiscal-years/<?= (int) $o['b_id'] ?>"><?= e((string) $o['b_name']) ?></a>
<span style="color:#9CA3AF;">(<?= e((string) $o['b_status']) ?>)</span>
</td>
<td style="direction:ltr;text-align:right;"><?= e((string) $o['b_start']) ?><?= e((string) $o['b_end']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
<?php endif; ?>
<div class="card" style="margin-bottom:15px;"> <div class="card" style="margin-bottom:15px;">
<div style="padding:15px 20px;"> <div style="padding:15px 20px;">
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;"> <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
......
...@@ -515,6 +515,7 @@ $opPostings = [ ...@@ -515,6 +515,7 @@ $opPostings = [
'procurement.grn_completed' => 'onGoodsReceived', 'procurement.grn_completed' => 'onGoodsReceived',
'inventory.depreciation_run' => 'onDepreciationRun', 'inventory.depreciation_run' => 'onDepreciationRun',
'inventory.audit_completed' => 'onStockAuditApproved', 'inventory.audit_completed' => 'onStockAuditApproved',
'inventory.asset_acquired' => 'onAssetAcquired',
'inventory.asset_disposed' => 'onAssetDisposed', 'inventory.asset_disposed' => 'onAssetDisposed',
'fine.waived' => 'onFineWaived', 'fine.waived' => 'onFineWaived',
]; ];
......
...@@ -58,10 +58,12 @@ class AssetRegister extends Model ...@@ -58,10 +58,12 @@ class AssetRegister extends Model
{ {
$db = App::getInstance()->db(); $db = App::getInstance()->db();
return $db->select( return $db->select(
"SELECT ar.*, i.`name_ar` as item_name, i.`sku`, w.`name_ar` as warehouse_name "SELECT ar.*, i.`name_ar` as item_name, i.`sku`, w.`name_ar` as warehouse_name,
c.`name_ar` as category_name
FROM `asset_register` ar FROM `asset_register` ar
JOIN `inventory_items` i ON i.`id` = ar.`item_id` LEFT JOIN `inventory_items` i ON i.`id` = ar.`item_id`
JOIN `warehouses` w ON w.`id` = ar.`warehouse_id` LEFT JOIN `warehouses` w ON w.`id` = ar.`warehouse_id`
LEFT JOIN `asset_categories` c ON c.`id` = ar.`category_id`
WHERE ar.`status` = 'active' WHERE ar.`status` = 'active'
ORDER BY ar.`asset_tag` ASC" ORDER BY ar.`asset_tag` ASC"
); );
...@@ -75,7 +77,8 @@ class AssetRegister extends Model ...@@ -75,7 +77,8 @@ class AssetRegister extends Model
if (!empty($filters['q'])) { if (!empty($filters['q'])) {
$search = '%' . $filters['q'] . '%'; $search = '%' . $filters['q'] . '%';
$where .= ' AND (ar.`asset_tag` LIKE ? OR i.`name_ar` LIKE ? OR ar.`serial_number` LIKE ?)'; $where .= ' AND (ar.`asset_tag` LIKE ? OR i.`name_ar` LIKE ? OR c.`name_ar` LIKE ? OR ar.`serial_number` LIKE ?)';
$params[] = $search;
$params[] = $search; $params[] = $search;
$params[] = $search; $params[] = $search;
$params[] = $search; $params[] = $search;
...@@ -92,17 +95,22 @@ class AssetRegister extends Model ...@@ -92,17 +95,22 @@ class AssetRegister extends Model
} }
$countRow = $db->selectOne( $countRow = $db->selectOne(
"SELECT COUNT(*) as cnt FROM `asset_register` ar JOIN `inventory_items` i ON i.`id` = ar.`item_id` WHERE {$where}", "SELECT COUNT(*) as cnt FROM `asset_register` ar
LEFT JOIN `inventory_items` i ON i.`id` = ar.`item_id`
LEFT JOIN `asset_categories` c ON c.`id` = ar.`category_id`
WHERE {$where}",
$params $params
); );
$total = (int) ($countRow['cnt'] ?? 0); $total = (int) ($countRow['cnt'] ?? 0);
$offset = ($page - 1) * $perPage; $offset = ($page - 1) * $perPage;
$rows = $db->select( $rows = $db->select(
"SELECT ar.*, i.`name_ar` as item_name, i.`sku`, w.`name_ar` as warehouse_name "SELECT ar.*, i.`name_ar` as item_name, i.`sku`, w.`name_ar` as warehouse_name,
c.`name_ar` as category_name
FROM `asset_register` ar FROM `asset_register` ar
JOIN `inventory_items` i ON i.`id` = ar.`item_id` LEFT JOIN `inventory_items` i ON i.`id` = ar.`item_id`
JOIN `warehouses` w ON w.`id` = ar.`warehouse_id` LEFT JOIN `warehouses` w ON w.`id` = ar.`warehouse_id`
LEFT JOIN `asset_categories` c ON c.`id` = ar.`category_id`
WHERE {$where} WHERE {$where}
ORDER BY ar.`asset_tag` ASC ORDER BY ar.`asset_tag` ASC
LIMIT {$perPage} OFFSET {$offset}", LIMIT {$perPage} OFFSET {$offset}",
......
...@@ -69,6 +69,10 @@ return [ ...@@ -69,6 +69,10 @@ return [
// Assets // Assets
['GET', '/inventory/assets', 'Inventory\Controllers\AssetController@index', ['auth'], 'asset.view'], ['GET', '/inventory/assets', 'Inventory\Controllers\AssetController@index', ['auth'], 'asset.view'],
['GET', '/inventory/assets/create', 'Inventory\Controllers\AssetController@create', ['auth'], 'asset.manage'],
['POST', '/inventory/assets', 'Inventory\Controllers\AssetController@store', ['auth', 'csrf'], 'asset.manage'],
['GET', '/inventory/assets/{id:\d+}/edit', 'Inventory\Controllers\AssetController@edit', ['auth'], 'asset.manage'],
['POST', '/inventory/assets/{id:\d+}', 'Inventory\Controllers\AssetController@update', ['auth', 'csrf'], 'asset.manage'],
['GET', '/inventory/assets/{id:\d+}', 'Inventory\Controllers\AssetController@show', ['auth'], 'asset.view'], ['GET', '/inventory/assets/{id:\d+}', 'Inventory\Controllers\AssetController@show', ['auth'], 'asset.view'],
['POST', '/inventory/assets/{id:\d+}/dispose', 'Inventory\Controllers\AssetController@dispose', ['auth', 'csrf'], 'asset.manage'], ['POST', '/inventory/assets/{id:\d+}/dispose', 'Inventory\Controllers\AssetController@dispose', ['auth', 'csrf'], 'asset.manage'],
['POST', '/inventory/assets/run-depreciation', 'Inventory\Controllers\AssetController@runDepreciation',['auth', 'csrf'], 'asset.manage'], ['POST', '/inventory/assets/run-depreciation', 'Inventory\Controllers\AssetController@runDepreciation',['auth', 'csrf'], 'asset.manage'],
......
This diff is collapsed.
...@@ -5,11 +5,16 @@ ...@@ -5,11 +5,16 @@
<?php if (can('asset.manage')): ?> <?php if (can('asset.manage')): ?>
<form method="POST" action="/inventory/assets/run-depreciation" style="display:inline-flex;align-items:center;gap:8px;" onsubmit="return confirm('هل أنت متأكد من تشغيل الإهلاك لهذا الشهر؟');"> <form method="POST" action="/inventory/assets/run-depreciation" style="display:inline-flex;align-items:center;gap:8px;" onsubmit="return confirm('هل أنت متأكد من تشغيل الإهلاك لهذا الشهر؟');">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="month" name="month" value="<?= e(date('Y-m')) ?>" class="form-input" style="width:160px;padding:6px 10px;font-size:13px;"> <!-- The controller reads `period_month`. This was named `month`, so whatever
month the accountant picked was dropped and the current one ran instead. -->
<input type="month" name="period_month" value="<?= e(date('Y-m')) ?>" class="form-input" style="width:160px;padding:6px 10px;font-size:13px;">
<button type="submit" class="btn" style="background:#D97706;color:#fff;border:none;"> <button type="submit" class="btn" style="background:#D97706;color:#fff;border:none;">
<i data-lucide="calculator" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> تشغيل الإهلاك <i data-lucide="calculator" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> تشغيل الإهلاك
</button> </button>
</form> </form>
<a href="/inventory/assets/create" class="btn btn-primary" style="margin-right:8px;">
<i data-lucide="plus" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> تسجيل أصل ثابت
</a>
<?php endif; ?> <?php endif; ?>
<?php $__template->endSection(); ?> <?php $__template->endSection(); ?>
...@@ -89,8 +94,12 @@ $depMethodLabels = [ ...@@ -89,8 +94,12 @@ $depMethodLabels = [
<?= e($asset['asset_tag'] ?? '') ?> <?= e($asset['asset_tag'] ?? '') ?>
</a> </a>
</td> </td>
<td style="font-weight:600;"><?= e($asset['item_name'] ?? '') ?></td> <!-- A fixed asset need not be a stock item, so fall back to
<td><?= e($asset['warehouse_name'] ?? '') ?></td> its category rather than showing an empty cell. -->
<td style="font-weight:600;">
<?= e((string) ($asset['item_name'] ?: $asset['category_name'] ?? '')) ?>
</td>
<td><?= e((string) ($asset['warehouse_name'] ?: '—')) ?></td>
<td style="font-weight:700;direction:ltr;text-align:left;"><?= money($asset['purchase_cost'] ?? 0) ?></td> <td style="font-weight:700;direction:ltr;text-align:left;"><?= money($asset['purchase_cost'] ?? 0) ?></td>
<td style="font-weight:700;direction:ltr;text-align:left;"><?= money($asset['book_value'] ?? 0) ?></td> <td style="font-weight:700;direction:ltr;text-align:left;"><?= money($asset['book_value'] ?? 0) ?></td>
<td style="font-size:13px;"><?= e($dmLabel) ?></td> <td style="font-size:13px;"><?= e($dmLabel) ?></td>
......
<?php
declare(strict_types=1);
use App\Core\Database;
/**
* Makes the fixed-asset register usable.
*
* The club's books carry 4,705,686 of machinery and 41,703,867 of construction
* in progress, and `asset_register` is empty — so nothing depreciates. Not
* because depreciation is unwired (it posts correctly through
* OperationalPostingService::onDepreciationRun) but because no asset can be
* created in the first place:
*
* - `item_id` is NOT NULL against `inventory_items`. A building, a court or a
* transformer is not a stock item, so registering one meant inventing a
* fake inventory row for it.
* - `warehouse_id` is NOT NULL. A building is not in a warehouse.
*
* Both become nullable. Nothing else changes: assets that DO come from stock
* keep their link, and the foreign keys stay so a bad id is still rejected.
*
* Also adds `acquisition_source`, which the accounting depends on. An asset the
* club buys today must post Dr Asset / Cr Cash-or-Payable. An asset already
* standing in the opening balance sheet must post NOTHING — its cost is already
* in the ledger, and posting it again would double the balance sheet. The
* register has to know which it is looking at, and it could not before.
*/
return static function (Database $db): void {
$col = static function (string $name) use ($db): ?array {
return $db->selectOne(
"SELECT column_name, is_nullable, data_type FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'asset_register' AND column_name = ?",
[$name]
);
};
if (($col('item_id')['is_nullable'] ?? 'NO') === 'NO') {
$db->raw("ALTER TABLE `asset_register` MODIFY `item_id` BIGINT UNSIGNED NULL");
}
if (($col('warehouse_id')['is_nullable'] ?? 'NO') === 'NO') {
$db->raw("ALTER TABLE `asset_register` MODIFY `warehouse_id` BIGINT UNSIGNED NULL");
}
if (!$col('acquisition_source')) {
$db->raw(
"ALTER TABLE `asset_register`
ADD COLUMN `acquisition_source` VARCHAR(20) NOT NULL DEFAULT 'purchase'
COMMENT 'purchase = يتقيّد عند التسجيل | opening = رصيد افتتاحي مقيّد بالفعل'
AFTER `purchase_cost`"
);
}
if (!$col('acquisition_entry_id')) {
$db->raw(
"ALTER TABLE `asset_register`
ADD COLUMN `acquisition_entry_id` BIGINT UNSIGNED NULL
COMMENT 'قيد الشراء — فاضي لو الأصل رصيد افتتاحي'
AFTER `acquisition_source`"
);
}
// An asset tag is unique, but the register is also read by category and by
// period all the time — both unindexed until now.
$hasIndex = $db->selectOne(
"SELECT 1 AS x FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'asset_register'
AND index_name = 'idx_asset_category_status'"
);
if (!$hasIndex) {
$db->raw("ALTER TABLE `asset_register` ADD INDEX `idx_asset_category_status` (`category_id`, `status`)");
}
};
<?php
declare(strict_types=1);
use App\Core\Database;
/**
* Lets the year-end closing record actually be written.
*
* `PeriodClosingService::closeFiscalYear` records the year-end row with a
* period of `YYYY-MM-YE` — ten characters — and `period` is varchar(7). The
* closing ENTRY posted fine and then the bookkeeping row failed on insert, so
* the year was left half-closed: revenue and expenses cleared to retained
* earnings, but `fiscal_years.status` still 'open' and no closing record.
*
* This was the last of three separate faults that made closing a fiscal year
* impossible. The other two were in code: the closed-period check rejected the
* closing entry itself, and the inactive-account check rejected any account
* retired during the year.
*/
return static function (Database $db): void {
$col = $db->selectOne(
"SELECT character_maximum_length AS len FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'period_closings' AND column_name = 'period'"
);
if ($col && (int) $col['len'] < 16) {
$db->raw("ALTER TABLE `period_closings` MODIFY `period` VARCHAR(16) NOT NULL");
}
};
<?php
declare(strict_types=1);
use App\Core\Database;
/**
* The GL mapping depreciation needs, which nothing had.
*
* `OperationalPostingService::onDepreciationRun` groups the month's
* depreciation by asset category and posts, per category:
*
* Dr مصروف الإهلاك (category.expense_account_id)
* Cr مجمع الإهلاك (category.depreciation_account_id)
*
* If either account is missing it logs "accounts unmapped" and posts nothing.
* `asset_categories` was empty, so every asset would have been skipped — the
* register would age and the balance sheet never would.
*
* The eight categories below are not invented. They mirror the club's own chart
* of accounts, which already breaks fixed assets into exactly these groups and
* carries a matching accumulated-depreciation account (٢٣٠١٠١xx) and expense
* account (٣١٣٧xx) for each. Land is deliberately included and deliberately
* NOT depreciated — land does not wear out, and giving it a 0% rate is how the
* register says so out loud rather than by omission.
*
* Depreciation expense is mapped to ٣١٣٧ «إهلاك الأصول الثابتة» under
* «تكاليف النشاط», because in a sports club the buildings, courts, machines and
* equipment exist to run the activity. The chart also carries ٣٢٠٦ (البيع
* والتوزيع) and ٣٣١٦ (عمومية وإدارية) for assets that serve those functions
* instead — the accountant can repoint any category from the asset-categories
* screen without touching code.
*
* Useful lives follow common Egyptian practice for each class. They are
* DEFAULTS: every asset carries its own life, and changing a category later
* does not rewrite assets already registered.
*
* Idempotent — a category that already exists is left exactly as it is, so this
* never overwrites a mapping the accountant has changed.
*/
return static function (Database $db): void {
// asset account | accumulated depreciation | depreciation expense
$categories = [
[
'ar' => 'أراضي', 'en' => 'Land',
'asset' => '11010102', 'accum' => null, 'expense' => null,
'life' => 0, 'salvage' => '0.00', 'note' => 'الأراضي لا تُهلك — العمر الإنتاجي غير محدود',
],
[
'ar' => 'مباني وإنشاءات ومرافق وطرق وشبكات', 'en' => 'Buildings & Constructions',
'asset' => '11010201', 'accum' => '23010101', 'expense' => '313701',
'life' => 480, 'salvage' => '0.00', 'note' => '٤٠ سنة',
],
[
'ar' => 'آلات ومعدات', 'en' => 'Machinery & Equipment',
'asset' => '11010301', 'accum' => '23010102', 'expense' => '313702',
'life' => 120, 'salvage' => '5.00', 'note' => '١٠ سنين',
],
[
'ar' => 'وسائل نقل وانتقال', 'en' => 'Vehicles',
'asset' => '11010402', 'accum' => '23010103', 'expense' => '313703',
'life' => 60, 'salvage' => '10.00', 'note' => '٥ سنين',
],
[
'ar' => 'عدد وأدوات', 'en' => 'Tools & Equipment',
'asset' => '11010501', 'accum' => '23010104', 'expense' => '313704',
'life' => 48, 'salvage' => '0.00', 'note' => '٤ سنين',
],
[
'ar' => 'أثاث ومعدات وتركيبات مكاتب ومفروشات', 'en' => 'Furniture & Fixtures',
'asset' => '11010601', 'accum' => '23010105', 'expense' => '313705',
'life' => 120, 'salvage' => '0.00', 'note' => '١٠ سنين',
],
[
'ar' => 'أجهزة كمبيوتر ومشتملاته', 'en' => 'Computers & Peripherals',
'asset' => '11010701', 'accum' => '23010106', 'expense' => '313706',
'life' => 36, 'salvage' => '0.00', 'note' => '٣ سنين',
],
[
'ar' => 'أجهزة كهربائية وإلكترونية', 'en' => 'Electrical & Electronic Devices',
'asset' => '11010801', 'accum' => '23010107', 'expense' => '313707',
'life' => 60, 'salvage' => '0.00', 'note' => '٥ سنين',
],
];
$accountId = static function (?string $code) use ($db): ?int {
if ($code === null) {
return null;
}
$row = $db->selectOne(
"SELECT id FROM chart_of_accounts
WHERE account_code = ? AND is_header = 0 AND is_active = 1 AND is_archived = 0",
[$code]
);
return $row ? (int) $row['id'] : null;
};
foreach ($categories as $c) {
if ($db->selectOne("SELECT id FROM asset_categories WHERE name_ar = ?", [$c['ar']])) {
continue; // already there — leave the mapping alone
}
$assetId = $accountId($c['asset']);
if ($assetId === null) {
continue; // chart differs on this deployment
}
$db->insert('asset_categories', [
'name_ar' => $c['ar'],
'name_en' => $c['en'],
'depreciation_method' => 'straight_line',
'default_useful_life_months' => $c['life'] > 0 ? $c['life'] : 1,
'default_salvage_percentage' => $c['salvage'],
'asset_account_id' => $assetId,
'depreciation_account_id' => $accountId($c['accum']),
'expense_account_id' => $accountId($c['expense']),
'is_active' => 1,
'created_at' => date('Y-m-d H:i:s'),
]);
}
};
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