Commit 5ed78ec8 authored by DevPilot's avatar DevPilot

feat(accounting): tools for the four things that still needed a human

Every item I had left as "the accountant must decide" now has a screen to decide
it on. Nothing accounting-related is left needing code.

**فئات الأصول — /inventory/asset-categories.** The screen I referenced in the
manual and in a seed comment, and had not written. Depreciation posts through
these three accounts per category; a category missing either depreciation
account is silently skipped, so the asset ages in the register and the balance
sheet never moves. The screen leads with exactly that failure — categories that
hold assets but cannot post — offers only postable accounts, and refuses a
half-mapped category, because one depreciation account without the other cannot
balance an entry.

**إعادة تبويب الحسابات — /accounting/reclassification.** A balance in the wrong
account cannot be edited: the ledger is the record, and a balance that disagrees
with the entries behind it is worse than a wrong one. So it moves by a dated,
balanced, reversible entry. It lists what is genuinely stuck — 8 accounts here,
including 43.9m on «مشروعات تحت التنفيذ» and 4.8m on a header — derives the
DIRECTION from the balance rather than asking (getting that backwards doubles a
balance instead of moving it), and refuses a header or cross-type destination.

To empty a header at all, JournalService needed to allow one posted there. That
is the only way out in double-entry, so the flag exists, is set by this service
alone, and the service independently refuses to let a header be the destination.

**رسملة مشروع تحت التنفيذ.** A finished project is not a purchase — the money
was spent over months and sits in CIP, which deliberately does not depreciate.
Capitalising moves the accumulated cost to the asset account so depreciation can
start. Booking it as a purchase would credit cash for money already spent. Now a
third option on the asset form, next to purchase and opening balance.

**أرشفة سنة مالية — /accounting/fiscal-years.** Overlapping years were flagged
but unfixable from the UI. Archiving retires one without deleting it, and is
refused outright when the year carries entries or is the current one.

The manual is rewritten around this: every wizard now has numbered UI steps, and
a new index lists all 39 accountant-facing tools with what each does. The
decisions section now names the tool for each item instead of describing the
problem.

