Commit 00dfe062 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(accounting): allocation wizard with a live running remainder

Splitting a payment across GL accounts previously meant the advanced rule
editor — a table of lines with no feedback until save. The finance team's
actual question is "20% here, 30% there, where does the rest go?", which
needs the remainder visible while you allocate.

- New wizard screen: base amount (pre-filled from the stream's actual
  collection average), member-category scope, progressive allocation with
  the unallocated balance as a running figure plus a proportional bar,
  a mandatory remainder destination, and a live journal-entry preview
  that balances before you can save.
- The wizard's arithmetic mirrors RevenueAllocator exactly — tax off the
  top, fixed lines from the pool, percentages of net-after-fixed — so the
  preview is what actually posts. Verified against the live allocator:
  150,000 → 20%/30%/rest = 30,000 / 45,000 / 75,000, and with 14%
  inclusive VAT = 26,315.79 / 39,473.69 / 65,789.47 on net 131,578.95.
- Wire member_category through resolveRule() with specificity scoring
  (category > branch > payment method), so a working-member split wins
  over the general rule with no extra configuration.
- update() resolves and supersedes only the same-scope rule, and no
  longer reads $memberCategory before assigning it.
- Wizard is now the default action from the mapping list and the
  connection centre; the advanced editor moves behind a  link.
