Commit c95a8dd4 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(accounting): one split, applied across any scope of revenue

The wizard could only ever rewire one stream at a time, so making a policy
("this is how we split membership money") meant repeating the same work per
stream — 97 streams across 14 categories. The split is now written once and
pushed onto whatever it should govern.

Scopes: this stream, a whole category, a hand-picked set, everything not yet
mapped, or all of it. The screen shows the resolved target list and the count
before anything is written, and each target still gets its own versioned rule
— nothing is shared and nothing is retroactive.

AllocationPlanService is the guard rail. Every save runs resolveTargets ->
validate -> apply, apply() re-validates on its own (there is no FK on
account_id, so an unvalidated write would point rules at accounts that do not
exist), and the whole batch is one transaction — a single bad target rolls
back all of it rather than leaving the chart half-rewired.

Refused before a row is written, all verified against a clone of production:
header accounts, missing/archived accounts, no remainder line, two remainder
lines, percentages over 100, zero and negative values, a line type that does
not match its account's type, an expense line on a collection, mixing inflow
and outflow streams in one scope, an effective date inside a closed period,
a deferred line with no recognition account, and a stage the category cannot
produce (skipped with a reason rather than written).

Also:
- Direction now drives which line types exist at all, and is editable per
  stream from the wizard. Phase_106_001 corrects 11 payroll/procurement
  streams seeded as 'inflow' — a salary expense and a supplier payment are
  debits, and the screen was offering revenue accounts for them.
- parseLines() accepted 5 of the 12 line types the schema allows, which made
  outflow streams impossible to configure at all. It now accepts all of them.
- Deferred revenue is configurable from the wizard (months + recognition
  account, filtered to revenue accounts).
- The entry preview inverts for outflow: split lines debit, cash credits.

