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 ...@@ -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. */ /** A sensible first stage to offer for a stream that has none configured. */
private static function defaultStageFor(array $stream): string private static function defaultStageFor(array $stream): string
{ {
...@@ -248,7 +332,9 @@ class RevenueMappingController extends Controller ...@@ -248,7 +332,9 @@ class RevenueMappingController extends Controller
return $this->redirect('/accounting/revenue-mapping/' . $streamId . '/edit') return $this->redirect('/accounting/revenue-mapping/' . $streamId . '/edit')
->withError('مرحلة قيد غير معروفة'); ->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'); $direction = (string) $request->post('direction', 'inflow');
if (!\in_array($direction, ['inflow', 'outflow'], true)) { if (!\in_array($direction, ['inflow', 'outflow'], true)) {
...@@ -265,7 +351,15 @@ class RevenueMappingController extends Controller ...@@ -265,7 +351,15 @@ class RevenueMappingController extends Controller
$effectiveFrom = date('Y-m-d'); $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; $nextVersion = 1;
$maxRow = $db->selectOne( $maxRow = $db->selectOne(
"SELECT MAX(version) AS v FROM revenue_posting_rules WHERE stream_id = ? AND stage = ?", "SELECT MAX(version) AS v FROM revenue_posting_rules WHERE stream_id = ? AND stage = ?",
...@@ -313,6 +407,7 @@ class RevenueMappingController extends Controller ...@@ -313,6 +407,7 @@ class RevenueMappingController extends Controller
'name_ar' => $request->post('name_ar') ?: ('إصدار ' . $nextVersion), 'name_ar' => $request->post('name_ar') ?: ('إصدار ' . $nextVersion),
'branch_id' => $branchId, 'branch_id' => $branchId,
'payment_method' => $paymentMethod, 'payment_method' => $paymentMethod,
'member_category' => $memberCategory,
'debit_account_id' => $debitAccountId, 'debit_account_id' => $debitAccountId,
'debit_source' => $debitSource, 'debit_source' => $debitSource,
'tax_profile_id' => $taxProfileId, 'tax_profile_id' => $taxProfileId,
...@@ -352,7 +447,8 @@ class RevenueMappingController extends Controller ...@@ -352,7 +447,8 @@ class RevenueMappingController extends Controller
// Supersede the rule this one replaces (same scope only). // Supersede the rule this one replaces (same scope only).
if ($current) { if ($current) {
$sameScope = ((int) ($current['branch_id'] ?? 0)) === ((int) ($branchId ?? 0)) $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) { if ($sameScope) {
$db->update('revenue_posting_rules', [ $db->update('revenue_posting_rules', [
'status' => 'superseded', 'status' => 'superseded',
......
...@@ -163,6 +163,7 @@ return [ ...@@ -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/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/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+}/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'],
......
...@@ -315,6 +315,18 @@ final class RevenuePostingEngine ...@@ -315,6 +315,18 @@ final class RevenuePostingEngine
$method = $ctx['payment_method'] ?? null; $method = $ctx['payment_method'] ?? null;
$stage = $ctx['stage'] ?? 'collection'; $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( $candidates = $db->select(
"SELECT * FROM revenue_posting_rules "SELECT * FROM revenue_posting_rules
WHERE stream_id = ? WHERE stream_id = ?
...@@ -324,18 +336,23 @@ final class RevenuePostingEngine ...@@ -324,18 +336,23 @@ final class RevenuePostingEngine
AND (effective_to IS NULL OR effective_to >= ?) AND (effective_to IS NULL OR effective_to >= ?)
AND (branch_id IS NULL OR branch_id = ?) AND (branch_id IS NULL OR branch_id = ?)
AND (payment_method IS NULL OR payment_method = ?) AND (payment_method IS NULL OR payment_method = ?)
AND (member_category IS NULL OR member_category = ?)
ORDER BY effective_from DESC, version DESC", ORDER BY effective_from DESC, version DESC",
[$streamId, $stage, $onDate, $onDate, $branchId, $method] [$streamId, $stage, $onDate, $onDate, $branchId, $method, $category]
); );
if (empty($candidates)) { if (empty($candidates)) {
return null; 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 { usort($candidates, static function (array $a, array $b): int {
$score = static fn(array $r): 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); $diff = $score($b) <=> $score($a);
if ($diff !== 0) { if ($diff !== 0) {
return $diff; return $diff;
...@@ -350,6 +367,33 @@ final class RevenuePostingEngine ...@@ -350,6 +367,33 @@ final class RevenuePostingEngine
return $candidates[0]; 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? */ /** Does this stream have an active rule for a stage right now? */
public static function isConfigured(int $streamId, string $stage = 'collection'): bool public static function isConfigured(int $streamId, string $stage = 'collection'): bool
{ {
......
...@@ -167,10 +167,14 @@ ...@@ -167,10 +167,14 @@
</td> </td>
<td style="text-align:left;"> <td style="text-align:left;">
<?php if (can('accounting.revenue_mapping.manage')): ?> <?php if (can('accounting.revenue_mapping.manage')): ?>
<a href="/accounting/revenue-mapping/<?= (int) $s['id'] ?>/edit?stage=<?= e($s['rule_stage']) ?>" <div style="display:flex;gap:5px;justify-content:flex-start;">
class="btn btn-sm <?= $isSingle ? 'btn-secondary' : 'btn-outline' ?>"> <a href="/accounting/revenue-mapping/<?= (int) $s['id'] ?>/wizard?stage=<?= e($s['rule_stage']) ?>"
<?= $isSingle ? 'قسّم على حسابات' : 'عدّل التوزيع' ?> class="btn btn-sm btn-secondary">
</a> <?= $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: ?> <?php else: ?>
<a href="/accounting/revenue-mapping/<?= (int) $s['id'] ?>/edit" class="btn btn-sm btn-outline">عرض</a> <a href="/accounting/revenue-mapping/<?= (int) $s['id'] ?>/edit" class="btn btn-sm btn-outline">عرض</a>
<?php endif; ?> <?php endif; ?>
......
...@@ -211,13 +211,18 @@ foreach ($streams as $s) { ...@@ -211,13 +211,18 @@ foreach ($streams as $s) {
<div style="display:flex;flex-direction:column;gap:4px;align-items:stretch;"> <div style="display:flex;flex-direction:column;gap:4px;align-items:stretch;">
<?php if (!empty($s['stages'])): ?> <?php if (!empty($s['stages'])): ?>
<?php foreach (array_keys($s['stages']) as $stageKey): ?> <?php foreach (array_keys($s['stages']) as $stageKey): ?>
<a href="/accounting/revenue-mapping/<?= (int) $s['id'] ?>/edit?stage=<?= e($stageKey) ?>" <div style="display:flex;gap:3px;">
class="btn btn-sm btn-outline" style="font-size:11px;padding:3px 8px;"> <a href="/accounting/revenue-mapping/<?= (int) $s['id'] ?>/wizard?stage=<?= e($stageKey) ?>"
<?= e($stageLabels[$stageKey] ?? $stageKey) ?> class="btn btn-sm btn-outline" style="font-size:11px;padding:3px 8px;flex:1;">
</a> <?= 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 endforeach; ?>
<?php else: ?> <?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; ?> <?php endif; ?>
</div> </div>
</td> </td>
......
This diff is collapsed.
This diff is collapsed.
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