- Arabic tutorial in docs/معالج-توزيع-المبالغ.md.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent eede3224
......@@ -216,6 +216,90 @@ class RevenueMappingController extends Controller
]);
}
/**
* معالج توزيع المبلغ — the guided version of the split builder.
*
* The rule editor asks you to think in lines. The wizard asks the question the
* way a finance meeting asks it: here is 150,000 — carve a piece off, see what
* is left, carve the next, and whatever remains lands in the last account. The
* remaining balance is the thing on screen at all times, because that is what
* everyone in the room is tracking.
*
* Same rules, same versioning, same engine — only the framing differs.
*/
public function wizard(Request $request, string $id): Response
{
$this->authorize('accounting.revenue_mapping.manage');
$db = App::getInstance()->db();
$stream = $db->selectOne("SELECT * FROM revenue_streams WHERE id = ?", [(int) $id]);
if (!$stream) {
return $this->redirect('/accounting/revenue-mapping')->withError('مصدر الإيراد غير موجود');
}
$configured = RevenuePostingEngine::configuredStages((int) $id);
$stage = (string) $request->get('stage', '');
if (!\in_array($stage, RevenuePostingEngine::STAGES, true)) {
$stage = $configured[0] ?? self::defaultStageFor($stream);
}
$category = trim((string) $request->get('member_category', ''));
// Load the rule matching this exact scope, so editing the working-member
// split does not silently show the general one.
$rule = $db->selectOne(
"SELECT * FROM revenue_posting_rules
WHERE stream_id = ? AND stage = ? AND status = 'active'
AND effective_from <= CURDATE()
AND (effective_to IS NULL OR effective_to >= CURDATE())
AND " . ($category !== '' ? "member_category = ?" : "member_category IS NULL") . "
ORDER BY version DESC LIMIT 1",
$category !== '' ? [(int) $id, $stage, $category] : [(int) $id, $stage]
);
$lines = [];
if ($rule) {
$lines = $db->select(
"SELECT l.*, coa.account_code, coa.name_ar AS account_name,
rec.account_code AS recognized_code, rec.name_ar AS recognized_name
FROM revenue_posting_rule_lines l
JOIN chart_of_accounts coa ON coa.id = l.account_id
LEFT JOIN chart_of_accounts rec ON rec.id = l.recognized_account_id
WHERE l.rule_id = ? AND l.is_active = 1
ORDER BY l.sort_order ASC",
[(int) $rule['id']]
);
}
// A realistic default amount so the wizard opens with something meaningful
// rather than zero — the average of what this stream has actually collected.
$suggested = '150000.00';
if ($stream['source_module'] === 'payments' && !empty($stream['source_key'])) {
$avg = $db->selectOne(
"SELECT ROUND(AVG(amount), 2) AS a FROM payments
WHERE payment_type = ? AND is_voided = 0 AND amount > 0",
[$stream['source_key']]
);
if (!empty($avg['a'])) {
$suggested = (string) $avg['a'];
}
}
return $this->view('Accounting.Views.revenue_mapping.wizard', [
'stream' => $stream,
'rule' => $rule,
'lines' => $lines,
'stage' => $stage,
'stages' => RevenuePostingEngine::STAGE_LABELS,
'configured' => $configured,
'category' => $category,
'categories' => RevenuePostingEngine::memberCategories(),
'suggested' => $suggested,
'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"),
]);
}
/** A sensible first stage to offer for a stream that has none configured. */
private static function defaultStageFor(array $stream): string
{
......@@ -248,7 +332,9 @@ class RevenueMappingController extends Controller
return $this->redirect('/accounting/revenue-mapping/' . $streamId . '/edit')
->withError('مرحلة قيد غير معروفة');
}
$back = '/accounting/revenue-mapping/' . $streamId . '/edit?stage=' . $stage;
$back = '/accounting/revenue-mapping/' . $streamId
. ((string) $request->post('return_to', '') === 'wizard' ? '/wizard' : '/edit')
. '?stage=' . $stage;
$direction = (string) $request->post('direction', 'inflow');
if (!\in_array($direction, ['inflow', 'outflow'], true)) {
......@@ -265,7 +351,15 @@ class RevenueMappingController extends Controller
$effectiveFrom = date('Y-m-d');
}
$current = RevenuePostingEngine::resolveRule($streamId, date('Y-m-d'), ['stage' => $stage]);
$memberCategory = trim((string) $request->post('member_category', ''));
$memberCategory = $memberCategory !== '' ? $memberCategory : null;
// The rule this save replaces — resolved in the SAME scope, so editing the
// working-member split never supersedes the general rule.
$current = RevenuePostingEngine::resolveRule($streamId, date('Y-m-d'), [
'stage' => $stage,
'member_category' => $memberCategory,
]);
$nextVersion = 1;
$maxRow = $db->selectOne(
"SELECT MAX(version) AS v FROM revenue_posting_rules WHERE stream_id = ? AND stage = ?",
......@@ -313,6 +407,7 @@ class RevenueMappingController extends Controller
'name_ar' => $request->post('name_ar') ?: ('إصدار ' . $nextVersion),
'branch_id' => $branchId,
'payment_method' => $paymentMethod,
'member_category' => $memberCategory,
'debit_account_id' => $debitAccountId,
'debit_source' => $debitSource,
'tax_profile_id' => $taxProfileId,
......@@ -352,7 +447,8 @@ class RevenueMappingController extends Controller
// Supersede the rule this one replaces (same scope only).
if ($current) {
$sameScope = ((int) ($current['branch_id'] ?? 0)) === ((int) ($branchId ?? 0))
&& ((string) ($current['payment_method'] ?? '')) === ((string) ($paymentMethod ?? ''));
&& ((string) ($current['payment_method'] ?? '')) === ((string) ($paymentMethod ?? ''))
&& ((string) ($current['member_category'] ?? '')) === ((string) ($memberCategory ?? ''));
if ($sameScope) {
$db->update('revenue_posting_rules', [
'status' => 'superseded',
......
......@@ -163,6 +163,7 @@ return [
['POST', '/accounting/revenue-mapping/create-account', 'Accounting\Controllers\RevenueMappingController@createAccount', ['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/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+}/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'],
......
......@@ -315,6 +315,18 @@ final class RevenuePostingEngine
$method = $ctx['payment_method'] ?? null;
$stage = $ctx['stage'] ?? 'collection';
// Member category, so "قيمة العضوية للعضو العامل" can split differently from
// the same fee charged to a foreign or sports member. Resolved from the
// member when the caller did not supply it.
$category = $ctx['member_category'] ?? null;
if ($category === null && !empty($ctx['member_id'])) {
$m = $db->selectOne(
"SELECT member_category, membership_type FROM members WHERE id = ?",
[(int) $ctx['member_id']]
);
$category = $m['member_category'] ?? $m['membership_type'] ?? null;
}
$candidates = $db->select(
"SELECT * FROM revenue_posting_rules
WHERE stream_id = ?
......@@ -324,18 +336,23 @@ final class RevenuePostingEngine
AND (effective_to IS NULL OR effective_to >= ?)
AND (branch_id IS NULL OR branch_id = ?)
AND (payment_method IS NULL OR payment_method = ?)
AND (member_category IS NULL OR member_category = ?)
ORDER BY effective_from DESC, version DESC",
[$streamId, $stage, $onDate, $onDate, $branchId, $method]
[$streamId, $stage, $onDate, $onDate, $branchId, $method, $category]
);
if (empty($candidates)) {
return null;
}
// Score specificity so the narrowest match wins deterministically.
// Score specificity so the narrowest match wins deterministically. Member
// category outranks branch, which outranks payment method: a rule written
// for working members is more deliberate than one written for a branch.
usort($candidates, static function (array $a, array $b): int {
$score = static fn(array $r): int =>
($r['branch_id'] !== null ? 2 : 0) + ($r['payment_method'] !== null ? 1 : 0);
($r['member_category'] !== null ? 4 : 0)
+ ($r['branch_id'] !== null ? 2 : 0)
+ ($r['payment_method'] !== null ? 1 : 0);
$diff = $score($b) <=> $score($a);
if ($diff !== 0) {
return $diff;
......@@ -350,6 +367,33 @@ final class RevenuePostingEngine
return $candidates[0];
}
/** Member categories present in the data, for the scope picker. */
public static function memberCategories(): array
{
$db = App::getInstance()->db();
$labels = [
'working_member' => 'عضو عامل',
'foreign_member' => 'عضو أجنبي',
'sports_member' => 'عضو رياضي',
'honorary_member' => 'عضو فخري',
'seasonal_member' => 'عضو موسمي',
];
$rows = $db->select(
"SELECT member_category AS c, COUNT(*) AS n
FROM members WHERE is_archived = 0 AND member_category IS NOT NULL
GROUP BY member_category ORDER BY n DESC"
);
$out = [];
foreach ($rows as $r) {
$code = (string) $r['c'];
$out[$code] = ($labels[$code] ?? $code) . ' (' . number_format((int) $r['n']) . ')';
}
return $out;
}
/** Does this stream have an active rule for a stage right now? */
public static function isConfigured(int $streamId, string $stage = 'collection'): bool
{
......
......@@ -167,10 +167,14 @@
</td>
<td style="text-align:left;">
<?php if (can('accounting.revenue_mapping.manage')): ?>
<a href="/accounting/revenue-mapping/<?= (int) $s['id'] ?>/edit?stage=<?= e($s['rule_stage']) ?>"
class="btn btn-sm <?= $isSingle ? 'btn-secondary' : 'btn-outline' ?>">
<?= $isSingle ? 'قسّم على حسابات' : 'عدّل التوزيع' ?>
</a>
<div style="display:flex;gap:5px;justify-content:flex-start;">
<a href="/accounting/revenue-mapping/<?= (int) $s['id'] ?>/wizard?stage=<?= e($s['rule_stage']) ?>"
class="btn btn-sm btn-secondary">
<?= $isSingle ? 'قسّم على حسابات' : 'عدّل التوزيع' ?>
</a>
<a href="/accounting/revenue-mapping/<?= (int) $s['id'] ?>/edit?stage=<?= e($s['rule_stage']) ?>"
class="btn btn-sm btn-outline" title="الوضع المتقدّم">متقدّم</a>
</div>
<?php else: ?>
<a href="/accounting/revenue-mapping/<?= (int) $s['id'] ?>/edit" class="btn btn-sm btn-outline">عرض</a>
<?php endif; ?>
......
......@@ -211,13 +211,18 @@ foreach ($streams as $s) {
<div style="display:flex;flex-direction:column;gap:4px;align-items:stretch;">
<?php if (!empty($s['stages'])): ?>
<?php foreach (array_keys($s['stages']) as $stageKey): ?>
<a href="/accounting/revenue-mapping/<?= (int) $s['id'] ?>/edit?stage=<?= e($stageKey) ?>"
class="btn btn-sm btn-outline" style="font-size:11px;padding:3px 8px;">
<?= e($stageLabels[$stageKey] ?? $stageKey) ?>
</a>
<div style="display:flex;gap:3px;">
<a href="/accounting/revenue-mapping/<?= (int) $s['id'] ?>/wizard?stage=<?= e($stageKey) ?>"
class="btn btn-sm btn-outline" style="font-size:11px;padding:3px 8px;flex:1;">
<?= e($stageLabels[$stageKey] ?? $stageKey) ?>
</a>
<a href="/accounting/revenue-mapping/<?= (int) $s['id'] ?>/edit?stage=<?= e($stageKey) ?>"
class="btn btn-sm btn-ghost" style="font-size:11px;padding:3px 6px;"
title="الوضع المتقدّم"></a>
</div>
<?php endforeach; ?>
<?php else: ?>
<a href="/accounting/revenue-mapping/<?= (int) $s['id'] ?>/edit" class="btn btn-sm btn-primary">ربط الحسابات</a>
<a href="/accounting/revenue-mapping/<?= (int) $s['id'] ?>/wizard" class="btn btn-sm btn-primary">ربط الحسابات</a>
<?php endif; ?>
</div>
</td>
......
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>معالج توزيع المبلغ<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div style="margin-bottom:14px;">
<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>
<p style="margin:0;color:#6B7280;font-size:13px;">
اقتطع جزءًا، شوف الباقي، اقتطع الجزء اللي بعده. اللي يفضل في الآخر بيروح للحساب الأخير.
</p>
</div>
<!-- Scope: which fee, for whom -->
<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: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>
<label class="form-label">المرحلة</label>
<select name="stage" class="form-select" onchange="this.form.submit()">
<?php foreach ($stages as $k => $lbl): ?>
<option value="<?= e($k) ?>" <?= $stage === $k ? 'selected' : '' ?>>
<?= e($lbl) ?><?= \in_array($k, $configured, true) ? ' ✓' : '' ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label">فئة العضو</label>
<select name="member_category" class="form-select" onchange="this.form.submit()">
<option value="">كل الأعضاء — قاعدة عامة</option>
<?php foreach ($categories as $code => $lbl): ?>
<option value="<?= e($code) ?>" <?= $category === $code ? 'selected' : '' ?>><?= e($lbl) ?></option>
<?php endforeach; ?>
</select>
<div class="form-help">قاعدة لفئة معيّنة بتغلب القاعدة العامة تلقائيًا.</div>
</div>
<div><button type="submit" class="btn btn-outline">تحميل</button></div>
</form>
<?php if ($category !== ''): ?>
<div style="margin-top:12px;background:#EFF6FF;border:1px solid #BFDBFE;border-radius:6px;padding:10px 12px;font-size:12.5px;color:#1E40AF;">
بتعدّل التوزيع الخاص بـ <strong><?= e($categories[$category] ?? $category) ?></strong> فقط.
باقي الفئات هتفضل على القاعدة العامة.
</div>
<?php endif; ?>
</div>
</div>
<form method="POST" action="/accounting/revenue-mapping/<?= (int) $stream['id'] ?>" id="wz-form">
<?= csrf_field() ?>
<input type="hidden" name="lines" id="lines-payload">
<input type="hidden" name="stage" value="<?= e($stage) ?>">
<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')) ?>">
<div style="display:grid;grid-template-columns:minmax(0,1.5fr) minmax(0,1fr);gap:16px;align-items:start;">
<div>
<!-- The amount being carved up -->
<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:16px 18px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;">
<div>
<label class="form-label">مبلغ مرجعي للحساب</label>
<input type="number" id="wz-base" class="form-input" step="0.01" min="0"
value="<?= e($suggested) ?>" dir="ltr"
style="text-align:right;font-size:20px;font-weight:700;">
<div class="form-help">للتوضيح فقط — النسب بتتطبّق على أي مبلغ فعلي.</div>
</div>
<div>
<label class="form-label">المعالجة الضريبية</label>
<select name="tax_profile_id" id="wz-tax" class="form-select">
<option value="" data-rate="0" data-inclusive="1">بدون ضريبة</option>
<?php foreach ($taxProfiles as $tp): ?>
<option value="<?= (int) $tp['id'] ?>"
data-rate="<?= e((string) $tp['rate']) ?>"
data-inclusive="<?= (int) $tp['is_price_inclusive'] ?>"
<?= ($rule && (int) ($rule['tax_profile_id'] ?? 0) === (int) $tp['id']) ? 'selected' : '' ?>>
<?= e($tp['name_ar']) ?> (<?= e(rtrim(rtrim(number_format((float) $tp['rate'], 2), '0'), '.')) ?>%)
</option>
<?php endforeach; ?>
</select>
<div class="form-help">الضريبة بتتفصل الأول، والتوزيع بيتم على الصافي.</div>
</div>
</div>
<?php if (!empty($costCenters)): ?>
<div style="margin-top:14px;max-width:340px;">
<label class="form-label">مركز التكلفة (اختياري)</label>
<select name="cost_center_id" class="form-select">
<option value="">بدون</option>
<?php foreach ($costCenters as $cc): ?>
<option value="<?= (int) $cc['id'] ?>"
<?= ($rule && (int) ($rule['cost_center_id'] ?? 0) === (int) $cc['id']) ? 'selected' : '' ?>>
<?= e($cc['code']) ?><?= e($cc['name_ar']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<?php endif; ?>
</div>
</div>
<!-- The running allocation -->
<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;">
<h3 style="margin:0;font-size:14px;">٣ — اقتطع الأجزاء</h3>
<button type="button" id="wz-add" class="btn btn-sm btn-secondary">+ اقتطع جزء</button>
</div>
<!-- Remaining, always visible -->
<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;">
<span style="font-size:13px;color:#6B7280;">الباقي غير الموزَّع</span>
<span id="wz-remaining" style="font-size:30px;font-weight:800;color:#059669;font-variant-numeric:tabular-nums;">0.00</span>
</div>
<div style="height:22px;border-radius:5px;background:#E5E7EB;overflow:hidden;display:flex;" id="wz-bar"></div>
<div id="wz-legend" style="margin-top:8px;display:flex;flex-wrap:wrap;gap:12px;font-size:11.5px;"></div>
</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="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
<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>
</div>
<div style="display:grid;grid-template-columns:1fr 190px;gap:10px;">
<div>
<input type="text" class="form-input acct-search" id="wz-rem-search"
placeholder="ابحث عن الحساب النهائي" autocomplete="off">
<input type="hidden" id="wz-rem-acct">
<div class="acct-results" id="wz-rem-results"></div>
</div>
<div>
<select class="form-select" id="wz-rem-type">
<option value="revenue">إيراد</option>
<option value="deferred_revenue">إيراد مؤجل</option>
<option value="passthrough">تحصيل لحساب الغير</option>
</select>
</div>
</div>
<div class="form-help" style="color:#065F46;">
البند ده بياخد المتبقي وكسور القرش، فالقيد بيفضل متوازن دايمًا.
</div>
</div>
</div>
<div class="card" style="margin-bottom:14px;">
<div style="padding:14px 18px;display:grid;grid-template-columns:1fr 1fr;gap:14px;">
<div>
<label class="form-label">ساري اعتبارًا من</label>
<input type="date" class="form-input" value="<?= e(date('Y-m-d')) ?>"
onchange="document.getElementById('wz-effective').value=this.value;">
<div class="form-help">القيود المرحّلة قبل التاريخ ده ما بتتغيرش.</div>
</div>
<div>
<label class="form-label">سبب التغيير</label>
<input type="text" name="notes" class="form-input" placeholder="قرار مجلس الإدارة رقم …">
</div>
</div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button type="submit" class="btn btn-primary btn-lg" id="wz-save">حفظ وتفعيل التوزيع</button>
<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>
</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>
<!-- Resulting entry -->
<div style="position:sticky;top:14px;">
<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:16px 18px;" id="wz-preview"></div>
</div>
</div>
</div>
</form>
<template id="wz-tpl">
<div class="wz-line" style="border:1px solid #E5E7EB;border-radius:8px;padding:12px;margin-bottom:10px;background:#fff;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
<span class="wz-idx" style="font-weight:600;font-size:12.5px;color:#374151;"></span>
<div style="display:flex;align-items:center;gap:10px;">
<span class="wz-amt" style="font-weight:700;font-size:15px;color:#2563EB;font-variant-numeric:tabular-nums;">0.00</span>
<button type="button" class="btn btn-sm btn-ghost wz-up" title="لأعلى"></button>
<button type="button" class="btn btn-sm btn-ghost wz-down" title="لأسفل"></button>
<button type="button" class="btn btn-sm btn-ghost wz-del" style="color:#DC2626;">حذف</button>
</div>
</div>
<div style="display:grid;grid-template-columns:130px 120px 1fr;gap:10px;align-items:end;">
<div>
<label class="form-label" style="font-size:11px;">الطريقة</label>
<select class="form-select wz-method">
<option value="percentage">نسبة %</option>
<option value="fixed">مبلغ ثابت</option>
</select>
</div>
<div>
<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;">
</div>
<div>
<label class="form-label" style="font-size:11px;display:flex;justify-content:space-between;">
<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>
</label>
<input type="text" class="form-input acct-search wz-search" placeholder="ابحث بالكود أو الاسم">
<input type="hidden" class="wz-acct">
<div class="acct-results"></div>
</div>
</div>
<div style="display:grid;grid-template-columns:200px 1fr;gap:10px;margin-top:10px;">
<div>
<label class="form-label" style="font-size:11px;">نوع البند</label>
<select class="form-select wz-type">
<option value="revenue">إيراد</option>
<option value="passthrough">تحصيل لحساب الغير (التزام)</option>
<option value="deferred_revenue">إيراد مؤجل</option>
</select>
</div>
<div>
<label class="form-label" style="font-size:11px;">وصف البند في القيد</label>
<input type="text" class="form-input wz-desc" placeholder="اختياري">
</div>
</div>
<div class="wz-after" style="margin-top:8px;font-size:11.5px;color:#6B7280;"></div>
</div>
</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 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;">
<h3 style="margin:0;font-size:15px;">حساب جديد</h3>
<button type="button" id="acct-close" class="btn btn-sm btn-ghost">إغلاق</button>
</div>
<div style="padding:18px;display:flex;flex-direction:column;gap:12px;">
<div>
<label class="form-label">تحت أي حساب رئيسي؟</label>
<select id="acct-parent" class="form-select"><option value="">— اختر —</option></select>
</div>
<div>
<label class="form-label">اسم الحساب</label>
<input type="text" id="acct-name-ar" class="form-input" placeholder="مثال: صندوق دعم النشاط الرياضي">
</div>
<div id="acct-error" style="display:none;background:#FEF2F2;border:1px solid #FECACA;border-radius:6px;padding:9px;color:#991B1B;font-size:12px;"></div>
<div style="display:flex;gap:8px;">
<button type="button" id="acct-save" class="btn btn-primary">إنشاء وتحديد</button>
<button type="button" id="acct-cancel" class="btn btn-ghost">إلغاء</button>
</div>
</div>
</div>
</div>
<script>
(function () {
'use strict';
var box = document.getElementById('wz-lines');
var tpl = document.getElementById('wz-tpl');
var baseEl = document.getElementById('wz-base');
var taxEl = document.getElementById('wz-tax');
var csrf = document.querySelector('input[name="_csrf_token"]');
var COLORS = ['#2563EB','#7C3AED','#D97706','#0891B2','#DB2777','#65A30D','#DC2626'];
var existing = <?= json_encode(array_map(static function (array $l): array {
$raw = $l['allocation_method'] === 'percentage' ? $l['percentage'] : $l['fixed_amount'];
return [
'method' => $l['allocation_method'],
'value' => $raw === null ? '' : (string) (float) $raw, // 20.0000 → 20
'account' => (int) $l['account_id'],
'label' => $l['account_code'] . ' — ' . $l['account_name'],
'type' => $l['line_type'],
'desc' => $l['description_ar'],
];
}, $lines), JSON_UNESCAPED_UNICODE) ?>;
function fmt(n) {
return Number(n || 0).toLocaleString('en-US', {minimumFractionDigits:2, maximumFractionDigits:2});
}
function r2(n) { return Math.round((Number(n) + Number.EPSILON) * 100) / 100; }
// ── Account search ──────────────────────────────────────────
function wireSearch(input, hidden, results) {
var timer = null;
input.addEventListener('input', function () {
clearTimeout(timer);
var q = input.value.trim();
if (q.length < 2) { results.innerHTML = ''; return; }
timer = setTimeout(function () {
fetch('/accounting/revenue-mapping/search-accounts?q=' + encodeURIComponent(q))
.then(function (r) { return r.json(); })
.then(function (d) {
results.innerHTML = '';
var b = document.createElement('div');
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;';
(d.accounts || []).forEach(function (a) {
var row = document.createElement('div');
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:70px;">' + a.account_code + '</span> ' + a.name_ar;
row.addEventListener('click', function () {
hidden.value = a.id;
input.value = a.account_code + ' — ' + a.name_ar;
results.innerHTML = '';
recalc();
});
b.appendChild(row);
});
results.appendChild(b);
});
}, 220);
});
}
wireSearch(document.getElementById('wz-rem-search'), document.getElementById('wz-rem-acct'), document.getElementById('wz-rem-results'));
// ── Lines ───────────────────────────────────────────────────
function addLine(data) {
var node = tpl.content.cloneNode(true);
var el = node.querySelector('.wz-line');
box.appendChild(node);
var method = el.querySelector('.wz-method');
var vlabel = el.querySelector('.wz-vlabel');
wireSearch(el.querySelector('.wz-search'), el.querySelector('.wz-acct'), el.querySelector('.acct-results'));
method.addEventListener('change', function () {
vlabel.textContent = method.value === 'percentage' ? 'نسبة % من الصافي' : 'مبلغ ثابت';
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-del').addEventListener('click', function () { el.remove(); recalc(); });
el.querySelector('.wz-up').addEventListener('click', function () {
if (el.previousElementSibling) { box.insertBefore(el, el.previousElementSibling); recalc(); }
});
el.querySelector('.wz-down').addEventListener('click', function () {
if (el.nextElementSibling) { box.insertBefore(el.nextElementSibling, el); recalc(); }
});
el.querySelector('.wz-new').addEventListener('click', function () {
openModal(el.querySelector('.wz-acct'), el.querySelector('.wz-search'));
});
if (data) {
method.value = data.method === 'fixed' ? 'fixed' : 'percentage';
vlabel.textContent = method.value === 'percentage' ? 'نسبة % من الصافي' : 'مبلغ ثابت';
el.querySelector('.wz-value').value = data.value || '';
el.querySelector('.wz-acct').value = data.account || '';
el.querySelector('.wz-search').value = data.label || '';
el.querySelector('.wz-type').value = data.type || 'revenue';
el.querySelector('.wz-desc').value = data.desc || '';
}
recalc();
}
// ── The heart: running remainder ────────────────────────────
// This MUST mirror RevenueAllocator exactly, or the preview lies about what
// will post: tax off the top, then fixed lines, then percentages of the net
// AFTER fixed — a percentage is always "of the amount", never "of the rest".
function recalc() {
var gross = Number(baseEl.value || 0);
var taxOpt = taxEl.options[taxEl.selectedIndex];
var taxRate = Number((taxOpt && taxOpt.dataset.rate) || 0) / 100;
var taxIncl = !taxOpt || taxOpt.dataset.inclusive !== '0';
var tax = 0, net = gross;
if (taxEl.value && taxRate > 0) {
if (taxIncl) { net = r2(gross / (1 + taxRate)); tax = r2(gross - net); }
else { tax = r2(gross * taxRate); net = gross; }
}
var rows = Array.prototype.slice.call(box.querySelectorAll('.wz-line'));
var seg = rows.map(function (el, i) {
return {
el: el,
method: el.querySelector('.wz-method').value,
raw: Number(el.querySelector('.wz-value').value || 0),
amount: 0,
label: el.querySelector('.wz-search').value || ('الجزء ' + (i + 1)),
color: COLORS[i % COLORS.length],
type: el.querySelector('.wz-type').value,
account: el.querySelector('.wz-acct').value,
desc: el.querySelector('.wz-desc').value
};
});
var pool = net, fixedTotal = 0, overrun = false;
seg.forEach(function (s) {
if (s.method !== 'fixed') return;
var a = r2(s.raw);
if (a > pool) { a = Math.max(0, r2(pool)); overrun = true; }
s.amount = a; pool = r2(pool - a); fixedTotal = r2(fixedTotal + a);
});
var netAfterFixed = r2(net - fixedTotal);
seg.forEach(function (s) {
if (s.method !== 'percentage') return;
var a = r2(netAfterFixed * (s.raw / 100));
if (a > pool) { a = Math.max(0, r2(pool)); overrun = true; }
s.amount = a; pool = r2(pool - a);
});
// Running strip in the order the user sees them.
var remaining = net;
seg.forEach(function (s, i) {
var before = remaining;
remaining = r2(remaining - s.amount);
s.el.querySelector('.wz-idx').textContent = 'الجزء ' + (i + 1);
s.el.querySelector('.wz-amt').textContent = fmt(s.amount);
s.el.querySelector('.wz-after').innerHTML =
(s.method === 'percentage'
? '<span style="color:#9CA3AF;">' + s.raw + '% من ' + fmt(netAfterFixed) + '</span> · '
: '')
+ 'قبله <strong>' + fmt(before) + '</strong> · بياخد <strong>' + fmt(s.amount)
+ '</strong> · يفضل <strong style="color:#059669;">' + fmt(remaining) + '</strong>';
});
var segments = seg;
document.getElementById('wz-remaining').textContent = fmt(remaining);
document.getElementById('wz-remaining').style.color = remaining < 0 ? '#DC2626' : '#059669';
document.getElementById('wz-rem-amount').textContent = fmt(remaining);
// Bar
var bar = document.getElementById('wz-bar');
var legend = document.getElementById('wz-legend');
bar.innerHTML = ''; legend.innerHTML = '';
var basis = net > 0 ? net : 1;
segments.forEach(function (s) {
if (s.amount <= 0) return;
var seg = document.createElement('div');
seg.style.cssText = 'width:' + ((s.amount / basis) * 100) + '%;background:' + s.color + ';';
seg.title = s.label + ' — ' + fmt(s.amount);
bar.appendChild(seg);
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>'
+ s.label + ' <strong>' + fmt(s.amount) + '</strong>';
legend.appendChild(li);
});
if (remaining > 0) {
var rest = document.createElement('div');
rest.style.cssText = 'width:' + ((remaining / basis) * 100) + '%;background:#A7F3D0;';
rest.title = 'الباقي — ' + fmt(remaining);
bar.appendChild(rest);
var li2 = document.createElement('span');
li2.innerHTML = '<span style="display:inline-block;width:9px;height:9px;border-radius:2px;background:#A7F3D0;margin-inline-end:5px;"></span>الباقي <strong>' + fmt(remaining) + '</strong>';
legend.appendChild(li2);
}
renderPreview(gross, net, tax, segments, remaining, overrun);
buildPayload(segments);
}
function renderPreview(gross, net, tax, segments, remaining, overrun) {
var h = '';
if (overrun) {
h += '<div style="background:#FEF2F2;border:1px solid #FECACA;border-radius:6px;padding:9px;color:#991B1B;font-size:12px;margin-bottom:10px;">'
+ 'اقتطاع أكبر من المتاح — تم تخفيضه.</div>';
}
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:#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>';
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>'
+ '<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:66px;">دائن</th></tr></thead><tbody>';
function row(label, dr, cr, bg) {
var s = bg ? 'background:' + bg + ';' : '';
return '<tr><td style="padding:6px;border-bottom:1px solid #F3F4F6;' + s + '">' + label + '</td>'
+ '<td style="padding:6px;border-bottom:1px solid #F3F4F6;text-align:left;font-weight:600;' + s + '">' + (dr ? fmt(dr) : '') + '</td>'
+ '<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);
if (tax > 0) h += row('ضريبة القيمة المضافة <span style="font-size:10px;color:#92400E;">التزام</span>', 0, tax, '#FFFBEB');
segments.forEach(function (s) { if (s.amount > 0) h += row(s.label, 0, s.amount, null); });
if (remaining > 0) {
var rl = document.getElementById('wz-rem-search').value || '<span style="color:#DC2626;">الحساب النهائي — لسه ما اتحددش</span>';
h += row(rl + ' <span style="font-size:10px;color:#065F46;">الباقي</span>', 0, remaining, '#ECFDF5');
}
var cr = tax + segments.reduce(function (a, s) { return a + s.amount; }, 0) + Math.max(0, remaining);
var ok = Math.abs(cr - gross) < 0.005;
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;color:' + (ok ? '#059669' : '#DC2626') + ';">' + fmt(cr) + '</td></tr>';
h += '</tbody></table>';
h += '<div style="margin-top:8px;font-size:11.5px;font-weight:600;color:' + (ok ? '#059669' : '#DC2626') + ';">'
+ (ok ? '✓ القيد متوازن' : '✗ القيد غير متوازن') + '</div>';
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) {
var out = [];
segments.forEach(function (s) {
// Keep the line if it is configured, even when the reference amount
// makes it resolve to zero — the reference is illustrative only.
if (!s.account || !(s.raw > 0)) return;
out.push({
line_type: s.type,
allocation_method: s.method,
percentage: s.method === 'percentage' ? s.raw : '',
fixed_amount: s.method === 'fixed' ? s.raw : '',
percentage_base: 'net_after_fixed',
account_id: s.account,
description_ar: s.desc,
recognition_method: 'immediate',
recognition_months: '',
recognized_account_id: '',
max_amount: ''
});
});
var remAcct = document.getElementById('wz-rem-acct').value;
if (remAcct) {
out.push({
line_type: document.getElementById('wz-rem-type').value,
allocation_method: 'remainder',
percentage: '', fixed_amount: '',
percentage_base: 'net_after_fixed',
account_id: remAcct,
description_ar: 'الباقي',
recognition_method: 'immediate',
recognition_months: '', recognized_account_id: '', max_amount: ''
});
}
document.getElementById('lines-payload').value = JSON.stringify(out);
}
document.getElementById('wz-form').addEventListener('submit', function (e) {
var err = document.getElementById('wz-error');
if (!document.getElementById('wz-rem-acct').value) {
e.preventDefault();
err.textContent = 'لازم تحدد الحساب اللي يروح له الباقي قبل الحفظ.';
err.style.display = 'block';
window.scrollTo({ top: err.offsetTop - 120, behavior: 'smooth' });
return false;
}
var missing = false;
box.querySelectorAll('.wz-line').forEach(function (el) {
if (Number(el.querySelector('.wz-value').value || 0) > 0 && !el.querySelector('.wz-acct').value) missing = true;
});
if (missing) {
e.preventDefault();
err.textContent = 'فيه جزء بمبلغ من غير حساب — حدد الحساب أو احذف الجزء.';
err.style.display = 'block';
return false;
}
err.style.display = 'none';
});
// ── Create-account modal ────────────────────────────────────
var modal = document.getElementById('acct-modal');
var pendingH = null, pendingI = null, loaded = false;
function openModal(h, i) {
pendingH = h; pendingI = i;
document.getElementById('acct-error').style.display = 'none';
document.getElementById('acct-name-ar').value = '';
modal.style.display = 'flex';
if (loaded) return;
fetch('/accounting/revenue-mapping/parent-accounts')
.then(function (r) { return r.json(); })
.then(function (d) {
var sel = document.getElementById('acct-parent');
(d.parents || []).forEach(function (p) {
var o = document.createElement('option');
o.value = p.id; o.textContent = p.account_code + ' — ' + p.name_ar;
sel.appendChild(o);
});
loaded = true;
});
}
document.getElementById('acct-close').addEventListener('click', function () { modal.style.display = 'none'; });
document.getElementById('acct-cancel').addEventListener('click', function () { modal.style.display = 'none'; });
document.getElementById('acct-save').addEventListener('click', function () {
var body = new FormData();
body.append('parent_id', document.getElementById('acct-parent').value);
body.append('name_ar', document.getElementById('acct-name-ar').value);
if (csrf) body.append('_csrf_token', csrf.value);
fetch('/accounting/revenue-mapping/create-account', {
method: 'POST', body: body,
headers: { 'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-TOKEN': csrf ? csrf.value : '' }
}).then(function (r) { return r.json(); }).then(function (d) {
var e = document.getElementById('acct-error');
if (!d.success) { e.textContent = d.error || 'تعذر الإنشاء'; e.style.display = 'block'; return; }
if (pendingH && pendingI) {
pendingH.value = d.account.id;
pendingI.value = d.account.account_code + ' — ' + d.account.name_ar;
}
modal.style.display = 'none';
recalc();
});
});
// ── Boot ────────────────────────────────────────────────────
baseEl.addEventListener('input', recalc);
taxEl.addEventListener('change', recalc);
document.getElementById('wz-rem-type').addEventListener('change', recalc);
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];
existing.filter(function (l) { return l.method !== 'remainder'; }).forEach(addLine);
if (rem) {
document.getElementById('wz-rem-acct').value = rem.account;
document.getElementById('wz-rem-search').value = rem.label;
document.getElementById('wz-rem-type').value = rem.type || 'revenue';
}
if (!box.children.length) addLine(null);
recalc();
})();
</script>
<?php $__template->endSection(); ?>
# معالج توزيع المبالغ — دليل الاستخدام
> **الغرض من الملف ده:** تعرف إزاي تمسك أي مبلغ في النادي — قيمة عضوية، اشتراك،
> حجز، إيجار محل — وتقول: النسبة دي تروح للكود ده، والنسبة دي للكود ده، والباقي
> يروح فين. من غير ما تكلّم مبرمج.
---
## الفكرة في سطرين
المبلغ اللي بيدفعه العضو مش لازم يروح لحساب واحد. الـ **معالج التوزيع** (الويزارد)
بيخليك تقسّم المبلغ على أي عدد حسابات — بنسبة مئوية أو بمبلغ ثابت — وبيوريك
**الباقي غير الموزَّع** وإنت بتقسّم، لحظة بلحظة. وبند "الباقي" في الآخر بياخد اللي
فضل مهما كان، فالقيد بيفضل متوازن ١٠٠٪.
---
## مصطلحات هتقابلها
| المصطلح | المعنى بالبلدي |
|---|---|
| **مصدر الإيراد** (Revenue Stream) | نوع الفلوس. "قيمة العضوية"، "اشتراك سنوي"، "حجز ملعب"، "إيجار محل". |
| **المرحلة** (Stage) | امتى بيتعمل القيد. **استحقاق** = وقت ما نطالب. **تحصيل** = وقت ما نقبض. **صرف** = وقت ما ندفع. **استرداد** = لما نرجّع. |
| **القاعدة** (Rule) | ورقة التعليمات: "المبلغ ده يتوزّع كذا". لها إصدارات ولها تاريخ سريان. |
| **البند** (Line) | سطر واحد من التوزيع: "٢٠٪ لحساب كذا". |
| **الباقي** (Remainder) | البند الأخير — بياخد اللي فضل + كسور القرش. لازم يكون موجود. |
| **الصافي** (Net) | المبلغ بعد ما نفصل الضريبة. التوزيع بيتم على الصافي مش على المحصَّل. |
| **حساب رئيسي** (Header) | حساب بيتجمّع تحته حسابات تانية. **القيد ما بينزلش عليه أبدًا.** |
| **فئة العضو** | عضو عامل / أجنبي / رياضي / فخري / موسمي. |
---
## ١. تفتح الويزارد إزاي
**المسار:** المحاسبة ← محرك القيود (`/accounting/revenue-mapping`)
هتلاقي جدول بكل مصادر الإيراد في النادي. جنب كل واحد أزرار بأسماء المراحل.
- اضغط على اسم المرحلة (مثلًا **تحصيل**) → يفتح **الويزارد**.
- زرار الترس ⚙ جنبه → يفتح **الوضع المتقدّم** (فيه كل الخيارات، لكنه أعقد).
- لو المصدر لسه مش متوصّل خالص → هتلاقي زرار **ربط الحسابات**.
كمان من **مركز التوصيل** (`/accounting/revenue-mapping/connections`) — الشاشة اللي
بتوريك الموصّل والمش موصّل — كل صف فيه زرار **قسّم على حسابات** بيوديك لنفس الويزارد.
---
## ٢. الشاشة من فوق لتحت
### الخطوة ١ — التوزيع ده بيخص مين
فيها اختيارين:
**المرحلة** — غالبًا هتسيبها **تحصيل** (وقت ما العضو يدفع). العلامة ✓ جنب المرحلة
معناها إن فيه قاعدة شغالة عليها دلوقتي.
**فئة العضو** — دي أهم حاجة:
- سيبها **"كل الأعضاء — قاعدة عامة"** → التوزيع ده هيطبّق على أي حد يدفع.
- اختار **"عضو عامل (١٢٠)"** → التوزيع ده هيطبّق **على العضو العامل بس**.
> **القاعدة الأخص بتغلب العامة تلقائيًا.** يعني لو عملت قاعدة عامة وقاعدة تانية
> للعضو العامل، لما عضو عامل يدفع السيستم هيمشي على بتاعت العضو العامل. أي حد
> تاني هيمشي على العامة. مش محتاج تعمل أي حاجة عشان ده يحصل.
الرقم بين القوسين (١٢٠) هو عدد الأعضاء الفعليين في الفئة دي في الداتا دلوقتي.
---
### الخطوة ٢ — المبلغ اللي هنوزّعه
**مبلغ مرجعي للحساب** — الرقم ده **للتوضيح بس**. السيستم بيملاه لك تلقائيًا بمتوسط
اللي المصدر ده حصّله فعلًا (مثال: "قيمة العضوية" بيفتح على ١٢٩٬٤٥٣٫١٦). إنت
بتغيّره عشان تشوف الأرقام بعينك وإنت بتوزّع. النسب اللي هتحطها هتتطبّق على أي مبلغ
فعلي العضو يدفعه، مش على الرقم ده.
**المعالجة الضريبية** — لو الخدمة دي عليها ضريبة قيمة مضافة، اختار البروفايل هنا
(مثلًا ١٤٪ شامل). ساعتها:
- الضريبة **بتتفصل الأول** وبتروح لحساب التزام ضريبي (مش إيراد).
- التوزيع بيتم على **الصافي** بعد الضريبة.
**مثال حقيقي:** ١٥٠٬٠٠٠ بضريبة ١٤٪ شاملة →
الصافي **١٣١٬٥٧٨٫٩٥** والضريبة **١٨٬٤٢١٫٠٥**. لو حطيت ٢٠٪ هتاخد ٢٦٬٣١٥٫٧٩ (٢٠٪ من
الصافي، مش من الـ١٥٠ ألف).
**مركز التكلفة** — اختياري. لو النشاط ده بيتراقب على مركز تكلفة معيّن.
---
### الخطوة ٣ — اقتطع الأجزاء
دي قلب الشاشة. فوق خالص لوح كبير مكتوب فيه:
```
الباقي غير الموزَّع 150,000.00
[███████████████████████████████████████████]
```
الرقم ده بيتحدّث **مع كل حرف بتكتبه**. والشريط الملوّن تحته بيوريك كل جزء بلون
وحجمه الحقيقي من المبلغ.
اضغط **+ اقتطع جزء**، هيطلع كارت فيه:
| الحقل | تعمل بيه إيه |
|---|---|
| **الطريقة** | **نسبة %** أو **مبلغ ثابت** |
| **القيمة** | ٢٠ (يعني ٢٠٪) أو ٥٠٠٠ (يعني ٥٬٠٠٠ جنيه بالظبط) |
| **يروح لحساب** | اكتب حرفين من الكود أو الاسم وهيبحث لك. ولو الحساب مش موجود اضغط **+ حساب جديد** |
| **نوع البند** | إيراد / تحصيل لحساب الغير / إيراد مؤجل (شرح تحت) |
| **الوصف** | اللي هيظهر في القيد. اختياري بس مفيد. |
تحت كل كارت سطر رمادي بيقول لك بالظبط:
> ٢٠٪ من ١٥٠٬٠٠٠٫٠٠ · قبله **١٥٠٬٠٠٠٫٠٠** · بياخد **٣٠٬٠٠٠٫٠٠** · يفضل **١٢٠٬٠٠٠٫٠٠**
الأسهم ▲▼ بتحرّك الجزء فوق وتحت. **حذف** بيشيله.
> **مهم جدًا:** النسبة دايمًا **من صافي المبلغ**، مش من الباقي. يعني لو كتبت ٢٠٪
> بعدين ٣٠٪، الاتنين مجموعهم ٥٠٪ من المبلغ. ده اللي أي محاسب بيقصده لما يقول
> "٢٠٪ منه لكذا و٣٠٪ منه لكذا". **المبالغ الثابتة بتتخصم الأول**، وبعدين النسب
> بتتحسب على اللي فضل بعد الثابت.
---
### الخطوة ٤ — الباقي يروح لـ
سطر إجباري في الأخضر تحت خالص. اختار الحساب اللي هياخد اللي فضل.
**ليه إجباري؟** عشان القيد يفضل متوازن مهما حصل. لو المبلغ اتغيّر، أو كسور
القرش ما اتقسمتش بالظبط، البند ده بيبلع الفرق. من غيره القيد ممكن ما يتوازنش
والسيستم هيرفض الحفظ.
---
### لوحة "القيد الناتج" (على الشمال)
بتوريك **بالظبط** القيد اللي هينزل الأستاذ العام، قبل ما تحفظ:
| الحساب | مدين | دائن |
|---|---|---|
| النقدية / البنك | 150,000.00 | |
| ضريبة القيمة المضافة *(التزام)* | | 18,421.05 |
| ٤١٠١٠١ — صندوق النشاط الرياضي | | 26,315.79 |
| ٤١٠٥١٥ — إيرادات عضويات | | 39,473.69 |
| ٢٣٠٩٠١ — صندوق دعم المنشآت *(الباقي)* | | 65,789.47 |
| **الإجمالي** | **150,000.00** | **150,000.00** |
وتحتها: **✓ القيد متوازن**. لو ظهر **✗ القيد غير متوازن** بالأحمر — **ما تحفظش**،
راجع الأرقام.
---
### الحفظ
**ساري اعتبارًا من** — التاريخ اللي التوزيع الجديد يبدأ منه.
**سبب التغيير** — اكتب فيه رقم قرار مجلس الإدارة أو المذكرة. ده بيتسجّل في التاريخ.
اضغط **حفظ وتفعيل التوزيع**.
> **القيود اللي اترحّلت قبل التاريخ ده ما بتتغيّرش أبدًا.** الحفظ ما بيعدّلش
> القاعدة القديمة — بيعمل **إصدار جديد** ويوقف القديم. لو حد سأل "طب القيد اللي
> اتعمل الشهر اللي فات كان بأي توزيع؟" الإجابة موجودة ومحفوظة.
---
## ٣. المثال الكامل: قيمة عضوية العضو العامل
المطلوب: **٢٠٪ لصندوق النشاط الرياضي، ٣٠٪ لإيرادات العضويات، والباقي لصندوق دعم
المنشآت** — على العضو العامل بس.
1. المحاسبة ← محرك القيود ← دوّر على **"قيمة العضوية"** ← اضغط **تحصيل**.
2. **فئة العضو:** اختار **عضو عامل (١٢٠)**. هيطلع شريط أزرق: *"بتعدّل التوزيع
الخاص بـ عضو عامل فقط."*
3. المبلغ المرجعي هيفتح على **١٢٩٬٤٥٣٫١٦** (متوسط اللي اتحصّل فعلًا). غيّره
لـ **١٥٠٬٠٠٠** لو ده المبلغ اللي عايز تشوف عليه الأرقام.
4. **+ اقتطع جزء** → نسبة **٢٠** → دوّر على *صندوق النشاط الرياضي* → نوع البند
**إيراد**.
الباقي فوق بقى: **١٢٠٬٠٠٠٫٠٠**
5. **+ اقتطع جزء** → نسبة **٣٠***إيرادات العضويات***إيراد**.
الباقي فوق بقى: **٧٥٬٠٠٠٫٠٠**
6. في المربع الأخضر تحت: اختار *صندوق دعم المنشآت***٧٥٬٠٠٠٫٠٠**
7. راجع القيد على الشمال → **✓ القيد متوازن**
8. اكتب سبب التغيير → **حفظ وتفعيل التوزيع**
خلاص. من دلوقتي أي عضو عامل يدفع قيمة عضوية، الفلوس بتتقسم كده لوحدها.
**عايز تتأكد؟** غيّر المبلغ المرجعي لـ ٢٠٠٬٠٠٠ وشوف الأرقام: ٤٠٬٠٠٠ / ٦٠٬٠٠٠ /
١٠٠٬٠٠٠. النسب بتشتغل على أي مبلغ.
---
## ٤. أنواع البنود — امتى تستخدم إيه
| النوع | استخدمه لما | أثره المحاسبي |
|---|---|---|
| **إيراد** | الفلوس دي بتاعة النادي وكسبناها دلوقتي | دائن حساب إيراد → بيدخل قائمة الدخل |
| **تحصيل لحساب الغير** | الفلوس دي مش بتاعتنا — بنجمّعها لحد تاني (دمغة، اتحاد، تأمين) | دائن حساب **التزام** → بيقعد في الميزانية لحد ما ندفعه |
| **إيراد مؤجل** | قبضنا دلوقتي بس الخدمة على مدار السنة (اشتراك سنوي) | دائن التزام، وبعدين بيتحوّل لإيراد شهر بشهر |
**غلطة شائعة:** حاجة زي "رسوم اتحاد" أو "دمغة" تتحط كـ**إيراد**. دي فلوس بتتحصّل
لحساب جهة تانية — لو اتسجّلت إيراد، أرباح النادي بتظهر أعلى من الحقيقة والضريبة
بتتحسب غلط. خليها **تحصيل لحساب الغير**.
---
## ٥. الحساب مش موجود؟ اعمله من مكانك
جنب أي خانة "يروح لحساب" فيه **+ حساب جديد**. اضغط، هيطلع مربع صغير:
1. **تحت أي حساب رئيسي؟** — اختار الأب من القايمة.
2. **اسم الحساب** — مثلًا "صندوق دعم النشاط الرياضي".
3. **إنشاء وتحديد**.
السيستم بيحسب رقم الكود التالي لوحده تحت الأب اللي اخترته، وبيحط الحساب الجديد في
الخانة على طول. مش محتاج تروح لشاشة تانية ولا ترجع.
---
## ٦. حاجات لازم تعرفها
**النسب لازم تسيب مساحة للباقي.** لو حطيت نسب مجموعها ١٠٠٪، بند الباقي هياخد
صفر — والسيستم هيقول لك *"لم يتبقَّ مبلغ لبند الباقي"*. ده مسموح لكن الأحسن تسيب
له نصيب، أو تخلّي البند الأخير هو الباقي بدل ما تحط له نسبة.
**لو النسب زادت عن المتاح** هيظهر لك *"اقتطاع أكبر من المتاح — تم تخفيضه"* بالأحمر.
راجع الأرقام.
**كل حساب في التوزيع لازم يكون حساب فرعي، مش رئيسي.** لو اخترت حساب رئيسي
(header) السيستم هيرفض القيد. البحث بيفلتر الرئيسية أصلًا فمش هتقابل المشكلة دي
غالبًا.
**التعديل بيعمل إصدار جديد.** ما بيمسحش القديم. القيود القديمة بتفضل مربوطة
بالإصدار اللي عملها.
**الوضع المتقدّم** (زرار ⚙) فيه حاجات مش في الويزارد: تحديد الحساب المدين يدويًا
بدل النقدية، الإيراد المؤجل بالتقسيط الشهري، السقف الأقصى للبند، والتخصيص حسب
الفرع أو طريقة الدفع.
---
## ٧. أسئلة سريعة
**س: ممكن أعمل توزيع مختلف لكل فرع؟**
ج: أيوه — من الوضع المتقدّم (⚙). فيه اختيار **الفرع**. نفس منطق الأخص بيغلب العام.
**س: وطريقة الدفع؟ لو دفع شيك بدل كاش؟**
ج: كمان من الوضع المتقدّم. وبالمناسبة الشيك بينزل على **أوراق قبض** مش على البنك
لحد ما يتحصّل فعلًا — ده مظبوط تلقائيًا.
**س: لو غلطت وحفظت؟**
ج: ادخل تاني، عدّل، واحفظ. هيتعمل إصدار جديد. القيود اللي نزلت قبل كده بتفضل زي
ما هي — وده الصح محاسبيًا.
**س: فين أشوف اللي موصّل واللي لأ؟**
ج: **مركز التوصيل**`/accounting/revenue-mapping/connections`. بيوريك كل مصادر
الإيراد، الموصّل منها بلون والمش موصّل بلون، وكل واحد جنبه زرار يوديك للويزارد.
**س: إزاي أتأكد إن التوزيع شغال فعلًا؟**
ج: شاشة **التشخيص** (`/accounting/revenue-mapping/diagnostics`) بتوريك المصادر
اللي فيها مشكلة، وحساب مربوط على header، وأي قاعدة ناقصة بند الباقي.
---
*ملفات ذات صلة:*
*`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