Commit caca1389 authored by DevPilot's avatar DevPilot

feat(accounting): gap tools so finance can close what the scanner won't guess

Six streams were left unbooked because the source records no amount: a
pool zone with no ticket price and no attendance, a player card with no
fee column, a booking the code writes as zero. The scanner refuses to
invent a figure, and should — a wrong number in the books is harder to
find than a missing one, and it looks settled.

But "the system cannot tell you" is not "nobody knows". Finance knows
what a lane costs. This adds the screen where they say so.

/accounting/gaps — one card per gap:

  see the blocker      what exactly is missing, and why it blocks
  propose a value      a flat rate per unit, or the recorded amount
  see the consequence  "احسبلي هينزل كام" computes without writing
  commit               reason and effective date are mandatory

The reason is required because it is what turns an invented number into
a management estimate — a legitimate basis to account on, provided it is
stated, approved and attributable. The effective date is required
because backdating a rate onto years of history rewrites results for
periods already reported.

Nothing posts from this screen. It records a decision; the accrual
scanner acts on it next pass. Four new runners stay silent until a
decision exists, so the rules ship configured but inert.

Academy settlements get a different tool, because it is a different
problem: the settlement engine reads `academy_contracts` (empty) while
the 13 real contracts live in `sa_academy_contracts`. The tables are not
copies — the settlement table carries settlement_day, grace_period_days
and penalty_rate_pct, which the engine calculates with and the other
table lacks. So the tool COPIES rather than moves, with the missing
terms supplied by the accountant rather than defaulted silently, and the
source contracts keep working untouched.

