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];
}
}
......@@ -51,6 +51,12 @@ final class AccrualRunner
'tournamentFees' => 'رسوم الاشتراك في البطولات',
'academyDeposits' => 'تأمينات عقود الأكاديميات',
'academyRent' => 'إيجار الأكاديميات الشهري',
// Only run once finance has declared how to value them — see GapToolService.
'poolZoneBookings' => 'حجوزات مناطق حمام السباحة (بتسعيرة معتمدة)',
'playerCards' => 'كارنيهات اللاعبين (برسم معتمد)',
'poolBookings' => 'حجز حمام السباحة (بتسعيرة معتمدة)',
'privateMatches' => 'الماتشات الخاصة (بالمبلغ المسجّل)',
];
/**
......@@ -528,6 +534,225 @@ final class AccrualRunner
// ────────────────────────────────────────────────────────────────────
// ────────────────────────────────────────────────────────────────────
// Gaps finance has chosen to close — see GapToolService
// ────────────────────────────────────────────────────────────────────
//
// These four stay silent until somebody with a name against it declares how
// to value them. That is the difference between an estimate and a guess: the
// rate, the reason and the approver are all on record, and the screen shows
// what the rate would book before it is committed to.
public static function poolZoneBookings(): array
{
$setting = GapToolService::activeSetting('sa:pool_zone_booking');
if ($setting === null) {
return self::result(0, 0, '0.00');
}
$rows = App::getInstance()->db()->select(
"SELECT b.id, b.booking_date, b.label, b.current_occupancy, b.status
FROM sa_pool_zone_bookings b
WHERE COALESCE(b.status, '') NOT IN ('cancelled')
" . self::sinceClause($setting, 'b.booking_date') . "
ORDER BY b.id"
);
$items = [];
foreach ($rows as $r) {
$amount = self::valueByRate($setting, 1, (int) ($r['current_occupancy'] ?? 0));
if (bccomp($amount, '0.00', self::SCALE) <= 0) {
continue;
}
$items[] = [
'document_id' => (int) $r['id'],
'amount' => $amount,
'due_date' => (string) ($r['booking_date'] ?? date('Y-m-d')),
'description_ar' => 'حجز منطقة حمام سباحة ' . ($r['label'] ?? '') . ' — بتسعيرة معتمدة',
];
}
return self::post('sa:pool_zone_booking', $items, [
'document_type' => 'sa_pool_zone_booking',
'source_module' => 'sports_activity',
'description_ar' => 'استحقاق حجوزات مناطق حمام السباحة (تسعيرة معتمدة)',
'reference_type' => 'pool_zone_accrual',
]);
}
public static function playerCards(): array
{
$setting = GapToolService::activeSetting('sa:player_card');
if ($setting === null) {
return self::result(0, 0, '0.00');
}
$rows = App::getInstance()->db()->select(
"SELECT c.id, c.card_number, c.card_type, c.created_at,
p.member_id, p.full_name_ar
FROM sa_player_cards c
LEFT JOIN sa_players p ON p.id = c.player_id
WHERE COALESCE(c.status, '') NOT IN ('cancelled', 'expired')
" . self::sinceClause($setting, 'DATE(c.created_at)') . "
ORDER BY c.id"
);
$items = [];
foreach ($rows as $r) {
$amount = self::valueByRate($setting, 1, 1);
if (bccomp($amount, '0.00', self::SCALE) <= 0) {
continue;
}
$items[] = [
'document_id' => (int) $r['id'],
'member_id' => !empty($r['member_id']) ? (int) $r['member_id'] : null,
'counterparty_name' => $r['full_name_ar'] ?? null,
'amount' => $amount,
'document_number' => $r['card_number'] ?? null,
'due_date' => substr((string) ($r['created_at'] ?? date('Y-m-d')), 0, 10),
'description_ar' => 'رسم كارنيه لاعب ' . ($r['card_number'] ?? '')
. ' — ' . ($r['full_name_ar'] ?? ''),
];
}
return self::post('sa:player_card', $items, [
'document_type' => 'sa_player_card',
'source_module' => 'sports_activity',
'description_ar' => 'استحقاق رسوم كارنيهات اللاعبين',
'reference_type' => 'player_card_accrual',
]);
}
public static function poolBookings(): array
{
$setting = GapToolService::activeSetting('facility:pool_booking');
if ($setting === null) {
return self::result(0, 0, '0.00');
}
$rows = App::getInstance()->db()->select(
"SELECT b.id, b.booking_code, b.booking_date, b.total_amount,
b.actual_swimmers, b.expected_swimmers, b.booker_member_id, b.booker_name
FROM pool_bookings b
WHERE COALESCE(b.status, '') NOT IN ('cancelled')
AND b.payment_id IS NULL
" . self::sinceClause($setting, 'b.booking_date') . "
ORDER BY b.id"
);
$items = [];
foreach ($rows as $r) {
$swimmers = (int) ($r['actual_swimmers'] ?? 0) ?: (int) ($r['expected_swimmers'] ?? 0);
$amount = (string) ($setting['mode'] ?? '') === 'recorded_amount'
? self::money((string) ($r['total_amount'] ?? '0'))
: self::valueByRate($setting, 1, $swimmers);
if (bccomp($amount, '0.00', self::SCALE) <= 0) {
continue;
}
$items[] = [
'document_id' => (int) $r['id'],
'member_id' => !empty($r['booker_member_id']) ? (int) $r['booker_member_id'] : null,
'counterparty_name' => $r['booker_name'] ?? null,
'amount' => $amount,
'document_number' => $r['booking_code'] ?? null,
'due_date' => (string) ($r['booking_date'] ?? date('Y-m-d')),
'description_ar' => 'حجز حمام سباحة ' . ($r['booking_code'] ?? ''),
];
}
return self::post('facility:pool_booking', $items, [
'document_type' => 'pool_booking',
'source_module' => 'pool_management',
'description_ar' => 'استحقاق حجوزات حمام السباحة',
'reference_type' => 'pool_booking_accrual',
]);
}
public static function privateMatches(): array
{
$setting = GapToolService::activeSetting('facility:private_match');
if ($setting === null) {
return self::result(0, 0, '0.00');
}
$rows = App::getInstance()->db()->select(
"SELECT b.id, b.booking_date, b.total_cost, b.deposit_paid,
b.booked_by_member_id, b.booked_by_name, b.team_a_name, b.team_b_name
FROM private_match_bookings b
WHERE COALESCE(b.status, '') NOT IN ('cancelled')
AND COALESCE(b.payment_status, '') <> 'paid'
" . self::sinceClause($setting, 'b.booking_date') . "
ORDER BY b.id"
);
$items = [];
foreach ($rows as $r) {
// The recorded cost is the claim; the deposit is only what has been
// handed over so far, and is the fallback when no cost was entered.
$amount = self::money((string) ($r['total_cost'] ?? '0'));
if (bccomp($amount, '0.00', self::SCALE) <= 0) {
$amount = self::money((string) ($r['deposit_paid'] ?? '0'));
}
if (bccomp($amount, '0.00', self::SCALE) <= 0) {
continue;
}
$items[] = [
'document_id' => (int) $r['id'],
'member_id' => !empty($r['booked_by_member_id']) ? (int) $r['booked_by_member_id'] : null,
'counterparty_name' => $r['booked_by_name'] ?? null,
'amount' => $amount,
'due_date' => (string) ($r['booking_date'] ?? date('Y-m-d')),
'description_ar' => 'ماتش خاص — ' . trim(($r['team_a_name'] ?? '') . ' / ' . ($r['team_b_name'] ?? ''), ' /'),
];
}
return self::post('facility:private_match', $items, [
'document_type' => 'private_match_booking',
'source_module' => 'reservations',
'description_ar' => 'استحقاق حجوزات الماتشات الخاصة',
'reference_type' => 'private_match_accrual',
]);
}
/** One document's value under a declared rate. */
private static function valueByRate(array $setting, int $documents, int $attendees): string
{
if ((string) ($setting['mode'] ?? '') !== 'flat_rate') {
return '0.00';
}
$rate = self::money((string) ($setting['rate'] ?? '0'));
if (bccomp($rate, '0.00', self::SCALE) <= 0) {
return '0.00';
}
$count = \in_array((string) ($setting['rate_basis'] ?? 'booking'), ['attendee', 'swimmer'], true)
? $attendees
: $documents;
return bcmul($rate, (string) max(0, $count), self::SCALE);
}
/**
* A declared rate applies from a date forward.
*
* Backdating a price onto years of history would rewrite results for periods
* that are already reported and possibly closed, so the accountant says where
* it starts and everything before that is left alone.
*/
private static function sinceClause(array $setting, string $dateColumn): string
{
$from = $setting['effective_from'] ?? null;
if (!$from || !preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $from)) {
return '';
}
return " AND {$dateColumn} >= '" . $from . "'";
}
// ────────────────────────────────────────────────────────────────────
// Releasing what has since been paid, or was never owed
// ────────────────────────────────────────────────────────────────────
......@@ -590,6 +815,29 @@ final class AccrualRunner
'settled' => "SELECT id FROM tournament_participants WHERE payment_id IS NOT NULL",
'cancelled' => "SELECT id FROM tournament_participants WHERE status IN ('withdrawn','cancelled','rejected')",
],
'pool_booking' => [
'stream' => 'facility:pool_booking',
'settled' => "SELECT id FROM pool_bookings WHERE payment_id IS NOT NULL",
'cancelled' => "SELECT id FROM pool_bookings WHERE status = 'cancelled'",
],
'private_match_booking' => [
'stream' => 'facility:private_match',
'settled' => "SELECT id FROM private_match_bookings WHERE payment_status = 'paid'",
'cancelled' => "SELECT id FROM private_match_bookings WHERE status = 'cancelled'",
],
'sa_pool_zone_booking' => [
'stream' => 'sa:pool_zone_booking',
// Nothing records payment against a zone booking, so only cancellation
// can close one. An estimate that is never collected stays open and
// visible, which is the honest outcome.
'settled' => "SELECT id FROM sa_pool_zone_bookings WHERE 1 = 0",
'cancelled' => "SELECT id FROM sa_pool_zone_bookings WHERE status = 'cancelled'",
],
'sa_player_card' => [
'stream' => 'sa:player_card',
'settled' => "SELECT id FROM sa_player_cards WHERE 1 = 0",
'cancelled' => "SELECT id FROM sa_player_cards WHERE status IN ('cancelled','expired')",
],
];
/**
......@@ -703,12 +951,15 @@ final class AccrualRunner
}
/**
* Obligations this scanner deliberately refuses to book, and why.
* Obligations this scanner will not book on its own, and what would unblock
* each one.
*
* Each of these has money implied somewhere in the product but no amount and
* no counterparty the ledger could stand behind. Posting a guess would be
* worse than the gap: a wrong number in the accounts is harder to find than
* a missing one, and it would look settled.
* The scanner never invents a number. But "the system cannot tell you" is
* not "nobody knows" — the finance team knows what a lane costs. So each of
* these has a tool at /accounting/gaps where an accountant declares the
* valuation, sees what it would book before committing, and puts their name
* to it. Once declared, the matching runner starts working and the row here
* reports it as closed.
*/
public static function unbookable(): array
{
......@@ -722,35 +973,55 @@ final class AccrualRunner
}
};
$closed = static fn(string $stream): bool => GapToolService::activeSetting($stream) !== null;
return [
[
'stream' => 'sa:pool_zone_booking',
'label' => 'حجوزات مناطق حمام السباحة',
'rows' => $count("SELECT COUNT(*) n FROM sa_pool_zone_bookings"),
'why' => 'الجدول فيه سعر التذكرة وعدد الحاضرين، بس مفيش سجل لمين دخل ولا هل دفع. '
. 'الاستحقاق هنا هيبقى تقدير مش مطالبة، فمش هينزل الدفاتر.',
'needs' => 'سجل دخول لكل شخص (أو تذكرة) عشان يبقى فيه مطالبة حقيقية تتقيّد.',
'why' => 'الجدول فيه سعر التذكرة وعدد الحاضرين، بس كلها فاضية — مفيش سجل '
. 'لمين دخل ولا هل دفع، فالماسح مش هيخترع رقم.',
'needs' => 'حدّد تسعيرة معتمدة من شاشة «سد الفجوات»، أو ضيف سجل دخول لكل شخص.',
'tool' => '/accounting/gaps',
'closed' => $closed('sa:pool_zone_booking'),
],
[
'stream' => 'sa:player_card',
'label' => 'كارنيهات اللاعبين',
'rows' => $count("SELECT COUNT(*) n FROM sa_player_cards"),
'why' => 'الجدول مفيهوش عمود مبلغ أصلًا — الكارنيه بيتصدر من غير رسم مسجّل.',
'needs' => 'رسم إصدار/تجديد على الكارنيه، وبعدين الاستحقاق بيمشي لوحده.',
'needs' => 'حدّد رسم الإصدار من شاشة «سد الفجوات».',
'tool' => '/accounting/gaps',
'closed' => $closed('sa:player_card'),
],
[
'stream' => 'facility:pool_booking',
'label' => 'حجز حمام السباحة',
'rows' => $count("SELECT COUNT(*) n FROM pool_bookings"),
'why' => 'الكود بيسجّل كل حجز بسعر صفر ثابت — مش مشكلة محاسبية، ده تسعير ناقص.',
'needs' => 'تسعيرة للحجز في الكود أو في دليل الخدمات.',
'needs' => 'حدّد تسعيرة من شاشة «سد الفجوات» لحد ما التسعير يتظبط في الكود.',
'tool' => '/accounting/gaps',
'closed' => $closed('facility:pool_booking'),
],
[
'stream' => 'facility:private_match',
'label' => 'حجوزات الماتشات الخاصة',
'rows' => $count("SELECT COUNT(*) n FROM private_match_bookings"),
'why' => 'المقدم بيتكتب في عمود deposit_paid من غير إيصال ولا دفعة، فمفيش مستند يتقيّد عليه.',
'needs' => 'تحصيل المقدم كدفعة عادية بإيصال.',
'why' => 'المبلغ متسجّل في الجدول بس من غير إيصال ولا دفعة، فمفيش مستند تحصيل.',
'needs' => 'فعّل «المبلغ المسجّل» من شاشة «سد الفجوات» عشان يتقيّد كمطالبة.',
'tool' => '/accounting/gaps',
'closed' => $closed('facility:private_match'),
],
[
'stream' => 'academy:settlement',
'label' => 'تسويات الأكاديميات الشهرية',
'rows' => $count("SELECT COUNT(*) n FROM sa_academy_contracts WHERE COALESCE(is_archived,0)=0"),
'why' => 'محرك التسويات بيقرا academy_contracts وهو فاضي، والعقود الحقيقية في '
. 'sa_academy_contracts — نفس الميزة اتبنت مرتين في موديولين.',
'needs' => 'انقل العقود لجدول التسويات من شاشة «سد الفجوات» وحدّد شروط التسوية.',
'tool' => '/accounting/gaps',
'closed' => $count("SELECT COUNT(*) n FROM academy_contracts") > 0,
],
[
'stream' => 'academy:enrollment',
......@@ -758,6 +1029,8 @@ final class AccrualRunner
'rows' => $count("SELECT COUNT(*) n FROM academy_enrollments"),
'why' => 'القيد نفسه مالوش رسوم — هو بوابة للفوترة الشهرية اللي بتيجي من اشتراكات النشاط.',
'needs' => 'لا شيء محاسبيًا — الإيراد بيتقيّد من اشتراك النشاط مش من القيد.',
'tool' => null,
'closed' => true,
],
];
}
......
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services\Revenue;
use App\Core\App;
use App\Core\Logger;
/**
* The tools that let an accountant close the gaps the scanner will not guess at.
*
* Each gap is a different missing thing, so each gets a different tool:
*
* a missing PRICE → declare a rate, see what it would book, approve it
* a missing LINK → the amount is recorded, just say to book it
* a missing CONTRACT → import the ones that exist in the other module
*
* None of these post anything on their own. They record a decision; the accrual
* scanner acts on it on its next pass, and the accountant can preview the
* consequence before committing to it. That separation matters — a screen that
* silently posted 300,000 the moment someone typed a number in a box would be a
* worse problem than the gap it closed.
*/
final class GapToolService
{
private const SCALE = 2;
/**
* Every gap, what is blocking it, and what the tool for it is.
*
* `unit_sql` counts the things that would be valued; `amount_sql` is the sum
* the documents already carry, where they carry one at all.
*/
public const GAPS = [
'sa:pool_zone_booking' => [
'label' => 'حجوزات مناطق حمام السباحة',
'blocker' => 'الجدول فيه أعمدة سعر تذكرة وعدد حاضرين، بس كلها فاضية — '
. 'مفيش سجل لمين دخل ولا هل دفع.',
'tool' => 'flat_rate',
'bases' => ['booking' => 'لكل حجز', 'attendee' => 'لكل حاضر'],
'unit_sql' => "SELECT COUNT(*) AS n, COALESCE(SUM(current_occupancy),0) AS attendees
FROM sa_pool_zone_bookings
WHERE COALESCE(status,'') NOT IN ('cancelled')",
'amount_sql' => null,
'note' => 'العدّاد شغّال على الحجوزات غير الملغية. لو اخترت «لكل حاضر» '
. 'وعدد الحاضرين متسجّل صفر، مش هينزل حاجة — استخدم «لكل حجز».',
],
'sa:player_card' => [
'label' => 'كارنيهات اللاعبين',
'blocker' => 'الجدول مفيهوش عمود مبلغ أصلًا — الكارنيه بيتصدر من غير رسم مسجّل.',
'tool' => 'flat_rate',
'bases' => ['card' => 'لكل كارنيه'],
'unit_sql' => "SELECT COUNT(*) AS n, COUNT(*) AS attendees
FROM sa_player_cards
WHERE COALESCE(status,'') NOT IN ('cancelled','expired')",
'amount_sql' => null,
'note' => 'رسم إصدار الكارنيه. لو الرسم بيختلف حسب النوع، حدّد الأشيع '
. 'هنا وعدّل الباقي يدوي.',
],
'facility:pool_booking' => [
'label' => 'حجز حمام السباحة',
'blocker' => 'الكود بيسجّل كل حجز بسعر صفر — الجدول فيه عمود مبلغ بس بيتكتب صفر.',
'tool' => 'flat_rate',
'bases' => ['booking' => 'لكل حجز', 'swimmer' => 'لكل سبّاح'],
'unit_sql' => "SELECT COUNT(*) AS n,
COALESCE(SUM(COALESCE(actual_swimmers, expected_swimmers, 0)),0) AS attendees
FROM pool_bookings
WHERE COALESCE(status,'') NOT IN ('cancelled')
AND payment_id IS NULL",
'amount_sql' => "SELECT COALESCE(SUM(total_amount),0) AS total
FROM pool_bookings
WHERE COALESCE(status,'') NOT IN ('cancelled') AND payment_id IS NULL",
'note' => 'لو التسعير اتظبط في الكود بعد كده، حوّل الوضع لـ«المبلغ المسجّل» '
. 'وهيستخدم مبلغ الحجز نفسه بدل السعر الثابت.',
],
'facility:private_match' => [
'label' => 'حجوزات الماتشات الخاصة',
'blocker' => 'المبلغ متسجّل في الجدول بس من غير إيصال ولا دفعة، فمفيش مستند '
. 'التحصيل يتعلّق عليه.',
'tool' => 'recorded_amount',
'bases' => ['booking' => 'لكل حجز'],
'unit_sql' => "SELECT COUNT(*) AS n, COUNT(*) AS attendees
FROM private_match_bookings
WHERE COALESCE(status,'') NOT IN ('cancelled')
AND COALESCE(payment_status,'') <> 'paid'",
'amount_sql' => "SELECT COALESCE(SUM(COALESCE(total_cost, deposit_paid, 0)),0) AS total
FROM private_match_bookings
WHERE COALESCE(status,'') NOT IN ('cancelled')
AND COALESCE(payment_status,'') <> 'paid'",
'note' => 'الحجز نفسه فيه تكلفة متسجّلة — التشغيل هنا معناه إن النادي '
. 'يعتبرها مطالبة حقيقية على الحاجز.',
],
];
/** @return array<int, array> */
public static function gaps(): array
{
$db = App::getInstance()->db();
$settings = self::settings();
$out = [];
foreach (self::GAPS as $streamCode => $g) {
$units = 0;
$attendees = 0;
$recorded = '0.00';
$available = true;
try {
$row = $db->selectOne($g['unit_sql']);
$units = (int) ($row['n'] ?? 0);
$attendees = (int) ($row['attendees'] ?? 0);
} catch (\Throwable $e) {
$available = false; // table absent on this deployment
}
if ($g['amount_sql'] !== null && $available) {
try {
$recorded = self::money((string) ($db->selectOne($g['amount_sql'])['total'] ?? '0'));
} catch (\Throwable) {
$recorded = '0.00';
}
}
$setting = $settings[$streamCode] ?? null;
$stream = $db->selectOne(
"SELECT name_ar, wiring_status FROM revenue_streams WHERE stream_code = ?",
[$streamCode]
);
$out[] = [
'stream_code' => $streamCode,
'stream_name' => $stream['name_ar'] ?? $streamCode,
'label' => $g['label'],
'blocker' => $g['blocker'],
'note' => $g['note'],
'tool' => $g['tool'],
'bases' => $g['bases'],
'available' => $available,
'units' => $units,
'attendees' => $attendees,
'recorded' => $recorded,
'setting' => $setting,
'has_rule' => self::hasAccrualRule($streamCode),
'projected' => self::project($g, $setting, $units, $attendees, $recorded),
];
}
return $out;
}
/** What the current setting would book, if the scanner ran now. */
private static function project(array $gap, ?array $setting, int $units, int $attendees, string $recorded): string
{
if ($setting === null || ($setting['mode'] ?? 'off') === 'off' || (int) ($setting['is_active'] ?? 0) === 0) {
return '0.00';
}
if ($setting['mode'] === 'recorded_amount') {
return $recorded;
}
$rate = self::money((string) ($setting['rate'] ?? '0'));
if (bccomp($rate, '0.00', self::SCALE) <= 0) {
return '0.00';
}
$count = \in_array($setting['rate_basis'] ?? 'booking', ['attendee', 'swimmer'], true) ? $attendees : $units;
return bcmul($rate, (string) $count, self::SCALE);
}
/**
* What a proposed rate would book, before anyone commits to it.
*
* The whole point of the screen: type a number, see the consequence, then
* decide. Nothing is written by this call.
*/
public static function preview(string $streamCode, string $mode, ?string $rate, ?string $basis): array
{
$gap = self::GAPS[$streamCode] ?? null;
if ($gap === null) {
return ['ok' => false, 'error' => 'مصدر غير معروف', 'amount' => '0.00', 'units' => 0];
}
$db = App::getInstance()->db();
try {
$row = $db->selectOne($gap['unit_sql']);
$units = (int) ($row['n'] ?? 0);
$attendees = (int) ($row['attendees'] ?? 0);
} catch (\Throwable $e) {
return ['ok' => false, 'error' => 'الجدول مش موجود على النسخة دي', 'amount' => '0.00', 'units' => 0];
}
$recorded = '0.00';
if ($gap['amount_sql'] !== null) {
try {
$recorded = self::money((string) ($db->selectOne($gap['amount_sql'])['total'] ?? '0'));
} catch (\Throwable) {
$recorded = '0.00';
}
}
$amount = self::project(
$gap,
['mode' => $mode, 'rate' => $rate, 'rate_basis' => $basis, 'is_active' => 1],
$units,
$attendees,
$recorded
);
$count = \in_array($basis ?? 'booking', ['attendee', 'swimmer'], true) ? $attendees : $units;
return [
'ok' => true,
'error' => null,
'amount' => $amount,
'units' => $units,
'attendees' => $attendees,
'counted' => $count,
'recorded' => $recorded,
];
}
/** Record the decision. The scanner acts on it next pass. */
public static function save(string $streamCode, array $input, ?int $employeeId): array
{
if (!isset(self::GAPS[$streamCode])) {
return ['success' => false, 'error' => 'مصدر غير معروف'];
}
$mode = (string) ($input['mode'] ?? 'off');
if (!\in_array($mode, ['off', 'flat_rate', 'recorded_amount'], true)) {
return ['success' => false, 'error' => 'وضع غير معروف'];
}
$rate = $mode === 'flat_rate' ? self::money((string) ($input['rate'] ?? '0')) : null;
$basis = $mode === 'flat_rate' ? (string) ($input['rate_basis'] ?? 'booking') : null;
if ($mode === 'flat_rate') {
if (bccomp((string) $rate, '0.00', self::SCALE) <= 0) {
return ['success' => false, 'error' => 'السعر لازم يكون أكبر من صفر'];
}
if (!isset(self::GAPS[$streamCode]['bases'][$basis])) {
return ['success' => false, 'error' => 'أساس التسعير غير مناسب للمصدر ده'];
}
}
// A rate with no reason behind it is a number nobody can defend later.
$notes = trim((string) ($input['notes'] ?? ''));
if ($mode !== 'off' && $notes === '') {
return ['success' => false, 'error' => 'اكتب سبب/مرجع التسعيرة — ده اللي المراجع هيسأل عنه'];
}
$from = trim((string) ($input['effective_from'] ?? ''));
if ($from !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $from)) {
return ['success' => false, 'error' => 'تاريخ السريان غير صحيح'];
}
$db = App::getInstance()->db();
$now = date('Y-m-d H:i:s');
$payload = [
'mode' => $mode,
'rate' => $rate,
'rate_basis' => $basis,
'effective_from' => $from !== '' ? $from : null,
'notes' => $notes !== '' ? mb_substr($notes, 0, 500) : null,
'approved_by' => $employeeId,
'approved_at' => $now,
'is_active' => $mode === 'off' ? 0 : 1,
'updated_at' => $now,
];
$existing = $db->selectOne("SELECT id FROM accrual_gap_settings WHERE stream_code = ?", [$streamCode]);
if ($existing) {
$db->update('accrual_gap_settings', $payload, '`id` = ?', [(int) $existing['id']]);
} else {
$db->insert('accrual_gap_settings', $payload + ['stream_code' => $streamCode, 'created_at' => $now]);
}
Logger::info('Accrual gap setting saved', [
'stream' => $streamCode, 'mode' => $mode, 'rate' => $rate, 'by' => $employeeId,
]);
return ['success' => true, 'error' => null];
}
/** @return array<string, array> keyed by stream_code */
public static function settings(): array
{
try {
$rows = App::getInstance()->db()->select("SELECT * FROM accrual_gap_settings");
} catch (\Throwable) {
return [];
}
$out = [];
foreach ($rows as $r) {
$out[(string) $r['stream_code']] = $r;
}
return $out;
}
/** The one setting a runner needs, or null when it must stay quiet. */
public static function activeSetting(string $streamCode): ?array
{
try {
return App::getInstance()->db()->selectOne(
"SELECT * FROM accrual_gap_settings
WHERE stream_code = ? AND is_active = 1 AND mode <> 'off'",
[$streamCode]
);
} catch (\Throwable) {
return null; // table not migrated yet — behave as if nothing is set
}
}
public static function ready(): bool
{
try {
$row = App::getInstance()->db()->selectOne(
"SELECT COUNT(*) AS n FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'accrual_gap_settings'"
);
return ((int) ($row['n'] ?? 0)) === 1;
} catch (\Throwable) {
return false;
}
}
private static function hasAccrualRule(string $streamCode): bool
{
$row = App::getInstance()->db()->selectOne(
"SELECT 1 AS ok
FROM revenue_posting_rules r
JOIN revenue_streams s ON s.id = r.stream_id
WHERE s.stream_code = ? AND r.stage = 'accrual' AND r.status = 'active'
LIMIT 1",
[$streamCode]
);
return $row !== null;
}
private static function money(string $v): string
{
return number_format((float) $v, self::SCALE, '.', '');
}
}
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>سد الفجوات المحاسبية<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div style="margin-bottom:16px;">
<a href="/accounting/accruals" style="color:#6B7280;font-size:13px;text-decoration:none;">→ رجوع للاستحقاقات</a>
<h2 style="margin:6px 0 4px;">سد الفجوات المحاسبية</h2>
<p style="margin:0;color:#6B7280;font-size:13px;line-height:1.9;max-width:820px;">
فيه حاجات النظام مش قادر يقيّدها لوحده لأن المبلغ مش متسجّل أصلًا — حجز مكتوب
بصفر، كارنيه من غير عمود رسم، منطقة حمام سباحة من غير سجل دخول. الماسح بيرفض
يخترع رقم، وده صح.
<br><br>
بس «النظام مش عارف» مش معناها «محدش عارف». إنت عارف الحارة بكام. لما تحدد
التسعيرة هنا، الرقم بيبقى <strong>تقدير إداري معتمد</strong> — وده أساس محاسبي
مقبول طالما مكتوب ومعتمد وباسم حد. الشاشة دي بتسجّل التسعيرة، ومين حددها، وليه.
<br><br>
<strong>مفيش حاجة بتتقيّد من الشاشة دي على طول.</strong> إنت بتسجّل قرار،
وماسح الاستحقاقات بينفّذه في أول جولة بعد كده — وتقدر تشوف هينزل كام قبل ما تعتمد.
</p>
</div>
<?php if (!$ready): ?>
<div class="card" style="border-right:3px solid #DC2626;">
<div style="padding:16px 18px;color:#991B1B;">
جدول التسعيرات لسه مش منصّب. شغّل <code>php cli.php migrate</code> ثم <code>php cli.php seed</code>.
</div>
</div>
<?php else: ?>
<?php foreach ($gaps as $g): ?>
<?php
$s = $g['setting'];
$isOn = $s !== null && ($s['mode'] ?? 'off') !== 'off' && (int) ($s['is_active'] ?? 0) === 1;
$accent = !$g['available'] ? '#9CA3AF' : ($isOn ? '#059669' : '#D97706');
$formId = 'gap-' . preg_replace('/[^a-z0-9]/i', '-', $g['stream_code']);
?>
<div class="card" style="margin-bottom:14px;border-right:3px solid <?= $accent ?>;">
<div style="padding:14px 18px;">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:16px;flex-wrap:wrap;">
<div style="flex:1;min-width:300px;">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
<span style="font-size:15px;font-weight:700;"><?= e($g['label']) ?></span>
<?php if (!$g['available']): ?>
<span class="badge badge-neutral">الجدول مش موجود</span>
<?php elseif ($isOn): ?>
<span class="badge badge-success">مفعّل</span>
<?php else: ?>
<span class="badge badge-warning">متوقف</span>
<?php endif; ?>
<?php if (!$g['has_rule']): ?>
<span class="badge badge-danger">مفيش قاعدة ترحيل</span>
<?php endif; ?>
<span style="font-size:11px;color:#9CA3AF;direction:ltr;"><?= e($g['stream_code']) ?></span>
</div>
<p style="margin:8px 0 0;color:#991B1B;font-size:12.5px;line-height:1.9;">
<strong>المشكلة:</strong> <?= e($g['blocker']) ?>
</p>
<p style="margin:6px 0 0;color:#6B7280;font-size:12px;line-height:1.9;">
<?= e($g['note']) ?>
</p>
</div>
<div style="text-align:left;min-width:170px;">
<div style="font-size:11px;color:#6B7280;">عدد المستندات</div>
<div style="font-size:18px;font-weight:700;"><?= number_format((int) $g['units']) ?></div>
<?php if ((int) $g['attendees'] > 0): ?>
<div style="font-size:11px;color:#6B7280;margin-top:4px;">
عدد الحاضرين: <?= number_format((int) $g['attendees']) ?>
</div>
<?php endif; ?>
<?php if (bccomp($g['projected'], '0.00', 2) > 0): ?>
<div style="font-size:11px;color:#065F46;margin-top:6px;">هيتقيّد بالوضع الحالي</div>
<div style="font-size:17px;font-weight:700;color:#065F46;"><?= money($g['projected']) ?></div>
<?php endif; ?>
</div>
</div>
<?php if ($g['available']): ?>
<form method="POST" action="/accounting/gaps" id="<?= $formId ?>"
style="margin-top:14px;padding-top:14px;border-top:1px dashed #E5E7EB;">
<?= csrf_field() ?>
<input type="hidden" name="stream_code" value="<?= e($g['stream_code']) ?>">
<div style="display:flex;gap:14px;flex-wrap:wrap;align-items:flex-end;">
<div style="min-width:180px;">
<label class="form-label">الوضع</label>
<select name="mode" class="form-select gap-mode" data-form="<?= $formId ?>">
<option value="off" <?= !$isOn ? 'selected' : '' ?>>متوقف — ما تقيّدش</option>
<?php if ($g['tool'] === 'flat_rate'): ?>
<option value="flat_rate" <?= ($s['mode'] ?? '') === 'flat_rate' ? 'selected' : '' ?>>تسعيرة ثابتة</option>
<?php endif; ?>
<?php if (bccomp($g['recorded'], '0.00', 2) > 0 || $g['tool'] === 'recorded_amount'): ?>
<option value="recorded_amount" <?= ($s['mode'] ?? '') === 'recorded_amount' ? 'selected' : '' ?>>
المبلغ المسجّل في المستند
</option>
<?php endif; ?>
</select>
</div>
<?php if ($g['tool'] === 'flat_rate'): ?>
<div style="min-width:130px;">
<label class="form-label">السعر</label>
<input type="number" name="rate" step="0.01" min="0" class="form-input gap-rate"
dir="ltr" style="text-align:right;"
value="<?= e((string) ($s['rate'] ?? '')) ?>">
</div>
<div style="min-width:150px;">
<label class="form-label">الأساس</label>
<select name="rate_basis" class="form-select gap-basis">
<?php foreach ($g['bases'] as $k => $lbl): ?>
<option value="<?= e($k) ?>" <?= ($s['rate_basis'] ?? 'booking') === $k ? 'selected' : '' ?>>
<?= e($lbl) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<?php endif; ?>
<div style="min-width:160px;">
<label class="form-label">يسري من تاريخ</label>
<input type="date" name="effective_from" class="form-input"
value="<?= e((string) ($s['effective_from'] ?? '')) ?>">
<div style="font-size:10.5px;color:#9CA3AF;margin-top:3px;">اللي قبل التاريخ ده مش هيتلمس</div>
</div>
<div style="flex:1;min-width:260px;">
<label class="form-label">السبب / المرجع</label>
<input type="text" name="notes" class="form-input"
placeholder="قرار مجلس رقم كذا، أو تسعيرة سنة كذا"
value="<?= e((string) ($s['notes'] ?? '')) ?>">
</div>
<div style="display:flex;gap:8px;">
<button type="button" class="btn btn-outline gap-preview"
data-form="<?= $formId ?>" data-stream="<?= e($g['stream_code']) ?>">
احسبلي هينزل كام
</button>
<button type="submit" class="btn btn-primary">احفظ</button>
</div>
</div>
<div class="gap-preview-out" style="margin-top:10px;font-size:13px;color:#065F46;display:none;"></div>
<?php if ($isOn && !empty($s['approved_at'])): ?>
<div style="margin-top:10px;font-size:11.5px;color:#6B7280;">
آخر اعتماد <?= e(substr((string) $s['approved_at'], 0, 16)) ?>
</div>
<?php endif; ?>
</form>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
<!-- ══ Academy contracts: the duplicate-table gap ══ -->
<div class="card" style="margin-bottom:14px;border-right:3px solid <?= $academyImported > 0 ? '#059669' : '#D97706' ?>;">
<div style="padding:14px 18px;">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
<span style="font-size:15px;font-weight:700;">تسويات الأكاديميات الشهرية</span>
<?php if ($academyImported > 0): ?>
<span class="badge badge-success"><?= number_format($academyImported) ?> عقد في جدول التسويات</span>
<?php else: ?>
<span class="badge badge-warning">جدول التسويات فاضي</span>
<?php endif; ?>
</div>
<p style="margin:8px 0 0;color:#991B1B;font-size:12.5px;line-height:1.9;">
<strong>المشكلة:</strong> نفس الميزة اتبنت مرتين. محرك التسويات الشهرية بيقرا
جدول <code>academy_contracts</code> وهو فاضي، والعقود الحقيقية في
<code>sa_academy_contracts</code>. فمفيش أي تسوية اتحسبت ولا حصة نادي اتطالب بيها.
</p>
<p style="margin:6px 0 0;color:#6B7280;font-size:12px;line-height:1.9;">
الجدولين مش نسخة من بعض: جدول التسويات فيه «يوم التسوية» و«مهلة السماح»
و«نسبة الغرامة» — وهي شروط المحرك بيحسب بيها ومش موجودة في الجدول التاني.
عشان كده الأداة دي <strong>بتنسخ</strong> العقود مش بتنقلها، وإنت اللي بتحدد
الشروط الناقصة. العقود الأصلية بتفضل زي ما هي وشغّالة عادي.
</p>
<?php if (empty($academyPending)): ?>
<div style="margin-top:12px;padding:12px;background:#F0FDF4;border-radius:6px;color:#065F46;font-size:13px;">
مفيش عقود جديدة تتنقل — كل العقود موجودة في جدول التسويات.
</div>
<?php else: ?>
<form method="POST" action="/accounting/gaps/academy-contracts"
style="margin-top:14px;padding-top:14px;border-top:1px dashed #E5E7EB;"
onsubmit="return confirm('هيتم نسخ العقود المختارة لجدول التسويات. متأكد؟');">
<?= csrf_field() ?>
<div class="table-responsive" style="margin-bottom:12px;">
<table class="data-table" style="width:100%;">
<thead>
<tr>
<th style="width:36px;"><input type="checkbox" id="gap-check-all"></th>
<th>رقم العقد</th><th>الأكاديمية</th><th>من</th><th>إلى</th>
<th>إيجار شهري</th><th>حصة النادي</th><th>التأمين</th>
</tr>
</thead>
<tbody>
<?php foreach ($academyPending as $c): ?>
<tr>
<td><input type="checkbox" name="contract_ids[]" value="<?= (int) $c['id'] ?>" class="gap-contract" checked></td>
<td style="direction:ltr;text-align:right;font-size:12px;"><?= e($c['contract_number']) ?></td>
<td><?= e($c['academy_name'] ?? '—') ?></td>
<td style="font-size:12px;color:#6B7280;"><?= e($c['start_date']) ?></td>
<td style="font-size:12px;color:#6B7280;"><?= e($c['end_date'] ?? '—') ?></td>
<td style="font-weight:600;"><?= money($c['fixed_monthly_rent'] ?? '0') ?></td>
<td><?= e((string) ($c['club_commission_pct'] ?? '0')) ?>٪</td>
<td><?= money($c['deposit_amount'] ?? '0') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div style="display:flex;gap:14px;flex-wrap:wrap;align-items:flex-end;">
<div style="min-width:150px;">
<label class="form-label">يوم التسوية من الشهر</label>
<input type="number" name="settlement_day" class="form-input" min="1" max="28" value="1" dir="ltr" style="text-align:right;">
</div>
<div style="min-width:150px;">
<label class="form-label">مهلة السماح (يوم)</label>
<input type="number" name="grace_period_days" class="form-input" min="0" max="90" value="0" dir="ltr" style="text-align:right;">
</div>
<div style="min-width:150px;">
<label class="form-label">نسبة غرامة التأخير ٪</label>
<input type="number" name="penalty_rate_pct" class="form-input" min="0" max="100" step="0.01" value="0" dir="ltr" style="text-align:right;">
</div>
<div>
<button type="submit" class="btn btn-primary">انسخ العقود المختارة</button>
</div>
</div>
<div style="margin-top:8px;font-size:11.5px;color:#9CA3AF;">
الشروط دي بتتطبّق على كل العقود المختارة. لو عقد ليه شروط مختلفة، عدّله بعد النقل من شاشة العقود.
</div>
</form>
<?php endif; ?>
</div>
</div>
<script>
(function () {
document.querySelectorAll('.gap-preview').forEach(function (btn) {
btn.addEventListener('click', function () {
var form = document.getElementById(btn.dataset.form);
var out = form.querySelector('.gap-preview-out');
var mode = form.querySelector('[name=mode]').value;
var rate = form.querySelector('[name=rate]') ? form.querySelector('[name=rate]').value : '';
var basis= form.querySelector('[name=rate_basis]') ? form.querySelector('[name=rate_basis]').value : '';
out.style.display = 'block';
out.style.color = '#6B7280';
out.textContent = 'بحسب…';
var q = '/accounting/gaps/preview?stream=' + encodeURIComponent(btn.dataset.stream)
+ '&mode=' + encodeURIComponent(mode)
+ '&rate=' + encodeURIComponent(rate)
+ '&basis=' + encodeURIComponent(basis);
fetch(q, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.ok) { out.style.color = '#991B1B'; out.textContent = d.error || 'تعذّر الحساب'; return; }
var n = Number(d.amount).toLocaleString('en-US', { minimumFractionDigits: 2 });
if (Number(d.amount) <= 0) {
out.style.color = '#92400E';
out.textContent = 'مش هينزل حاجة — العدد المحسوب ' + d.counted
+ '. جرّب أساس تاني أو تأكد إن فيه مستندات.';
} else {
out.style.color = '#065F46';
out.textContent = 'هيتقيّد ' + n + ' جنيه على ' + d.counted + ' وحدة.';
}
})
.catch(function () { out.style.color = '#991B1B'; out.textContent = 'تعذّر الاتصال'; });
});
});
// Hide the rate inputs when the mode does not use them.
document.querySelectorAll('.gap-mode').forEach(function (sel) {
var sync = function () {
var form = document.getElementById(sel.dataset.form);
var show = sel.value === 'flat_rate';
['.gap-rate', '.gap-basis'].forEach(function (cls) {
var el = form.querySelector(cls);
if (el) { el.closest('div').style.opacity = show ? '1' : '0.4'; el.disabled = !show; }
});
};
sel.addEventListener('change', sync);
sync();
});
var all = document.getElementById('gap-check-all');
if (all) {
all.addEventListener('change', function () {
document.querySelectorAll('.gap-contract').forEach(function (c) { c.checked = all.checked; });
});
}
})();
</script>
<?php endif; ?>
<?php $__template->endSection(); ?>
......@@ -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