Verified on a production clone with all 409 foreign keys: reclassification moves
4,799,436 off a header and leaves it at 0.00 with all guards firing,
capitalisation posts Dr Asset / Cr CIP and posts nothing without a CIP account,
categories screen offers 593 postable accounts and no headers, 1,499 routes
resolve, 254 controllers instantiate, 242 services load, trial balance diff 0.00,
and all 56 diagrams across docs/ parse against the real mermaid parser.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
parent c4a8f6cb
...@@ -126,6 +126,62 @@ class FiscalYearController extends Controller ...@@ -126,6 +126,62 @@ class FiscalYearController extends Controller
]); ]);
} }
/**
* Retire a fiscal year that should not be there.
*
* The club carries calendar years alongside a July–June year, so some days
* fall inside two open years at once — an entry in the overlap belongs to
* both, and closing one leaves the other open over the same transactions.
* Picking one convention means retiring the other.
*
* Archiving, not deleting: a fiscal year with entries against it is part of
* the record. Refused outright when entries exist, because removing it from
* date resolution would leave those entries pointing at a year the system
* no longer considers — they must be moved first.
*/
public function archive(Request $request, string $id): Response
{
$this->authorize('accounting.fiscal_year.manage');
$session = App::getInstance()->session();
$db = App::getInstance()->db();
$fy = FiscalYear::find((int) $id);
if (!$fy) {
$session->flash('_alerts', [['type' => 'error', 'message' => 'السنة المالية غير موجودة']]);
return $this->redirect('/accounting/fiscal-years');
}
$entries = (int) $db->selectOne(
"SELECT COUNT(*) AS c FROM journal_entries WHERE fiscal_year_id = ?",
[(int) $id]
)['c'];
if ($entries > 0) {
$session->flash('_alerts', [['type' => 'error', 'message' =>
'مينفعش تأرشف السنة دي — فيها ' . number_format($entries) . ' قيد. '
. 'القيود دي لازم تتنقل لسنة تانية الأول، وإلا هتفضل مربوطة بسنة النظام مش شايفها.']]);
return $this->redirect('/accounting/fiscal-years');
}
if ((int) $fy->is_current === 1) {
$session->flash('_alerts', [['type' => 'error', 'message' =>
'دي السنة الحالية — حدّد سنة تانية كحالية الأول.']]);
return $this->redirect('/accounting/fiscal-years');
}
$db->update('fiscal_years', [
'is_archived' => 1,
'archived_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]);
$session->flash('_alerts', [['type' => 'success', 'message' =>
'اتأرشفت السنة المالية. مش هتدخل في تحديد سنة أي قيد جديد.']]);
return $this->redirect('/accounting/fiscal-years');
}
public function close(Request $request, string $id): Response public function close(Request $request, string $id): Response
{ {
$this->authorize('accounting.fiscal_year.close'); $this->authorize('accounting.fiscal_year.close');
......
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Modules\Accounting\Services\AccountReclassificationService;
/**
* إعادة تبويب الحسابات — moving a balance that ended up in the wrong account.
*
* The screen leads with the balances that are actually stuck: money sitting on
* a header account or on one that has been retired, where no ordinary entry can
* reach it. Nothing posts without a preview and a written reason.
*/
class ReclassificationController extends Controller
{
public function index(Request $request): Response
{
$this->authorize('accounting.journal.view');
return $this->view('Accounting.Views.reclassification.index', [
'stranded' => AccountReclassificationService::strandedBalances(),
]);
}
/** What the correction would book. Answers without writing anything. */
public function preview(Request $request): Response
{
$this->authorize('accounting.journal.view');
return $this->json(AccountReclassificationService::preview(
(int) $request->get('from', 0),
(int) $request->get('to', 0),
$request->get('amount')
));
}
/** The destinations a given account's balance may legitimately move to. */
public function destinations(Request $request): Response
{
$this->authorize('accounting.journal.view');
return $this->json([
'accounts' => AccountReclassificationService::destinationsFor((int) $request->get('account', 0)),
]);
}
public function store(Request $request): Response
{
$this->authorize('accounting.journal.create');
$employee = $this->currentEmployee();
$result = AccountReclassificationService::post(
(int) $request->post('from_account_id', 0),
(int) $request->post('to_account_id', 0),
$request->post('amount'),
(string) $request->post('reason', ''),
$employee ? (int) $employee->id : null
);
if (empty($result['success'])) {
return $this->redirect('/accounting/reclassification')->withError($result['error']);
}
return $this->redirect('/accounting/reclassification')->withSuccess(
'اتعمل قيد إعادة التبويب' . (!empty($result['entry_number']) ? ' — ' . $result['entry_number'] : '')
. '. تقدر تعكسه من شاشة قيود اليومية لو حصل غلط.'
);
}
}
...@@ -11,6 +11,14 @@ return [ ...@@ -11,6 +11,14 @@ return [
['POST', '/accounting/fiscal-years', 'Accounting\Controllers\FiscalYearController@store', ['auth', 'csrf'], 'accounting.fiscal_year.manage'], ['POST', '/accounting/fiscal-years', 'Accounting\Controllers\FiscalYearController@store', ['auth', 'csrf'], 'accounting.fiscal_year.manage'],
['GET', '/accounting/fiscal-years/{id:\d+}', 'Accounting\Controllers\FiscalYearController@show', ['auth'], 'accounting.fiscal_year.view'], ['GET', '/accounting/fiscal-years/{id:\d+}', 'Accounting\Controllers\FiscalYearController@show', ['auth'], 'accounting.fiscal_year.view'],
['POST', '/accounting/fiscal-years/{id:\d+}/close', 'Accounting\Controllers\FiscalYearController@close', ['auth', 'csrf'], 'accounting.fiscal_year.close'], ['POST', '/accounting/fiscal-years/{id:\d+}/close', 'Accounting\Controllers\FiscalYearController@close', ['auth', 'csrf'], 'accounting.fiscal_year.close'],
// Resolving overlapping / superseded fiscal years without deleting history
['POST', '/accounting/fiscal-years/{id:\d+}/archive', 'Accounting\Controllers\FiscalYearController@archive', ['auth', 'csrf'], 'accounting.fiscal_year.manage'],
// إعادة تبويب الحسابات — moving a balance stuck on a header or retired account
['GET', '/accounting/reclassification', 'Accounting\Controllers\ReclassificationController@index', ['auth'], 'accounting.journal.view'],
['GET', '/accounting/reclassification/preview', 'Accounting\Controllers\ReclassificationController@preview', ['auth'], 'accounting.journal.view'],
['GET', '/accounting/reclassification/destinations', 'Accounting\Controllers\ReclassificationController@destinations', ['auth'], 'accounting.journal.view'],
['POST', '/accounting/reclassification', 'Accounting\Controllers\ReclassificationController@store', ['auth', 'csrf'], 'accounting.journal.create'],
// ── Chart of Accounts ──────────────────────────────────── // ── Chart of Accounts ────────────────────────────────────
['GET', '/accounting/chart-of-accounts', 'Accounting\Controllers\ChartOfAccountsController@index', ['auth'], 'accounting.coa.view'], ['GET', '/accounting/chart-of-accounts', 'Accounting\Controllers\ChartOfAccountsController@index', ['auth'], 'accounting.coa.view'],
......
...@@ -104,7 +104,15 @@ final class JournalService ...@@ -104,7 +104,15 @@ final class JournalService
if (!isset($accountMap[(int) $accId])) { if (!isset($accountMap[(int) $accId])) {
return ['success' => false, 'error' => 'الحساب رقم ' . $accId . ' غير موجود']; return ['success' => false, 'error' => 'الحساب رقم ' . $accId . ' غير موجود'];
} }
if ((int) $accountMap[(int) $accId]['is_header'] === 1) { // A header is a heading, not a place to post — with one exception.
// When a balance is ALREADY stranded on a header (a legacy opening
// entry, an import), double-entry offers no way to move it off
// except by posting the opposite side to that same header. Refusing
// here would make the error permanent. Only
// AccountReclassificationService sets this, and it independently
// refuses to let a header be the DESTINATION.
if ((int) $accountMap[(int) $accId]['is_header'] === 1
&& empty($header['allow_header_accounts'])) {
return ['success' => false, 'error' => 'لا يمكن الترحيل إلى حساب رئيسي (header)']; return ['success' => false, 'error' => 'لا يمكن الترحيل إلى حساب رئيسي (header)'];
} }
// An inactive account takes no new business — but the year-end entry // An inactive account takes no new business — but the year-end entry
......
...@@ -447,7 +447,8 @@ final class OperationalPostingService ...@@ -447,7 +447,8 @@ final class OperationalPostingService
// Already on the books — the register is catching up with the ledger, // Already on the books — the register is catching up with the ledger,
// not adding to it. // not adding to it.
if ((string) ($asset['acquisition_source'] ?? 'purchase') === 'opening') { $source = (string) ($asset['acquisition_source'] ?? 'purchase');
if ($source === 'opening') {
return; return;
} }
...@@ -465,12 +466,26 @@ final class OperationalPostingService ...@@ -465,12 +466,26 @@ final class OperationalPostingService
return; return;
} }
// A finished project is not a purchase. The money was spent over months
// and already sits in «مشروعات تحت التنفيذ» — an asset that does not
// depreciate because it is not in service yet. Capitalising it moves the
// accumulated cost to the real asset account, from where it starts to
// depreciate. Crediting cash instead would book the spend twice.
if ($source === 'capitalization') {
$cipAccount = (int) ($data['cip_account_id'] ?? 0);
if ($cipAccount <= 0) {
Logger::error('Capitalisation not posted — no CIP account given', ['asset_id' => $assetId]);
return;
}
$creditAccount = $cipAccount;
} else {
// How it was paid for decides only the credit side. // How it was paid for decides only the credit side.
$creditAccount = match ((string) ($data['payment_source'] ?? 'payable')) { $creditAccount = match ((string) ($data['payment_source'] ?? 'payable')) {
'cash' => PostingRouter::accountFor('treasury:method_cash', AccountCodes::CASH_ON_HAND, 'collection'), 'cash' => PostingRouter::accountFor('treasury:method_cash', AccountCodes::CASH_ON_HAND, 'collection'),
'bank' => PostingRouter::accountFor('treasury:method_bank_transfer', AccountCodes::CASH_AT_BANK, 'collection'), 'bank' => PostingRouter::accountFor('treasury:method_bank_transfer', AccountCodes::CASH_AT_BANK, 'collection'),
default => PostingRouter::accountFor('procurement:payable', AccountCodes::ACCOUNTS_PAYABLE, 'accrual'), default => PostingRouter::accountFor('procurement:payable', AccountCodes::ACCOUNTS_PAYABLE, 'accrual'),
}; };
}
if ($creditAccount === null) { if ($creditAccount === null) {
Logger::error('Asset acquisition not posted — funding account unresolved', [ Logger::error('Asset acquisition not posted — funding account unresolved', [
...@@ -481,7 +496,7 @@ final class OperationalPostingService ...@@ -481,7 +496,7 @@ final class OperationalPostingService
} }
$name = $asset['asset_tag'] ?: ('#' . $assetId); $name = $asset['asset_tag'] ?: ('#' . $assetId);
$desc = 'شراء أصل ثابت — ' . $name $desc = ($source === 'capitalization' ? 'رسملة مشروع تحت التنفيذ — ' : 'شراء أصل ثابت — ') . $name
. ($asset['category_name'] ? ' (' . $asset['category_name'] . ')' : ''); . ($asset['category_name'] ? ' (' . $asset['category_name'] . ')' : '');
$result = JournalService::createEntry([ $result = JournalService::createEntry([
...@@ -494,7 +509,8 @@ final class OperationalPostingService ...@@ -494,7 +509,8 @@ final class OperationalPostingService
'is_auto_generated' => 1, 'is_auto_generated' => 1,
], [ ], [
['account_id' => $assetAccount, 'debit' => $cost, 'credit' => '0.00', 'description_ar' => 'إثبات تكلفة الأصل — ' . $name], ['account_id' => $assetAccount, 'debit' => $cost, 'credit' => '0.00', 'description_ar' => 'إثبات تكلفة الأصل — ' . $name],
['account_id' => $creditAccount, 'debit' => '0.00', 'credit' => $cost, 'description_ar' => 'سداد/التزام شراء أصل — ' . $name], ['account_id' => $creditAccount, 'debit' => '0.00', 'credit' => $cost,
'description_ar' => ($source === 'capitalization' ? 'إقفال تكلفة المشروع — ' : 'سداد/التزام شراء أصل — ') . $name],
], true); ], true);
if (empty($result['success'])) { if (empty($result['success'])) {
......
...@@ -37,11 +37,25 @@ ...@@ -37,11 +37,25 @@
<td> <td>
<a href="/accounting/fiscal-years/<?= (int) $o['a_id'] ?>"><?= e((string) $o['a_name']) ?></a> <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> <span style="color:#9CA3AF;">(<?= e((string) $o['a_status']) ?>)</span>
<?php if (can('accounting.fiscal_year.manage')): ?>
<form method="POST" action="/accounting/fiscal-years/<?= (int) $o['a_id'] ?>/archive" style="display:inline;"
onsubmit="return confirm('هتأرشف «<?= e((string) $o['a_name']) ?>». مش هتدخل في تحديد سنة أي قيد جديد. متأكد؟');">
<?= csrf_field() ?>
<button type="submit" class="btn btn-sm btn-outline">أرشِف</button>
</form>
<?php endif; ?>
</td> </td>
<td style="direction:ltr;text-align:right;"><?= e((string) $o['a_start']) ?><?= e((string) $o['a_end']) ?></td> <td style="direction:ltr;text-align:right;"><?= e((string) $o['a_start']) ?><?= e((string) $o['a_end']) ?></td>
<td> <td>
<a href="/accounting/fiscal-years/<?= (int) $o['b_id'] ?>"><?= e((string) $o['b_name']) ?></a> <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> <span style="color:#9CA3AF;">(<?= e((string) $o['b_status']) ?>)</span>
<?php if (can('accounting.fiscal_year.manage')): ?>
<form method="POST" action="/accounting/fiscal-years/<?= (int) $o['b_id'] ?>/archive" style="display:inline;"
onsubmit="return confirm('هتأرشف «<?= e((string) $o['b_name']) ?>». مش هتدخل في تحديد سنة أي قيد جديد. متأكد؟');">
<?= csrf_field() ?>
<button type="submit" class="btn btn-sm btn-outline">أرشِف</button>
</form>
<?php endif; ?>
</td> </td>
<td style="direction:ltr;text-align:right;"><?= e((string) $o['b_start']) ?><?= e((string) $o['b_end']) ?></td> <td style="direction:ltr;text-align:right;"><?= e((string) $o['b_start']) ?><?= e((string) $o['b_end']) ?></td>
</tr> </tr>
......
This diff is collapsed.
...@@ -176,6 +176,7 @@ MenuRegistry::register('accounting', [ ...@@ -176,6 +176,7 @@ MenuRegistry::register('accounting', [
['label_ar' => 'الأبعاد المحاسبية', 'label_en' => 'Dimensions', 'route' => '/accounting/dimensions', 'permission' => 'accounting.dimensions.view', 'order' => 9], ['label_ar' => 'الأبعاد المحاسبية', 'label_en' => 'Dimensions', 'route' => '/accounting/dimensions', 'permission' => 'accounting.dimensions.view', 'order' => 9],
['label_ar' => 'المطابقة البنكية', 'label_en' => 'Bank Reconciliation', 'route' => '/accounting/bank-reconciliation', 'permission' => 'accounting.bank_recon.view', 'order' => 10], ['label_ar' => 'المطابقة البنكية', 'label_en' => 'Bank Reconciliation', 'route' => '/accounting/bank-reconciliation', 'permission' => 'accounting.bank_recon.view', 'order' => 10],
['label_ar' => 'الحركات اليومية', 'label_en' => 'Daily Transactions', 'route' => '/accounting/daily-transactions', 'permission' => 'accounting.daily_tx.view', 'order' => 11], ['label_ar' => 'الحركات اليومية', 'label_en' => 'Daily Transactions', 'route' => '/accounting/daily-transactions', 'permission' => 'accounting.daily_tx.view', 'order' => 11],
['label_ar' => 'إعادة تبويب الحسابات', 'label_en' => 'Reclassification', 'route' => '/accounting/reclassification', 'permission' => 'accounting.journal.view', 'order' => 26],
['label_ar' => 'القيود الافتتاحية', 'label_en' => 'Opening Entries', 'route' => '/accounting/opening-entries', 'permission' => 'accounting.opening_entry.view', 'order' => 12], ['label_ar' => 'القيود الافتتاحية', 'label_en' => 'Opening Entries', 'route' => '/accounting/opening-entries', 'permission' => 'accounting.opening_entry.view', 'order' => 12],
['label_ar' => 'إقفال الفترات', 'label_en' => 'Period Closing', 'route' => '/accounting/period-closing', 'permission' => 'accounting.period.view', 'order' => 12], ['label_ar' => 'إقفال الفترات', 'label_en' => 'Period Closing', 'route' => '/accounting/period-closing', 'permission' => 'accounting.period.view', 'order' => 12],
['label_ar' => 'ميزان المراجعة', 'label_en' => 'Trial Balance', 'route' => '/accounting/reports/trial-balance', 'permission' => 'accounting.reports.trial_balance', 'order' => 9], ['label_ar' => 'ميزان المراجعة', 'label_en' => 'Trial Balance', 'route' => '/accounting/reports/trial-balance', 'permission' => 'accounting.reports.trial_balance', 'order' => 9],
......
<?php
declare(strict_types=1);
namespace App\Modules\Inventory\Controllers;
use App\Core\App;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
/**
* Fixed-asset categories, and the three ledger accounts each one carries.
*
* This screen is the whole reason depreciation reaches the books. The monthly
* run groups by category and posts, per category:
*
* Dr مصروف الإهلاك (expense_account_id)
* Cr مجمع الإهلاك (depreciation_account_id)
*
* A category with either account missing is skipped and logged — the asset ages
* in the register and the balance sheet never moves. That failure is silent by
* design elsewhere, so it is made loud here instead.
*/
class AssetCategoryController extends Controller
{
public function index(Request $request): Response
{
$this->authorize('asset.view');
return $this->view('Inventory.Views.asset_categories.index', [
'categories' => $this->listWithAccounts(),
]);
}
public function create(Request $request): Response
{
$this->authorize('asset.manage');
return $this->view('Inventory.Views.asset_categories.form', [
'category' => null,
'accounts' => $this->postableAccounts(),
'methods' => ['straight_line' => 'القسط الثابت', 'declining_balance' => 'القسط المتناقص'],
]);
}
public function edit(Request $request, string $id): Response
{
$this->authorize('asset.manage');
$category = App::getInstance()->db()->selectOne(
"SELECT * FROM asset_categories WHERE id = ?",
[(int) $id]
);
if (!$category) {
return $this->redirect('/inventory/asset-categories')->withError('الفئة غير موجودة');
}
return $this->view('Inventory.Views.asset_categories.form', [
'category' => $category,
'accounts' => $this->postableAccounts(),
'methods' => ['straight_line' => 'القسط الثابت', 'declining_balance' => 'القسط المتناقص'],
]);
}
public function store(Request $request): Response
{
$this->authorize('asset.manage');
$data = $this->categoryInput($request);
if (isset($data['error'])) {
return $this->redirect('/inventory/asset-categories/create')->withError($data['error']);
}
$db = App::getInstance()->db();
if ($db->selectOne("SELECT id FROM asset_categories WHERE name_ar = ?", [$data['name_ar']])) {
return $this->redirect('/inventory/asset-categories/create')->withError('فيه فئة بنفس الاسم');
}
$db->insert('asset_categories', $data + ['is_active' => 1, 'created_at' => date('Y-m-d H:i:s')]);
return $this->redirect('/inventory/asset-categories')->withSuccess('اتضافت الفئة.');
}
public function update(Request $request, string $id): Response
{
$this->authorize('asset.manage');
$db = App::getInstance()->db();
if (!$db->selectOne("SELECT id FROM asset_categories WHERE id = ?", [(int) $id])) {
return $this->redirect('/inventory/asset-categories')->withError('الفئة غير موجودة');
}
$data = $this->categoryInput($request);
if (isset($data['error'])) {
return $this->redirect('/inventory/asset-categories/' . $id . '/edit')->withError($data['error']);
}
$data['is_active'] = (int) $request->post('is_active', 1) === 1 ? 1 : 0;
$db->update('asset_categories', $data, '`id` = ?', [(int) $id]);
// Changing a category re-points every FUTURE depreciation posting for
// its assets. Entries already posted are history and stay where they
// are — moving them would rewrite closed periods.
return $this->redirect('/inventory/asset-categories')->withSuccess(
'اتحدّثت الفئة. الإهلاك الجاي هيتقيّد على الحسابات الجديدة — القيود القديمة زي ما هي.'
);
}
/** Categories with their accounts resolved, and how many assets ride on each. */
private function listWithAccounts(): array
{
return App::getInstance()->db()->select(
"SELECT c.*,
a.account_code AS asset_code, a.name_ar AS asset_name,
d.account_code AS accum_code, d.name_ar AS accum_name,
e.account_code AS expense_code, e.name_ar AS expense_name,
(SELECT COUNT(*) FROM asset_register r
WHERE r.category_id = c.id AND r.status = 'active') AS asset_count,
(SELECT ROUND(COALESCE(SUM(r.book_value), 0), 2) FROM asset_register r
WHERE r.category_id = c.id AND r.status = 'active') AS book_value
FROM asset_categories c
LEFT JOIN chart_of_accounts a ON a.id = c.asset_account_id
LEFT JOIN chart_of_accounts d ON d.id = c.depreciation_account_id
LEFT JOIN chart_of_accounts e ON e.id = c.expense_account_id
ORDER BY c.is_active DESC, c.id"
);
}
/**
* Only accounts an entry can actually land on.
*
* A header account is a heading, not a place to post — offering one here
* would produce a category that fails at the moment depreciation runs,
* which is the worst possible time to find out.
*/
private function postableAccounts(): array
{
return App::getInstance()->db()->select(
"SELECT id, account_code, name_ar, account_type
FROM chart_of_accounts
WHERE is_header = 0 AND is_active = 1 AND is_archived = 0
ORDER BY account_code"
);
}
/** @return array<string, mixed> with an `error` key when unusable */
private function categoryInput(Request $request): array
{
$name = trim((string) $request->post('name_ar', ''));
if ($name === '') {
return ['error' => 'اسم الفئة مطلوب'];
}
$assetAccount = (int) $request->post('asset_account_id', 0);
if ($assetAccount <= 0) {
return ['error' => 'حساب الأصل مطلوب — من غيره الأصل مش هيتقيّد'];
}
$accum = (int) $request->post('depreciation_account_id', 0);
$expense = (int) $request->post('expense_account_id', 0);
$life = (int) $request->post('default_useful_life_months', 60);
// Either both depreciation accounts or neither. One alone cannot post a
// balanced entry, and a category that silently skips is how the balance
// sheet stops matching the register.
if (($accum > 0) !== ($expense > 0)) {
return ['error' => 'حسابات الإهلاك لازم يتحطوا مع بعض — مجمع الإهلاك ومصروف الإهلاك، أو تسيبهم الاتنين فاضيين لأصل ما بيتهلكش'];
}
if ($accum > 0 && $life < 1) {
return ['error' => 'العمر الإنتاجي لازم يكون شهر على الأقل لفئة بتتهلك'];
}
$method = (string) $request->post('depreciation_method', 'straight_line');
$rate = $request->post('declining_rate');
if ($method === 'declining_balance' && (!is_numeric($rate) || (float) $rate <= 0)) {
return ['error' => 'نسبة القسط المتناقص مطلوبة ولازم تكون أكبر من صفر'];
}
// Every account must exist and be postable, whatever the form sent.
$db = App::getInstance()->db();
foreach (['حساب الأصل' => $assetAccount, 'مجمع الإهلاك' => $accum, 'مصروف الإهلاك' => $expense] as $label => $accId) {
if ($accId <= 0) {
continue;
}
$acc = $db->selectOne(
"SELECT is_header, is_active FROM chart_of_accounts WHERE id = ? AND is_archived = 0",
[$accId]
);
if (!$acc) {
return ['error' => $label . ' — الحساب غير موجود'];
}
if ((int) $acc['is_header'] === 1) {
return ['error' => $label . ' — مينفعش حساب رئيسي، اختار حساب فرعي'];
}
if ((int) $acc['is_active'] === 0) {
return ['error' => $label . ' — الحساب موقوف'];
}
}
return [
'name_ar' => $name,
'name_en' => trim((string) $request->post('name_en', '')) ?: null,
'depreciation_method' => \in_array($method, ['straight_line', 'declining_balance'], true) ? $method : 'straight_line',
'default_useful_life_months' => max(1, $life),
'default_salvage_percentage' => number_format((float) $request->post('default_salvage_percentage', 0), 2, '.', ''),
'declining_rate' => $method === 'declining_balance' ? number_format((float) $rate, 2, '.', '') : null,
'asset_account_id' => $assetAccount,
'depreciation_account_id' => $accum > 0 ? $accum : null,
'expense_account_id' => $expense > 0 ? $expense : null,
];
}
}
...@@ -129,6 +129,7 @@ class AssetController extends Controller ...@@ -129,6 +129,7 @@ class AssetController extends Controller
'warehouses' => Warehouse::allActive(), 'warehouses' => Warehouse::allActive(),
'methods' => AssetRegister::getDepreciationMethods(), 'methods' => AssetRegister::getDepreciationMethods(),
'nextTag' => $this->nextAssetTag(), 'nextTag' => $this->nextAssetTag(),
'cipAccounts' => $this->cipAccounts(),
]); ]);
} }
...@@ -180,12 +181,15 @@ class AssetController extends Controller ...@@ -180,12 +181,15 @@ class AssetController extends Controller
EventBus::dispatch('inventory.asset_acquired', [ EventBus::dispatch('inventory.asset_acquired', [
'asset_id' => $assetId, 'asset_id' => $assetId,
'payment_source' => $data['payment_source'], 'payment_source' => $data['payment_source'],
'cip_account_id' => $data['cip_account_id'],
]); ]);
return $this->redirect('/inventory/assets/' . $assetId)->withSuccess( return $this->redirect('/inventory/assets/' . $assetId)->withSuccess(
$data['acquisition_source'] === 'opening' match ($data['acquisition_source']) {
? 'اتسجّل الأصل في السجل. رصيد افتتاحي — مفيش قيد اتعمل لأن التكلفة موجودة في الدفاتر أصلًا.' 'opening' => 'اتسجّل الأصل في السجل. رصيد افتتاحي — مفيش قيد اتعمل لأن التكلفة موجودة في الدفاتر أصلًا.',
: 'اتسجّل الأصل واتعمل قيد الشراء.' 'capitalization' => 'اتسجّل الأصل واتعمل قيد الرسملة — التكلفة اتنقلت من المشروعات تحت التنفيذ لحساب الأصل، ويبدأ الإهلاك من دلوقتي.',
default => 'اتسجّل الأصل واتعمل قيد الشراء.',
}
); );
} }
...@@ -207,6 +211,7 @@ class AssetController extends Controller ...@@ -207,6 +211,7 @@ class AssetController extends Controller
'warehouses' => Warehouse::allActive(), 'warehouses' => Warehouse::allActive(),
'methods' => AssetRegister::getDepreciationMethods(), 'methods' => AssetRegister::getDepreciationMethods(),
'nextTag' => $asset['asset_tag'], 'nextTag' => $asset['asset_tag'],
'cipAccounts' => $this->cipAccounts(),
]); ]);
} }
...@@ -280,6 +285,26 @@ class AssetController extends Controller ...@@ -280,6 +285,26 @@ class AssetController extends Controller
); );
} }
/**
* Accounts a finished project can be capitalised OUT of.
*
* «مشروعات تحت التنفيذ» is an asset that deliberately does not depreciate —
* the thing is not in service yet. When it is finished the accumulated cost
* moves to the real asset account and depreciation starts. Anything under
* 1103, plus any postable asset account whose name says so.
*/
private function cipAccounts(): array
{
return App::getInstance()->db()->select(
"SELECT id, account_code, name_ar, current_balance
FROM chart_of_accounts
WHERE is_header = 0 AND is_active = 1 AND is_archived = 0
AND account_type = 'asset'
AND (account_code LIKE '1103%' OR name_ar LIKE '%تحت التنفيذ%')
ORDER BY account_code"
);
}
private function nextAssetTag(): string private function nextAssetTag(): string
{ {
$row = App::getInstance()->db()->selectOne( $row = App::getInstance()->db()->selectOne(
...@@ -337,6 +362,10 @@ class AssetController extends Controller ...@@ -337,6 +362,10 @@ class AssetController extends Controller
$source = (string) $request->post('acquisition_source', 'purchase'); $source = (string) $request->post('acquisition_source', 'purchase');
if ($source === 'capitalization' && (int) $request->post('cip_account_id', 0) <= 0) {
return ['error' => 'لازم تحدد حساب المشروع تحت التنفيذ اللي التكلفة هتتنقل منه'];
}
return [ return [
'asset_tag' => $tag, 'asset_tag' => $tag,
'category_id' => $categoryId, 'category_id' => $categoryId,
...@@ -345,8 +374,9 @@ class AssetController extends Controller ...@@ -345,8 +374,9 @@ class AssetController extends Controller
'serial_number' => trim((string) $request->post('serial_number', '')) ?: null, 'serial_number' => trim((string) $request->post('serial_number', '')) ?: null,
'purchase_date' => $date, 'purchase_date' => $date,
'purchase_cost' => number_format((float) $cost, 2, '.', ''), 'purchase_cost' => number_format((float) $cost, 2, '.', ''),
'acquisition_source' => \in_array($source, ['purchase', 'opening'], true) ? $source : 'purchase', 'acquisition_source' => \in_array($source, ['purchase', 'opening', 'capitalization'], true) ? $source : 'purchase',
'payment_source' => (string) $request->post('payment_source', 'payable'), 'payment_source' => (string) $request->post('payment_source', 'payable'),
'cip_account_id' => ((int) $request->post('cip_account_id', 0)) ?: null,
'useful_life_months' => max(1, (int) $request->post('useful_life_months', 60)), 'useful_life_months' => max(1, (int) $request->post('useful_life_months', 60)),
'salvage_value' => number_format((float) $request->post('salvage_value', 0), 2, '.', ''), 'salvage_value' => number_format((float) $request->post('salvage_value', 0), 2, '.', ''),
'depreciation_method' => (string) $request->post('depreciation_method', 'straight_line'), 'depreciation_method' => (string) $request->post('depreciation_method', 'straight_line'),
......
...@@ -77,6 +77,13 @@ return [ ...@@ -77,6 +77,13 @@ return [
['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'],
// Fixed-asset categories — the GL mapping depreciation posts through
['GET', '/inventory/asset-categories', 'Inventory\Controllers\AssetCategoryController@index', ['auth'], 'asset.view'],
['GET', '/inventory/asset-categories/create', 'Inventory\Controllers\AssetCategoryController@create', ['auth'], 'asset.manage'],
['POST', '/inventory/asset-categories', 'Inventory\Controllers\AssetCategoryController@store', ['auth', 'csrf'], 'asset.manage'],
['GET', '/inventory/asset-categories/{id:\d+}/edit', 'Inventory\Controllers\AssetCategoryController@edit', ['auth'], 'asset.manage'],
['POST', '/inventory/asset-categories/{id:\d+}', 'Inventory\Controllers\AssetCategoryController@update', ['auth', 'csrf'], 'asset.manage'],
// Bill of Materials (BOM) // Bill of Materials (BOM)
['GET', '/inventory/bom', 'Inventory\Controllers\BomController@index', ['auth'], 'inventory.bom.view'], ['GET', '/inventory/bom', 'Inventory\Controllers\BomController@index', ['auth'], 'inventory.bom.view'],
['GET', '/inventory/bom/create', 'Inventory\Controllers\BomController@create', ['auth'], 'inventory.bom.manage'], ['GET', '/inventory/bom/create', 'Inventory\Controllers\BomController@create', ['auth'], 'inventory.bom.manage'],
......
<?php
$isEdit = $category !== null;
$__template->layout('Layout.main');
/** Render one account picker, grouped so the right account is findable. */
$accountSelect = static function (string $name, $selected, array $accounts, bool $required = false): void {
echo '<select name="' . e($name) . '" class="form-input"' . ($required ? ' required' : '') . '>';
echo '<option value="">— مش محدد —</option>';
$groups = ['asset' => 'أصول', 'liability' => 'التزامات', 'equity' => 'حقوق ملكية', 'revenue' => 'إيرادات', 'expense' => 'مصروفات'];
foreach ($groups as $type => $label) {
$rows = array_filter($accounts, static fn ($a) => (string) $a['account_type'] === $type);
if (!$rows) {
continue;
}
echo '<optgroup label="' . e($label) . '">';
foreach ($rows as $a) {
$sel = (int) $selected === (int) $a['id'] ? ' selected' : '';
echo '<option value="' . (int) $a['id'] . '"' . $sel . '>'
. e((string) $a['account_code']) . ' — ' . e((string) $a['name_ar'])
. '</option>';
}
echo '</optgroup>';
}
echo '</select>';
};
?>
<?php $__template->section('title'); ?><?= $isEdit ? 'تعديل فئة أصول' : 'فئة أصول جديدة' ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/inventory/asset-categories" class="btn btn-outline">العودة للفئات</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<form method="POST" action="<?= $isEdit ? '/inventory/asset-categories/' . (int) $category['id'] : '/inventory/asset-categories' ?>">
<?= csrf_field() ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;color:#0D7377;font-size:15px;">بيانات الفئة</h3>
</div>
<div style="padding:20px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
<div class="form-group">
<label class="form-label">اسم الفئة <span style="color:#DC2626;">*</span></label>
<input type="text" name="name_ar" class="form-input" required
value="<?= e(old('name_ar', (string) ($category['name_ar'] ?? ''))) ?>">
</div>
<div class="form-group">
<label class="form-label">الاسم بالإنجليزي</label>
<input type="text" name="name_en" class="form-input" style="direction:ltr;text-align:left;"
value="<?= e(old('name_en', (string) ($category['name_en'] ?? ''))) ?>">
</div>
</div>
<?php if ($isEdit): ?>
<div class="form-group" style="margin-top:12px;">
<label style="font-size:13px;">
<input type="checkbox" name="is_active" value="1" <?= (int) ($category['is_active'] ?? 1) === 1 ? 'checked' : '' ?>>
الفئة نشطة
</label>
</div>
<?php endif; ?>
</div>
</div>
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;color:#0D7377;font-size:15px;">حسابات الأستاذ</h3>
</div>
<div style="padding:20px;">
<div style="margin-bottom:16px;padding:12px 14px;background:#F0F9FF;border-radius:6px;color:#075985;font-size:12.5px;line-height:1.9;">
القيد الشهري للفئة دي هيبقى:
<br>
<span style="direction:ltr;display:inline-block;margin-top:4px;">
<strong>Dr</strong> مصروف الإهلاك &nbsp;&nbsp; <strong>Cr</strong> مجمع الإهلاك
</span>
<br>
<strong>القوائم بتعرض بس الحسابات الفرعية النشطة</strong> — الحساب الرئيسي
مينفعش يتقيّد عليه.
</div>
<div style="display:grid;grid-template-columns:1fr;gap:16px;">
<div class="form-group">
<label class="form-label">حساب الأصل <span style="color:#DC2626;">*</span></label>
<?php $accountSelect('asset_account_id', $category['asset_account_id'] ?? 0, $accounts, true); ?>
<small style="color:#6B7280;">الحساب اللي تكلفة الأصل بتتقيّد عليه — مثال: ١١٠١٠٣٠١ آلات</small>
</div>
<div class="form-group">
<label class="form-label">مجمع الإهلاك</label>
<?php $accountSelect('depreciation_account_id', $category['depreciation_account_id'] ?? 0, $accounts); ?>
<small style="color:#6B7280;">حساب مقابل للأصل — مثال: ٢٣٠١٠١٠٢</small>
</div>
<div class="form-group">
<label class="form-label">مصروف الإهلاك</label>
<?php $accountSelect('expense_account_id', $category['expense_account_id'] ?? 0, $accounts); ?>
<small style="color:#6B7280;">
بيتبع وظيفة الأصل: <strong>٣١٣٧xx</strong> للنشاط،
<strong>٣٢٠٦xx</strong> للبيع والتوزيع،
<strong>٣٣١٦xx</strong> للعمومية والإدارية
</small>
</div>
</div>
<div style="margin-top:14px;padding:12px 14px;background:#FFFBEB;border-radius:6px;color:#92400E;font-size:12.5px;line-height:1.9;">
<strong>الاتنين مع بعض أو ولا واحد.</strong> لو حطيت مجمع الإهلاك من غير
مصروف الإهلاك (أو العكس) القيد مش هيتوازن والفئة هتتخطّى بالكامل.
سيبهم الاتنين فاضيين بس لو الفئة دي <strong>ما بتتهلكش</strong> — زي الأراضي.
</div>
</div>
</div>
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;color:#0D7377;font-size:15px;">سياسة الإهلاك الافتراضية</h3>
</div>
<div style="padding:20px;">
<p style="margin:0 0 14px;color:#6B7280;font-size:12px;line-height:1.9;">
دي <strong>قيم افتراضية</strong> بتتملّى لوحدها لما تسجّل أصل جديد في الفئة دي.
كل أصل بيحتفظ بعمره هو — تغيير الفئة دلوقتي ما بيغيّرش الأصول المسجّلة قبل كده.
</p>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:20px;">
<div class="form-group">
<label class="form-label">طريقة الإهلاك</label>
<select name="depreciation_method" id="methodSel" class="form-input"
onchange="document.getElementById('rateBox').style.display = this.value === 'declining_balance' ? '' : 'none';">
<?php foreach ($methods as $k => $label): ?>
<option value="<?= e($k) ?>" <?= old('depreciation_method', (string) ($category['depreciation_method'] ?? 'straight_line')) === $k ? 'selected' : '' ?>>
<?= e($label) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">العمر الإنتاجي (شهور)</label>
<input type="number" min="1" name="default_useful_life_months" class="form-input" style="direction:ltr;text-align:left;"
value="<?= e(old('default_useful_life_months', (string) ($category['default_useful_life_months'] ?? 60))) ?>">
</div>
<div class="form-group">
<label class="form-label">القيمة التخريدية %</label>
<input type="number" step="0.01" min="0" max="100" name="default_salvage_percentage" class="form-input" style="direction:ltr;text-align:left;"
value="<?= e(old('default_salvage_percentage', (string) ($category['default_salvage_percentage'] ?? '0.00'))) ?>">
</div>
<div class="form-group" id="rateBox" style="display:<?= old('depreciation_method', (string) ($category['depreciation_method'] ?? 'straight_line')) === 'declining_balance' ? '' : 'none' ?>;">
<label class="form-label">نسبة القسط المتناقص %</label>
<input type="number" step="0.01" min="0" name="declining_rate" class="form-input" style="direction:ltr;text-align:left;"
value="<?= e(old('declining_rate', (string) ($category['declining_rate'] ?? ''))) ?>">
</div>
</div>
</div>
</div>
<div style="display:flex;gap:10px;">
<button type="submit" class="btn btn-primary"><?= $isEdit ? 'حفظ التعديلات' : 'إضافة الفئة' ?></button>
<a href="/inventory/asset-categories" class="btn btn-outline">إلغاء</a>
</div>
</form>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>فئات الأصول الثابتة<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<?php if (can('asset.manage')): ?>
<a href="/inventory/asset-categories/create" class="btn btn-primary">+ فئة جديدة</a>
<?php endif; ?>
<a href="/inventory/assets" class="btn btn-outline">سجل الأصول</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php
$broken = [];
foreach ($categories as $c) {
if ((int) $c['is_active'] !== 1) {
continue;
}
// A category with no depreciation accounts is only correct when nothing in
// it depreciates — land. Anything else with assets on it is a hole.
$noDep = empty($c['accum_code']) || empty($c['expense_code']);
if ($noDep && (int) $c['asset_count'] > 0) {
$broken[] = $c;
}
}
?>
<?php if ($broken): ?>
<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($broken)) ?>
</div>
<p style="margin:0;color:#991B1B;font-size:12.5px;line-height:1.9;">
الأصول اللي في الفئات دي <strong>مش بيتقيّد إهلاكها</strong>. الإهلاك بيتحسب في
السجل، وأول ما يوصل للترحيل بيتخطّى الفئة عشان الحساب ناقص — يعني الأرباح
بتطلع أعلى من الحقيقة والأصول في الميزانية أعلى من الحقيقة.
<br>
حدّد <strong>مجمع الإهلاك</strong> و<strong>مصروف الإهلاك</strong> للفئات دي.
</p>
<ul style="margin:10px 0 0;padding-right:18px;color:#374151;font-size:12.5px;line-height:2;">
<?php foreach ($broken as $c): ?>
<li>
<a href="/inventory/asset-categories/<?= (int) $c['id'] ?>/edit"><?= e((string) $c['name_ar']) ?></a>
<?= number_format((int) $c['asset_count']) ?> أصل بقيمة <?= money((string) $c['book_value']) ?>
</li>
<?php endforeach; ?>
</ul>
</div>
</div>
<?php endif; ?>
<div class="card" style="margin-bottom:15px;border-right:3px solid #0D7377;">
<div style="padding:14px 18px;color:#374151;font-size:12.5px;line-height:1.9;">
الفئة هي اللي بتوصّل الأصل بالدفاتر. كل فئة شايلة <strong>٣ حسابات</strong>:
حساب الأصل نفسه، ومجمع الإهلاك، ومصروف الإهلاك. قيد الإهلاك الشهري بيتجمّع
<strong>بالفئة</strong> مش بالأصل — يعني قيد واحد فيه سطرين لكل فئة.
<br>
<strong>مصروف الإهلاك</strong> بيتبع وظيفة الأصل: ٣١٣٧ للنشاط، ٣٢٠٦ للبيع
والتوزيع، ٣٣١٦ للعمومية والإدارية.
</div>
</div>
<div class="card">
<div class="table-responsive">
<table class="table" style="font-size:12.5px;">
<thead>
<tr>
<th>الفئة</th>
<th>حساب الأصل</th>
<th>مجمع الإهلاك</th>
<th>مصروف الإهلاك</th>
<th>الطريقة</th>
<th>العمر</th>
<th>أصول</th>
<th>القيمة الدفترية</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($categories as $c): ?>
<?php $inactive = (int) $c['is_active'] !== 1; ?>
<tr style="<?= $inactive ? 'opacity:.55;' : '' ?>">
<td style="font-weight:600;">
<?= e((string) $c['name_ar']) ?>
<?php if ($inactive): ?><span class="badge">موقوفة</span><?php endif; ?>
</td>
<td>
<?php if ($c['asset_code']): ?>
<code><?= e((string) $c['asset_code']) ?></code>
<?php else: ?>
<span style="color:#DC2626;">ناقص</span>
<?php endif; ?>
</td>
<td>
<?php if ($c['accum_code']): ?>
<code><?= e((string) $c['accum_code']) ?></code>
<?php else: ?>
<span style="color:#9CA3AF;">— لا تُهلك</span>
<?php endif; ?>
</td>
<td>
<?php if ($c['expense_code']): ?>
<code><?= e((string) $c['expense_code']) ?></code>
<?php else: ?>
<span style="color:#9CA3AF;"></span>
<?php endif; ?>
</td>
<td><?= (string) $c['depreciation_method'] === 'declining_balance' ? 'قسط متناقص' : 'قسط ثابت' ?></td>
<td><?= number_format((int) $c['default_useful_life_months']) ?> شهر</td>
<td><?= number_format((int) $c['asset_count']) ?></td>
<td><?= money((string) $c['book_value']) ?></td>
<td>
<?php if (can('asset.manage')): ?>
<a class="btn btn-sm btn-outline" href="/inventory/asset-categories/<?= (int) $c['id'] ?>/edit">تعديل</a>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php $__template->endSection(); ?>
...@@ -111,24 +111,30 @@ $__template->layout('Layout.main'); ...@@ -111,24 +111,30 @@ $__template->layout('Layout.main');
<?php if (!$isEdit): ?> <?php if (!$isEdit): ?>
<div class="form-group" style="margin-bottom:18px;"> <div class="form-group" style="margin-bottom:18px;">
<label class="form-label">الأصل ده جاي منين؟ <span style="color:#DC2626;">*</span></label> <label class="form-label">الأصل ده جاي منين؟ <span style="color:#DC2626;">*</span></label>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:6px;"> <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px;margin-top:6px;">
<label style="border:1px solid #E5E7EB;border-radius:8px;padding:12px 14px;cursor:pointer;display:block;"> <label style="border:1px solid #E5E7EB;border-radius:8px;padding:12px 14px;cursor:pointer;display:block;">
<input type="radio" name="acquisition_source" value="purchase" checked <input type="radio" name="acquisition_source" value="purchase" checked onchange="srcChanged(this.value)">
onchange="document.getElementById('payRow').style.display='';">
<strong style="font-size:13px;">شراء جديد</strong> <strong style="font-size:13px;">شراء جديد</strong>
<div style="color:#6B7280;font-size:11.5px;line-height:1.8;margin-top:4px;"> <div style="color:#6B7280;font-size:11.5px;line-height:1.8;margin-top:4px;">
هيتعمل قيد: <span style="direction:ltr;display:inline-block;">من ح/ الأصل الثابت — إلى ح/ النقدية أو الموردين</span> هيتعمل قيد: <span style="direction:ltr;display:inline-block;">من ح/ الأصل الثابت — إلى ح/ النقدية أو الموردين</span>
</div> </div>
</label> </label>
<label style="border:1px solid #E5E7EB;border-radius:8px;padding:12px 14px;cursor:pointer;display:block;"> <label style="border:1px solid #E5E7EB;border-radius:8px;padding:12px 14px;cursor:pointer;display:block;">
<input type="radio" name="acquisition_source" value="opening" <input type="radio" name="acquisition_source" value="opening" onchange="srcChanged(this.value)">
onchange="document.getElementById('payRow').style.display='none';">
<strong style="font-size:13px;">رصيد افتتاحي — موجود في الدفاتر</strong> <strong style="font-size:13px;">رصيد افتتاحي — موجود في الدفاتر</strong>
<div style="color:#6B7280;font-size:11.5px;line-height:1.8;margin-top:4px;"> <div style="color:#6B7280;font-size:11.5px;line-height:1.8;margin-top:4px;">
<strong>مش هيتعمل أي قيد.</strong> التكلفة مقيّدة أصلًا، والتسجيل هنا <strong>مش هيتعمل أي قيد.</strong> التكلفة مقيّدة أصلًا، والتسجيل هنا
عشان الأصل يبدأ يتهلك من دلوقتي. عشان الأصل يبدأ يتهلك من دلوقتي.
</div> </div>
</label> </label>
<label style="border:1px solid #E5E7EB;border-radius:8px;padding:12px 14px;cursor:pointer;display:block;">
<input type="radio" name="acquisition_source" value="capitalization" onchange="srcChanged(this.value)">
<strong style="font-size:13px;">رسملة مشروع تحت التنفيذ</strong>
<div style="color:#6B7280;font-size:11.5px;line-height:1.8;margin-top:4px;">
المشروع خلص وبقى جاهز للاستخدام. التكلفة بتتنقل من
«مشروعات تحت التنفيذ» لحساب الأصل ويبدأ الإهلاك.
</div>
</label>
</div> </div>
</div> </div>
...@@ -140,6 +146,28 @@ $__template->layout('Layout.main'); ...@@ -140,6 +146,28 @@ $__template->layout('Layout.main');
<option value="bank">تحويل بنكي</option> <option value="bank">تحويل بنكي</option>
</select> </select>
</div> </div>
<div class="form-group" id="cipRow" style="margin-bottom:18px;display:none;">
<label class="form-label">المشروع اللي التكلفة هتتنقل منه <span style="color:#DC2626;">*</span></label>
<select name="cip_account_id" class="form-input" style="max-width:520px;">
<option value="">— اختار حساب المشروع —</option>
<?php foreach (($cipAccounts ?? []) as $a): ?>
<option value="<?= (int) $a['id'] ?>">
<?= e((string) $a['account_code']) ?><?= e((string) $a['name_ar']) ?>
(رصيد <?= money((string) $a['current_balance']) ?>)
</option>
<?php endforeach; ?>
</select>
<small style="color:#6B7280;line-height:1.8;display:block;margin-top:4px;">
القيد هيبقى: <span style="direction:ltr;display:inline-block;">من ح/ الأصل الثابت — إلى ح/ المشروع تحت التنفيذ</span>.
<strong>مفيش فلوس بتتصرف هنا</strong> — الفلوس اتصرفت وقت التنفيذ.
</small>
<?php if (empty($cipAccounts)): ?>
<small style="color:#B45309;display:block;margin-top:6px;">
مفيش حسابات مشروعات تحت التنفيذ قابلة للترحيل في دليل الحسابات.
</small>
<?php endif; ?>
</div>
<?php endif; ?> <?php endif; ?>
<?php if ($locked): ?> <?php if ($locked): ?>
...@@ -225,6 +253,17 @@ $__template->layout('Layout.main'); ...@@ -225,6 +253,17 @@ $__template->layout('Layout.main');
</form> </form>
<script> <script>
// Each acquisition route asks for a different thing: a purchase asks how it was
// paid, a capitalisation asks which project it came out of, an opening balance
// asks nothing because it posts nothing.
function srcChanged(v) {
document.getElementById('payRow').style.display = v === 'purchase' ? '' : 'none';
var cip = document.getElementById('cipRow');
if (cip) { cip.style.display = v === 'capitalization' ? '' : 'none'; }
var sel = document.querySelector('select[name="cip_account_id"]');
if (sel) { sel.required = v === 'capitalization'; }
}
// Picking a category fills in that class's policy, so the accountant is not // Picking a category fills in that class's policy, so the accountant is not
// retyping the club's own depreciation rules for every desk and laptop. // retyping the club's own depreciation rules for every desk and laptop.
document.getElementById('categorySelect')?.addEventListener('change', function () { document.getElementById('categorySelect')?.addEventListener('change', function () {
......
...@@ -53,6 +53,7 @@ MenuRegistry::register('inventory', [ ...@@ -53,6 +53,7 @@ MenuRegistry::register('inventory', [
['label_ar' => 'الموردين', 'label_en' => 'Suppliers', 'route' => '/inventory/suppliers', 'permission' => 'supplier.view', 'order' => 7], ['label_ar' => 'الموردين', 'label_en' => 'Suppliers', 'route' => '/inventory/suppliers', 'permission' => 'supplier.view', 'order' => 7],
['label_ar' => 'أوامر الشراء', 'label_en' => 'Purchase Orders', 'route' => '/inventory/purchase-orders', 'permission' => 'purchase.view', 'order' => 8], ['label_ar' => 'أوامر الشراء', 'label_en' => 'Purchase Orders', 'route' => '/inventory/purchase-orders', 'permission' => 'purchase.view', 'order' => 8],
['label_ar' => 'الأصول والإهلاك', 'label_en' => 'Assets', 'route' => '/inventory/assets', 'permission' => 'asset.view', 'order' => 9], ['label_ar' => 'الأصول والإهلاك', 'label_en' => 'Assets', 'route' => '/inventory/assets', 'permission' => 'asset.view', 'order' => 9],
['label_ar' => 'فئات الأصول', 'label_en' => 'Asset Categories', 'route' => '/inventory/asset-categories', 'permission' => 'asset.view', 'order' => 10],
['label_ar' => 'عهدة الأصول', 'label_en' => 'Asset Custody', 'route' => '/inventory/assets/custody', 'permission' => 'asset.view', 'order' => 10], ['label_ar' => 'عهدة الأصول', 'label_en' => 'Asset Custody', 'route' => '/inventory/assets/custody', 'permission' => 'asset.view', 'order' => 10],
['label_ar' => 'قوائم المواد (BOM)', 'label_en' => 'Bill of Materials', 'route' => '/inventory/bom', 'permission' => 'inventory.bom.view','order' => 11], ['label_ar' => 'قوائم المواد (BOM)', 'label_en' => 'Bill of Materials', 'route' => '/inventory/bom', 'permission' => 'inventory.bom.view','order' => 11],
['label_ar' => 'أرصدة افتتاحية', 'label_en' => 'Opening Balances', 'route' => '/inventory/opening-balances', 'permission' => 'inventory.opening.manage', 'order' => 12], ['label_ar' => 'أرصدة افتتاحية', 'label_en' => 'Opening Balances', 'route' => '/inventory/opening-balances', 'permission' => 'inventory.opening.manage', 'order' => 12],
......
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