Verified: gaps stay silent with no decision; a rate of 25/booking books
8,800 over 352 bookings and re-running books nothing; switching off
stops future accrual without reversing what was booked; all four
validation rules reject bad input; 13 contracts import and re-import is
a no-op. Trial balance still nets to 0.00.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
parent 1bc26cc1
......@@ -6,3 +6,4 @@
/storage/logs/*.log
/storage/cache/*
.DS_Store
/storage/sessions/*
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Controllers;
use App\Core\App;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Modules\Accounting\Services\Revenue\AcademyContractImportService;
use App\Modules\Accounting\Services\Revenue\AccrualRunner;
use App\Modules\Accounting\Services\Revenue\GapToolService;
/**
* سد الفجوات — the screen where an accountant closes what the scanner will not
* guess at.
*
* Every tool here follows the same shape: see the blocker, propose a value, see
* what it would book, then commit. Nothing posts from this screen — it records a
* decision that the accrual scanner acts on, which keeps the person deciding and
* the machine posting cleanly separated.
*/
class GapController extends Controller
{
public function index(Request $request): Response
{
$this->authorize('accounting.gaps.view');
$ready = GapToolService::ready();
return $this->view('Accounting.Views.gaps.index', [
'ready' => $ready,
'gaps' => $ready ? GapToolService::gaps() : [],
'unbookable' => AccrualRunner::unbookable(),
'academyPending' => AcademyContractImportService::pending(),
'academyImported' => AcademyContractImportService::alreadyImported(),
]);
}
/**
* What a proposed rate would book. Answered without writing anything, so the
* accountant can try three numbers before picking one.
*/
public function preview(Request $request): Response
{
$this->authorize('accounting.gaps.view');
return $this->json(GapToolService::preview(
(string) $request->get('stream', ''),
(string) $request->get('mode', 'off'),
$request->get('rate'),
$request->get('basis')
));
}
public function save(Request $request): Response
{
$this->authorize('accounting.gaps.manage');
$stream = (string) $request->post('stream_code', '');
$employee = $this->currentEmployee();
$result = GapToolService::save($stream, [
'mode' => $request->post('mode', 'off'),
'rate' => $request->post('rate'),
'rate_basis' => $request->post('rate_basis'),
'effective_from' => $request->post('effective_from'),
'notes' => $request->post('notes'),
], $employee ? (int) $employee->id : null);
if (!$result['success']) {
return $this->redirect('/accounting/gaps')->withError($result['error']);
}
return $this->redirect('/accounting/gaps')->withSuccess(
'اتسجّلت التسعيرة. شغّل فحص الاستحقاقات عشان تتقيّد.'
);
}
public function importAcademyContracts(Request $request): Response
{
$this->authorize('accounting.gaps.manage');
$employee = $this->currentEmployee();
$ids = (array) $request->post('contract_ids', []);
$result = AcademyContractImportService::import($ids, [
'settlement_day' => $request->post('settlement_day', 1),
'grace_period_days' => $request->post('grace_period_days', 0),
'penalty_rate_pct' => $request->post('penalty_rate_pct', '0'),
], $employee ? (int) $employee->id : null);
if (!$result['success']) {
return $this->redirect('/accounting/gaps')->withError($result['error']);
}
return $this->redirect('/accounting/gaps')->withSuccess(
'اتنقل ' . $result['imported'] . ' عقد لجدول التسويات — التسويات الشهرية هتشوفهم دلوقتي.'
);
}
private function currentEmployee()
{
return App::getInstance()->currentEmployee();
}
}
......@@ -183,6 +183,12 @@ return [
['GET', '/accounting/accruals', 'Accounting\Controllers\AccrualController@index', ['auth'], 'accounting.accruals.view'],
['POST', '/accounting/accruals/run', 'Accounting\Controllers\AccrualController@run', ['auth', 'csrf'], 'accounting.accruals.manage'],
// ── Gap tools (close what the scanner will not guess at) ─
['GET', '/accounting/gaps', 'Accounting\Controllers\GapController@index', ['auth'], 'accounting.gaps.view'],
['GET', '/accounting/gaps/preview', 'Accounting\Controllers\GapController@preview', ['auth'], 'accounting.gaps.view'],
['POST', '/accounting/gaps', 'Accounting\Controllers\GapController@save', ['auth', 'csrf'], 'accounting.gaps.manage'],
['POST', '/accounting/gaps/academy-contracts', 'Accounting\Controllers\GapController@importAcademyContracts', ['auth', 'csrf'], 'accounting.gaps.manage'],
// ── Billing (universal collection) ──────────────────────
['GET', '/accounting/billing', 'Accounting\Controllers\BillingController@index', ['auth'], 'accounting.billing.view'],
['POST', '/accounting/billing/collect', 'Accounting\Controllers\BillingController@collect', ['auth', 'csrf'], 'accounting.billing.collect'],
......
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services\Revenue;
use App\Core\App;
use App\Core\Logger;
/**
* The same feature was built twice, and the settlement engine reads the empty one.
*
* `academy_contracts` belongs to the AcademyContracts module and is where the
* monthly revenue-share settlement engine looks. It holds nothing. The real
* contracts — thirteen of them — live in `sa_academy_contracts`, created by the
* SportsActivity module's own screen.
*
* So no settlement has ever been calculated, and no academy revenue share has
* ever been billed.
*
* This is NOT a typo that can be fixed by repointing one query. The tables
* differ: `academy_contracts` carries `settlement_day`, `grace_period_days`,
* `penalty_rate_pct`, `auto_renew` and `renewal_notice_days`, and the settlement
* engine reads all of them. `sa_academy_contracts` has none. Pointing the engine
* at the other table would leave it reading columns that are not there, and
* repointing the module's model, reports and joins as well is a bigger change
* than a bug fix — it is a decision about which module owns academy contracts.
*
* What an accountant can safely do, and what this offers, is copy the contracts
* that exist into the table the settlement engine reads, filling the settlement
* terms with values they choose and can see. Nothing is deleted, nothing is
* moved, and the source contracts go on working exactly as before.
*/
final class AcademyContractImportService
{
/**
* Contracts in the sports-activity table with no counterpart in the
* contracts module, matched on contract number.
*/
public static function pending(): array
{
$db = App::getInstance()->db();
try {
return $db->select(
"SELECT c.id, c.contract_number, c.academy_id, c.contract_type,
c.start_date, c.end_date, c.club_commission_pct, c.academy_share_pct,
c.fixed_monthly_rent, c.minimum_revenue_guarantee, c.deposit_amount,
c.deposit_status, c.status, c.branch_id, c.notes,
a.name_ar AS academy_name
FROM sa_academy_contracts c
LEFT JOIN sa_academies a ON a.id = c.academy_id
WHERE COALESCE(c.is_archived, 0) = 0
AND NOT EXISTS (
SELECT 1 FROM academy_contracts t
WHERE t.contract_number = c.contract_number
)
ORDER BY c.id"
);
} catch (\Throwable $e) {
Logger::error('Academy contract import scan failed: ' . $e->getMessage());
return [];
}
}
/** How many are already in place, so the screen can say when there is nothing to do. */
public static function alreadyImported(): int
{
try {
$row = App::getInstance()->db()->selectOne("SELECT COUNT(*) AS n FROM academy_contracts");
return (int) ($row['n'] ?? 0);
} catch (\Throwable) {
return 0;
}
}
/**
* Copy the selected contracts across.
*
* The settlement terms the source table does not carry are supplied by the
* accountant rather than defaulted silently — a grace period or a penalty
* rate invented by a migration would go on to bill an academy for money
* nobody agreed to.
*
* @param int[] $ids sa_academy_contracts ids
* @param array $terms settlement_day, grace_period_days, penalty_rate_pct
*/
public static function import(array $ids, array $terms, ?int $employeeId): array
{
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), static fn(int $i): bool => $i > 0)));
if (!$ids) {
return ['success' => false, 'error' => 'ما اخترتش أي عقد', 'imported' => 0];
}
$settlementDay = (int) ($terms['settlement_day'] ?? 0);
$graceDays = (int) ($terms['grace_period_days'] ?? 0);
$penaltyPct = (string) ($terms['penalty_rate_pct'] ?? '0');
if ($settlementDay < 1 || $settlementDay > 28) {
return ['success' => false, 'error' => 'يوم التسوية لازم يكون بين ١ و٢٨', 'imported' => 0];
}
if ($graceDays < 0 || $graceDays > 90) {
return ['success' => false, 'error' => 'مهلة السماح لازم تكون بين ٠ و٩٠ يوم', 'imported' => 0];
}
if (bccomp($penaltyPct, '0', 2) < 0 || bccomp($penaltyPct, '100', 2) > 0) {
return ['success' => false, 'error' => 'نسبة الغرامة لازم تكون بين ٠ و١٠٠', 'imported' => 0];
}
$db = App::getInstance()->db();
$now = date('Y-m-d H:i:s');
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$rows = $db->select(
"SELECT * FROM sa_academy_contracts WHERE id IN ({$placeholders})",
$ids
);
$imported = 0;
$db->beginTransaction();
try {
foreach ($rows as $c) {
$exists = $db->selectOne(
"SELECT id FROM academy_contracts WHERE contract_number = ?",
[$c['contract_number']]
);
if ($exists) {
continue;
}
$db->insert('academy_contracts', [
'contract_number' => $c['contract_number'],
'academy_id' => $c['academy_id'],
'contract_type' => $c['contract_type'],
'start_date' => $c['start_date'],
'end_date' => $c['end_date'],
'minimum_revenue_guarantee' => $c['minimum_revenue_guarantee'] ?? '0.00',
'club_commission_pct' => $c['club_commission_pct'] ?? '0.00',
'academy_share_pct' => $c['academy_share_pct'] ?? '0.00',
'fixed_monthly_rent' => $c['fixed_monthly_rent'] ?? '0.00',
'deposit_amount' => $c['deposit_amount'] ?? '0.00',
'deposit_status' => $c['deposit_status'] ?? 'pending',
'settlement_day' => $settlementDay,
'grace_period_days' => $graceDays,
'penalty_rate_pct' => $penaltyPct,
'auto_renew' => 0,
'renewal_notice_days' => 0,
'status' => $c['status'] ?? 'active',
'terms_json' => $c['terms_json'] ?? null,
'notes' => trim(
(string) ($c['notes'] ?? '') . "\n" .
'منقول من عقود النشاط الرياضي (sa_academy_contracts #' . $c['id'] . ') '
. 'عشان محرك التسويات الشهرية يشوفه. شروط التسوية اتحددت وقت النقل.'
),
'branch_id' => $c['branch_id'] ?? null,
'is_archived' => 0,
'created_at' => $now,
'updated_at' => $now,
'created_by' => $employeeId,
]);
$imported++;
}
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
Logger::error('Academy contract import failed: ' . $e->getMessage());
return ['success' => false, 'error' => $e->getMessage(), 'imported' => 0];
}
Logger::info('Academy contracts imported for settlement', ['count' => $imported, 'by' => $employeeId]);
return ['success' => true, 'error' => null, 'imported' => $imported];
}
}
This diff is collapsed.
This diff is collapsed.
......@@ -115,6 +115,10 @@ PermissionRegistry::register('accounting', [
'accounting.accruals.view' => ['ar' => 'عرض الاستحقاقات', 'en' => 'View Accruals'],
'accounting.accruals.manage' => ['ar' => 'تشغيل فحص الاستحقاقات', 'en' => 'Run Accrual Scan'],
// Gap tools (declared valuations for what the system prices at nothing)
'accounting.gaps.view' => ['ar' => 'عرض الفجوات المحاسبية', 'en' => 'View Accounting Gaps'],
'accounting.gaps.manage' => ['ar' => 'اعتماد تسعيرات سد الفجوات', 'en' => 'Approve Gap Valuations'],
// Vouchers
'accounting.voucher.view' => ['ar' => 'عرض السندات', 'en' => 'View Vouchers'],
'accounting.voucher.create' => ['ar' => 'إنشاء سند', 'en' => 'Create Voucher'],
......@@ -148,6 +152,7 @@ MenuRegistry::register('accounting', [
['label_ar' => 'مسار الفلوس', 'label_en' => 'Posting Chains', 'route' => '/accounting/posting-chains', 'permission' => 'accounting.chains.view', 'order' => 2],
['label_ar' => 'فين الفلوس دلوقتي', 'label_en' => 'Money in Transit', 'route' => '/accounting/posting-chains/parked', 'permission' => 'accounting.chains.view', 'order' => 2],
['label_ar' => 'الاستحقاقات', 'label_en' => 'Accruals', 'route' => '/accounting/accruals', 'permission' => 'accounting.accruals.view', 'order' => 2],
['label_ar' => 'سد الفجوات', 'label_en' => 'Accounting Gaps', 'route' => '/accounting/gaps', 'permission' => 'accounting.gaps.view', 'order' => 2],
['label_ar' => 'المطالبات والتحصيل', 'label_en' => 'Billing & Collection', 'route' => '/accounting/billing', 'permission' => 'accounting.billing.view', 'order' => 2],
['label_ar' => 'سندات الصرف والقبض', 'label_en' => 'Vouchers', 'route' => '/accounting/vouchers', 'permission' => 'accounting.voucher.view', 'order' => 3],
['label_ar' => 'الإيراد المؤجل', 'label_en' => 'Deferred Revenue', 'route' => '/accounting/revenue-mapping/recognition', 'permission' => 'accounting.revenue_mapping.view', 'order' => 2],
......
<?php
declare(strict_types=1);
use App\Core\Database;
/**
* Where finance declares how to value things the system priced at nothing.
*
* Some obligations cannot be accrued because the source records no amount: a
* pool zone booked with no ticket price and no attendance, a player card issued
* with no fee column, a pool booking the code writes as zero. The accrual
* scanner refuses to guess at those, and rightly — a wrong number in the books
* is harder to find than a missing one, and it looks settled.
*
* But "the system cannot tell you" is not the same as "nobody knows". The
* finance team knows what a lane costs. Declaring it here turns an invented
* figure into a management estimate — which is a legitimate basis to account on,
* provided it is stated, approved, attributable and visible. That is exactly
* what this table records: the rate, who set it, when, and why.
*
* A stream with no row here, or with mode 'off', stays unbooked. Nothing starts
* posting because this table exists — it starts posting when somebody decides
* it should and puts their name to the number.
*/
return [
'up' => static function (Database $db): void {
$db->raw("
CREATE TABLE IF NOT EXISTS accrual_gap_settings (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
stream_code VARCHAR(100) NOT NULL COMMENT 'the revenue_streams code this valuation applies to',
mode ENUM('off','flat_rate','recorded_amount') NOT NULL DEFAULT 'off'
COMMENT 'off = do not book. flat_rate = value each unit at the declared rate. recorded_amount = the document does carry a figure, book that.',
rate DECIMAL(18,2) NULL COMMENT 'for mode=flat_rate',
rate_basis VARCHAR(40) NULL COMMENT 'what one unit is: booking, card, attendee, swimmer',
effective_from DATE NULL COMMENT 'documents before this date are left alone',
notes VARCHAR(500) NULL COMMENT 'why this rate — the justification an auditor will ask for',
approved_by BIGINT UNSIGNED NULL,
approved_at DATETIME NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_gap_stream (stream_code),
KEY idx_gap_active (is_active, mode)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
},
'down' => static function (Database $db): void {
$db->raw("DROP TABLE IF EXISTS accrual_gap_settings");
},
];
<?php
declare(strict_types=1);
use App\Core\Database;
/**
* Posting rules for the four streams the gap tools can switch on.
*
* These ship configured but INERT. The rule says where the money would land;
* `accrual_gap_settings` decides whether anything is valued at all, and it
* defaults to off. So nothing starts posting because this seed ran — it starts
* posting when an accountant declares a rate and puts their name to it.
*
* That order matters. If the rule were missing, an accountant who set a rate
* would get silence and no explanation; if the rule posted without a rate, the
* system would be inventing revenue. Configured-but-off is the only combination
* that is both ready and honest.
*
* Idempotent.
*/
return static function (Database $db): void {
$accountId = static function (string $code) use ($db): ?int {
$row = $db->selectOne(
"SELECT id FROM chart_of_accounts
WHERE account_code = ? AND is_archived = 0 AND is_active = 1 AND is_header = 0",
[$code]
);
return $row ? (int) $row['id'] : null;
};
$rules = [
'sa:pool_zone_booking' => [
'counter' => '120301006', 'account' => '410518',
'name' => 'استحقاق حجز منطقة حمام سباحة',
'note' => 'حساب «تذاكر دخول» هو الأقرب لدخول حمام السباحة. '
. 'مش هينزل حاجة غير لما تتحدد تسعيرة معتمدة من شاشة سد الفجوات.',
],
'sa:player_card' => [
'counter' => '120301006', 'account' => '410516',
'name' => 'استحقاق رسم كارنيه لاعب',
'note' => 'حساب «استمارات نشاط» — نفس الحساب اللي رسوم النشاط بتنزل عليه.',
],
'facility:pool_booking' => [
'counter' => '120301006', 'account' => '410518',
'name' => 'استحقاق حجز حمام السباحة',
'note' => 'نفس حساب تذاكر الدخول.',
],
'facility:private_match' => [
'counter' => '120301006', 'account' => '410523',
'name' => 'استحقاق ماتش خاص',
'note' => 'حساب «حجز ملاعب» — الماتش الخاص حجز ملعب في الآخر.',
],
];
$now = date('Y-m-d H:i:s');
foreach ($rules as $streamCode => $spec) {
$stream = $db->selectOne("SELECT id FROM revenue_streams WHERE stream_code = ?", [$streamCode]);
if (!$stream) {
continue;
}
$streamId = (int) $stream['id'];
$existing = $db->selectOne(
"SELECT id FROM revenue_posting_rules
WHERE stream_id = ? AND stage = 'accrual' AND status = 'active'",
[$streamId]
);
if ($existing) {
continue;
}
$counterId = $accountId($spec['counter']);
$creditId = $accountId($spec['account']);
if ($counterId === null || $creditId === null) {
continue;
}
$maxRow = $db->selectOne(
"SELECT MAX(version) AS v FROM revenue_posting_rules WHERE stream_id = ? AND stage = 'accrual'",
[$streamId]
);
$version = $maxRow && $maxRow['v'] !== null ? ((int) $maxRow['v']) + 1 : 1;
$ruleId = $db->insert('revenue_posting_rules', [
'stream_id' => $streamId,
'version' => $version,
'stage' => 'accrual',
'direction' => 'inflow',
'name_ar' => $spec['name'],
'debit_account_id' => $counterId,
'debit_source' => 'accounts_receivable',
'status' => 'active',
'effective_from' => date('Y-m-d'),
'notes' => $spec['note'],
'created_at' => $now,
'updated_at' => $now,
'activated_at' => $now,
]);
$db->insert('revenue_posting_rule_lines', [
'rule_id' => $ruleId,
'sort_order' => 1,
'line_type' => 'revenue',
'allocation_method' => 'remainder',
'account_id' => $creditId,
'description_ar' => $spec['name'],
'is_active' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
}
// Grant the gap tools to the same roles that hold the accrual screen.
$grants = [
'accountant' => ['accounting.gaps.view', 'accounting.gaps.manage'],
'auditor' => ['accounting.gaps.view'],
];
foreach ($grants as $roleCode => $permissions) {
$role = $db->selectOne("SELECT id FROM roles WHERE role_code = ?", [$roleCode]);
if (!$role) {
continue;
}
foreach ($permissions as $key) {
$exists = $db->selectOne(
"SELECT id FROM role_permissions WHERE role_id = ? AND permission_key = ?",
[(int) $role['id'], $key]
);
if (!$exists) {
$db->insert('role_permissions', [
'role_id' => (int) $role['id'],
'permission_key' => $key,
'granted_at' => $now,
]);
}
}
}
// The wiring note now points at the tool instead of at a developer.
$db->query(
"UPDATE revenue_streams
SET wiring_note = 'الربط جاهز — محتاج تسعيرة معتمدة من شاشة «سد الفجوات» عشان يبدأ يقيّد.',
updated_at = NOW()
WHERE stream_code IN ('sa:pool_zone_booking','sa:player_card','facility:pool_booking','facility:private_match')"
);
$db->query(
"UPDATE revenue_streams
SET wiring_note = 'محتاج نقل العقود لجدول التسويات من شاشة «سد الفجوات».',
updated_at = NOW()
WHERE stream_code = 'academy:settlement'"
);
};
......@@ -107,39 +107,54 @@
---
## اللي **ما اتوصّلش** — وليه
## اللي محتاج قرار منك — وفيه أداة ليه
دي **مش** مشاكل ربط. دي حاجات فيها فلوس ضمنيًا بس مفيش مبلغ مسجّل ولا جهة محددة.
أي رقم هنحطه هيبقى تخمين — ورقم غلط في الدفاتر أصعب في اكتشافه من رقم ناقص، وكمان
بيبان إنه مظبوط.
دي **مش** مشاكل ربط. دي حاجات فيها فلوس ضمنيًا بس المبلغ مش متسجّل في النظام أصلًا.
الماسح مش هيخترع رقم — ورقم غلط في الدفاتر أصعب في اكتشافه من رقم ناقص، وكمان بيبان
إنه مظبوط.
| المصدر | السطور | المشكلة | المطلوب |
**بس «النظام مش عارف» مش معناها «محدش عارف».** إنت عارف الحارة بكام. عشان كده كل واحدة
فيهم ليها أداة في شاشة **«سد الفجوات»**`/accounting/gaps`.
| المصدر | السطور | المشكلة | الأداة |
|---|---|---|---|
| `sa:pool_zone_booking` | ٧٣٠ | فيه سعر تذكرة وعدد حاضرين، بس مفيش سجل لمين دخل ولا هل دفع | سجل دخول لكل شخص أو تذكرة |
| `sa:player_card` | ٥ | الجدول مفيهوش عمود مبلغ أصلًا | رسم إصدار/تجديد على الكارنيه |
| `facility:pool_booking` | ٠ | الكود بيسجّل كل حجز بصفر ثابت | تسعيرة — دي مشكلة تسعير مش محاسبة |
| `facility:private_match` | ٠ | المقدم بيتكتب في عمود من غير إيصال | تحصيل المقدم كدفعة عادية |
| `academy:enrollment` | ١ | القيد مالوش رسوم | مفيش مطلوب — الإيراد بييجي من اشتراك النشاط |
| `academy:settlement` | ٠ | **ميزة متكررة في موديولين** | قرار: أي جدول هو الأصل |
| `sa:pool_zone_booking` | ٧٣٠ | سعر التذكرة وعدد الحاضرين كلهم فاضيين | حدّد تسعيرة لكل حجز |
| `sa:player_card` | ٥ | الجدول مفيهوش عمود مبلغ أصلًا | حدّد رسم الإصدار |
| `facility:pool_booking` | ٠ | الكود بيسجّل كل حجز بصفر | حدّد تسعيرة مؤقتة |
| `facility:private_match` | ٠ | المبلغ متسجّل بس من غير إيصال | فعّل «المبلغ المسجّل» |
| `academy:settlement` | ١٣ عقد | ميزة متكررة في موديولين | انسخ العقود لجدول التسويات |
| `academy:enrollment` | ١ | القيد مالوش رسوم | مفيش مطلوب |
### حكاية `academy:settlement`
### إزاي الأداة شغّالة
محرك التسويات بيقرا `academy_contracts` (**فاضي**)، والعقود الحقيقية في
`sa_academy_contracts` (**١٣ عقد**). الجدولين شكلهم شبه بعض تقريبًا — يعني الميزة
اتبنت مرتين في موديولين مختلفين.
١. **تشوف المشكلة** — كل كارت بيقول بالظبط إيه الناقص وليه.
٢. **تحط رقم وتضغط «احسبلي هينزل كام»** — الشاشة بتقولك هيتقيّد كام قبل ما تعتمد.
٣. **تكتب السبب وتاريخ السريان** — السبب إجباري، وده اللي المراجع هيسأل عنه.
٤. **تحفظ** — الشاشة **مش بتقيّد حاجة**. بتسجّل قرار، وماسح الاستحقاقات ينفّذه في أول
جولة.
ده مش خطأ إملائي أصلّحه بسطر. لو غيّرت `SettlementService` يقرا الجدول التاني،
الموديل والتقارير والـ joins كلها لسه بتقرا الجدول الأول. القرار «أي جدول هو الأصل
وإيه اللي يتنقل» قرار منتج، ودمجهم من غير ما حد يقرر ممكن يضيّع بيانات.
> **ليه السبب إجباري؟** لأن الرقم بيتحوّل من «تخمين» لـ**تقدير إداري معتمد** — وده أساس
> محاسبي مقبول طالما مكتوب ومعتمد وباسم حد. من غير سبب، الرقم مالوش سند.
**بس محاسبيًا مش ضايع حاجة**: ماسح الاستحقاقات بيقرا `sa_academy_contracts` — الجدول
اللي فيه البيانات — وبيقيّد التأمينات والإيجار منه.
> **ليه تاريخ السريان؟** التسعيرة بتطبّق من التاريخ ده ورايح بس. من غيره كنت هتعيد كتابة
> نتائج فترات اتقفلت واتعرضت خلاص.
---
> **الوقف مش بيعكس اللي اتقيّد.** لو رجعت وقفلت التسعيرة، اللي اتقيّد بيفضل زي ما هو —
> لو عايز تشيله، اعكس القيد من شاشة القيود.
### حكاية `academy:settlement`
محرك التسويات بيقرا `academy_contracts` (**فاضي**)، والعقود الحقيقية في
`sa_academy_contracts` (**١٣ عقد**). فمفيش أي تسوية شهرية اتحسبت ولا حصة نادي اتطالب بيها.
**الجدولين مش نسخة من بعض.** جدول التسويات فيه `settlement_day` و`grace_period_days`
و`penalty_rate_pct` — والمحرك بيحسب بيهم — ومش موجودين في الجدول التاني. عشان كده
الأداة **بتنسخ مش بتنقل**، وإنت اللي بتحدد الشروط الناقصة. العقود الأصلية بتفضل شغّالة
زي ما هي.
## الشاشة
`/accounting/accruals`**الاستحقاقات**
### `/accounting/accruals` — الاستحقاقات
- اللي اتقيّد حسب المصدر، واللي لسه مفتوح
- أقدم المطالبات المفتوحة (اللي محدش لحق يجريها)
......@@ -150,6 +165,12 @@
> `AccrualReconcileJob` جاهزة وبتشتغل مرة في اليوم لما يتفعّل — ولحد ما يتفعّل،
> شغّل الفحص من الزرار.
### `/accounting/gaps` — سد الفجوات
- كارت لكل فجوة: المشكلة، عدد المستندات، وهيتقيّد كام بالوضع الحالي
- زرار **«احسبلي هينزل كام»** بيحسب من غير ما يحفظ حاجة
- أداة نقل عقود الأكاديميات لجدول التسويات
---
## ملفات
......@@ -163,6 +184,9 @@
| `cron/jobs/AccrualReconcileJob.php` | الجولة الليلية |
| `database/seeds/Phase_109_001_seed_accrual_rules.php` | قواعد الاستحقاق |
| `database/seeds/Phase_109_002_seed_operational_rules.php` | قواعد الصرف والمخزون |
| `Services/Revenue/GapToolService.php` | تسعيرات سد الفجوات + المعاينة |
| `Services/Revenue/AcademyContractImportService.php` | نقل عقود الأكاديميات لجدول التسويات |
| `Controllers/GapController.php` + `Views/gaps/` | شاشة سد الفجوات |
---
......
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