Arithmetic verified against RevenueAllocator at 0.03, 1.00, 150,000.00 and
999,999.99 EGP, and with 14% inclusive VAT — balanced in every case.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 00dfe062
...@@ -7,6 +7,7 @@ use App\Core\App; ...@@ -7,6 +7,7 @@ use App\Core\App;
use App\Core\Controller; use App\Core\Controller;
use App\Core\Request; use App\Core\Request;
use App\Core\Response; use App\Core\Response;
use App\Modules\Accounting\Services\Revenue\AllocationPlanService;
use App\Modules\Accounting\Services\Revenue\RevenuePostingEngine; use App\Modules\Accounting\Services\Revenue\RevenuePostingEngine;
use App\Modules\Accounting\Services\Revenue\RevenueRecognitionService; use App\Modules\Accounting\Services\Revenue\RevenueRecognitionService;
use App\Modules\Accounting\Services\Revenue\RevenueStreamRegistry; use App\Modules\Accounting\Services\Revenue\RevenueStreamRegistry;
...@@ -17,6 +18,12 @@ use App\Modules\Accounting\Services\Revenue\RevenueStreamRegistry; ...@@ -17,6 +18,12 @@ use App\Modules\Accounting\Services\Revenue\RevenueStreamRegistry;
*/ */
class RevenueMappingController extends Controller class RevenueMappingController extends Controller
{ {
/** Every line type the schema allows — inflow and outflow alike. */
private const LINE_TYPES = [
'revenue', 'deferred_revenue', 'passthrough', 'contra_revenue', 'receivable_offset',
'expense', 'prepaid_expense', 'asset', 'inventory', 'payable_offset', 'writeoff', 'equity',
];
// ──────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────
// Streams list // Streams list
// ──────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────
...@@ -232,29 +239,71 @@ class RevenueMappingController extends Controller ...@@ -232,29 +239,71 @@ class RevenueMappingController extends Controller
$this->authorize('accounting.revenue_mapping.manage'); $this->authorize('accounting.revenue_mapping.manage');
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$stream = $db->selectOne("SELECT * FROM revenue_streams WHERE id = ?", [(int) $id]); $streamId = (int) $id;
$stream = $db->selectOne("SELECT * FROM revenue_streams WHERE id = ?", [$streamId]);
if (!$stream) { if (!$stream) {
return $this->redirect('/accounting/revenue-mapping')->withError('مصدر الإيراد غير موجود'); return $this->redirect('/accounting/revenue-mapping')->withError('مصدر الإيراد غير موجود');
} }
$configured = RevenuePostingEngine::configuredStages((int) $id); // ── Scope: which streams this split will govern ─────────────
$scope = (string) $request->get('scope', 'stream');
if (!\in_array($scope, ['stream', 'category', 'selection', 'unmapped', 'all'], true)) {
$scope = 'stream';
}
$scopeCategory = (string) $request->get('category', $stream['category']);
if (!isset(AllocationPlanService::CATEGORY_STAGES[$scopeCategory])) {
$scopeCategory = (string) $stream['category'];
}
$selection = array_values(array_filter(array_map(
'intval',
explode(',', (string) $request->get('ids', ''))
)));
if ($scope === 'selection' && !$selection) {
$selection = [$streamId];
}
// ── Stage ───────────────────────────────────────────────────
$configured = RevenuePostingEngine::configuredStages($streamId);
$streamStages = AllocationPlanService::stagesFor($stream);
$stage = (string) $request->get('stage', ''); $stage = (string) $request->get('stage', '');
if (!\in_array($stage, RevenuePostingEngine::STAGES, true)) { if (!\in_array($stage, RevenuePostingEngine::STAGES, true)) {
$stage = $configured[0] ?? self::defaultStageFor($stream); $stage = $configured[0] ?? AllocationPlanService::primaryStage($stream);
} }
$category = trim((string) $request->get('member_category', '')); $memberCategory = trim((string) $request->get('member_category', ''));
$targets = AllocationPlanService::resolveTargets([
'scope' => $scope,
'stream_id' => $streamId,
'category' => $scopeCategory,
'stream_ids' => $selection,
'stage' => $stage,
]);
// Load the rule matching this exact scope, so editing the working-member $targetCount = 0;
// split does not silently show the general one. foreach ($targets as $t) {
$targetCount += count($t['stages']);
}
// The direction decides which line types are even offered — you cannot
// credit a revenue account on a payroll payment.
$direction = AllocationPlanService::directionFor($stream, $stage);
foreach ($targets as $t) {
foreach ($t['stages'] as $st) {
$direction = AllocationPlanService::directionFor($t['stream'], $st);
break 2;
}
}
// ── The rule currently governing this exact scope ───────────
$rule = $db->selectOne( $rule = $db->selectOne(
"SELECT * FROM revenue_posting_rules "SELECT * FROM revenue_posting_rules
WHERE stream_id = ? AND stage = ? AND status = 'active' WHERE stream_id = ? AND stage = ? AND status = 'active'
AND effective_from <= CURDATE() AND effective_from <= CURDATE()
AND (effective_to IS NULL OR effective_to >= CURDATE()) AND (effective_to IS NULL OR effective_to >= CURDATE())
AND " . ($category !== '' ? "member_category = ?" : "member_category IS NULL") . " AND " . ($memberCategory !== '' ? "member_category = ?" : "member_category IS NULL") . "
ORDER BY version DESC LIMIT 1", ORDER BY version DESC LIMIT 1",
$category !== '' ? [(int) $id, $stage, $category] : [(int) $id, $stage] $memberCategory !== '' ? [$streamId, $stage, $memberCategory] : [$streamId, $stage]
); );
$lines = []; $lines = [];
...@@ -271,9 +320,226 @@ class RevenueMappingController extends Controller ...@@ -271,9 +320,226 @@ class RevenueMappingController extends Controller
); );
} }
// A realistic default amount so the wizard opens with something meaningful return $this->view('Accounting.Views.revenue_mapping.wizard', [
// rather than zero — the average of what this stream has actually collected. 'stream' => $stream,
$suggested = '150000.00'; 'rule' => $rule,
'lines' => $lines,
'stage' => $stage,
'stages' => RevenuePostingEngine::STAGE_LABELS,
'streamStages' => $streamStages,
'configured' => $configured,
'category' => $memberCategory,
'categories' => RevenuePostingEngine::memberCategories(),
'suggested' => self::suggestedAmount($stream),
'taxProfiles' => $db->select("SELECT * FROM revenue_tax_profiles WHERE is_active = 1 ORDER BY tax_code"),
'costCenters' => $db->select("SELECT id, code, name_ar FROM cost_centers WHERE is_active = 1 ORDER BY code"),
'branches' => $db->select("SELECT id, name_ar FROM branches WHERE is_active = 1 ORDER BY name_ar"),
'scope' => $scope,
'scopeCategory' => $scopeCategory,
'selection' => $selection,
'targets' => $targets,
'targetCount' => $targetCount,
'direction' => $direction,
'lineTypes' => AllocationPlanService::lineTypesFor($direction),
'catSummary' => AllocationPlanService::categorySummary(),
'allStreams' => $db->select(
"SELECT id, name_ar, category, default_direction,
EXISTS(SELECT 1 FROM revenue_posting_rules r
WHERE r.stream_id = revenue_streams.id AND r.status = 'active'
AND r.effective_from <= CURDATE()
AND (r.effective_to IS NULL OR r.effective_to >= CURDATE())) AS is_mapped
FROM revenue_streams WHERE is_active = 1
ORDER BY category ASC, name_ar ASC"
),
]);
}
/**
* Live target count + the resolved list, so the wizard can say "هيتطبّق على
* ١١ مصدر" before anything is written.
*/
public function planTargets(Request $request): Response
{
$this->authorize('accounting.revenue_mapping.manage');
$targets = AllocationPlanService::resolveTargets([
'scope' => (string) $request->get('scope', 'stream'),
'stream_id' => (int) $request->get('stream_id', 0),
'category' => (string) $request->get('category', ''),
'stream_ids' => array_values(array_filter(array_map('intval', explode(',', (string) $request->get('ids', ''))))),
'stage' => (string) $request->get('stage', ''),
]);
$rows = [];
$count = 0;
$directions = [];
foreach ($targets as $t) {
foreach ($t['stages'] as $st) {
$dir = AllocationPlanService::directionFor($t['stream'], $st);
$directions[$dir] = true;
$count++;
$rows[] = [
'stream_id' => (int) $t['stream']['id'],
'name' => $t['stream']['name_ar'],
'category' => AllocationPlanService::CATEGORY_LABELS[$t['stream']['category']] ?? $t['stream']['category'],
'stage' => RevenuePostingEngine::STAGE_LABELS[$st] ?? $st,
'direction' => $dir,
];
}
foreach ($t['skipped'] as $why) {
$rows[] = [
'stream_id' => (int) $t['stream']['id'],
'name' => $t['stream']['name_ar'],
'category' => AllocationPlanService::CATEGORY_LABELS[$t['stream']['category']] ?? $t['stream']['category'],
'stage' => '—',
'skipped' => $why,
];
}
}
$direction = count($directions) === 1 ? array_key_first($directions) : 'mixed';
return $this->json([
'count' => $count,
'targets' => $rows,
'direction' => $direction,
'lineTypes' => $direction === 'mixed'
? []
: array_map(
static fn(string $k, array $v): array => ['key' => $k, 'label' => $v['label']],
array_keys(AllocationPlanService::lineTypesFor($direction)),
array_values(AllocationPlanService::lineTypesFor($direction))
),
]);
}
/**
* Apply one split to every stream in the chosen scope. Validation runs across
* ALL targets first — nothing is written unless every one of them passes.
*/
public function applyPlan(Request $request): Response
{
$this->authorize('accounting.revenue_mapping.manage');
$streamId = (int) $request->post('stream_id', 0);
$scope = (string) $request->post('scope', 'stream');
$stage = (string) $request->post('stage', '');
$memberCategory = trim((string) $request->post('member_category', ''));
$back = '/accounting/revenue-mapping/' . $streamId . '/wizard?stage=' . urlencode($stage)
. '&scope=' . urlencode($scope)
. '&category=' . urlencode((string) $request->post('category', ''))
. '&ids=' . urlencode((string) $request->post('ids', ''))
. ($memberCategory !== '' ? '&member_category=' . urlencode($memberCategory) : '');
if (!\in_array($stage, RevenuePostingEngine::STAGES, true)) {
return $this->redirect($back)->withError('مرحلة قيد غير معروفة');
}
$payload = $this->parseLines($request);
if (!empty($payload['errors'])) {
return $this->redirect($back)->withError(implode(' — ', $payload['errors']));
}
$targets = AllocationPlanService::resolveTargets([
'scope' => $scope,
'stream_id' => $streamId,
'category' => (string) $request->post('category', ''),
'stream_ids' => array_values(array_filter(array_map('intval', explode(',', (string) $request->post('ids', ''))))),
'stage' => $stage,
]);
$opts = [
'effective_from' => (string) $request->post('effective_from', date('Y-m-d')),
'notes' => $request->post('notes'),
'name_ar' => $request->post('name_ar'),
'tax_profile_id' => $request->post('tax_profile_id'),
'cost_center_id' => $request->post('cost_center_id'),
'branch_id' => $request->post('branch_id'),
'payment_method' => $request->post('payment_method'),
'member_category' => $memberCategory,
'debit_source' => (string) $request->post('debit_source', 'auto_treasury'),
'debit_account_id'=> $request->post('debit_account_id'),
];
$check = AllocationPlanService::validate($payload['lines'], $targets, $opts);
if ($check['errors']) {
return $this->redirect($back)->withError(implode(' — ', array_slice($check['errors'], 0, 4)));
}
$employee = App::getInstance()->currentEmployee();
$employeeId = $employee ? (int) $employee->id : null;
$result = AllocationPlanService::apply($payload['lines'], $targets, $opts, $employeeId);
if (!$result['success']) {
return $this->redirect($back)->withError('فشل الحفظ — ما اتغيّرش أي حاجة: ' . $result['error']);
}
$msg = $result['count'] === 1
? 'اتحفظت التقسيمة على "' . ($result['applied'][0]['stream'] ?? '') . '"'
: 'اتحفظت نفس التقسيمة على ' . $result['count'] . ' قاعدة قيد عبر '
. count(array_unique(array_column($result['applied'], 'stream_id'))) . ' مصدر إيراد';
$superseded = array_sum(array_column($result['applied'], 'superseded'));
if ($superseded > 0) {
$msg .= ' — واتوقف ' . $superseded . ' إصدار قديم (القيود المرحّلة ما اتغيّرتش)';
}
$response = $this->redirect($back)->withSuccess($msg);
if ($check['warnings']) {
$response = $response->withWarning(implode(' — ', array_slice($check['warnings'], 0, 3)));
}
return $response;
}
/**
* Flip a stream between "money in" and "money out" from the screen. Direction
* decides which line types the wizard offers and what it validates against,
* so getting it wrong is the difference between crediting a revenue account
* and debiting an expense one.
*/
public function setDirection(Request $request, string $id): Response
{
$this->authorize('accounting.revenue_mapping.manage');
$db = App::getInstance()->db();
$streamId = (int) $id;
$direction = (string) $request->post('direction', 'inflow');
if (!\in_array($direction, ['inflow', 'outflow'], true)) {
$direction = 'inflow';
}
$stream = $db->selectOne("SELECT * FROM revenue_streams WHERE id = ?", [$streamId]);
if (!$stream) {
return $this->redirect('/accounting/revenue-mapping')->withError('مصدر الإيراد غير موجود');
}
$back = '/accounting/revenue-mapping/' . $streamId . '/wizard'
. '?stage=' . urlencode((string) $request->post('stage', ''));
if ((string) $stream['default_direction'] === $direction) {
return $this->redirect($back);
}
// A stream with posted history keeps its old rules on the old direction;
// only the default for NEW rules changes. Nothing in the ledger moves.
$db->update('revenue_streams', [
'default_direction' => $direction,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$streamId]);
return $this->redirect($back)->withSuccess(
'اتغيّر اتجاه "' . $stream['name_ar'] . '" لـ '
. ($direction === 'outflow' ? 'صرف' : 'تحصيل')
. ' — القواعد القديمة والقيود المرحّلة زي ما هي'
);
}
/** A realistic opening amount: what this stream has actually been collecting. */
private static function suggestedAmount(array $stream): string
{
$db = App::getInstance()->db();
if ($stream['source_module'] === 'payments' && !empty($stream['source_key'])) { if ($stream['source_module'] === 'payments' && !empty($stream['source_key'])) {
$avg = $db->selectOne( $avg = $db->selectOne(
"SELECT ROUND(AVG(amount), 2) AS a FROM payments "SELECT ROUND(AVG(amount), 2) AS a FROM payments
...@@ -281,23 +547,38 @@ class RevenueMappingController extends Controller ...@@ -281,23 +547,38 @@ class RevenueMappingController extends Controller
[$stream['source_key']] [$stream['source_key']]
); );
if (!empty($avg['a'])) { if (!empty($avg['a'])) {
$suggested = (string) $avg['a']; return (string) $avg['a'];
} }
} }
return $this->view('Accounting.Views.revenue_mapping.wizard', [ // Anything with an evidence table declared can answer the same question.
'stream' => $stream, $table = (string) ($stream['evidence_table'] ?? '');
'rule' => $rule, $column = (string) ($stream['evidence_amount_column'] ?? '');
'lines' => $lines, if ($table !== '' && $column !== ''
'stage' => $stage, && preg_match('/^[A-Za-z0-9_]+$/', $table) && preg_match('/^[A-Za-z0-9_]+$/', $column)) {
'stages' => RevenuePostingEngine::STAGE_LABELS, $exists = $db->selectOne(
'configured' => $configured, "SELECT 1 AS ok FROM information_schema.columns
'category' => $category, WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?",
'categories' => RevenuePostingEngine::memberCategories(), [$table, $column]
'suggested' => $suggested, );
'taxProfiles' => $db->select("SELECT * FROM revenue_tax_profiles WHERE is_active = 1 ORDER BY tax_code"), if ($exists) {
'costCenters' => $db->select("SELECT id, code, name_ar FROM cost_centers WHERE is_active = 1 ORDER BY code"), try {
]); $where = (string) ($stream['evidence_where'] ?? '');
$avg = $db->selectOne(
"SELECT ROUND(AVG(`{$column}`), 2) AS a FROM `{$table}`"
. ($where !== '' ? " WHERE {$where}" : '')
. ($where !== '' ? " AND" : " WHERE") . " `{$column}` > 0"
);
if (!empty($avg['a'])) {
return (string) $avg['a'];
}
} catch (\Throwable) {
// Evidence config is user-editable; a bad one must not break the screen.
}
}
}
return '150000.00';
} }
/** A sensible first stage to offer for a stream that has none configured. */ /** A sensible first stage to offer for a stream that has none configured. */
...@@ -1134,7 +1415,7 @@ class RevenueMappingController extends Controller ...@@ -1134,7 +1415,7 @@ class RevenueMappingController extends Controller
} }
$lineType = (string) ($row['line_type'] ?? 'revenue'); $lineType = (string) ($row['line_type'] ?? 'revenue');
if (!\in_array($lineType, ['revenue', 'deferred_revenue', 'passthrough', 'contra_revenue', 'receivable_offset'], true)) { if (!\in_array($lineType, self::LINE_TYPES, true)) {
$lineType = 'revenue'; $lineType = 'revenue';
} }
......
...@@ -161,9 +161,12 @@ return [ ...@@ -161,9 +161,12 @@ return [
['GET', '/accounting/revenue-mapping/search-accounts', 'Accounting\Controllers\RevenueMappingController@searchAccounts', ['auth'], 'accounting.revenue_mapping.view'], ['GET', '/accounting/revenue-mapping/search-accounts', 'Accounting\Controllers\RevenueMappingController@searchAccounts', ['auth'], 'accounting.revenue_mapping.view'],
['GET', '/accounting/revenue-mapping/parent-accounts', 'Accounting\Controllers\RevenueMappingController@parentAccounts', ['auth'], 'accounting.revenue_mapping.view'], ['GET', '/accounting/revenue-mapping/parent-accounts', 'Accounting\Controllers\RevenueMappingController@parentAccounts', ['auth'], 'accounting.revenue_mapping.view'],
['POST', '/accounting/revenue-mapping/create-account', 'Accounting\Controllers\RevenueMappingController@createAccount', ['auth', 'csrf'], 'accounting.revenue_mapping.manage'], ['POST', '/accounting/revenue-mapping/create-account', 'Accounting\Controllers\RevenueMappingController@createAccount', ['auth', 'csrf'], 'accounting.revenue_mapping.manage'],
['GET', '/accounting/revenue-mapping/plan-targets', 'Accounting\Controllers\RevenueMappingController@planTargets', ['auth'], 'accounting.revenue_mapping.manage'],
['POST', '/accounting/revenue-mapping/apply', 'Accounting\Controllers\RevenueMappingController@applyPlan', ['auth', 'csrf'], 'accounting.revenue_mapping.manage'],
['POST', '/accounting/revenue-mapping/simulate', 'Accounting\Controllers\RevenueMappingController@simulate', ['auth', 'csrf'], 'accounting.revenue_mapping.view'], ['POST', '/accounting/revenue-mapping/simulate', 'Accounting\Controllers\RevenueMappingController@simulate', ['auth', 'csrf'], 'accounting.revenue_mapping.view'],
['POST', '/accounting/revenue-mapping/sync', 'Accounting\Controllers\RevenueMappingController@sync', ['auth', 'csrf'], 'accounting.revenue_mapping.manage'], ['POST', '/accounting/revenue-mapping/sync', 'Accounting\Controllers\RevenueMappingController@sync', ['auth', 'csrf'], 'accounting.revenue_mapping.manage'],
['GET', '/accounting/revenue-mapping/{id:\d+}/wizard', 'Accounting\Controllers\RevenueMappingController@wizard', ['auth'], 'accounting.revenue_mapping.manage'], ['GET', '/accounting/revenue-mapping/{id:\d+}/wizard', 'Accounting\Controllers\RevenueMappingController@wizard', ['auth'], 'accounting.revenue_mapping.manage'],
['POST', '/accounting/revenue-mapping/{id:\d+}/direction', 'Accounting\Controllers\RevenueMappingController@setDirection', ['auth', 'csrf'], 'accounting.revenue_mapping.manage'],
['GET', '/accounting/revenue-mapping/{id:\d+}/edit', 'Accounting\Controllers\RevenueMappingController@edit', ['auth'], 'accounting.revenue_mapping.view'], ['GET', '/accounting/revenue-mapping/{id:\d+}/edit', 'Accounting\Controllers\RevenueMappingController@edit', ['auth'], 'accounting.revenue_mapping.view'],
['POST', '/accounting/revenue-mapping/{id:\d+}', 'Accounting\Controllers\RevenueMappingController@update', ['auth', 'csrf'], 'accounting.revenue_mapping.manage'], ['POST', '/accounting/revenue-mapping/{id:\d+}', 'Accounting\Controllers\RevenueMappingController@update', ['auth', 'csrf'], 'accounting.revenue_mapping.manage'],
......
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services\Revenue;
use App\Core\App;
/**
* تطبيق تقسيمة واحدة على مجموعة مصادر إيراد — apply one allocation split across
* many revenue streams at once.
*
* The wizard lets a finance user describe a split ONCE ("20% here, 30% there,
* the rest to that") and push it onto every stream it should govern: one stream,
* a whole category, a hand-picked set, or everything currently unmapped. Each
* target still gets its OWN versioned rule — nothing is shared, nothing is
* retroactive, and a posted journal entry never changes because of a save here.
*
* This class is the guard rail. Everything the wizard offers passes through
* resolveTargets() -> validate() -> apply(), and apply() refuses to write
* anything at all if a single target fails validation.
*/
final class AllocationPlanService
{
/**
* Which posting stages make sense for a stream, by category. A membership fee
* accrues then gets collected; a payroll run is only ever paid. Bulk apply
* never writes a stage a category cannot produce.
*/
public const CATEGORY_STAGES = [
'membership' => ['accrual', 'collection', 'refund', 'writeoff'],
'subscription' => ['accrual', 'collection', 'refund', 'writeoff'],
'activity' => ['accrual', 'collection', 'refund'],
'facility' => ['accrual', 'collection', 'refund'],
'academy' => ['accrual', 'collection', 'refund', 'writeoff'],
'rental' => ['accrual', 'collection', 'refund', 'writeoff'],
'retail' => ['accrual', 'collection', 'refund'],
'penalty' => ['accrual', 'collection', 'writeoff'],
'transfer' => ['accrual', 'collection', 'transfer'],
'treasury' => ['collection', 'payment', 'transfer'],
'procurement' => ['accrual', 'payment', 'refund'],
'payroll' => ['accrual', 'payment'],
'writeoff' => ['writeoff'],
'other' => ['accrual', 'collection', 'payment', 'refund', 'writeoff', 'transfer'],
];
public const CATEGORY_LABELS = [
'membership' => 'العضويات',
'subscription' => 'الاشتراكات',
'activity' => 'الأنشطة',
'facility' => 'المنشآت والملاعب',
'academy' => 'الأكاديميات',
'rental' => 'الإيجارات والمحلات',
'retail' => 'المبيعات والمنافذ',
'penalty' => 'الغرامات والجزاءات',
'transfer' => 'الانتقالات',
'treasury' => 'الخزينة والبنوك',
'procurement' => 'المشتريات',
'payroll' => 'الأجور والرواتب',
'writeoff' => 'الإعدام والإسقاط',
'other' => 'أخرى',
];
/** Line types that credit value away (money coming in). */
public const INFLOW_LINE_TYPES = [
'revenue' => ['label' => 'إيراد', 'types' => ['revenue']],
'deferred_revenue' => ['label' => 'إيراد مؤجل', 'types' => ['liability']],
'passthrough' => ['label' => 'تحصيل لحساب الغير (التزام)', 'types' => ['liability']],
'contra_revenue' => ['label' => 'خصم من الإيراد', 'types' => ['revenue', 'expense']],
'receivable_offset' => ['label' => 'تسوية ذمم مدينة', 'types' => ['asset']],
'equity' => ['label' => 'حقوق ملكية', 'types' => ['liability']],
];
/** Line types that debit value out (money going out). */
public const OUTFLOW_LINE_TYPES = [
'expense' => ['label' => 'مصروف', 'types' => ['expense']],
'prepaid_expense' => ['label' => 'مصروف مقدَّم', 'types' => ['asset']],
'asset' => ['label' => 'أصل ثابت', 'types' => ['asset']],
'inventory' => ['label' => 'مخزون', 'types' => ['asset']],
'payable_offset' => ['label' => 'تسوية ذمم دائنة', 'types' => ['liability']],
'writeoff' => ['label' => 'إعدام / إسقاط', 'types' => ['expense']],
'passthrough' => ['label' => 'دفع لحساب الغير', 'types' => ['liability']],
];
// ────────────────────────────────────────────────────────────
// Targets
// ────────────────────────────────────────────────────────────
/**
* Expand a scope into concrete (stream, stage) pairs.
*
* @param array $c [
* scope: 'stream'|'category'|'selection'|'unmapped'|'all',
* stream_id, category, stream_ids[],
* stage: a stage key or '' for "every stage this stream can produce",
* only_active: bool
* ]
* @return array<int, array{stream:array, stages:string[], skipped:string[]}>
*/
public static function resolveTargets(array $c): array
{
$db = App::getInstance()->db();
$scope = (string) ($c['scope'] ?? 'stream');
$where = ['s.is_active = 1'];
$args = [];
switch ($scope) {
case 'category':
$cat = (string) ($c['category'] ?? '');
if ($cat === '' || !isset(self::CATEGORY_STAGES[$cat])) {
return [];
}
$where[] = 's.category = ?';
$args[] = $cat;
break;
case 'selection':
$ids = array_values(array_unique(array_filter(
array_map('intval', (array) ($c['stream_ids'] ?? [])),
static fn(int $i): bool => $i > 0
)));
if (!$ids) {
return [];
}
$where[] = 's.id IN (' . implode(',', array_fill(0, count($ids), '?')) . ')';
foreach ($ids as $i) {
$args[] = $i;
}
break;
case 'unmapped':
$where[] = "NOT EXISTS (SELECT 1 FROM revenue_posting_rules r
WHERE r.stream_id = s.id AND r.status = 'active'
AND r.effective_from <= CURDATE()
AND (r.effective_to IS NULL OR r.effective_to >= CURDATE()))";
break;
case 'all':
break;
case 'stream':
default:
$where[] = 's.id = ?';
$args[] = (int) ($c['stream_id'] ?? 0);
break;
}
$streams = $db->select(
"SELECT s.* FROM revenue_streams s WHERE " . implode(' AND ', $where)
. " ORDER BY s.category ASC, s.name_ar ASC",
$args
);
$wanted = (string) ($c['stage'] ?? '');
$targets = [];
foreach ($streams as $s) {
$allowed = self::stagesFor($s);
$skipped = [];
if ($wanted !== '') {
if (\in_array($wanted, $allowed, true)) {
$stages = [$wanted];
} else {
$stages = [];
$skipped[] = 'المرحلة "' . (RevenuePostingEngine::STAGE_LABELS[$wanted] ?? $wanted)
. '" لا تنطبق على ' . (self::CATEGORY_LABELS[$s['category']] ?? $s['category']);
}
} else {
// "every applicable stage" means the ones already configured, and
// failing that the category's primary stage — not all six, which
// would invent postings nobody asked for.
$configured = RevenuePostingEngine::configuredStages((int) $s['id']);
$configured = array_values(array_intersect($configured, $allowed));
$stages = $configured ?: [self::primaryStage($s)];
}
$targets[] = ['stream' => $s, 'stages' => $stages, 'skipped' => $skipped];
}
return $targets;
}
/** Stages this stream may legitimately post. */
public static function stagesFor(array $stream): array
{
return self::CATEGORY_STAGES[$stream['category'] ?? 'other'] ?? self::CATEGORY_STAGES['other'];
}
/** The one stage to use when the user did not pick one. */
public static function primaryStage(array $stream): string
{
return match ($stream['category'] ?? 'other') {
'procurement', 'payroll' => 'payment',
'treasury' => 'transfer',
'writeoff' => 'writeoff',
default => 'collection',
};
}
/** A stream's direction, honouring what the stream itself declares. */
public static function directionFor(array $stream, string $stage): string
{
if ($stage === 'refund' || $stage === 'writeoff') {
return 'outflow';
}
if ($stage === 'payment') {
return ($stream['default_direction'] ?? 'inflow') === 'outflow' ? 'outflow' : 'inflow';
}
return (string) ($stream['default_direction'] ?? 'inflow');
}
/** Line types valid for a direction, for the picker and for validation. */
public static function lineTypesFor(string $direction): array
{
return $direction === 'outflow' ? self::OUTFLOW_LINE_TYPES : self::INFLOW_LINE_TYPES;
}
// ────────────────────────────────────────────────────────────
// Validation
// ────────────────────────────────────────────────────────────
/**
* Everything that can be wrong with a split, checked before a single row is
* written. Errors block the save; warnings are shown and allowed.
*
* @return array{errors: string[], warnings: string[]}
*/
public static function validate(array $lines, array $targets, array $opts): array
{
$db = App::getInstance()->db();
$errors = [];
$warnings = [];
// ── Shape of the split itself ───────────────────────────────
if (!$lines) {
$errors[] = 'التقسيمة فاضية — أضف بندًا واحدًا على الأقل';
return ['errors' => $errors, 'warnings' => $warnings];
}
$remainders = array_values(array_filter($lines, static fn(array $l): bool => $l['allocation_method'] === 'remainder'));
if (count($remainders) === 0) {
$errors[] = 'لازم بند واحد اسمه "الباقي" يستوعب المتبقي وكسور القرش — من غيره القيد ممكن ما يتوازنش';
} elseif (count($remainders) > 1) {
$errors[] = 'مش ممكن يكون فيه أكتر من بند "باقي" واحد';
}
$pctTotal = '0.00';
$fixedTotal = '0.00';
foreach ($lines as $i => $l) {
$n = $i + 1;
if ($l['allocation_method'] === 'percentage') {
$p = (string) ($l['percentage'] ?? '0');
if (bccomp($p, '0', 4) <= 0) {
$errors[] = "البند {$n}: النسبة لازم تكون أكبر من صفر";
}
if (bccomp($p, '100', 4) > 0) {
$errors[] = "البند {$n}: النسبة أكبر من ١٠٠٪";
}
$pctTotal = bcadd($pctTotal, $p, 4);
}
if ($l['allocation_method'] === 'fixed') {
$f = (string) ($l['fixed_amount'] ?? '0');
if (bccomp($f, '0', 2) <= 0) {
$errors[] = "البند {$n}: المبلغ الثابت لازم يكون أكبر من صفر";
}
$fixedTotal = bcadd($fixedTotal, $f, 2);
}
if ((int) ($l['account_id'] ?? 0) <= 0) {
$errors[] = "البند {$n}: ما اخترتش حساب";
}
if (($l['recognition_method'] ?? 'immediate') === 'straight_line') {
if ((int) ($l['recognition_months'] ?? 0) < 1) {
$errors[] = "البند {$n}: التوزيع على أشهر محتاج عدد شهور";
}
if ((int) ($l['recognized_account_id'] ?? 0) <= 0) {
$errors[] = "البند {$n}: حدد حساب الإيراد اللي المؤجل هيترحّل له";
}
}
}
if (bccomp($pctTotal, '100', 4) > 0) {
$errors[] = 'مجموع النسب ' . rtrim(rtrim($pctTotal, '0'), '.') . '٪ — أكبر من ١٠٠٪';
} elseif (bccomp($pctTotal, '100', 4) === 0) {
$warnings[] = 'النسب بتاخد ١٠٠٪ بالكامل، فبند "الباقي" هياخد صفر إلا لو فيه كسور تقريب';
}
// ── The accounts themselves ─────────────────────────────────
$ids = array_values(array_unique(array_merge(
array_map(static fn(array $l): int => (int) ($l['account_id'] ?? 0), $lines),
array_map(static fn(array $l): int => (int) ($l['recognized_account_id'] ?? 0), $lines)
)));
$ids = array_values(array_filter($ids, static fn(int $i): bool => $i > 0));
$accounts = [];
if ($ids) {
foreach ($db->select(
"SELECT id, account_code, name_ar, account_type, is_header, is_active, is_archived
FROM chart_of_accounts WHERE id IN (" . implode(',', array_fill(0, count($ids), '?')) . ")",
$ids
) as $a) {
$accounts[(int) $a['id']] = $a;
}
}
foreach ($ids as $id) {
if (!isset($accounts[$id])) {
$errors[] = "الحساب رقم {$id} مش موجود";
continue;
}
$a = $accounts[$id];
if ((int) $a['is_header'] === 1) {
$errors[] = 'الحساب ' . $a['account_code'] . ' — ' . $a['name_ar']
. ' حساب رئيسي، والقيد ما بينزلش على حساب رئيسي. اختار حساب فرعي تحته.';
}
if ((int) $a['is_archived'] === 1 || (int) $a['is_active'] === 0) {
$errors[] = 'الحساب ' . $a['account_code'] . ' — ' . $a['name_ar'] . ' موقوف أو مؤرشف';
}
}
// ── Effective date vs closed periods ────────────────────────
$from = (string) ($opts['effective_from'] ?? date('Y-m-d'));
if (!self::isDate($from)) {
$errors[] = 'تاريخ السريان غير صحيح';
$from = date('Y-m-d');
}
$closed = $db->selectOne(
"SELECT period FROM period_closings
WHERE status = 'closed' AND is_archived = 0 AND ? BETWEEN period_start AND period_end LIMIT 1",
[$from]
);
if ($closed) {
$errors[] = 'تاريخ السريان (' . $from . ') واقع في فترة مقفولة (' . $closed['period'] . ')';
}
if (strtotime($from) < strtotime('-1 year')) {
$warnings[] = 'تاريخ السريان قديم جدًا — تأكد إنه مقصود';
}
// ── Each target, against its own direction ──────────────────
if (!$targets) {
$errors[] = 'ما فيش أي مصدر إيراد مطابق للنطاق اللي اخترته';
}
$seenDirections = [];
foreach ($targets as $t) {
$s = $t['stream'];
foreach ($t['skipped'] as $why) {
$warnings[] = $s['name_ar'] . ': ' . $why;
}
if (!$t['stages']) {
continue;
}
foreach ($t['stages'] as $stage) {
$dir = self::directionFor($s, $stage);
$seenDirections[$dir] = true;
$allowedTypes = self::lineTypesFor($dir);
foreach ($lines as $i => $l) {
$lt = (string) $l['line_type'];
if (!isset($allowedTypes[$lt])) {
$errors[] = $s['name_ar'] . ' (' . (RevenuePostingEngine::STAGE_LABELS[$stage] ?? $stage) . '): '
. 'نوع البند رقم ' . ($i + 1) . ' مش مناسب لحركة '
. ($dir === 'outflow' ? 'صرف' : 'تحصيل');
continue;
}
$a = $accounts[(int) $l['account_id']] ?? null;
if ($a && !\in_array($a['account_type'], $allowedTypes[$lt]['types'], true)) {
$errors[] = 'الحساب ' . $a['account_code'] . ' نوعه "' . $a['account_type']
. '" ومش مناسب لبند "' . $allowedTypes[$lt]['label'] . '"';
}
}
}
}
if (count($seenDirections) > 1) {
$errors[] = 'النطاق ده فيه مصادر تحصيل ومصادر صرف مع بعض — قسّمهم على دفعتين، '
. 'لأن نفس البنود ما تنفعش للاتنين';
}
return [
'errors' => array_values(array_unique($errors)),
'warnings' => array_values(array_unique($warnings)),
];
}
// ────────────────────────────────────────────────────────────
// Apply
// ────────────────────────────────────────────────────────────
/**
* Write the split onto every target. All-or-nothing: one transaction, and a
* failure anywhere rolls the whole batch back so the chart is never left
* half-rewired.
*
* @return array{success:bool, error:?string, applied:array, count:int}
*/
public static function apply(array $lines, array $targets, array $opts, ?int $employeeId): array
{
$db = App::getInstance()->db();
$now = date('Y-m-d H:i:s');
// Never trust the caller. There is no foreign key on account_id, so an
// unvalidated apply() would happily write rules pointing at accounts that
// do not exist, or at header accounts the ledger silently refuses.
$check = self::validate($lines, $targets, $opts);
if ($check['errors']) {
return [
'success' => false,
'error' => implode(' — ', array_slice($check['errors'], 0, 4)),
'applied' => [],
'count' => 0,
];
}
$from = (string) ($opts['effective_from'] ?? date('Y-m-d'));
if (!self::isDate($from)) {
$from = date('Y-m-d');
}
$prevDay = date('Y-m-d', strtotime($from . ' -1 day'));
$branchId = self::nullableInt($opts['branch_id'] ?? null);
$costCenterId = self::nullableInt($opts['cost_center_id'] ?? null);
$taxProfileId = self::nullableInt($opts['tax_profile_id'] ?? null);
$paymentMethod = self::nullableString($opts['payment_method'] ?? null);
$memberCategory = self::nullableString($opts['member_category'] ?? null);
$debitSource = (string) ($opts['debit_source'] ?? 'auto_treasury');
if (!\in_array($debitSource, ['auto_treasury', 'fixed_account', 'accounts_receivable', 'accounts_payable'], true)) {
$debitSource = 'auto_treasury';
}
$debitAccountId = $debitSource === 'auto_treasury' ? null : self::nullableInt($opts['debit_account_id'] ?? null);
$notes = self::nullableString($opts['notes'] ?? null);
$nameAr = self::nullableString($opts['name_ar'] ?? null);
$applied = [];
$db->beginTransaction();
try {
foreach ($targets as $t) {
$stream = $t['stream'];
foreach ($t['stages'] as $stage) {
$streamId = (int) $stream['id'];
$direction = self::directionFor($stream, $stage);
// Version numbers are per stream+stage, read inside the
// transaction so two people saving at once cannot collide.
$maxRow = $db->selectOne(
"SELECT MAX(version) AS v FROM revenue_posting_rules
WHERE stream_id = ? AND stage = ?",
[$streamId, $stage]
);
$version = $maxRow && $maxRow['v'] !== null ? ((int) $maxRow['v']) + 1 : 1;
$ruleId = $db->insert('revenue_posting_rules', [
'stream_id' => $streamId,
'version' => $version,
'stage' => $stage,
'direction' => $direction,
'name_ar' => $nameAr ?: ('إصدار ' . $version),
'branch_id' => $branchId,
'payment_method' => $paymentMethod,
'member_category' => $memberCategory,
'debit_account_id' => $debitAccountId,
'debit_source' => $debitSource,
'tax_profile_id' => $taxProfileId,
'cost_center_id' => $costCenterId,
'status' => 'active',
'effective_from' => $from,
'notes' => $notes,
'created_at' => $now,
'updated_at' => $now,
'created_by' => $employeeId,
'activated_at' => $now,
'activated_by' => $employeeId,
]);
foreach ($lines as $i => $l) {
$db->insert('revenue_posting_rule_lines', [
'rule_id' => $ruleId,
'sort_order' => $i + 1,
'line_type' => $l['line_type'],
'allocation_method' => $l['allocation_method'],
'fixed_amount' => $l['allocation_method'] === 'fixed' ? $l['fixed_amount'] : null,
'percentage' => $l['allocation_method'] === 'percentage' ? $l['percentage'] : null,
'percentage_base' => $l['percentage_base'] ?? 'net_after_fixed',
'account_id' => (int) $l['account_id'],
'cost_center_id' => self::nullableInt($l['cost_center_id'] ?? null),
'recognition_method' => $l['recognition_method'] ?? 'immediate',
'recognition_months' => self::nullableInt($l['recognition_months'] ?? null),
'recognized_account_id' => self::nullableInt($l['recognized_account_id'] ?? null),
'description_ar' => self::nullableString($l['description_ar'] ?? null),
'max_amount' => self::nullableString($l['max_amount'] ?? null),
'is_active' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
}
// Retire whatever this replaces — same scope only, so a
// working-member split never cancels the general rule.
$superseded = $db->select(
"SELECT id FROM revenue_posting_rules
WHERE stream_id = ? AND stage = ? AND status = 'active' AND id <> ?
AND (branch_id <=> ?)
AND (payment_method <=> ?)
AND (member_category <=> ?)",
[$streamId, $stage, $ruleId, $branchId, $paymentMethod, $memberCategory]
);
foreach ($superseded as $old) {
$db->update('revenue_posting_rules', [
'status' => 'superseded',
'effective_to' => $prevDay,
'superseded_by_id' => $ruleId,
'updated_at' => $now,
'updated_by' => $employeeId,
], '`id` = ?', [(int) $old['id']]);
}
$db->update('revenue_streams', ['notes' => null, 'updated_at' => $now], '`id` = ?', [$streamId]);
$applied[] = [
'stream_id' => $streamId,
'stream' => $stream['name_ar'],
'category' => $stream['category'],
'stage' => $stage,
'direction' => $direction,
'rule_id' => $ruleId,
'version' => $version,
'superseded' => count($superseded),
];
}
}
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
return ['success' => false, 'error' => $e->getMessage(), 'applied' => [], 'count' => 0];
}
return ['success' => true, 'error' => null, 'applied' => $applied, 'count' => count($applied)];
}
// ────────────────────────────────────────────────────────────
// Counts for the scope picker
// ────────────────────────────────────────────────────────────
/** Per-category counts, so the picker can say "العضويات (١١ مصدر، ٣ منها غير موصّلة)". */
public static function categorySummary(): array
{
$db = App::getInstance()->db();
$rows = $db->select(
"SELECT s.category,
COUNT(*) AS total,
SUM(CASE WHEN EXISTS (
SELECT 1 FROM revenue_posting_rules r
WHERE r.stream_id = s.id AND r.status = 'active'
AND r.effective_from <= CURDATE()
AND (r.effective_to IS NULL OR r.effective_to >= CURDATE())
) THEN 1 ELSE 0 END) AS mapped
FROM revenue_streams s
WHERE s.is_active = 1
GROUP BY s.category"
);
$out = [];
foreach ($rows as $r) {
$cat = (string) $r['category'];
$total = (int) $r['total'];
$mapped = (int) $r['mapped'];
$out[$cat] = [
'code' => $cat,
'label' => self::CATEGORY_LABELS[$cat] ?? $cat,
'total' => $total,
'mapped' => $mapped,
'unmapped' => $total - $mapped,
'direction' => self::categoryDirection($cat),
'stages' => self::CATEGORY_STAGES[$cat] ?? [],
];
}
uasort($out, static fn(array $a, array $b): int => $b['total'] <=> $a['total']);
return $out;
}
/** The dominant direction of a category, for the line-type picker. */
public static function categoryDirection(string $category): string
{
$db = App::getInstance()->db();
$row = $db->selectOne(
"SELECT default_direction AS d, COUNT(*) AS n FROM revenue_streams
WHERE is_active = 1 AND category = ?
GROUP BY default_direction ORDER BY n DESC LIMIT 1",
[$category]
);
return ($row['d'] ?? 'inflow') === 'outflow' ? 'outflow' : 'inflow';
}
// ────────────────────────────────────────────────────────────
private static function isDate(string $d): bool
{
return (bool) preg_match('/^\d{4}-\d{2}-\d{2}$/', $d) && strtotime($d) !== false;
}
private static function nullableInt(mixed $v): ?int
{
return ($v === null || $v === '' || (int) $v <= 0) ? null : (int) $v;
}
private static function nullableString(mixed $v): ?string
{
$v = $v === null ? '' : trim((string) $v);
return $v === '' ? null : $v;
}
}
...@@ -26,6 +26,10 @@ $stageColors = [ ...@@ -26,6 +26,10 @@ $stageColors = [
</p> </p>
</div> </div>
<div style="display:flex;gap:8px;flex-wrap:wrap;"> <div style="display:flex;gap:8px;flex-wrap:wrap;">
<?php if (can('accounting.revenue_mapping.manage') && !empty($streams)): ?>
<a href="/accounting/revenue-mapping/<?= (int) $streams[0]['id'] ?>/wizard?scope=category&category=<?= e($streams[0]['category']) ?>"
class="btn btn-primary">وزّع على مجموعة</a>
<?php endif; ?>
<a href="/accounting/revenue-mapping/diagnostics" class="btn btn-outline">فحص الحالة</a> <a href="/accounting/revenue-mapping/diagnostics" class="btn btn-outline">فحص الحالة</a>
<a href="/accounting/revenue-mapping/recognition" class="btn btn-outline">الإيراد المؤجل</a> <a href="/accounting/revenue-mapping/recognition" class="btn btn-outline">الإيراد المؤجل</a>
<a href="/accounting/revenue-mapping/tax-profiles" class="btn btn-outline">الملفات الضريبية</a> <a href="/accounting/revenue-mapping/tax-profiles" class="btn btn-outline">الملفات الضريبية</a>
......
<?php $__template->layout('Layout.main'); ?> <?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>معالج توزيع المبلغ<?php $__template->endSection(); ?> <?php $__template->section('title'); ?>معالج توزيع المبالغ<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?> <?php $__template->section('content'); ?>
<div style="margin-bottom:14px;"> <div style="margin-bottom:14px;">
<a href="/accounting/revenue-mapping" style="color:#6B7280;font-size:13px;text-decoration:none;">→ رجوع إلى محرك القيود</a> <a href="/accounting/revenue-mapping" style="color:#6B7280;font-size:13px;text-decoration:none;">→ رجوع إلى محرك القيود</a>
<h2 style="margin:6px 0 4px;">معالج توزيع المبلغ — <?= e($stream['name_ar']) ?></h2> <h2 style="margin:6px 0 4px;">معالج توزيع المبالغ</h2>
<p style="margin:0;color:#6B7280;font-size:13px;"> <p style="margin:0;color:#6B7280;font-size:13px;">
اقتطع جزءًا، شوف الباقي، اقتطع الجزء اللي بعده. اللي يفضل في الآخر بيروح للحساب الأخير. اكتب التقسيمة مرة واحدة، وحدّد هي تحكم أنهي إيرادات. اقتطع جزءًا، شوف الباقي،
اقتطع اللي بعده — واللي يفضل يروح للحساب الأخير.
</p> </p>
</div> </div>
<!-- Scope: which fee, for whom --> <form method="GET" action="/accounting/revenue-mapping/<?= (int) $stream['id'] ?>/wizard" id="scope-form">
<input type="hidden" name="ids" id="scope-ids" value="<?= e(implode(',', $selection)) ?>">
<input type="hidden" name="member_category" id="scope-member-cat" value="<?= e($category) ?>">
<!-- ① SCOPE -->
<div class="card" style="margin-bottom:14px;"> <div class="card" style="margin-bottom:14px;">
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;"><h3 style="margin:0;font-size:14px;">١ — التوزيع ده بيخص مين</h3></div> <div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;font-size:14px;">١ — التقسيمة دي هتحكم إيه</h3>
</div>
<div style="padding:16px 18px;"> <div style="padding:16px 18px;">
<form method="GET" action="/accounting/revenue-mapping/<?= (int) $stream['id'] ?>/wizard"
style="display:grid;grid-template-columns:1fr 1fr auto;gap:14px;align-items:end;"> <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px;">
<?php
$scopeOptions = [
'stream' => ['t' => 'المصدر ده بس', 'd' => e($stream['name_ar'])],
'category' => ['t' => 'كل فئة إيراد', 'd' => 'كل المصادر اللي من نفس النوع'],
'selection'=> ['t' => 'مصادر أختارها', 'd' => 'اختار بإيدك من القايمة'],
'unmapped' => ['t' => 'كل اللي لسه مش موصّل', 'd' => 'أي مصدر ما لوش قاعدة قيد'],
'all' => ['t' => 'كل مصادر الإيراد', 'd' => 'كل حاجة في السيستم'],
];
foreach ($scopeOptions as $k => $o):
$on = $scope === $k;
?>
<label style="border:2px solid <?= $on ? '#2563EB' : '#E5E7EB' ?>;background:<?= $on ? '#EFF6FF' : '#fff' ?>;
border-radius:8px;padding:11px 13px;cursor:pointer;display:block;">
<input type="radio" name="scope" value="<?= e($k) ?>" <?= $on ? 'checked' : '' ?>
onchange="document.getElementById('scope-form').submit();" style="margin-inline-end:6px;">
<strong style="font-size:13px;"><?= e($o['t']) ?></strong>
<div style="font-size:11px;color:#6B7280;margin-top:3px;"><?= $o['d'] ?></div>
</label>
<?php endforeach; ?>
</div>
<!-- Category picker -->
<div id="scope-cat-box" style="margin-top:14px;<?= $scope === 'category' ? '' : 'display:none;' ?>">
<label class="form-label">فئة الإيراد</label>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(215px,1fr));gap:8px;">
<?php foreach ($catSummary as $c):
$on = $scope === 'category' && $scopeCategory === $c['code']; ?>
<label style="border:1px solid <?= $on ? '#2563EB' : '#E5E7EB' ?>;background:<?= $on ? '#EFF6FF' : '#fff' ?>;
border-radius:6px;padding:9px 11px;cursor:pointer;display:flex;gap:8px;align-items:flex-start;">
<input type="radio" name="category" value="<?= e($c['code']) ?>" <?= $on ? 'checked' : '' ?>
onchange="document.getElementById('scope-form').submit();" style="margin-top:2px;">
<span style="flex:1;">
<strong style="font-size:12.5px;"><?= e($c['label']) ?></strong>
<span style="display:block;font-size:11px;color:#6B7280;margin-top:2px;">
<?= (int) $c['total'] ?> مصدر
<?php if ($c['unmapped'] > 0): ?>
· <span style="color:#B45309;"><?= (int) $c['unmapped'] ?> غير موصّل</span>
<?php else: ?>
· <span style="color:#059669;">كلها موصّلة</span>
<?php endif; ?>
<?php if ($c['direction'] === 'outflow'): ?>
· <span style="color:#7C3AED;">صرف</span>
<?php endif; ?>
</span>
</span>
</label>
<?php endforeach; ?>
</div>
</div>
<!-- Hand-picked selection -->
<div id="scope-sel-box" style="margin-top:14px;<?= $scope === 'selection' ? '' : 'display:none;' ?>">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;">
<label class="form-label" style="margin:0;">اختار المصادر</label>
<div style="display:flex;gap:6px;">
<input type="text" id="sel-filter" class="form-input" placeholder="فلتر بالاسم" style="width:180px;padding:4px 8px;font-size:12px;">
<button type="button" class="btn btn-sm btn-ghost" id="sel-none">مسح الكل</button>
</div>
</div>
<div style="max-height:230px;overflow:auto;border:1px solid #E5E7EB;border-radius:6px;padding:8px;">
<?php $lastCat = null; foreach ($allStreams as $st): ?>
<?php if ($lastCat !== $st['category']): $lastCat = $st['category']; ?>
<div class="sel-head" style="font-size:11px;font-weight:700;color:#6B7280;margin:8px 0 4px;padding-top:6px;border-top:1px solid #F3F4F6;">
<?= e(\App\Modules\Accounting\Services\Revenue\AllocationPlanService::CATEGORY_LABELS[$st['category']] ?? $st['category']) ?>
<button type="button" class="sel-cat" data-cat="<?= e($st['category']) ?>"
style="background:none;border:none;color:#1F5FA8;font-size:10.5px;cursor:pointer;text-decoration:underline;">اختار الفئة كلها</button>
</div>
<?php endif; ?>
<label class="sel-row" data-name="<?= e($st['name_ar']) ?>" data-cat="<?= e($st['category']) ?>"
style="display:flex;gap:7px;align-items:center;padding:3px 4px;font-size:12px;cursor:pointer;">
<input type="checkbox" class="sel-cb" value="<?= (int) $st['id'] ?>"
<?= \in_array((int) $st['id'], $selection, true) ? 'checked' : '' ?>>
<span style="flex:1;"><?= e($st['name_ar']) ?></span>
<?php if ((int) $st['is_mapped'] === 1): ?>
<span style="font-size:10px;color:#059669;">موصّل</span>
<?php else: ?>
<span style="font-size:10px;color:#B45309;">غير موصّل</span>
<?php endif; ?>
</label>
<?php endforeach; ?>
</div>
<button type="button" class="btn btn-sm btn-secondary" id="sel-apply" style="margin-top:8px;">حمّل المختار</button>
</div>
<!-- Stage + member category -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:16px;">
<div> <div>
<label class="form-label">المرحلة</label> <label class="form-label">المرحلة</label>
<select name="stage" class="form-select" onchange="this.form.submit()"> <select name="stage" class="form-select" onchange="document.getElementById('scope-form').submit();">
<?php foreach ($stages as $k => $lbl): ?> <?php foreach ($stages as $k => $lbl): ?>
<option value="<?= e($k) ?>" <?= $stage === $k ? 'selected' : '' ?>> <option value="<?= e($k) ?>" <?= $stage === $k ? 'selected' : '' ?>
<?= \in_array($k, $streamStages, true) ? '' : 'data-off="1"' ?>>
<?= e($lbl) ?><?= \in_array($k, $configured, true) ? ' ✓' : '' ?> <?= e($lbl) ?><?= \in_array($k, $configured, true) ? ' ✓' : '' ?>
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
<div class="form-help">المصادر اللي المرحلة دي ما تنطبقش عليها هتتخطى تلقائيًا.</div>
</div> </div>
<div> <div>
<label class="form-label">فئة العضو</label> <label class="form-label">فئة العضو</label>
<select name="member_category" class="form-select" onchange="this.form.submit()"> <select class="form-select" id="member-cat-sel"
onchange="document.getElementById('scope-member-cat').value=this.value;document.getElementById('scope-form').submit();">
<option value="">كل الأعضاء — قاعدة عامة</option> <option value="">كل الأعضاء — قاعدة عامة</option>
<?php foreach ($categories as $code => $lbl): ?> <?php foreach ($categories as $code => $lbl): ?>
<option value="<?= e($code) ?>" <?= $category === $code ? 'selected' : '' ?>><?= e($lbl) ?></option> <option value="<?= e($code) ?>" <?= $category === $code ? 'selected' : '' ?>><?= e($lbl) ?></option>
...@@ -38,32 +134,83 @@ ...@@ -38,32 +134,83 @@
</select> </select>
<div class="form-help">قاعدة لفئة معيّنة بتغلب القاعدة العامة تلقائيًا.</div> <div class="form-help">قاعدة لفئة معيّنة بتغلب القاعدة العامة تلقائيًا.</div>
</div> </div>
<div><button type="submit" class="btn btn-outline">تحميل</button></div> </div>
</form>
<?php if ($category !== ''): ?> <!-- What this will hit -->
<div style="margin-top:12px;background:#EFF6FF;border:1px solid #BFDBFE;border-radius:6px;padding:10px 12px;font-size:12.5px;color:#1E40AF;"> <div style="margin-top:14px;border-radius:6px;padding:11px 13px;
بتعدّل التوزيع الخاص بـ <strong><?= e($categories[$category] ?? $category) ?></strong> فقط. background:<?= $direction === 'outflow' ? '#F5F3FF' : '#EFF6FF' ?>;
باقي الفئات هتفضل على القاعدة العامة. border:1px solid <?= $direction === 'outflow' ? '#DDD6FE' : '#BFDBFE' ?>;">
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;">
<div style="font-size:13px;color:<?= $direction === 'outflow' ? '#5B21B6' : '#1E40AF' ?>;">
هتتطبّق على <strong id="tgt-count"><?= (int) $targetCount ?></strong> قاعدة قيد
<?php if ($direction === 'outflow'): ?>
— حركة <strong>صرف</strong>، فالبنود هتكون مصروفات وأصول
<?php else: ?>
— حركة <strong>تحصيل</strong>
<?php endif; ?>
<?php if ($category !== ''): ?>
· على <strong><?= e($categories[$category] ?? $category) ?></strong> بس
<?php endif; ?>
</div>
<button type="button" class="btn btn-sm btn-ghost" id="tgt-toggle">اعرض القايمة</button>
</div>
<?php if ($scope === 'stream'): ?>
<div style="margin-top:9px;padding-top:9px;border-top:1px dashed rgba(0,0,0,.12);font-size:12px;">
اتجاه الحركة لـ<strong> <?= e($stream['name_ar']) ?></strong>:
<?= $stream['default_direction'] === 'outflow' ? 'صرف (البنود مدينة)' : 'تحصيل (البنود دائنة)' ?>
<button type="button" id="dir-toggle"
style="background:none;border:none;padding:0;margin-inline-start:6px;cursor:pointer;
color:#1F5FA8;text-decoration:underline;font-size:12px;">
غيّره لـ <?= $stream['default_direction'] === 'outflow' ? 'تحصيل' : 'صرف' ?>
</button>
</div>
<?php endif; ?>
<div id="tgt-list" style="display:none;margin-top:10px;max-height:200px;overflow:auto;
background:#fff;border-radius:5px;border:1px solid #E5E7EB;">
<table style="width:100%;border-collapse:collapse;font-size:11.5px;">
<?php foreach ($targets as $t): ?>
<?php foreach ($t['stages'] as $st): ?>
<tr>
<td style="padding:5px 9px;border-bottom:1px solid #F3F4F6;"><?= e($t['stream']['name_ar']) ?></td>
<td style="padding:5px 9px;border-bottom:1px solid #F3F4F6;color:#6B7280;">
<?= e(\App\Modules\Accounting\Services\Revenue\AllocationPlanService::CATEGORY_LABELS[$t['stream']['category']] ?? '') ?>
</td>
<td style="padding:5px 9px;border-bottom:1px solid #F3F4F6;color:#2563EB;"><?= e($stages[$st] ?? $st) ?></td>
</tr>
<?php endforeach; ?>
<?php foreach ($t['skipped'] as $why): ?>
<tr style="background:#FFFBEB;">
<td style="padding:5px 9px;border-bottom:1px solid #F3F4F6;"><?= e($t['stream']['name_ar']) ?></td>
<td colspan="2" style="padding:5px 9px;border-bottom:1px solid #F3F4F6;color:#92400E;">
اتخطّى — <?= e($why) ?>
</td>
</tr>
<?php endforeach; ?>
<?php endforeach; ?>
</table>
</div> </div>
<?php endif; ?> </div>
</div> </div>
</div> </div>
</form>
<form method="POST" action="/accounting/revenue-mapping/<?= (int) $stream['id'] ?>" id="wz-form"> <form method="POST" action="/accounting/revenue-mapping/apply" id="wz-form">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="lines" id="lines-payload"> <input type="hidden" name="lines" id="lines-payload">
<input type="hidden" name="stream_id" value="<?= (int) $stream['id'] ?>">
<input type="hidden" name="scope" value="<?= e($scope) ?>">
<input type="hidden" name="category" value="<?= e($scopeCategory) ?>">
<input type="hidden" name="ids" value="<?= e(implode(',', $selection)) ?>">
<input type="hidden" name="stage" value="<?= e($stage) ?>"> <input type="hidden" name="stage" value="<?= e($stage) ?>">
<input type="hidden" name="member_category" value="<?= e($category) ?>"> <input type="hidden" name="member_category" value="<?= e($category) ?>">
<input type="hidden" name="return_to" value="wizard">
<input type="hidden" name="direction" value="<?= e($rule['direction'] ?? 'inflow') ?>">
<input type="hidden" name="debit_source" value="<?= e($rule['debit_source'] ?? 'auto_treasury') ?>">
<input type="hidden" name="effective_from" id="wz-effective" value="<?= e(date('Y-m-d')) ?>"> <input type="hidden" name="effective_from" id="wz-effective" value="<?= e(date('Y-m-d')) ?>">
<input type="hidden" name="debit_source" value="auto_treasury">
<div style="display:grid;grid-template-columns:minmax(0,1.5fr) minmax(0,1fr);gap:16px;align-items:start;"> <div style="display:grid;grid-template-columns:minmax(0,1.5fr) minmax(0,1fr);gap:16px;align-items:start;">
<div> <div>
<!-- The amount being carved up --> <!-- ② AMOUNT -->
<div class="card" style="margin-bottom:14px;"> <div class="card" style="margin-bottom:14px;">
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;"><h3 style="margin:0;font-size:14px;">٢ — المبلغ اللي هنوزّعه</h3></div> <div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;"><h3 style="margin:0;font-size:14px;">٢ — المبلغ اللي هنوزّعه</h3></div>
<div style="padding:16px 18px;"> <div style="padding:16px 18px;">
...@@ -71,9 +218,8 @@ ...@@ -71,9 +218,8 @@
<div> <div>
<label class="form-label">مبلغ مرجعي للحساب</label> <label class="form-label">مبلغ مرجعي للحساب</label>
<input type="number" id="wz-base" class="form-input" step="0.01" min="0" <input type="number" id="wz-base" class="form-input" step="0.01" min="0"
value="<?= e($suggested) ?>" dir="ltr" value="<?= e($suggested) ?>" dir="ltr" style="text-align:right;font-size:20px;font-weight:700;">
style="text-align:right;font-size:20px;font-weight:700;"> <div class="form-help">للتوضيح بس — النسب بتتطبّق على أي مبلغ فعلي.</div>
<div class="form-help">للتوضيح فقط — النسب بتتطبّق على أي مبلغ فعلي.</div>
</div> </div>
<div> <div>
<label class="form-label">المعالجة الضريبية</label> <label class="form-label">المعالجة الضريبية</label>
...@@ -91,31 +237,47 @@ ...@@ -91,31 +237,47 @@
<div class="form-help">الضريبة بتتفصل الأول، والتوزيع بيتم على الصافي.</div> <div class="form-help">الضريبة بتتفصل الأول، والتوزيع بيتم على الصافي.</div>
</div> </div>
</div> </div>
<?php if (!empty($costCenters)): ?> <div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:14px;">
<div style="margin-top:14px;max-width:340px;"> <?php if (!empty($costCenters)): ?>
<label class="form-label">مركز التكلفة (اختياري)</label> <div>
<select name="cost_center_id" class="form-select"> <label class="form-label">مركز التكلفة (اختياري)</label>
<option value="">بدون</option> <select name="cost_center_id" class="form-select">
<?php foreach ($costCenters as $cc): ?> <option value="">بدون</option>
<option value="<?= (int) $cc['id'] ?>" <?php foreach ($costCenters as $cc): ?>
<?= ($rule && (int) ($rule['cost_center_id'] ?? 0) === (int) $cc['id']) ? 'selected' : '' ?>> <option value="<?= (int) $cc['id'] ?>"
<?= e($cc['code']) ?><?= e($cc['name_ar']) ?> <?= ($rule && (int) ($rule['cost_center_id'] ?? 0) === (int) $cc['id']) ? 'selected' : '' ?>>
</option> <?= e($cc['code']) ?><?= e($cc['name_ar']) ?>
<?php endforeach; ?> </option>
</select> <?php endforeach; ?>
</select>
</div>
<?php endif; ?>
<?php if (!empty($branches)): ?>
<div>
<label class="form-label">الفرع (اختياري)</label>
<select name="branch_id" class="form-select">
<option value="">كل الفروع</option>
<?php foreach ($branches as $b): ?>
<option value="<?= (int) $b['id'] ?>"
<?= ($rule && (int) ($rule['branch_id'] ?? 0) === (int) $b['id']) ? 'selected' : '' ?>>
<?= e($b['name_ar']) ?>
</option>
<?php endforeach; ?>
</select>
<div class="form-help">قاعدة لفرع معيّن بتغلب القاعدة العامة.</div>
</div>
<?php endif; ?>
</div> </div>
<?php endif; ?>
</div> </div>
</div> </div>
<!-- The running allocation --> <!-- ③ THE SPLIT -->
<div class="card" style="margin-bottom:14px;"> <div class="card" style="margin-bottom:14px;">
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;"> <div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;">
<h3 style="margin:0;font-size:14px;">٣ — اقتطع الأجزاء</h3> <h3 style="margin:0;font-size:14px;">٣ — اقتطع الأجزاء</h3>
<button type="button" id="wz-add" class="btn btn-sm btn-secondary">+ اقتطع جزء</button> <button type="button" id="wz-add" class="btn btn-sm btn-secondary">+ اقتطع جزء</button>
</div> </div>
<!-- Remaining, always visible -->
<div style="padding:16px 18px;background:#F8FAFC;border-bottom:1px solid #E5E7EB;"> <div style="padding:16px 18px;background:#F8FAFC;border-bottom:1px solid #E5E7EB;">
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:10px;flex-wrap:wrap;gap:8px;"> <div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:10px;flex-wrap:wrap;gap:8px;">
<span style="font-size:13px;color:#6B7280;">الباقي غير الموزَّع</span> <span style="font-size:13px;color:#6B7280;">الباقي غير الموزَّع</span>
...@@ -127,24 +289,23 @@ ...@@ -127,24 +289,23 @@
<div id="wz-lines" style="padding:14px 18px;"></div> <div id="wz-lines" style="padding:14px 18px;"></div>
<!-- The remainder destination -->
<div style="padding:14px 18px;border-top:2px solid #E5E7EB;background:#ECFDF5;"> <div style="padding:14px 18px;border-top:2px solid #E5E7EB;background:#ECFDF5;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
<strong style="font-size:13px;color:#065F46;">٤ — الباقي بعد كل الاقتطاعات يروح لـ</strong> <strong style="font-size:13px;color:#065F46;">٤ — الباقي بعد كل الاقتطاعات يروح لـ</strong>
<strong id="wz-rem-amount" style="font-size:16px;color:#065F46;font-variant-numeric:tabular-nums;">0.00</strong> <strong id="wz-rem-amount" style="font-size:16px;color:#065F46;font-variant-numeric:tabular-nums;">0.00</strong>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 190px;gap:10px;"> <div style="display:grid;grid-template-columns:1fr 210px;gap:10px;">
<div> <div style="position:relative;">
<input type="text" class="form-input acct-search" id="wz-rem-search" <input type="text" class="form-input acct-search" id="wz-rem-search"
placeholder="ابحث عن الحساب النهائي" autocomplete="off"> placeholder="ابحث عن الحساب النهائي بالكود أو الاسم" autocomplete="off">
<input type="hidden" id="wz-rem-acct"> <input type="hidden" id="wz-rem-acct">
<div class="acct-results" id="wz-rem-results"></div> <div class="acct-results" id="wz-rem-results"></div>
</div> </div>
<div> <div>
<select class="form-select" id="wz-rem-type"> <select class="form-select" id="wz-rem-type">
<option value="revenue">إيراد</option> <?php foreach ($lineTypes as $k => $v): ?>
<option value="deferred_revenue">إيراد مؤجل</option> <option value="<?= e($k) ?>"><?= e($v['label']) ?></option>
<option value="passthrough">تحصيل لحساب الغير</option> <?php endforeach; ?>
</select> </select>
</div> </div>
</div> </div>
...@@ -160,7 +321,7 @@ ...@@ -160,7 +321,7 @@
<label class="form-label">ساري اعتبارًا من</label> <label class="form-label">ساري اعتبارًا من</label>
<input type="date" class="form-input" value="<?= e(date('Y-m-d')) ?>" <input type="date" class="form-input" value="<?= e(date('Y-m-d')) ?>"
onchange="document.getElementById('wz-effective').value=this.value;"> onchange="document.getElementById('wz-effective').value=this.value;">
<div class="form-help">القيود المرحّلة قبل التاريخ ده ما بتتغيرش.</div> <div class="form-help">القيود المرحّلة قبل التاريخ ده ما بتتغيّرش.</div>
</div> </div>
<div> <div>
<label class="form-label">سبب التغيير</label> <label class="form-label">سبب التغيير</label>
...@@ -169,15 +330,17 @@ ...@@ -169,15 +330,17 @@
</div> </div>
</div> </div>
<div style="display:flex;gap:8px;flex-wrap:wrap;"> <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<button type="submit" class="btn btn-primary btn-lg" id="wz-save">حفظ وتفعيل التوزيع</button> <button type="submit" class="btn btn-primary btn-lg" id="wz-save">
حفظ وتفعيل على <span id="btn-count"><?= (int) $targetCount ?></span> قاعدة
</button>
<a href="/accounting/revenue-mapping/<?= (int) $stream['id'] ?>/edit?stage=<?= e($stage) ?>" class="btn btn-outline">الوضع المتقدّم</a> <a href="/accounting/revenue-mapping/<?= (int) $stream['id'] ?>/edit?stage=<?= e($stage) ?>" class="btn btn-outline">الوضع المتقدّم</a>
<a href="/accounting/revenue-mapping" class="btn btn-ghost">إلغاء</a> <a href="/accounting/revenue-mapping" class="btn btn-ghost">إلغاء</a>
</div> </div>
<div id="wz-error" style="display:none;margin-top:10px;background:#FEF2F2;border:1px solid #FECACA;border-radius:6px;padding:10px;color:#991B1B;font-size:12.5px;"></div> <div id="wz-error" style="display:none;margin-top:10px;background:#FEF2F2;border:1px solid #FECACA;border-radius:6px;padding:10px;color:#991B1B;font-size:12.5px;"></div>
</div> </div>
<!-- Resulting entry --> <!-- PREVIEW -->
<div style="position:sticky;top:14px;"> <div style="position:sticky;top:14px;">
<div class="card"> <div class="card">
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;"><h3 style="margin:0;font-size:14px;">القيد الناتج</h3></div> <div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;"><h3 style="margin:0;font-size:14px;">القيد الناتج</h3></div>
...@@ -198,7 +361,7 @@ ...@@ -198,7 +361,7 @@
<button type="button" class="btn btn-sm btn-ghost wz-del" style="color:#DC2626;">حذف</button> <button type="button" class="btn btn-sm btn-ghost wz-del" style="color:#DC2626;">حذف</button>
</div> </div>
</div> </div>
<div style="display:grid;grid-template-columns:130px 120px 1fr;gap:10px;align-items:end;"> <div style="display:grid;grid-template-columns:130px 130px 1fr;gap:10px;align-items:end;">
<div> <div>
<label class="form-label" style="font-size:11px;">الطريقة</label> <label class="form-label" style="font-size:11px;">الطريقة</label>
<select class="form-select wz-method"> <select class="form-select wz-method">
...@@ -210,23 +373,23 @@ ...@@ -210,23 +373,23 @@
<label class="form-label" style="font-size:11px;"><span class="wz-vlabel">نسبة % من الصافي</span></label> <label class="form-label" style="font-size:11px;"><span class="wz-vlabel">نسبة % من الصافي</span></label>
<input type="number" class="form-input wz-value" step="0.01" min="0" dir="ltr" style="text-align:right;"> <input type="number" class="form-input wz-value" step="0.01" min="0" dir="ltr" style="text-align:right;">
</div> </div>
<div> <div style="position:relative;">
<label class="form-label" style="font-size:11px;display:flex;justify-content:space-between;"> <label class="form-label" style="font-size:11px;display:flex;justify-content:space-between;">
<span>يروح لحساب</span> <span>يروح لحساب</span>
<button type="button" class="wz-new" style="background:none;border:none;padding:0;cursor:pointer;color:#1F5FA8;font-size:11px;text-decoration:underline;">+ حساب جديد</button> <button type="button" class="wz-new" style="background:none;border:none;padding:0;cursor:pointer;color:#1F5FA8;font-size:11px;text-decoration:underline;">+ حساب جديد</button>
</label> </label>
<input type="text" class="form-input acct-search wz-search" placeholder="ابحث بالكود أو الاسم"> <input type="text" class="form-input acct-search wz-search" placeholder="ابحث بالكود أو الاسم" autocomplete="off">
<input type="hidden" class="wz-acct"> <input type="hidden" class="wz-acct">
<div class="acct-results"></div> <div class="acct-results"></div>
</div> </div>
</div> </div>
<div style="display:grid;grid-template-columns:200px 1fr;gap:10px;margin-top:10px;"> <div style="display:grid;grid-template-columns:220px 1fr;gap:10px;margin-top:10px;">
<div> <div>
<label class="form-label" style="font-size:11px;">نوع البند</label> <label class="form-label" style="font-size:11px;">نوع البند</label>
<select class="form-select wz-type"> <select class="form-select wz-type">
<option value="revenue">إيراد</option> <?php foreach ($lineTypes as $k => $v): ?>
<option value="passthrough">تحصيل لحساب الغير (التزام)</option> <option value="<?= e($k) ?>"><?= e($v['label']) ?></option>
<option value="deferred_revenue">إيراد مؤجل</option> <?php endforeach; ?>
</select> </select>
</div> </div>
<div> <div>
...@@ -234,11 +397,24 @@ ...@@ -234,11 +397,24 @@
<input type="text" class="form-input wz-desc" placeholder="اختياري"> <input type="text" class="form-input wz-desc" placeholder="اختياري">
</div> </div>
</div> </div>
<div class="wz-defer" style="display:none;margin-top:10px;padding:10px;background:#FFFBEB;border:1px solid #FDE68A;border-radius:6px;">
<div style="display:grid;grid-template-columns:150px 1fr;gap:10px;align-items:end;">
<div>
<label class="form-label" style="font-size:11px;">يترحّل على كام شهر</label>
<input type="number" class="form-input wz-months" min="1" max="120" value="12" dir="ltr" style="text-align:right;">
</div>
<div style="position:relative;">
<label class="form-label" style="font-size:11px;">حساب الإيراد اللي يترحّل له</label>
<input type="text" class="form-input acct-search wz-rec-search" placeholder="حساب الإيراد" autocomplete="off">
<input type="hidden" class="wz-rec-acct">
<div class="acct-results"></div>
</div>
</div>
</div>
<div class="wz-after" style="margin-top:8px;font-size:11.5px;color:#6B7280;"></div> <div class="wz-after" style="margin-top:8px;font-size:11.5px;color:#6B7280;"></div>
</div> </div>
</template> </template>
<!-- reuse the create-account modal shape -->
<div id="acct-modal" style="display:none;position:fixed;inset:0;background:rgba(15,23,42,.55);z-index:900;align-items:center;justify-content:center;padding:16px;"> <div id="acct-modal" style="display:none;position:fixed;inset:0;background:rgba(15,23,42,.55);z-index:900;align-items:center;justify-content:center;padding:16px;">
<div class="card" style="max-width:520px;width:100%;"> <div class="card" style="max-width:520px;width:100%;">
<div style="padding:14px 18px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;"> <div style="padding:14px 18px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;">
...@@ -267,22 +443,30 @@ ...@@ -267,22 +443,30 @@
(function () { (function () {
'use strict'; 'use strict';
var box = document.getElementById('wz-lines'); var box = document.getElementById('wz-lines');
var tpl = document.getElementById('wz-tpl'); var tpl = document.getElementById('wz-tpl');
var baseEl = document.getElementById('wz-base'); var baseEl = document.getElementById('wz-base');
var taxEl = document.getElementById('wz-tax'); var taxEl = document.getElementById('wz-tax');
var csrf = document.querySelector('input[name="_csrf_token"]'); var csrf = document.querySelector('input[name="_csrf_token"]');
var COLORS = ['#2563EB','#7C3AED','#D97706','#0891B2','#DB2777','#65A30D','#DC2626']; var COLORS = ['#2563EB','#7C3AED','#D97706','#0891B2','#DB2777','#65A30D','#DC2626','#0F766E'];
var DIRECTION = <?= json_encode($direction) ?>;
// Line types that must name a liability the deferral later releases.
var DEFERRABLE = ['deferred_revenue'];
var existing = <?= json_encode(array_map(static function (array $l): array { var existing = <?= json_encode(array_map(static function (array $l): array {
$raw = $l['allocation_method'] === 'percentage' ? $l['percentage'] : $l['fixed_amount']; $raw = $l['allocation_method'] === 'percentage' ? $l['percentage'] : $l['fixed_amount'];
return [ return [
'method' => $l['allocation_method'], 'method' => $l['allocation_method'],
'value' => $raw === null ? '' : (string) (float) $raw, // 20.0000 → 20 'value' => $raw === null ? '' : (string) (float) $raw,
'account' => (int) $l['account_id'], 'account' => (int) $l['account_id'],
'label' => $l['account_code'] . ' — ' . $l['account_name'], 'label' => $l['account_code'] . ' — ' . $l['account_name'],
'type' => $l['line_type'], 'type' => $l['line_type'],
'desc' => $l['description_ar'], 'desc' => $l['description_ar'],
'months' => $l['recognition_months'],
'recAcct' => (int) ($l['recognized_account_id'] ?? 0),
'recLbl' => $l['recognized_code'] ? ($l['recognized_code'] . ' — ' . $l['recognized_name']) : '',
'defer' => $l['recognition_method'] === 'straight_line',
]; ];
}, $lines), JSON_UNESCAPED_UNICODE) ?>; }, $lines), JSON_UNESCAPED_UNICODE) ?>;
...@@ -291,38 +475,101 @@ ...@@ -291,38 +475,101 @@
} }
function r2(n) { return Math.round((Number(n) + Number.EPSILON) * 100) / 100; } function r2(n) { return Math.round((Number(n) + Number.EPSILON) * 100) / 100; }
// ── Scope panel ─────────────────────────────────────────────
(function scopePanel() {
var t = document.getElementById('tgt-toggle'), l = document.getElementById('tgt-list');
if (t) t.addEventListener('click', function () {
var on = l.style.display === 'none';
l.style.display = on ? 'block' : 'none';
t.textContent = on ? 'إخفاء القايمة' : 'اعرض القايمة';
});
var filter = document.getElementById('sel-filter');
if (filter) filter.addEventListener('input', function () {
var q = filter.value.trim();
document.querySelectorAll('.sel-row').forEach(function (r) {
r.style.display = (!q || r.dataset.name.indexOf(q) !== -1) ? 'flex' : 'none';
});
});
document.querySelectorAll('.sel-cat').forEach(function (b) {
b.addEventListener('click', function () {
document.querySelectorAll('.sel-row[data-cat="' + b.dataset.cat + '"] .sel-cb')
.forEach(function (cb) { cb.checked = true; });
});
});
var none = document.getElementById('sel-none');
if (none) none.addEventListener('click', function () {
document.querySelectorAll('.sel-cb').forEach(function (cb) { cb.checked = false; });
});
var dir = document.getElementById('dir-toggle');
if (dir) dir.addEventListener('click', function () {
if (!confirm('تغيير الاتجاه بيغيّر أنواع البنود المتاحة. القواعد القديمة والقيود المرحّلة ما بتتغيّرش. تمام؟')) return;
var f = document.createElement('form');
f.method = 'POST';
f.action = '/accounting/revenue-mapping/<?= (int) $stream['id'] ?>/direction';
f.innerHTML = '<input name="_csrf_token" value="' + (csrf ? csrf.value : '') + '">'
+ '<input name="direction" value="<?= $stream['default_direction'] === 'outflow' ? 'inflow' : 'outflow' ?>">'
+ '<input name="stage" value="<?= e($stage) ?>">';
document.body.appendChild(f);
f.submit();
});
var apply = document.getElementById('sel-apply');
if (apply) apply.addEventListener('click', function () {
var ids = [];
document.querySelectorAll('.sel-cb:checked').forEach(function (cb) { ids.push(cb.value); });
if (!ids.length) { alert('اختار مصدر واحد على الأقل'); return; }
document.getElementById('scope-ids').value = ids.join(',');
document.getElementById('scope-form').submit();
});
})();
// ── Account search ────────────────────────────────────────── // ── Account search ──────────────────────────────────────────
function wireSearch(input, hidden, results) { function wireSearch(input, hidden, results, typeFilter) {
var timer = null; var timer = null;
function close() { results.innerHTML = ''; }
input.addEventListener('blur', function () { setTimeout(close, 180); });
input.addEventListener('input', function () { input.addEventListener('input', function () {
clearTimeout(timer); clearTimeout(timer);
hidden.value = ''; // typing invalidates the previous pick
var q = input.value.trim(); var q = input.value.trim();
if (q.length < 2) { results.innerHTML = ''; return; } if (q.length < 2) { close(); return; }
timer = setTimeout(function () { timer = setTimeout(function () {
fetch('/accounting/revenue-mapping/search-accounts?q=' + encodeURIComponent(q)) var url = '/accounting/revenue-mapping/search-accounts?q=' + encodeURIComponent(q);
.then(function (r) { return r.json(); }) var t = typeFilter ? typeFilter() : '';
.then(function (d) { if (t) url += '&type=' + encodeURIComponent(t);
results.innerHTML = ''; fetch(url).then(function (r) { return r.json(); }).then(function (d) {
var b = document.createElement('div'); results.innerHTML = '';
b.style.cssText = 'border:1px solid #E5E7EB;border-radius:6px;margin-top:4px;max-height:190px;overflow:auto;background:#fff;position:relative;z-index:20;'; var b = document.createElement('div');
(d.accounts || []).forEach(function (a) { b.style.cssText = 'border:1px solid #E5E7EB;border-radius:6px;margin-top:4px;max-height:200px;'
var row = document.createElement('div'); + 'overflow:auto;background:#fff;position:absolute;z-index:40;width:100%;'
row.style.cssText = 'padding:6px 10px;cursor:pointer;font-size:12px;border-bottom:1px solid #F3F4F6;'; + 'box-shadow:0 6px 18px rgba(15,23,42,.12);';
row.innerHTML = '<span style="direction:ltr;display:inline-block;color:#6B7280;min-width:70px;">' + a.account_code + '</span> ' + a.name_ar; if (!(d.accounts || []).length) {
row.addEventListener('click', function () { b.innerHTML = '<div style="padding:8px 10px;font-size:12px;color:#6B7280;">مفيش حساب مطابق</div>';
hidden.value = a.id; }
input.value = a.account_code + ' — ' + a.name_ar; (d.accounts || []).forEach(function (a) {
results.innerHTML = ''; var row = document.createElement('div');
recalc(); row.style.cssText = 'padding:6px 10px;cursor:pointer;font-size:12px;border-bottom:1px solid #F3F4F6;';
}); row.innerHTML = '<span style="direction:ltr;display:inline-block;color:#6B7280;min-width:74px;">'
b.appendChild(row); + a.account_code + '</span> ' + a.name_ar
+ '<span style="float:left;color:#9CA3AF;font-size:10px;">' + a.account_type + '</span>';
row.addEventListener('mousedown', function (e) {
e.preventDefault();
hidden.value = a.id;
input.value = a.account_code + ' — ' + a.name_ar;
close();
recalc();
}); });
results.appendChild(b); b.appendChild(row);
}); });
results.appendChild(b);
});
}, 220); }, 220);
}); });
} }
wireSearch(document.getElementById('wz-rem-search'), document.getElementById('wz-rem-acct'), document.getElementById('wz-rem-results')); wireSearch(document.getElementById('wz-rem-search'),
document.getElementById('wz-rem-acct'),
document.getElementById('wz-rem-results'));
// ── Lines ─────────────────────────────────────────────────── // ── Lines ───────────────────────────────────────────────────
function addLine(data) { function addLine(data) {
...@@ -332,16 +579,24 @@ ...@@ -332,16 +579,24 @@
var method = el.querySelector('.wz-method'); var method = el.querySelector('.wz-method');
var vlabel = el.querySelector('.wz-vlabel'); var vlabel = el.querySelector('.wz-vlabel');
var type = el.querySelector('.wz-type');
var defer = el.querySelector('.wz-defer');
wireSearch(el.querySelector('.wz-search'), el.querySelector('.wz-acct'), el.querySelector('.acct-results')); wireSearch(el.querySelector('.wz-search'), el.querySelector('.wz-acct'), el.querySelector('.acct-results'));
wireSearch(el.querySelector('.wz-rec-search'), el.querySelector('.wz-rec-acct'),
el.querySelectorAll('.acct-results')[1], function () { return 'revenue'; });
function syncDefer() {
defer.style.display = DEFERRABLE.indexOf(type.value) !== -1 ? 'block' : 'none';
}
method.addEventListener('change', function () { method.addEventListener('change', function () {
vlabel.textContent = method.value === 'percentage' ? 'نسبة % من الصافي' : 'مبلغ ثابت'; vlabel.textContent = method.value === 'percentage' ? 'نسبة % من الصافي' : 'مبلغ ثابت';
recalc(); recalc();
}); });
type.addEventListener('change', function () { syncDefer(); recalc(); });
el.querySelector('.wz-value').addEventListener('input', recalc); el.querySelector('.wz-value').addEventListener('input', recalc);
el.querySelector('.wz-type').addEventListener('change', recalc);
el.querySelector('.wz-desc').addEventListener('input', recalc); el.querySelector('.wz-desc').addEventListener('input', recalc);
el.querySelector('.wz-months').addEventListener('input', recalc);
el.querySelector('.wz-del').addEventListener('click', function () { el.remove(); recalc(); }); el.querySelector('.wz-del').addEventListener('click', function () { el.remove(); recalc(); });
el.querySelector('.wz-up').addEventListener('click', function () { el.querySelector('.wz-up').addEventListener('click', function () {
if (el.previousElementSibling) { box.insertBefore(el, el.previousElementSibling); recalc(); } if (el.previousElementSibling) { box.insertBefore(el, el.previousElementSibling); recalc(); }
...@@ -359,20 +614,28 @@ ...@@ -359,20 +614,28 @@
el.querySelector('.wz-value').value = data.value || ''; el.querySelector('.wz-value').value = data.value || '';
el.querySelector('.wz-acct').value = data.account || ''; el.querySelector('.wz-acct').value = data.account || '';
el.querySelector('.wz-search').value = data.label || ''; el.querySelector('.wz-search').value = data.label || '';
el.querySelector('.wz-type').value = data.type || 'revenue'; // A saved line type from the other direction would not exist in this
// picker; leave the default rather than silently selecting nothing.
if (data.type && type.querySelector('option[value="' + data.type + '"]')) type.value = data.type;
el.querySelector('.wz-desc').value = data.desc || ''; el.querySelector('.wz-desc').value = data.desc || '';
if (data.defer) {
el.querySelector('.wz-months').value = data.months || 12;
el.querySelector('.wz-rec-acct').value = data.recAcct || '';
el.querySelector('.wz-rec-search').value = data.recLbl || '';
}
} }
syncDefer();
recalc(); recalc();
} }
// ── The heart: running remainder ──────────────────────────── // ── The heart: running remainder ────────────────────────────
// This MUST mirror RevenueAllocator exactly, or the preview lies about what // Mirrors RevenueAllocator exactly: tax off the top, fixed lines from the
// will post: tax off the top, then fixed lines, then percentages of the net // pool, percentages of net-AFTER-fixed. A percentage is always "of the
// AFTER fixed — a percentage is always "of the amount", never "of the rest". // amount", never "of what is left" — which is what an accountant means.
function recalc() { function recalc() {
var gross = Number(baseEl.value || 0); var gross = Number(baseEl.value || 0);
var taxOpt = taxEl.options[taxEl.selectedIndex]; var taxOpt = taxEl.options[taxEl.selectedIndex];
var taxRate = Number((taxOpt && taxOpt.dataset.rate) || 0) / 100; var taxRate = Number((taxOpt && taxOpt.dataset.rate) || 0) / 100;
var taxIncl = !taxOpt || taxOpt.dataset.inclusive !== '0'; var taxIncl = !taxOpt || taxOpt.dataset.inclusive !== '0';
var tax = 0, net = gross; var tax = 0, net = gross;
...@@ -392,11 +655,13 @@ ...@@ -392,11 +655,13 @@
color: COLORS[i % COLORS.length], color: COLORS[i % COLORS.length],
type: el.querySelector('.wz-type').value, type: el.querySelector('.wz-type').value,
account: el.querySelector('.wz-acct').value, account: el.querySelector('.wz-acct').value,
desc: el.querySelector('.wz-desc').value desc: el.querySelector('.wz-desc').value,
months: el.querySelector('.wz-months').value,
recAcct: el.querySelector('.wz-rec-acct').value
}; };
}); });
var pool = net, fixedTotal = 0, overrun = false; var pool = net, fixedTotal = 0, overrun = false, pctTotal = 0;
seg.forEach(function (s) { seg.forEach(function (s) {
if (s.method !== 'fixed') return; if (s.method !== 'fixed') return;
...@@ -409,12 +674,12 @@ ...@@ -409,12 +674,12 @@
seg.forEach(function (s) { seg.forEach(function (s) {
if (s.method !== 'percentage') return; if (s.method !== 'percentage') return;
pctTotal = r2(pctTotal + s.raw);
var a = r2(netAfterFixed * (s.raw / 100)); var a = r2(netAfterFixed * (s.raw / 100));
if (a > pool) { a = Math.max(0, r2(pool)); overrun = true; } if (a > pool) { a = Math.max(0, r2(pool)); overrun = true; }
s.amount = a; pool = r2(pool - a); s.amount = a; pool = r2(pool - a);
}); });
// Running strip in the order the user sees them.
var remaining = net; var remaining = net;
seg.forEach(function (s, i) { seg.forEach(function (s, i) {
var before = remaining; var before = remaining;
...@@ -429,28 +694,24 @@ ...@@ -429,28 +694,24 @@
+ '</strong> · يفضل <strong style="color:#059669;">' + fmt(remaining) + '</strong>'; + '</strong> · يفضل <strong style="color:#059669;">' + fmt(remaining) + '</strong>';
}); });
var segments = seg;
document.getElementById('wz-remaining').textContent = fmt(remaining); document.getElementById('wz-remaining').textContent = fmt(remaining);
document.getElementById('wz-remaining').style.color = remaining < 0 ? '#DC2626' : '#059669'; document.getElementById('wz-remaining').style.color = remaining < 0 ? '#DC2626' : '#059669';
document.getElementById('wz-rem-amount').textContent = fmt(remaining); document.getElementById('wz-rem-amount').textContent = fmt(remaining);
// Bar
var bar = document.getElementById('wz-bar'); var bar = document.getElementById('wz-bar');
var legend = document.getElementById('wz-legend'); var legend = document.getElementById('wz-legend');
bar.innerHTML = ''; legend.innerHTML = ''; bar.innerHTML = ''; legend.innerHTML = '';
var basis = net > 0 ? net : 1; var basis = net > 0 ? net : 1;
segments.forEach(function (s) { seg.forEach(function (s) {
if (s.amount <= 0) return; if (s.amount <= 0) return;
var seg = document.createElement('div'); var d = document.createElement('div');
seg.style.cssText = 'width:' + ((s.amount / basis) * 100) + '%;background:' + s.color + ';'; d.style.cssText = 'width:' + ((s.amount / basis) * 100) + '%;background:' + s.color + ';';
seg.title = s.label + ' — ' + fmt(s.amount); d.title = s.label + ' — ' + fmt(s.amount);
bar.appendChild(seg); bar.appendChild(d);
var li = document.createElement('span'); var li = document.createElement('span');
li.innerHTML = '<span style="display:inline-block;width:9px;height:9px;border-radius:2px;background:' + s.color + ';margin-inline-end:5px;"></span>' li.innerHTML = '<span style="display:inline-block;width:9px;height:9px;border-radius:2px;background:'
+ s.label + ' <strong>' + fmt(s.amount) + '</strong>'; + s.color + ';margin-inline-end:5px;"></span>' + s.label + ' <strong>' + fmt(s.amount) + '</strong>';
legend.appendChild(li); legend.appendChild(li);
}); });
if (remaining > 0) { if (remaining > 0) {
...@@ -463,26 +724,28 @@ ...@@ -463,26 +724,28 @@
legend.appendChild(li2); legend.appendChild(li2);
} }
renderPreview(gross, net, tax, segments, remaining, overrun); renderPreview(gross, net, tax, seg, remaining, overrun, pctTotal);
buildPayload(segments); buildPayload(seg);
} }
function renderPreview(gross, net, tax, segments, remaining, overrun) { function renderPreview(gross, net, tax, segments, remaining, overrun, pctTotal) {
var h = ''; var h = '';
if (overrun) { if (overrun) {
h += '<div style="background:#FEF2F2;border:1px solid #FECACA;border-radius:6px;padding:9px;color:#991B1B;font-size:12px;margin-bottom:10px;">' h += '<div style="background:#FEF2F2;border:1px solid #FECACA;border-radius:6px;padding:9px;color:#991B1B;font-size:12px;margin-bottom:10px;">اقتطاع أكبر من المتاح — تم تخفيضه.</div>';
+ 'اقتطاع أكبر من المتاح — تم تخفيضه.</div>'; }
if (pctTotal > 100) {
h += '<div style="background:#FEF2F2;border:1px solid #FECACA;border-radius:6px;padding:9px;color:#991B1B;font-size:12px;margin-bottom:10px;">مجموع النسب ' + pctTotal + '٪ — أكبر من ١٠٠٪.</div>';
} }
h += '<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:12px;text-align:center;">' h += '<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:12px;text-align:center;">'
+ '<div style="background:#F3F4F6;border-radius:6px;padding:8px;"><div style="font-size:10px;color:#6B7280;">المحصَّل</div><div style="font-weight:700;">' + fmt(gross) + '</div></div>' + '<div style="background:#F3F4F6;border-radius:6px;padding:8px;"><div style="font-size:10px;color:#6B7280;">' + (DIRECTION === 'outflow' ? 'المصروف' : 'المحصَّل') + '</div><div style="font-weight:700;">' + fmt(gross) + '</div></div>'
+ '<div style="background:#FEF3C7;border-radius:6px;padding:8px;"><div style="font-size:10px;color:#92400E;">الضريبة</div><div style="font-weight:700;color:#92400E;">' + fmt(tax) + '</div></div>' + '<div style="background:#FEF3C7;border-radius:6px;padding:8px;"><div style="font-size:10px;color:#92400E;">الضريبة</div><div style="font-weight:700;color:#92400E;">' + fmt(tax) + '</div></div>'
+ '<div style="background:#ECFDF5;border-radius:6px;padding:8px;"><div style="font-size:10px;color:#065F46;">الصافي</div><div style="font-weight:700;color:#065F46;">' + fmt(net) + '</div></div>' + '<div style="background:#ECFDF5;border-radius:6px;padding:8px;"><div style="font-size:10px;color:#065F46;">الصافي</div><div style="font-weight:700;color:#065F46;">' + fmt(net) + '</div></div>'
+ '</div>'; + '</div>';
h += '<table style="width:100%;border-collapse:collapse;font-size:11.5px;">' h += '<table style="width:100%;border-collapse:collapse;font-size:11.5px;">'
+ '<thead><tr style="background:#F9FAFB;"><th style="text-align:right;padding:6px;border-bottom:1px solid #E5E7EB;">الحساب</th>' + '<thead><tr style="background:#F9FAFB;"><th style="text-align:right;padding:6px;border-bottom:1px solid #E5E7EB;">الحساب</th>'
+ '<th style="text-align:left;padding:6px;border-bottom:1px solid #E5E7EB;width:66px;">مدين</th>' + '<th style="text-align:left;padding:6px;border-bottom:1px solid #E5E7EB;width:68px;">مدين</th>'
+ '<th style="text-align:left;padding:6px;border-bottom:1px solid #E5E7EB;width:66px;">دائن</th></tr></thead><tbody>'; + '<th style="text-align:left;padding:6px;border-bottom:1px solid #E5E7EB;width:68px;">دائن</th></tr></thead><tbody>';
function row(label, dr, cr, bg) { function row(label, dr, cr, bg) {
var s = bg ? 'background:' + bg + ';' : ''; var s = bg ? 'background:' + bg + ';' : '';
...@@ -491,36 +754,49 @@ ...@@ -491,36 +754,49 @@
+ '<td style="padding:6px;border-bottom:1px solid #F3F4F6;text-align:left;font-weight:600;' + s + '">' + (cr ? fmt(cr) : '') + '</td></tr>'; + '<td style="padding:6px;border-bottom:1px solid #F3F4F6;text-align:left;font-weight:600;' + s + '">' + (cr ? fmt(cr) : '') + '</td></tr>';
} }
h += row('<span style="color:#6B7280;">النقدية / البنك</span>', gross, 0, null); // On an outflow the split lines are the DEBITS and cash is the credit.
if (tax > 0) h += row('ضريبة القيمة المضافة <span style="font-size:10px;color:#92400E;">التزام</span>', 0, tax, '#FFFBEB'); var out = DIRECTION === 'outflow';
segments.forEach(function (s) { if (s.amount > 0) h += row(s.label, 0, s.amount, null); }); var counter = out ? '<span style="color:#6B7280;">النقدية / البنك / الدائنون</span>'
: '<span style="color:#6B7280;">النقدية / البنك</span>';
if (!out) h += row(counter, gross, 0, null);
if (tax > 0) {
h += row('ضريبة القيمة المضافة <span style="font-size:10px;color:#92400E;">' + (out ? 'خصم' : 'التزام') + '</span>',
out ? tax : 0, out ? 0 : tax, '#FFFBEB');
}
segments.forEach(function (s) {
if (s.amount > 0) h += row(s.label, out ? s.amount : 0, out ? 0 : s.amount, null);
});
if (remaining > 0) { if (remaining > 0) {
var rl = document.getElementById('wz-rem-search').value || '<span style="color:#DC2626;">الحساب النهائي — لسه ما اتحددش</span>'; var rl = document.getElementById('wz-rem-search').value
h += row(rl + ' <span style="font-size:10px;color:#065F46;">الباقي</span>', 0, remaining, '#ECFDF5'); || '<span style="color:#DC2626;">الحساب النهائي — لسه ما اتحددش</span>';
h += row(rl + ' <span style="font-size:10px;color:#065F46;">الباقي</span>',
out ? remaining : 0, out ? 0 : remaining, '#ECFDF5');
} }
if (out) h += row(counter, 0, gross, null);
var cr = tax + segments.reduce(function (a, s) { return a + s.amount; }, 0) + Math.max(0, remaining); var other = tax + segments.reduce(function (a, s) { return a + s.amount; }, 0) + Math.max(0, remaining);
var ok = Math.abs(cr - gross) < 0.005; var ok = Math.abs(other - gross) < 0.005;
h += '<tr style="background:#F9FAFB;font-weight:700;"><td style="padding:6px;">الإجمالي</td>' h += '<tr style="background:#F9FAFB;font-weight:700;"><td style="padding:6px;">الإجمالي</td>'
+ '<td style="padding:6px;text-align:left;">' + fmt(gross) + '</td>' + '<td style="padding:6px;text-align:left;">' + fmt(out ? other : gross) + '</td>'
+ '<td style="padding:6px;text-align:left;color:' + (ok ? '#059669' : '#DC2626') + ';">' + fmt(cr) + '</td></tr>'; + '<td style="padding:6px;text-align:left;color:' + (ok ? '#059669' : '#DC2626') + ';">' + fmt(out ? gross : other) + '</td></tr>'
h += '</tbody></table>'; + '</tbody></table>';
h += '<div style="margin-top:8px;font-size:11.5px;font-weight:600;color:' + (ok ? '#059669' : '#DC2626') + ';">' h += '<div style="margin-top:8px;font-size:11.5px;font-weight:600;color:' + (ok ? '#059669' : '#DC2626') + ';">'
+ (ok ? '✓ القيد متوازن' : '✗ القيد غير متوازن') + '</div>'; + (ok ? '✓ القيد متوازن' : '✗ القيد غير متوازن') + '</div>';
h += '<div style="margin-top:10px;padding-top:10px;border-top:1px dashed #E5E7EB;font-size:11.5px;color:#6B7280;">'
+ 'نفس القيد ده هيتطبّق على <strong id="pv-count">' + (document.getElementById('tgt-count') ? document.getElementById('tgt-count').textContent : '1') + '</strong> قاعدة قيد.'
+ '</div>';
document.getElementById('wz-preview').innerHTML = h; document.getElementById('wz-preview').innerHTML = h;
} }
// The wizard's percentages are "of what is left at that point", which is what a
// person means by "then 30% of the rest". The engine computes percentages on a
// fixed base, so each piece is stored as the fixed amount it resolves to at the
// reference amount, plus its intent in the description.
function buildPayload(segments) { function buildPayload(segments) {
var out = []; var out = [];
segments.forEach(function (s) { segments.forEach(function (s) {
// Keep the line if it is configured, even when the reference amount // Keep a configured line even when the reference amount makes it
// makes it resolve to zero — the reference is illustrative only. // resolve to zero — the reference is illustrative only.
if (!s.account || !(s.raw > 0)) return; if (!s.account || !(s.raw > 0)) return;
var deferred = DEFERRABLE.indexOf(s.type) !== -1 && s.recAcct;
out.push({ out.push({
line_type: s.type, line_type: s.type,
allocation_method: s.method, allocation_method: s.method,
...@@ -529,9 +805,9 @@ ...@@ -529,9 +805,9 @@
percentage_base: 'net_after_fixed', percentage_base: 'net_after_fixed',
account_id: s.account, account_id: s.account,
description_ar: s.desc, description_ar: s.desc,
recognition_method: 'immediate', recognition_method: deferred ? 'straight_line' : 'immediate',
recognition_months: '', recognition_months: deferred ? s.months : '',
recognized_account_id: '', recognized_account_id: deferred ? s.recAcct : '',
max_amount: '' max_amount: ''
}); });
}); });
...@@ -552,25 +828,43 @@ ...@@ -552,25 +828,43 @@
} }
document.getElementById('wz-form').addEventListener('submit', function (e) { document.getElementById('wz-form').addEventListener('submit', function (e) {
var err = document.getElementById('wz-error'); var err = document.getElementById('wz-error'), msgs = [];
if (!document.getElementById('wz-rem-acct').value) { if (!document.getElementById('wz-rem-acct').value) {
msgs.push('لازم تحدد الحساب اللي يروح له الباقي.');
}
var pct = 0;
box.querySelectorAll('.wz-line').forEach(function (el, i) {
var v = Number(el.querySelector('.wz-value').value || 0);
if (v > 0 && !el.querySelector('.wz-acct').value) {
msgs.push('الجزء ' + (i + 1) + ': فيه قيمة من غير حساب.');
}
if (el.querySelector('.wz-method').value === 'percentage') pct += v;
if (DEFERRABLE.indexOf(el.querySelector('.wz-type').value) !== -1
&& !el.querySelector('.wz-rec-acct').value) {
msgs.push('الجزء ' + (i + 1) + ': إيراد مؤجل من غير حساب يترحّل له.');
}
});
if (pct > 100) msgs.push('مجموع النسب ' + pct + '٪ — أكبر من ١٠٠٪.');
if (Number(document.getElementById('tgt-count').textContent || 0) < 1) {
msgs.push('النطاق اللي اخترته مفيهوش أي قاعدة قيد.');
}
if (msgs.length) {
e.preventDefault(); e.preventDefault();
err.textContent = 'لازم تحدد الحساب اللي يروح له الباقي قبل الحفظ.'; err.innerHTML = msgs.join('<br>');
err.style.display = 'block'; err.style.display = 'block';
window.scrollTo({ top: err.offsetTop - 120, behavior: 'smooth' }); window.scrollTo({ top: err.offsetTop - 140, behavior: 'smooth' });
return false; return false;
} }
var missing = false;
box.querySelectorAll('.wz-line').forEach(function (el) { var n = document.getElementById('tgt-count').textContent;
if (Number(el.querySelector('.wz-value').value || 0) > 0 && !el.querySelector('.wz-acct').value) missing = true; if (Number(n) > 1 && !confirm('هتطبّق نفس التقسيمة على ' + n + ' قاعدة قيد. تمام؟')) {
});
if (missing) {
e.preventDefault(); e.preventDefault();
err.textContent = 'فيه جزء بمبلغ من غير حساب — حدد الحساب أو احذف الجزء.';
err.style.display = 'block';
return false; return false;
} }
err.style.display = 'none'; err.style.display = 'none';
document.getElementById('wz-save').disabled = true;
}); });
// ── Create-account modal ──────────────────────────────────── // ── Create-account modal ────────────────────────────────────
...@@ -622,13 +916,14 @@ ...@@ -622,13 +916,14 @@
document.getElementById('wz-rem-type').addEventListener('change', recalc); document.getElementById('wz-rem-type').addEventListener('change', recalc);
document.getElementById('wz-add').addEventListener('click', function () { addLine(null); }); document.getElementById('wz-add').addEventListener('click', function () { addLine(null); });
// Existing rule: the remainder line becomes the destination, the rest become pieces.
var rem = existing.filter(function (l) { return l.method === 'remainder'; })[0]; var rem = existing.filter(function (l) { return l.method === 'remainder'; })[0];
existing.filter(function (l) { return l.method !== 'remainder'; }).forEach(addLine); existing.filter(function (l) { return l.method !== 'remainder'; }).forEach(addLine);
if (rem) { if (rem) {
document.getElementById('wz-rem-acct').value = rem.account; document.getElementById('wz-rem-acct').value = rem.account;
document.getElementById('wz-rem-search').value = rem.label; document.getElementById('wz-rem-search').value = rem.label;
document.getElementById('wz-rem-type').value = rem.type || 'revenue'; if (document.getElementById('wz-rem-type').querySelector('option[value="' + rem.type + '"]')) {
document.getElementById('wz-rem-type').value = rem.type;
}
} }
if (!box.children.length) addLine(null); if (!box.children.length) addLine(null);
recalc(); recalc();
......
<?php
declare(strict_types=1);
use App\Core\Database;
/**
* Every payroll and procurement stream was seeded with default_direction =
* 'inflow', which is the wrong side of the entry for most of them. A salary
* expense, a supplier payment, a depreciation charge and a staff loan are all
* money going OUT — their allocation lines are debits, not credits.
*
* Direction drives which line types the allocation wizard offers and what it
* validates against, so a wrong direction here means the screen offers revenue
* accounts for a payroll run. The streams left alone are the genuinely
* credit-side ones: tax withheld, insurance payable, deductions, and the
* supplier payable itself.
*
* Idempotent — re-running it changes nothing.
*/
return static function (Database $db): void {
$outflow = [
// Procurement / inventory — value coming in as an asset, cash going out.
'procurement:inventory_receipt',
'procurement:input_tax',
'procurement:cash_out',
'inventory:goods_receipt',
'inventory:depreciation',
// Payroll — cost incurred and cash paid.
'payroll:gross_salary',
'payroll:employer_insurance',
'payroll:net_paid',
'hr:loan_disbursement',
'hr:end_of_service',
'hr:coach_payment',
];
$placeholders = implode(',', array_fill(0, count($outflow), '?'));
$db->query(
"UPDATE revenue_streams
SET default_direction = 'outflow', updated_at = NOW()
WHERE stream_code IN ({$placeholders})
AND default_direction <> 'outflow'",
$outflow
);
// Rules already written against these streams carry the same wrong
// direction. Only touch the ones still active and still on the default —
// a rule someone deliberately set to inflow on an outflow stream is left
// alone rather than silently flipped underneath them.
$db->query(
"UPDATE revenue_posting_rules r
JOIN revenue_streams s ON s.id = r.stream_id
SET r.direction = 'outflow', r.updated_at = NOW()
WHERE s.stream_code IN ({$placeholders})
AND r.status = 'active'
AND r.direction = 'inflow'
AND r.stage IN ('payment', 'refund', 'writeoff')",
$outflow
);
};
# معالج توزيع المبالغ — دليل الاستخدام # معالج توزيع المبالغ — دليل الاستخدام
> **الغرض من الملف ده:** تعرف إزاي تمسك أي مبلغ في النادي — قيمة عضوية، اشتراك، > **الغرض:** تعرف إزاي تمسك **أي مبلغ** في النادي — عضوية، اشتراك، حجز، أكاديمية،
> حجز، إيجار محل — وتقول: النسبة دي تروح للكود ده، والنسبة دي للكود ده، والباقي > إيجار محل، غرامة، مشتريات، رواتب — وتقول: النسبة دي تروح للكود ده، والنسبة دي
> يروح فين. من غير ما تكلّم مبرمج. > للكود ده، والباقي فين. لمصدر واحد، أو لفئة كاملة، أو للسيستم كله. من غير مبرمج.
--- ---
## الفكرة في سطرين ## الفكرة في سطرين
المبلغ اللي بيدفعه العضو مش لازم يروح لحساب واحد. الـ **معالج التوزيع** (الويزارد) اكتب التقسيمة **مرة واحدة**، وقول هي تحكم إيه. الشاشة بتوريك **الباقي غير الموزَّع**
بيخليك تقسّم المبلغ على أي عدد حسابات — بنسبة مئوية أو بمبلغ ثابت — وبيوريك وإنت بتقسّم لحظة بلحظة، وبتوريك القيد الناتج قبل ما تحفظ. وبند "الباقي" في الآخر
**الباقي غير الموزَّع** وإنت بتقسّم، لحظة بلحظة. وبند "الباقي" في الآخر بياخد اللي بياخد اللي فضل مهما كان، فالقيد بيفضل متوازن ١٠٠٪.
فضل مهما كان، فالقيد بيفضل متوازن ١٠٠٪.
**النطاق (Scope) هو الفرق.** مش لازم تعيد نفس التقسيمة ٩٧ مرة — اختار "فئة
العضويات" مرة واحدة وهي تتكتب على الـ١١ مصدر.
--- ---
...@@ -19,14 +21,15 @@ ...@@ -19,14 +21,15 @@
| المصطلح | المعنى بالبلدي | | المصطلح | المعنى بالبلدي |
|---|---| |---|---|
| **مصدر الإيراد** (Revenue Stream) | نوع الفلوس. "قيمة العضوية"، "اشتراك سنوي"، "حجز ملعب"، "إيجار محل". | | **مصدر الإيراد** (Stream) | نوع الفلوس. "قيمة العضوية"، "حجز ملعب"، "مصروف الأجور". في السيستم **٩٧ مصدر**. |
| **المرحلة** (Stage) | امتى بيتعمل القيد. **استحقاق** = وقت ما نطالب. **تحصيل** = وقت ما نقبض. **صرف** = وقت ما ندفع. **استرداد** = لما نرجّع. | | **فئة الإيراد** (Category) | مجموعة مصادر من نفس النوع. **١٤ فئة**: العضويات، الاشتراكات، الأنشطة، المنشآت، الأكاديميات، الإيجارات، المبيعات، الغرامات، الانتقالات، الخزينة، المشتريات، الأجور، الإعدام، أخرى. |
| **القاعدة** (Rule) | ورقة التعليمات: "المبلغ ده يتوزّع كذا". لها إصدارات ولها تاريخ سريان. | | **النطاق** (Scope) | التقسيمة دي هتحكم إيه: مصدر واحد / فئة كاملة / مصادر أختارها / كل اللي مش موصّل / كل حاجة. |
| **البند** (Line) | سطر واحد من التوزيع: "٢٠٪ لحساب كذا". | | **المرحلة** (Stage) | امتى بيتعمل القيد. **استحقاق** = نطالب. **تحصيل** = نقبض. **صرف** = ندفع. **ارتجاع** / **إعدام** / **تحويل داخلي**. |
| **الباقي** (Remainder) | البند الأخير — بياخد اللي فضل + كسور القرش. لازم يكون موجود. | | **اتجاه الحركة** (Direction) | **تحصيل** = بنود التقسيمة دائنة (فلوس داخلة). **صرف** = بنودها مدينة (فلوس خارجة). |
| **الصافي** (Net) | المبلغ بعد ما نفصل الضريبة. التوزيع بيتم على الصافي مش على المحصَّل. | | **القاعدة** (Rule) | ورقة التعليمات: "المبلغ ده يتوزّع كذا". لها إصدارات وتاريخ سريان. |
| **حساب رئيسي** (Header) | حساب بيتجمّع تحته حسابات تانية. **القيد ما بينزلش عليه أبدًا.** | | **الباقي** (Remainder) | البند الأخير — بياخد اللي فضل + كسور القرش. إجباري. |
| **فئة العضو** | عضو عامل / أجنبي / رياضي / فخري / موسمي. | | **الصافي** (Net) | المبلغ بعد فصل الضريبة. التوزيع بيتم على الصافي مش على المحصَّل. |
| **حساب رئيسي** (Header) | حساب بيتجمّع تحته حسابات. **القيد ما بينزلش عليه أبدًا** — والسيستم بيرفض. |
--- ---
...@@ -34,108 +37,134 @@ ...@@ -34,108 +37,134 @@
**المسار:** المحاسبة ← محرك القيود (`/accounting/revenue-mapping`) **المسار:** المحاسبة ← محرك القيود (`/accounting/revenue-mapping`)
هتلاقي جدول بكل مصادر الإيراد في النادي. جنب كل واحد أزرار بأسماء المراحل. | من فين | بيفتح إيه |
|---|---|
| زرار **وزّع على مجموعة** فوق | الويزارد على **فئة كاملة** — ده اللي هتستخدمه أغلب الوقت |
| اسم المرحلة جنب أي مصدر في الجدول | الويزارد على **المصدر ده** في المرحلة دي |
| زرار الترس ⚙ | الوضع المتقدّم (كل الخيارات، أعقد) |
| **مركز التوصيل** ← قسّم على حسابات | نفس الويزارد للمصدر ده |
- اضغط على اسم المرحلة (مثلًا **تحصيل**) → يفتح **الويزارد**. ---
- زرار الترس ⚙ جنبه → يفتح **الوضع المتقدّم** (فيه كل الخيارات، لكنه أعقد).
- لو المصدر لسه مش متوصّل خالص → هتلاقي زرار **ربط الحسابات**.
كمان من **مركز التوصيل** (`/accounting/revenue-mapping/connections`) — الشاشة اللي ## ٢. الخطوة ١ — التقسيمة دي هتحكم إيه
بتوريك الموصّل والمش موصّل — كل صف فيه زرار **قسّم على حسابات** بيوديك لنفس الويزارد.
--- خمس اختيارات، كل واحد كارت تضغط عليه:
## ٢. الشاشة من فوق لتحت | النطاق | يعني إيه | امتى تستخدمه |
|---|---|---|
| **المصدر ده بس** | مصدر واحد | تقسيمة خاصة بحاجة واحدة |
| **كل فئة إيراد** | كل المصادر من نفس النوع | **الأكتر استخدامًا** — "كل إيرادات الأكاديميات تتوزّع كده" |
| **مصادر أختارها** | تعلّم بإيدك من قايمة الـ٩٧ | مجموعة مش متجانسة |
| **كل اللي لسه مش موصّل** | أي مصدر ما لوش قاعدة | تلمّ الناقص كله دفعة واحدة |
| **كل مصادر الإيراد** | كل حاجة في السيستم | سياسة عامة للنادي |
### الخطوة ١ — التوزيع ده بيخص مين لما تختار **فئة**، هتطلع لك كل الفئات وجنب كل واحدة عدد المصادر وكام واحد منها لسه
غير موصّل — مثال: *العضويات — ١١ مصدر · كلها موصّلة* أو *المنشآت والملاعب — ٩ مصادر
· ٦ غير موصّل*.
فيها اختيارين: لما تختار **مصادر أختارها**، فيه فلتر بالاسم، وزرار **اختار الفئة كلها** جنب كل
عنوان فئة، وزرار **مسح الكل**. علّم اللي عايزه واضغط **حمّل المختار**.
**المرحلة** — غالبًا هتسيبها **تحصيل** (وقت ما العضو يدفع). العلامة ✓ جنب المرحلة ### المرحلة
معناها إن فيه قاعدة شغالة عليها دلوقتي.
**فئة العضو** — دي أهم حاجة: اختار المرحلة (تحصيل / استحقاق / صرف / …). العلامة ✓ معناها إن فيه قاعدة شغالة
عليها. **المصادر اللي المرحلة دي ما تنطبقش عليها بتتخطّى تلقائيًا** — يعني لو
اخترت "تحصيل" على فئة الأجور، السيستم هيقول لك "اتخطّى — المرحلة دي ما تنطبقش"
بدل ما يكتب قيد غلط.
- سيبها **"كل الأعضاء — قاعدة عامة"** → التوزيع ده هيطبّق على أي حد يدفع. ### فئة العضو
- اختار **"عضو عامل (١٢٠)"** → التوزيع ده هيطبّق **على العضو العامل بس**.
> **القاعدة الأخص بتغلب العامة تلقائيًا.** يعني لو عملت قاعدة عامة وقاعدة تانية - **كل الأعضاء — قاعدة عامة** → التوزيع يطبّق على أي حد.
> للعضو العامل، لما عضو عامل يدفع السيستم هيمشي على بتاعت العضو العامل. أي حد - **عضو عامل (١٢٠)** → التوزيع ده على العضو العامل **بس**.
> تاني هيمشي على العامة. مش محتاج تعمل أي حاجة عشان ده يحصل.
الرقم بين القوسين (١٢٠) هو عدد الأعضاء الفعليين في الفئة دي في الداتا دلوقتي. > **القاعدة الأخص بتغلب العامة تلقائيًا.** لو عملت قاعدة عامة وقاعدة للعضو العامل،
> لما عضو عامل يدفع السيستم يمشي على بتاعت العضو العامل، وأي حد تاني على العامة.
> **الترتيب:** فئة العضو (٤) > الفرع (٢) > طريقة الدفع (١). مش محتاج تعمل حاجة
> عشان ده يحصل. الرقم بين القوسين هو عدد الأعضاء الفعليين في الفئة.
--- ### الشريط الأزرق
تحت خالص شريط بيقول: **هتتطبّق على ١١ قاعدة قيد — حركة تحصيل**. اضغط **اعرض
القايمة** تشوف بالظبط كل مصدر ومرحلته، والمتخطّى ليه اتخطّى.
### اتجاه الحركة
### الخطوة ٢ — المبلغ اللي هنوزّعه لو النطاق **مصدر واحد**، تحت الشريط هتلاقي: *اتجاه الحركة لـ… : تحصيل (البنود
دائنة)* وجنبه **غيّره لـ صرف**. الاتجاه بيحدد أنواع البنود المتاحة:
**مبلغ مرجعي للحساب** — الرقم ده **للتوضيح بس**. السيستم بيملاه لك تلقائيًا بمتوسط - **تحصيل** → إيراد / إيراد مؤجل / تحصيل لحساب الغير / خصم من الإيراد / تسوية ذمم مدينة
اللي المصدر ده حصّله فعلًا (مثال: "قيمة العضوية" بيفتح على ١٢٩٬٤٥٣٫١٦). إنت - **صرف** → مصروف / مصروف مقدّم / أصل ثابت / مخزون / تسوية ذمم دائنة / إعدام
بتغيّره عشان تشوف الأرقام بعينك وإنت بتوزّع. النسب اللي هتحطها هتتطبّق على أي مبلغ
فعلي العضو يدفعه، مش على الرقم ده.
**المعالجة الضريبية** — لو الخدمة دي عليها ضريبة قيمة مضافة، اختار البروفايل هنا تغيير الاتجاه **ما بيمسّش** القواعد القديمة ولا القيود المرحّلة — بيغيّر الافتراضي
(مثلًا ١٤٪ شامل). ساعتها: للقواعد الجديدة بس.
> **مهم:** مينفعش تحط تقسيمة واحدة على نطاق فيه مصادر تحصيل ومصادر صرف مع بعض.
> السيستم هيرفض ويقول لك قسّمهم على دفعتين — لأن نفس البنود ما تنفعش للاتنين.
---
## ٣. الخطوة ٢ — المبلغ اللي هنوزّعه
**مبلغ مرجعي للحساب** — الرقم ده **للتوضيح بس**. السيستم بيملاه لك بمتوسط اللي
المصدر ده حصّله فعلًا. إنت بتغيّره عشان تشوف الأرقام بعينك. النسب بتتطبّق على أي
مبلغ فعلي، مش على الرقم ده.
**المعالجة الضريبية** — لو الخدمة عليها ض.ق.م:
- الضريبة **بتتفصل الأول** وبتروح لحساب التزام ضريبي (مش إيراد). - الضريبة **بتتفصل الأول** وبتروح لحساب التزام ضريبي (مش إيراد).
- التوزيع بيتم على **الصافي** بعد الضريبة. - التوزيع بيتم على **الصافي**.
**مثال حقيقي:** ١٥٠٬٠٠٠ بضريبة ١٤٪ شاملة → **مثال حقيقي محسوب:** ١٥٠٬٠٠٠ بضريبة ١٤٪ شاملة →
الصافي **١٣١٬٥٧٨٫٩٥** والضريبة **١٨٬٤٢١٫٠٥**. لو حطيت ٢٠٪ هتاخد ٢٦٬٣١٥٫٧٩ (٢٠٪ من صافي **١٣١٬٥٧٨٫٩٥** + ضريبة **١٨٬٤٢١٫٠٥**. ٢٠٪ هتاخد **٢٦٬٣١٥٫٧٩**
الصافي، مش من الـ١٥٠ ألف). (٢٠٪ من الصافي، مش من الـ١٥٠ ألف).
**مركز التكلفة** — اختياري. لو النشاط ده بيتراقب على مركز تكلفة معيّن. **مركز التكلفة** و**الفرع** — اختياريين. قاعدة لفرع معيّن بتغلب القاعدة العامة.
--- ---
### الخطوة ٣ — اقتطع الأجزاء ## ٤. الخطوة ٣ — اقتطع الأجزاء
دي قلب الشاشة. فوق خالص لوح كبير مكتوب فيه: فوق خالص:
``` ```
الباقي غير الموزَّع 150,000.00 الباقي غير الموزَّع 150,000.00
[███████████████████████████████████████████] [███████████████████████████████████████████]
``` ```
الرقم ده بيتحدّث **مع كل حرف بتكتبه**. والشريط الملوّن تحته بيوريك كل جزء بلون الرقم ده بيتحدّث **مع كل حرف بتكتبه**، والشريط الملوّن بيوريك كل جزء بلونه وحجمه
وحجمه الحقيقي من المبلغ. الحقيقي.
اضغط **+ اقتطع جزء**، هيطلع كارت فيه: اضغط **+ اقتطع جزء**:
| الحقل | تعمل بيه إيه | | الحقل | تعمل بيه إيه |
|---|---| |---|---|
| **الطريقة** | **نسبة %** أو **مبلغ ثابت** | | **الطريقة** | **نسبة %** أو **مبلغ ثابت** |
| **القيمة** | ٢٠ (يعني ٢٠٪) أو ٥٠٠٠ (يعني ٥٬٠٠٠ جنيه بالظبط) | | **القيمة** | ٢٠ (يعني ٢٠٪) أو ٥٠٠٠ (يعني ٥٬٠٠٠ جنيه) |
| **يروح لحساب** | اكتب حرفين من الكود أو الاسم وهيبحث لك. ولو الحساب مش موجود اضغط **+ حساب جديد** | | **يروح لحساب** | اكتب حرفين من الكود أو الاسم. مش موجود؟ اضغط **+ حساب جديد** |
| **نوع البند** | إيراد / تحصيل لحساب الغير / إيراد مؤجل (شرح تحت) | | **نوع البند** | حسب الاتجاه (جدول تحت) |
| **الوصف** | اللي هيظهر في القيد. اختياري بس مفيد. | | **الوصف** | اللي يظهر في القيد. اختياري. |
تحت كل كارت سطر رمادي بيقول لك بالظبط:
> ٢٠٪ من ١٥٠٬٠٠٠٫٠٠ · قبله **١٥٠٬٠٠٠٫٠٠** · بياخد **٣٠٬٠٠٠٫٠٠** · يفضل **١٢٠٬٠٠٠٫٠٠** تحت كل كارت سطر بيقول بالظبط:
الأسهم ▲▼ بتحرّك الجزء فوق وتحت. **حذف** بيشيله. > ٢٠% من ١٥٠٬٠٠٠٫٠٠ · قبله **١٥٠٬٠٠٠٫٠٠** · بياخد **٣٠٬٠٠٠٫٠٠** · يفضل **١٢٠٬٠٠٠٫٠٠**
> **مهم جدًا:** النسبة دايمًا **من صافي المبلغ**، مش من الباقي. يعني لو كتبت ٢٠٪ الأسهم ▲▼ بتحرّك الجزء. **حذف** بيشيله.
> بعدين ٣٠٪، الاتنين مجموعهم ٥٠٪ من المبلغ. ده اللي أي محاسب بيقصده لما يقول
> "٢٠٪ منه لكذا و٣٠٪ منه لكذا". **المبالغ الثابتة بتتخصم الأول**، وبعدين النسب
> بتتحسب على اللي فضل بعد الثابت.
--- > **النسبة دايمًا من صافي المبلغ، مش من الباقي.** ٢٠٪ بعدين ٣٠٪ = ٥٠٪ من المبلغ.
> ده اللي أي محاسب بيقصده. **المبالغ الثابتة بتتخصم الأول**، وبعدين النسب بتتحسب
> على اللي فضل بعد الثابت.
### الخطوة ٤ — الباقي يروح لـ ### الخطوة ٤ — الباقي يروح لـ
سطر إجباري في الأخضر تحت خالص. اختار الحساب اللي هياخد اللي فضل. سطر إجباري أخضر تحت. اختار الحساب اللي هياخد اللي فضل.
**ليه إجباري؟** عشان القيد يفضل متوازن مهما حصل. لو المبلغ اتغيّر، أو كسور **ليه إجباري؟** عشان القيد يفضل متوازن مهما حصل. لو المبلغ اتغيّر أو كسور القرش ما
القرش ما اتقسمتش بالظبط، البند ده بيبلع الفرق. من غيره القيد ممكن ما يتوازنش اتقسمتش بالظبط، البند ده بيبلع الفرق. السيستم بيرفض الحفظ من غيره.
والسيستم هيرفض الحفظ.
--- ---
### لوحة "القيد الناتج" (على الشمال) ## ٥. لوحة "القيد الناتج"
بتوريك **بالظبط** القيد اللي هينزل الأستاذ العام، قبل ما تحفظ: على الشمال، بتوريك بالظبط اللي هينزل الأستاذ العام قبل ما تحفظ:
| الحساب | مدين | دائن | | الحساب | مدين | دائن |
|---|---|---| |---|---|---|
...@@ -146,122 +175,163 @@ ...@@ -146,122 +175,163 @@
| ٢٣٠٩٠١ — صندوق دعم المنشآت *(الباقي)* | | 65,789.47 | | ٢٣٠٩٠١ — صندوق دعم المنشآت *(الباقي)* | | 65,789.47 |
| **الإجمالي** | **150,000.00** | **150,000.00** | | **الإجمالي** | **150,000.00** | **150,000.00** |
وتحتها: **✓ القيد متوازن**. لو ظهر **✗ القيد غير متوازن** بالأحمر — **ما تحفظش**، وتحتها **✓ القيد متوازن**، وسطر: *"نفس القيد ده هيتطبّق على ١١ قاعدة قيد."*
راجع الأرقام.
لو ظهر **✗ القيد غير متوازن** بالأحمر — **ما تحفظش**، راجع الأرقام.
**على حركة صرف** اللوحة بتنقلب: بنود التقسيمة تبقى **مدينة** والنقدية **دائنة**.
--- ---
### الحفظ ## ٦. الحفظ
**ساري اعتبارًا من** — التاريخ اللي التوزيع يبدأ منه.
**سبب التغيير** — رقم قرار مجلس الإدارة أو المذكرة. بيتسجّل في التاريخ.
**ساري اعتبارًا من** — التاريخ اللي التوزيع الجديد يبدأ منه. الزرار بيقول: **حفظ وتفعيل على ١١ قاعدة**. لو أكتر من واحدة هيسألك تأكيد.
**سبب التغيير** — اكتب فيه رقم قرار مجلس الإدارة أو المذكرة. ده بيتسجّل في التاريخ.
اضغط **حفظ وتفعيل التوزيع**. > **القيود اللي اترحّلت قبل التاريخ ده ما بتتغيّرش أبدًا.** الحفظ ما بيعدّلش القاعدة
> القديمة — بيعمل **إصدار جديد** ويوقف القديم. سؤال "القيد بتاع الشهر اللي فات كان
> بأي توزيع؟" له إجابة محفوظة.
> **القيود اللي اترحّلت قبل التاريخ ده ما بتتغيّرش أبدًا.** الحفظ ما بيعدّلش **الحفظ كله أو ولا حاجة.** لو مصدر واحد في النطاق فيه مشكلة، السيستم بيرفض الدفعة
> القاعدة القديمة — بيعمل **إصدار جديد** ويوقف القديم. لو حد سأل "طب القيد اللي كلها وما بيكتبش ولا صف — عشان الشجرة ما تفضلش نص موصّلة.
> اتعمل الشهر اللي فات كان بأي توزيع؟" الإجابة موجودة ومحفوظة.
--- ---
## ٣. المثال الكامل: قيمة عضوية العضو العامل ## ٧. المثال الكامل: العضويات كلها
المطلوب: **٢٠٪ لصندوق النشاط الرياضي، ٣٠٪ لإيرادات العضويات، والباقي لصندوق دعم المطلوب: **٢٠٪ لصندوق النشاط الرياضي، ٣٠٪ لإيرادات العضويات، والباقي لصندوق دعم
المنشآت** — على العضو العامل بس. المنشآت** — على كل إيرادات العضويات، للعضو العامل.
1. المحاسبة ← محرك القيود ← دوّر على **"قيمة العضوية"** ← اضغط **تحصيل**. 1. المحاسبة ← محرك القيود ← **وزّع على مجموعة**.
2. **فئة العضو:** اختار **عضو عامل (١٢٠)**. هيطلع شريط أزرق: *"بتعدّل التوزيع 2. النطاق: **كل فئة إيراد** ← اختار **العضويات (١١ مصدر)**.
الخاص بـ عضو عامل فقط."* 3. المرحلة: **تحصيل**. فئة العضو: **عضو عامل (١٢٠)**.
3. المبلغ المرجعي هيفتح على **١٢٩٬٤٥٣٫١٦** (متوسط اللي اتحصّل فعلًا). غيّره الشريط الأزرق: *"هتتطبّق على ١١ قاعدة قيد — حركة تحصيل · على عضو عامل بس"*.
لـ **١٥٠٬٠٠٠** لو ده المبلغ اللي عايز تشوف عليه الأرقام. 4. المبلغ المرجعي: غيّره لـ **١٥٠٬٠٠٠**.
4. **+ اقتطع جزء** → نسبة **٢٠** → دوّر على *صندوق النشاط الرياضي* → نوع البند 5. **+ اقتطع جزء** → نسبة **٢٠***صندوق النشاط الرياضي***إيراد**.
**إيراد**. الباقي: **١٢٠٬٠٠٠٫٠٠**
الباقي فوق بقى: **١٢٠٬٠٠٠٫٠٠** 6. **+ اقتطع جزء** → نسبة **٣٠***إيرادات العضويات***إيراد**.
5. **+ اقتطع جزء** → نسبة **٣٠***إيرادات العضويات***إيراد**. الباقي: **٧٥٬٠٠٠٫٠٠**
الباقي فوق بقى: **٧٥٬٠٠٠٫٠٠** 7. المربع الأخضر: *صندوق دعم المنشآت***٧٥٬٠٠٠٫٠٠**
6. في المربع الأخضر تحت: اختار *صندوق دعم المنشآت***٧٥٬٠٠٠٫٠٠** 8. راجع القيد → **✓ القيد متوازن**
7. راجع القيد على الشمال → **✓ القيد متوازن** 9. سبب التغيير → **حفظ وتفعيل على ١١ قاعدة** → تأكيد.
8. اكتب سبب التغيير → **حفظ وتفعيل التوزيع**
خلاص. أي عضو عامل يدفع أي نوع عضوية من الـ١١، الفلوس بتتقسم كده لوحدها.
خلاص. من دلوقتي أي عضو عامل يدفع قيمة عضوية، الفلوس بتتقسم كده لوحدها.
**اتأكد:** غيّر المبلغ لـ ٢٠٠٬٠٠٠ → ٤٠٬٠٠٠ / ٦٠٬٠٠٠ / ١٠٠٬٠٠٠.
**عايز تتأكد؟** غيّر المبلغ المرجعي لـ ٢٠٠٬٠٠٠ وشوف الأرقام: ٤٠٬٠٠٠ / ٦٠٬٠٠٠ / جرّب ٠٫٠٣ جنيه → ٠٫٠١ / ٠٫٠١ / ٠٫٠١ — لسه متوازن.
١٠٠٬٠٠٠. النسب بتشتغل على أي مبلغ.
--- ---
## ٤. أنواع البنود — امتى تستخدم إيه ## ٨. أنواع البنود
### على حركة **تحصيل**
| النوع | استخدمه لما | نوع الحساب المطلوب |
|---|---|---|
| **إيراد** | الفلوس بتاعة النادي وكسبناها دلوقتي | إيراد |
| **تحصيل لحساب الغير** | الفلوس مش بتاعتنا (دمغة، اتحاد، تأمين) | التزام |
| **إيراد مؤجل** | قبضنا دلوقتي والخدمة على مدار السنة | التزام |
| **خصم من الإيراد** | خصومات ومردودات | إيراد أو مصروف |
| **تسوية ذمم مدينة** | تسوية مديونية عضو | أصل |
### على حركة **صرف**
| النوع | استخدمه لما | أثره المحاسبي | | النوع | استخدمه لما | نوع الحساب المطلوب |
|---|---|---| |---|---|---|
| **إيراد** | الفلوس دي بتاعة النادي وكسبناها دلوقتي | دائن حساب إيراد → بيدخل قائمة الدخل | | **مصروف** | تكلفة اتحمّلناها دلوقتي | مصروف |
| **تحصيل لحساب الغير** | الفلوس دي مش بتاعتنا — بنجمّعها لحد تاني (دمغة، اتحاد، تأمين) | دائن حساب **التزام** → بيقعد في الميزانية لحد ما ندفعه | | **مصروف مقدَّم** | دفعنا مقدّم لخدمة جاية | أصل |
| **إيراد مؤجل** | قبضنا دلوقتي بس الخدمة على مدار السنة (اشتراك سنوي) | دائن التزام، وبعدين بيتحوّل لإيراد شهر بشهر | | **أصل ثابت** | شراء أصل | أصل |
| **مخزون** | استلام بضاعة | أصل |
| **تسوية ذمم دائنة** | سداد لمورد | التزام |
| **إعدام / إسقاط** | إسقاط مديونية | مصروف |
**غلطة شائعة:** حاجة زي "رسوم اتحاد" أو "دمغة" تتحط كـ**إيراد**. دي فلوس بتتحصّل > **غلطة شائعة:** "رسوم اتحاد" أو "دمغة" تتحط **إيراد**. دي فلوس بتتحصّل لحساب جهة
لحساب جهة تانية — لو اتسجّلت إيراد، أرباح النادي بتظهر أعلى من الحقيقة والضريبة > تانية — لو اتسجّلت إيراد، أرباح النادي تظهر أعلى من الحقيقة والضريبة تتحسب غلط.
بتتحسب غلط. خليها **تحصيل لحساب الغير**. > خليها **تحصيل لحساب الغير**.
**السيستم بيتأكد من ده بنفسه:** لو اخترت بند "إيراد" ووديته على حساب التزام، هيرفض
ويقول لك *"الحساب ٢١٠١٠١ نوعه liability ومش مناسب لبند إيراد"*.
--- ---
## ٥. الحساب مش موجود؟ اعمله من مكانك ## ٩. الإيراد المؤجل
جنب أي خانة "يروح لحساب" فيه **+ حساب جديد**. اضغط، هيطلع مربع صغير: لما تختار نوع البند **إيراد مؤجل**، بيفتح لك مربع أصفر:
1. **تحت أي حساب رئيسي؟** — اختار الأب من القايمة. - **يترحّل على كام شهر** — مثلًا ١٢ للاشتراك السنوي.
2. **اسم الحساب** — مثلًا "صندوق دعم النشاط الرياضي". - **حساب الإيراد اللي يترحّل له** — البحث هنا بيفلتر حسابات الإيراد بس.
3. **إنشاء وتحديد**.
السيستم بيحسب رقم الكود التالي لوحده تحت الأب اللي اخترته، وبيحط الحساب الجديد في يعني: المبلغ يقعد التزام، وكل شهر جزء منه يتحوّل لإيراد. لو اخترت مؤجل ونسيت حساب
الخانة على طول. مش محتاج تروح لشاشة تانية ولا ترجع. الترحيل، السيستم يرفض الحفظ.
--- ---
## ٦. حاجات لازم تعرفها ## ١٠. الحساب مش موجود؟ اعمله من مكانك
جنب أي خانة "يروح لحساب" فيه **+ حساب جديد**:
1. **تحت أي حساب رئيسي؟** — اختار الأب.
2. **اسم الحساب** — مثلًا "صندوق دعم النشاط الرياضي".
3. **إنشاء وتحديد**.
**النسب لازم تسيب مساحة للباقي.** لو حطيت نسب مجموعها ١٠٠٪، بند الباقي هياخد السيستم بيحسب الكود التالي تحت الأب لوحده وبيحط الحساب في الخانة على طول.
صفر — والسيستم هيقول لك *"لم يتبقَّ مبلغ لبند الباقي"*. ده مسموح لكن الأحسن تسيب
له نصيب، أو تخلّي البند الأخير هو الباقي بدل ما تحط له نسبة.
**لو النسب زادت عن المتاح** هيظهر لك *"اقتطاع أكبر من المتاح — تم تخفيضه"* بالأحمر. ---
راجع الأرقام.
**كل حساب في التوزيع لازم يكون حساب فرعي، مش رئيسي.** لو اخترت حساب رئيسي ## ١١. كل الحالات اللي السيستم بيرفضها
(header) السيستم هيرفض القيد. البحث بيفلتر الرئيسية أصلًا فمش هتقابل المشكلة دي
غالبًا.
**التعديل بيعمل إصدار جديد.** ما بيمسحش القديم. القيود القديمة بتفضل مربوطة كلها مجرّبة ومتأكَّد منها، وكلها بترفض **قبل** ما يتكتب أي صف:
بالإصدار اللي عملها.
**الوضع المتقدّم** (زرار ⚙) فيه حاجات مش في الويزارد: تحديد الحساب المدين يدويًا | الحالة | الرسالة |
بدل النقدية، الإيراد المؤجل بالتقسيط الشهري، السقف الأقصى للبند، والتخصيص حسب |---|---|
الفرع أو طريقة الدفع. | من غير بند باقي | لازم بند واحد اسمه "الباقي" |
| بندين باقي | مش ممكن أكتر من بند "باقي" واحد |
| مجموع النسب > ١٠٠٪ | مجموع النسب ١٢٠٪ — أكبر من ١٠٠٪ |
| نسبة صفر أو سالبة | النسبة لازم تكون أكبر من صفر |
| مبلغ ثابت صفر | المبلغ الثابت لازم يكون أكبر من صفر |
| **حساب رئيسي** | حساب رئيسي، والقيد ما بينزلش عليه |
| حساب مش موجود أو موقوف | الحساب رقم … مش موجود / موقوف أو مؤرشف |
| بند من غير حساب | البند ١: ما اخترتش حساب |
| نوع حساب مش مناسب للبند | الحساب … نوعه liability ومش مناسب لبند "إيراد" |
| بند صرف على حركة تحصيل | نوع البند مش مناسب لحركة تحصيل |
| **خلط تحصيل مع صرف في نطاق واحد** | قسّمهم على دفعتين |
| تقسيمة فاضية | أضف بندًا واحدًا على الأقل |
| نطاق مفيهوش أي مصدر | ما فيش أي مصدر مطابق للنطاق |
| تاريخ سريان غلط | تاريخ السريان غير صحيح |
| **تاريخ في فترة مقفولة** | واقع في فترة مقفولة (٢٠٢٦-٠٩) |
| مؤجل من غير حساب ترحيل | حدد حساب الإيراد اللي المؤجل هيترحّل له |
| مرحلة ما تنطبقش على الفئة | اتخطّى — المرحلة دي ما تنطبقش على الأجور |
--- ---
## ٧. أسئلة سريعة ## ١٢. أسئلة سريعة
**س: ممكن أعمل توزيع مختلف لكل فرع؟** **س: أقدر أعمل توزيع مختلف لكل فرع؟**
ج: أيوه — من الوضع المتقدّم (⚙). فيه اختيار **الفرع**. نفس منطق الأخص بيغلب العام. ج: أيوه — من الويزارد نفسه، خانة **الفرع**. الأخص بيغلب العام.
**س: وطريقة الدفع؟ لو دفع شيك بدل كاش؟** **س: وطريقة الدفع؟ لو دفع شيك بدل كاش؟**
ج: كمان من الوضع المتقدّم. وبالمناسبة الشيك بينزل على **أوراق قبض** مش على البنك ج: من الوضع المتقدّم (⚙). وبالمناسبة الشيك بينزل **أوراق قبض** مش البنك لحد ما
لحد ما يتحصّل فعلًا — ده مظبوط تلقائيًا. يتحصّل فعلًا — ده مظبوط تلقائيًا.
**س: لو غلطت وحفظت؟** **س: لو غلطت وحفظت على ١١ مصدر؟**
ج: ادخل تاني، عدّل، واحفظ. هيتعمل إصدار جديد. القيود اللي نزلت قبل كده بتفضل زي ج: ادخل تاني بنفس النطاق، عدّل، احفظ. هيتعمل إصدار جديد ويوقف القديم على الـ١١
ما هي — وده الصح محاسبيًا. كلهم. القيود اللي نزلت قبل كده تفضل زي ما هي — وده الصح محاسبيًا.
**س: فين أشوف اللي موصّل واللي لأ؟** **س: فين أشوف اللي موصّل واللي لأ؟**
ج: **مركز التوصيل**`/accounting/revenue-mapping/connections`. بيوريك كل مصادر ج: **مركز التوصيل**`/accounting/revenue-mapping/connections`. أو من الويزارد
الإيراد، الموصّل منها بلون والمش موصّل بلون، وكل واحد جنبه زرار يوديك للويزارد. نفسه: نطاق **كل اللي لسه مش موصّل** بيوريك العدد على طول.
**س: إزاي أتأكد إن التوزيع شغال فعلًا؟** **س: إزاي أتأكد إن التوزيع شغال فعلًا؟**
ج: شاشة **التشخيص** (`/accounting/revenue-mapping/diagnostics`) بتوريك المصادر ج: شاشة **التشخيص** (`/accounting/revenue-mapping/diagnostics`) بتوريك المصادر
اللي فيها مشكلة، وحساب مربوط على header، وأي قاعدة ناقصة بند الباقي. اللي فيها مشكلة، وحساب مربوط على header، وأي قاعدة ناقصة بند الباقي.
**س: النادي فيه كام مصدر إيراد؟**
ج: **٩٧ مصدر** على **١٤ فئة**، بتغطي **٩٩ قاعدة قيد**. كلهم من نفس الويزارد.
--- ---
*ملفات ذات صلة:* *ملفات ذات صلة:*
*`docs/كيف-أوصّل-أي-إيراد.md` — سيناريوهات كاملة لكل نوع إيراد في النادي* *`docs/كيف-أوصّل-أي-إيراد.md` — سيناريوهات كاملة لكل نوع إيراد*
*`docs/دليل-المحاسبة-للاجتماع.md` — الصورة الكاملة للدورة المحاسبية* *`docs/دليل-المحاسبة-للاجتماع.md` — الصورة الكاملة للدورة المحاسبية*
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