Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
C
Clubphp
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Administrator
Clubphp
Commits
34bbb7fa
Commit
34bbb7fa
authored
Sep 06, 2026
by
Mahmoud Aglan
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
push all
parent
e4076074
Changes
12
Expand all
Hide whitespace changes
Inline
Side-by-side
Showing
12 changed files
with
628 additions
and
84 deletions
+628
-84
عرض تطبيق الاعضاء.md
Proposal/sayd-mobile/عرض تطبيق الاعضاء.md
+0
-0
OpeningEntryController.php
...Modules/Accounting/Controllers/OpeningEntryController.php
+21
-30
RevenueMappingController.php
...dules/Accounting/Controllers/RevenueMappingController.php
+4
-0
DailyTransactionService.php
app/Modules/Accounting/Services/DailyTransactionService.php
+19
-42
JournalService.php
app/Modules/Accounting/Services/JournalService.php
+7
-2
AllocationPlanService.php
...les/Accounting/Services/Revenue/AllocationPlanService.php
+127
-0
RevenueAllocator.php
app/Modules/Accounting/Services/Revenue/RevenueAllocator.php
+7
-0
RevenuePostingEngine.php
...ules/Accounting/Services/Revenue/RevenuePostingEngine.php
+124
-6
wizard.php
app/Modules/Accounting/Views/revenue_mapping/wizard.php
+82
-4
Phase_107_001_appropriation_and_deferral.php
...e/migrations/Phase_107_001_appropriation_and_deferral.php
+89
-0
Phase_107_002_reclassify_membership_advances.php
...grations/Phase_107_002_reclassify_membership_advances.php
+148
-0
تعلّم-النظام-المحاسبي-خطوة-بخطوة.md
docs/تعلّم-النظام-المحاسبي-خطوة-بخطوة.md
+0
-0
No files found.
Proposal/sayd-mobile/عرض تطبيق الاعضاء.md
0 → 100644
View file @
34bbb7fa
This diff is collapsed.
Click to expand it.
app/Modules/Accounting/Controllers/OpeningEntryController.php
View file @
34bbb7fa
...
...
@@ -6,6 +6,7 @@ namespace App\Modules\Accounting\Controllers;
use
App\Core\Controller
;
use
App\Core\Request
;
use
App\Core\App
;
use
App\Modules\Accounting\Services\JournalService
;
use
App\Core\Response
;
use
App\Modules\Accounting\Models\HistoricalBalance
;
...
...
@@ -64,7 +65,6 @@ class OpeningEntryController extends Controller
}
$db
=
App
::
getInstance
()
->
db
();
$session
=
App
::
getInstance
()
->
session
();
$fy
=
$db
->
selectOne
(
"SELECT * FROM fiscal_years WHERE id = ? AND status = 'open'"
,
[
$fiscalYearId
]);
if
(
!
$fy
)
{
...
...
@@ -100,35 +100,26 @@ class OpeningEntryController extends Controller
->
withError
(
'القيد غير متوازن — المدين: '
.
number_format
(
$totalDebit
,
2
)
.
' الدائن: '
.
number_format
(
$totalCredit
,
2
));
}
$now
=
date
(
'Y-m-d H:i:s'
);
$entryNumber
=
'OPN-'
.
substr
(
$fy
[
'start_date'
],
0
,
4
)
.
'-001'
;
$db
->
insert
(
'journal_entries'
,
[
'entry_number'
=>
$entryNumber
,
'entry_date'
=>
$fy
[
'start_date'
],
'fiscal_year_id'
=>
$fiscalYearId
,
'reference_type'
=>
'opening'
,
'description'
=>
'قيد افتتاحي — '
.
(
$fy
[
'name_ar'
]
??
''
),
'total_debit'
=>
$totalDebit
,
'total_credit'
=>
$totalCredit
,
'status'
=>
'posted'
,
'posted_at'
=>
$now
,
'created_by'
=>
(
int
)(
$session
->
get
(
'employee_id'
)
??
0
)
?:
null
,
'created_at'
=>
$now
,
'updated_at'
=>
$now
,
]);
$journalEntryId
=
(
int
)
$db
->
lastInsertId
();
foreach
(
$lines
as
$line
)
{
$db
->
insert
(
'journal_entry_lines'
,
[
'journal_entry_id'
=>
$journalEntryId
,
'account_id'
=>
$line
[
'account_id'
],
'debit'
=>
$line
[
'debit'
],
'credit'
=>
$line
[
'credit'
],
'description_ar'
=>
$line
[
'description'
],
'created_at'
=>
$now
,
]);
// Through JournalService, not around it: the fiscal-year and closed-period
// checks, the balance validation and the account-balance maintenance all
// live there, and an opening entry is not exempt from any of them.
$result
=
JournalService
::
createEntry
([
'entry_date'
=>
$fy
[
'start_date'
],
'reference_type'
=>
'opening'
,
'reference_number'
=>
'OPN-'
.
substr
((
string
)
$fy
[
'start_date'
],
0
,
4
),
'description_ar'
=>
'قيد افتتاحي — '
.
(
$fy
[
'name_ar'
]
??
''
),
'source_module'
=>
'accounting'
,
'is_auto_generated'
=>
0
,
],
array_map
(
static
fn
(
array
$l
)
:
array
=>
[
'account_id'
=>
$l
[
'account_id'
],
'debit'
=>
number_format
(
$l
[
'debit'
],
2
,
'.'
,
''
),
'credit'
=>
number_format
(
$l
[
'credit'
],
2
,
'.'
,
''
),
'description_ar'
=>
$l
[
'description'
],
],
$lines
),
true
);
if
(
empty
(
$result
[
'success'
]))
{
return
$this
->
redirect
(
'/accounting/opening-entries/create'
)
->
withError
(
$result
[
'error'
]
??
'فشل إنشاء القيد الافتتاحي'
);
}
return
$this
->
redirect
(
'/accounting/opening-entries?fiscal_year_id='
.
$fiscalYearId
)
...
...
app/Modules/Accounting/Controllers/RevenueMappingController.php
View file @
34bbb7fa
...
...
@@ -1455,6 +1455,10 @@ class RevenueMappingController extends Controller
'description_ar'
=>
(
$row
[
'description_ar'
]
??
''
)
!==
''
?
(
string
)
$row
[
'description_ar'
]
:
null
,
'max_amount'
=>
(
$row
[
'max_amount'
]
??
''
)
!==
''
?
(
string
)
$row
[
'max_amount'
]
:
null
,
'min_amount'
=>
null
,
'is_appropriation'
=>
!
empty
(
$row
[
'is_appropriation'
])
?
1
:
0
,
'appropriation_source_account_id'
=>
!
empty
(
$row
[
'appropriation_source_account_id'
])
?
(
int
)
$row
[
'appropriation_source_account_id'
]
:
null
,
'revenue_account_id'
=>
!
empty
(
$row
[
'revenue_account_id'
])
?
(
int
)
$row
[
'revenue_account_id'
]
:
null
,
'is_active'
=>
1
,
];
}
...
...
app/Modules/Accounting/Services/DailyTransactionService.php
View file @
34bbb7fa
...
...
@@ -49,51 +49,28 @@ class DailyTransactionService
];
}
$fy
=
$db
->
selectOne
(
"SELECT id FROM fiscal_years WHERE is_current = 1 AND is_archived = 0"
);
if
(
!
$fy
)
{
// Through JournalService: a consolidated daily entry must respect the
// closed-period lock and maintain account balances like any other.
$result
=
JournalService
::
createEntry
([
'entry_date'
=>
$date
,
'reference_type'
=>
'daily_consolidated'
,
'reference_number'
=>
'DT-'
.
str_replace
(
'-'
,
''
,
$date
),
'description_ar'
=>
'قيد مجمع — حركات يومية '
.
$date
,
'source_module'
=>
'accounting'
,
'is_auto_generated'
=>
1
,
],
array_map
(
static
fn
(
array
$l
)
:
array
=>
[
'account_id'
=>
$l
[
'account_id'
],
'debit'
=>
number_format
((
float
)
$l
[
'debit'
],
2
,
'.'
,
''
),
'credit'
=>
number_format
((
float
)
$l
[
'credit'
],
2
,
'.'
,
''
),
'cost_center_id'
=>
$l
[
'cost_center_id'
],
'description_ar'
=>
$l
[
'description_ar'
],
],
$lines
),
true
);
if
(
empty
(
$result
[
'success'
]))
{
return
null
;
}
$now
=
date
(
'Y-m-d H:i:s'
);
$entryNumber
=
'DT-'
.
str_replace
(
'-'
,
''
,
$date
)
.
'-001'
;
$existing
=
$db
->
selectOne
(
"SELECT id FROM journal_entries WHERE entry_number = ?"
,
[
$entryNumber
]);
if
(
$existing
)
{
$suffix
=
$db
->
selectOne
(
"SELECT COUNT(*) as cnt FROM journal_entries WHERE entry_number LIKE ?"
,
[
'DT-'
.
str_replace
(
'-'
,
''
,
$date
)
.
'%'
]
);
$num
=
((
int
)(
$suffix
[
'cnt'
]
??
0
))
+
1
;
$entryNumber
=
'DT-'
.
str_replace
(
'-'
,
''
,
$date
)
.
'-'
.
str_pad
((
string
)
$num
,
3
,
'0'
,
STR_PAD_LEFT
);
}
$db
->
insert
(
'journal_entries'
,
[
'entry_number'
=>
$entryNumber
,
'entry_date'
=>
$date
,
'fiscal_year_id'
=>
(
int
)
$fy
[
'id'
],
'reference_type'
=>
'daily_consolidated'
,
'description'
=>
'قيد مجمع — حركات يومية '
.
$date
,
'total_debit'
=>
$totalDebit
,
'total_credit'
=>
$totalCredit
,
'status'
=>
'posted'
,
'posted_at'
=>
$now
,
'created_at'
=>
$now
,
'updated_at'
=>
$now
,
]);
$journalEntryId
=
(
int
)
$db
->
lastInsertId
();
foreach
(
$lines
as
$line
)
{
$db
->
insert
(
'journal_entry_lines'
,
[
'journal_entry_id'
=>
$journalEntryId
,
'account_id'
=>
$line
[
'account_id'
],
'debit'
=>
$line
[
'debit'
],
'credit'
=>
$line
[
'credit'
],
'cost_center_id'
=>
$line
[
'cost_center_id'
],
'description_ar'
=>
$line
[
'description_ar'
],
'created_at'
=>
$now
,
]);
}
$journalEntryId
=
(
int
)
$result
[
'journal_entry_id'
];
$txIds
=
array_column
(
$transactions
,
'id'
);
if
(
!
empty
(
$txIds
))
{
...
...
app/Modules/Accounting/Services/JournalService.php
View file @
34bbb7fa
...
...
@@ -209,10 +209,15 @@ final class JournalService
return
[
'success'
=>
false
,
'error'
=>
'القيد ملغي — لا يمكن ترحيله'
];
}
// Re-validate period is open
// Re-validate the period AND the year. A draft raised while both were
// open must not slip into a year that has since been closed.
$fiscalYear
=
FiscalYear
::
find
((
int
)
$entry
->
fiscal_year_id
);
if
(
!
$fiscalYear
||
!
$fiscalYear
->
isOpen
())
{
return
[
'success'
=>
false
,
'error'
=>
'السنة المالية مغلقة — لا يمكن ترحيل القيد'
];
}
$period
=
substr
(
$entry
->
entry_date
,
0
,
7
);
if
(
PeriodClosing
::
isPeriodClosed
((
int
)
$entry
->
fiscal_year_id
,
$period
))
{
return
[
'success'
=>
false
,
'error'
=>
'الفترة مغلقة — لا يمكن ترحيل القيد'
];
return
[
'success'
=>
false
,
'error'
=>
'الفترة
'
.
$period
.
'
مغلقة — لا يمكن ترحيل القيد'
];
}
$db
->
beginTransaction
();
...
...
app/Modules/Accounting/Services/Revenue/AllocationPlanService.php
View file @
34bbb7fa
...
...
@@ -340,11 +340,53 @@ final class AllocationPlanService
$warnings
[]
=
'تاريخ السريان قديم جدًا — تأكد إنه مقصود'
;
}
// ── Appropriation lines ─────────────────────────────────────
// An earmark is not a different kind of revenue. The main entry credits
// revenue in full; the share is moved out of equity separately. So an
// appropriation line needs BOTH ends of that second entry.
foreach
(
$lines
as
$i
=>
$l
)
{
if
(
empty
(
$l
[
'is_appropriation'
]))
{
continue
;
}
$n
=
$i
+
1
;
if
(
$l
[
'allocation_method'
]
===
'remainder'
)
{
$errors
[]
=
"البند
{
$n
}
: بند «الباقي» ما ينفعش يكون تخصيص — التخصيص لازم نسبة أو مبلغ محدد"
;
}
if
((
int
)
(
$l
[
'revenue_account_id'
]
??
0
)
<=
0
)
{
$errors
[]
=
"البند
{
$n
}
: التخصيص محتاج حساب الإيراد اللي هيتسجّل فيه المبلغ كامل الأول"
;
}
if
((
int
)
(
$l
[
'appropriation_source_account_id'
]
??
0
)
<=
0
)
{
$errors
[]
=
"البند
{
$n
}
: التخصيص محتاج حساب مصدر التخصيص (أرباح مرحّلة أو حساب تخصيص)"
;
}
if
((
$l
[
'line_type'
]
??
''
)
===
'revenue'
)
{
$errors
[]
=
"البند
{
$n
}
: بند التخصيص وجهته صندوق أو احتياطي — مش حساب إيراد"
;
}
}
// ── Each target, against its own direction ──────────────────
if
(
!
$targets
)
{
$errors
[]
=
'ما فيش أي مصدر إيراد مطابق للنطاق اللي اخترته'
;
}
$enforceAccrual
=
self
::
setting
(
'accounting.enforce_accrual_before_collection'
,
'1'
)
===
'1'
;
$deferCategories
=
array_filter
(
array_map
(
'trim'
,
explode
(
','
,
self
::
setting
(
'accounting.deferral_required_categories'
,
'subscription'
))
));
// Line types that put revenue into the income statement right now.
$recognisesRevenue
=
static
function
(
array
$l
)
use
(
$accounts
)
:
bool
{
if
(
!
empty
(
$l
[
'is_appropriation'
]))
{
return
true
;
// the main entry still credits revenue
}
if
((
$l
[
'line_type'
]
??
''
)
!==
'revenue'
)
{
return
false
;
}
$a
=
$accounts
[(
int
)
(
$l
[
'account_id'
]
??
0
)]
??
null
;
return
$a
===
null
||
$a
[
'account_type'
]
===
'revenue'
;
};
$seenDirections
=
[];
foreach
(
$targets
as
$t
)
{
$s
=
$t
[
'stream'
];
...
...
@@ -373,6 +415,40 @@ final class AllocationPlanService
.
'" ومش مناسب لبند "'
.
$allowedTypes
[
$lt
][
'label'
]
.
'"'
;
}
}
// ── Revenue is earned once, not twice ───────────────
// If the stream already recognises revenue when the club raises
// the claim, then collection is a cash-for-receivable swap. A
// revenue line here would book the same income a second time and
// leave the receivable outstanding for ever.
if
(
$enforceAccrual
&&
$stage
===
'collection'
&&
self
::
recognisesAtAccrual
((
int
)
$s
[
'id'
]))
{
foreach
(
$lines
as
$i
=>
$l
)
{
if
(
$recognisesRevenue
(
$l
))
{
$errors
[]
=
$s
[
'name_ar'
]
.
': البند '
.
(
$i
+
1
)
.
' بيسجّل إيراد وقت التحصيل، والمصدر ده بيسجّل إيراده وقت الاستحقاق. '
.
'التحصيل هنا تسوية ذمم — استخدم نوع البند «تسوية ذمم مدينة».'
;
break
;
}
}
}
// ── Cash received ≠ revenue earned ──────────────────
// A year's subscription collected today is a promise to serve for
// a year, not a year's income. EAS 48 / IFRS 15 require deferral.
if
(
\in_array
(
$s
[
'category'
],
$deferCategories
,
true
)
&&
\in_array
(
$stage
,
[
'collection'
,
'accrual'
],
true
))
{
foreach
(
$lines
as
$i
=>
$l
)
{
if
((
$l
[
'line_type'
]
??
''
)
===
'revenue'
&&
(
$l
[
'recognition_method'
]
??
'immediate'
)
===
'immediate'
&&
empty
(
$l
[
'is_appropriation'
]))
{
$errors
[]
=
$s
[
'name_ar'
]
.
' ('
.
(
self
::
CATEGORY_LABELS
[
$s
[
'category'
]]
??
$s
[
'category'
])
.
'): البند '
.
(
$i
+
1
)
.
' بيسجّل الإيراد كامل فورًا. الفئة دي الخدمة فيها بتتقدّم على مدى فترة، '
.
'فلازم نوع البند يكون «إيراد مؤجل» ويترحّل شهريًا.'
;
break
;
}
}
}
}
}
...
...
@@ -493,6 +569,9 @@ final class AllocationPlanService
'recognized_account_id'
=>
self
::
nullableInt
(
$l
[
'recognized_account_id'
]
??
null
),
'description_ar'
=>
self
::
nullableString
(
$l
[
'description_ar'
]
??
null
),
'max_amount'
=>
self
::
nullableString
(
$l
[
'max_amount'
]
??
null
),
'is_appropriation'
=>
!
empty
(
$l
[
'is_appropriation'
])
?
1
:
0
,
'appropriation_source_account_id'
=>
self
::
nullableInt
(
$l
[
'appropriation_source_account_id'
]
??
null
),
'revenue_account_id'
=>
self
::
nullableInt
(
$l
[
'revenue_account_id'
]
??
null
),
'is_active'
=>
1
,
'created_at'
=>
$now
,
'updated_at'
=>
$now
,
...
...
@@ -601,6 +680,54 @@ final class AllocationPlanService
// ────────────────────────────────────────────────────────────
/**
* Does this stream already put revenue in the income statement at accrual?
* If it does, collection must not do it again.
*/
public
static
function
recognisesAtAccrual
(
int
$streamId
)
:
bool
{
$db
=
App
::
getInstance
()
->
db
();
$row
=
$db
->
selectOne
(
"SELECT 1 AS ok
FROM revenue_posting_rules r
JOIN revenue_posting_rule_lines l ON l.rule_id = r.id AND l.is_active = 1
JOIN chart_of_accounts c ON c.id = l.account_id
WHERE r.stream_id = ?
AND r.stage = 'accrual'
AND r.status = 'active'
AND r.effective_from <= CURDATE()
AND (r.effective_to IS NULL OR r.effective_to >= CURDATE())
AND (l.line_type = 'revenue' OR l.is_appropriation = 1)
AND c.account_type = 'revenue'
LIMIT 1"
,
[
$streamId
]
);
return
$row
!==
null
;
}
/** A policy switch out of system_config, so finance can change it. */
public
static
function
setting
(
string
$key
,
string
$default
)
:
string
{
static
$cache
=
[];
if
(
\array_key_exists
(
$key
,
$cache
))
{
return
$cache
[
$key
];
}
try
{
$row
=
App
::
getInstance
()
->
db
()
->
selectOne
(
"SELECT config_value FROM system_config WHERE config_key = ?"
,
[
$key
]
);
$cache
[
$key
]
=
$row
!==
null
&&
$row
[
'config_value'
]
!==
null
?
(
string
)
$row
[
'config_value'
]
:
$default
;
}
catch
(
\Throwable
)
{
$cache
[
$key
]
=
$default
;
// config table missing before its migration
}
return
$cache
[
$key
];
}
private
static
function
isDate
(
string
$d
)
:
bool
{
return
(
bool
)
preg_match
(
'/^\d{4}-\d{2}-\d{2}$/'
,
$d
)
&&
strtotime
(
$d
)
!==
false
;
...
...
app/Modules/Accounting/Services/Revenue/RevenueAllocator.php
View file @
34bbb7fa
...
...
@@ -246,6 +246,13 @@ final class RevenueAllocator
'recognized_account_id'
=>
isset
(
$line
[
'recognized_account_id'
])
&&
$line
[
'recognized_account_id'
]
?
(
int
)
$line
[
'recognized_account_id'
]
:
null
,
'is_appropriation'
=>
!
empty
(
$line
[
'is_appropriation'
]),
'appropriation_source_account_id'
=>
isset
(
$line
[
'appropriation_source_account_id'
])
&&
$line
[
'appropriation_source_account_id'
]
?
(
int
)
$line
[
'appropriation_source_account_id'
]
:
null
,
'revenue_account_id'
=>
isset
(
$line
[
'revenue_account_id'
])
&&
$line
[
'revenue_account_id'
]
?
(
int
)
$line
[
'revenue_account_id'
]
:
null
,
];
}
...
...
app/Modules/Accounting/Services/Revenue/RevenuePostingEngine.php
View file @
34bbb7fa
...
...
@@ -79,7 +79,82 @@ final class RevenuePostingEngine
RevenueRecognitionService
::
schedule
(
$plan
[
'deferrals'
],
$ctx
,
$entryId
,
$plan
[
'stream'
][
'id'
]
??
null
);
}
return
[
'success'
=>
true
,
'journal_entry_id'
=>
$entryId
,
'used_rule'
=>
true
];
// Earmarks move AFTER the income is recognised, as a transfer out of
// equity — never as a reduction of the revenue itself. Its own entry, so
// the income statement and the appropriation read separately.
$appropriationEntryId
=
null
;
if
(
!
empty
(
$plan
[
'appropriations'
]))
{
$appropriationEntryId
=
self
::
postAppropriations
(
$plan
,
$ctx
,
$entryId
);
}
return
[
'success'
=>
true
,
'journal_entry_id'
=>
$entryId
,
'appropriation_entry_id'
=>
$appropriationEntryId
,
'used_rule'
=>
true
,
];
}
/**
* Post the earmark transfers as one separate entry:
* Dr appropriation source (retained earnings / appropriation account)
* Cr the fund
*
* A failure here does not undo the revenue entry — the income was genuinely
* earned and recognised. It is logged so the transfer can be re-run.
*/
private
static
function
postAppropriations
(
array
$plan
,
array
$ctx
,
int
$originEntryId
)
:
?
int
{
$lines
=
[];
foreach
(
$plan
[
'appropriations'
]
as
$ap
)
{
if
(
bccomp
(
$ap
[
'amount'
],
'0.00'
,
self
::
SCALE
)
<=
0
)
{
continue
;
}
$lines
[]
=
[
'account_id'
=>
$ap
[
'source_account_id'
],
'debit'
=>
$ap
[
'amount'
],
'credit'
=>
'0.00'
,
'description_ar'
=>
$ap
[
'description_ar'
],
'cost_center_id'
=>
$ap
[
'cost_center_id'
],
'branch_id'
=>
$ap
[
'branch_id'
],
];
$lines
[]
=
[
'account_id'
=>
$ap
[
'fund_account_id'
],
'debit'
=>
'0.00'
,
'credit'
=>
$ap
[
'amount'
],
'description_ar'
=>
$ap
[
'description_ar'
],
'cost_center_id'
=>
$ap
[
'cost_center_id'
],
'branch_id'
=>
$ap
[
'branch_id'
],
];
}
if
(
count
(
$lines
)
<
2
)
{
return
null
;
}
$result
=
JournalService
::
createEntry
([
'entry_date'
=>
$plan
[
'header'
][
'entry_date'
]
??
date
(
'Y-m-d'
),
'description_ar'
=>
'تخصيص من الإيراد — '
.
(
$plan
[
'header'
][
'description_ar'
]
??
''
),
'reference_type'
=>
'appropriation'
,
'reference_id'
=>
$originEntryId
,
'reference_number'
=>
$ctx
[
'reference_number'
]
??
null
,
'source_module'
=>
$plan
[
'header'
][
'source_module'
]
??
null
,
'branch_id'
=>
$plan
[
'header'
][
'branch_id'
]
??
null
,
'cost_center_id'
=>
$plan
[
'header'
][
'cost_center_id'
]
??
null
,
'is_auto_generated'
=>
1
,
'notes'
=>
'تخصيص أرباح مقابل القيد رقم '
.
$originEntryId
.
' — الإيراد اتسجّل كامل، وده نقل للصندوق/الاحتياطي.'
,
],
$lines
,
true
);
if
(
empty
(
$result
[
'success'
]))
{
Logger
::
error
(
'appropriation entry failed'
,
[
'origin_entry_id'
=>
$originEntryId
,
'error'
=>
$result
[
'error'
]
??
null
,
]);
return
null
;
}
return
(
int
)
$result
[
'journal_entry_id'
];
}
/**
...
...
@@ -95,7 +170,8 @@ final class RevenuePostingEngine
$blank
=
[
'resolved'
=>
false
,
'stream'
=>
null
,
'rule'
=>
null
,
'allocation'
=>
null
,
'header'
=>
[],
'lines'
=>
[],
'deferrals'
=>
[],
'errors'
=>
[],
'warnings'
=>
[],
'header'
=>
[],
'lines'
=>
[],
'deferrals'
=>
[],
'appropriations'
=>
[],
'errors'
=>
[],
'warnings'
=>
[],
];
$amount
=
self
::
money
((
string
)
(
$ctx
[
'amount'
]
??
'0'
));
...
...
@@ -203,16 +279,57 @@ final class RevenuePostingEngine
}
}
$deferrals
=
[];
$deferrals
=
[];
$appropriations
=
[];
foreach
(
$alloc
[
'allocations'
]
as
$a
)
{
if
(
bccomp
(
$a
[
'amount'
],
'0.00'
,
self
::
SCALE
)
<=
0
)
{
continue
;
}
self
::
assertPostable
(
$a
[
'account_id'
],
'حساب البند'
,
$errors
);
$lineDesc
=
$a
[
'description_ar'
]
?:
$description
;
// ── Earmark, not a different kind of income ─────────────
// "20% goes to the facilities fund" does not change what the club
// earned — it decides what to do with it afterwards. So the main
// entry credits REVENUE for this share, and the earmark leaves as a
// separate transfer out of equity. Crediting the fund here instead
// would understate revenue and invent a liability owed to nobody.
if
(
!
empty
(
$a
[
'is_appropriation'
]))
{
$revenueAccountId
=
$a
[
'revenue_account_id'
];
$sourceAccountId
=
$a
[
'appropriation_source_account_id'
];
if
(
!
$revenueAccountId
||
!
$sourceAccountId
)
{
$errors
[]
=
'بند التخصيص بدون حساب إيراد أو حساب مصدر تخصيص'
;
continue
;
}
self
::
assertPostable
(
$revenueAccountId
,
'حساب إيراد التخصيص'
,
$errors
);
self
::
assertPostable
(
$sourceAccountId
,
'حساب مصدر التخصيص'
,
$errors
);
self
::
assertPostable
(
$a
[
'account_id'
],
'حساب الصندوق'
,
$errors
);
$jLines
[]
=
[
'account_id'
=>
$revenueAccountId
,
'debit'
=>
$isInflow
?
'0.00'
:
$a
[
'amount'
],
'credit'
=>
$isInflow
?
$a
[
'amount'
]
:
'0.00'
,
'description_ar'
=>
$lineDesc
,
'cost_center_id'
=>
$a
[
'cost_center_id'
]
??
$rule
[
'cost_center_id'
]
??
null
,
'branch_id'
=>
$a
[
'branch_id'
]
??
$ctx
[
'branch_id'
]
??
null
,
];
$appropriations
[]
=
[
'rule_line_id'
=>
$a
[
'rule_line_id'
],
'source_account_id'
=>
(
int
)
$sourceAccountId
,
'fund_account_id'
=>
(
int
)
$a
[
'account_id'
],
'amount'
=>
$a
[
'amount'
],
'cost_center_id'
=>
$a
[
'cost_center_id'
]
??
$rule
[
'cost_center_id'
]
??
null
,
'branch_id'
=>
$a
[
'branch_id'
]
??
$ctx
[
'branch_id'
]
??
null
,
'description_ar'
=>
'تخصيص — '
.
$lineDesc
,
];
continue
;
}
self
::
assertPostable
(
$a
[
'account_id'
],
'حساب البند'
,
$errors
);
// Contra-revenue always reduces revenue, so it is always a debit —
// on a collection with a discount and on a refund alike. Every other
// line type follows the entry's direction.
...
...
@@ -295,8 +412,9 @@ final class RevenuePostingEngine
'cost_center_id'
=>
$rule
[
'cost_center_id'
]
??
null
,
'is_auto_generated'
=>
1
,
],
'lines'
=>
$jLines
,
'deferrals'
=>
$deferrals
,
'lines'
=>
$jLines
,
'deferrals'
=>
$deferrals
,
'appropriations'
=>
$appropriations
,
'errors'
=>
$errors
,
'warnings'
=>
$warnings
,
'totals'
=>
[
'debit'
=>
$dr
,
'credit'
=>
$cr
],
...
...
app/Modules/Accounting/Views/revenue_mapping/wizard.php
View file @
34bbb7fa
...
...
@@ -411,6 +411,41 @@
</div>
</div>
</div>
<?php
if
(
$direction
!==
'outflow'
)
:
?>
<div
class=
"wz-approp-box"
style=
"margin-top:10px;"
>
<label
style=
"display:flex;gap:7px;align-items:flex-start;font-size:12px;cursor:pointer;"
>
<input
type=
"checkbox"
class=
"wz-approp"
style=
"margin-top:2px;"
>
<span>
<strong>
الجزء ده تخصيص، مش نوع إيراد تاني
</strong>
<span
style=
"display:block;color:#6B7280;font-size:11px;margin-top:2px;"
>
يعني الفلوس دي النادي كسبها فعلًا، وإحنا بس بنحجزها لصندوق. الإيراد هيتسجّل
كامل، والحجز هيتعمل بقيد تخصيص منفصل من الأرباح.
</span>
</span>
</label>
<div
class=
"wz-approp-fields"
style=
"display:none;margin-top:9px;padding:10px;background:#EEF2FF;border:1px solid #C7D2FE;border-radius:6px;"
>
<div
style=
"display:grid;grid-template-columns:1fr 1fr;gap:10px;"
>
<div
style=
"position:relative;"
>
<label
class=
"form-label"
style=
"font-size:11px;"
>
١ — الإيراد يتسجّل في
</label>
<input
type=
"text"
class=
"form-input acct-search wz-ap-rev-search"
placeholder=
"حساب الإيراد"
autocomplete=
"off"
>
<input
type=
"hidden"
class=
"wz-ap-rev"
>
<div
class=
"acct-results"
></div>
</div>
<div
style=
"position:relative;"
>
<label
class=
"form-label"
style=
"font-size:11px;"
>
٢ — التخصيص يتخصم من
</label>
<input
type=
"text"
class=
"form-input acct-search wz-ap-src-search"
placeholder=
"أرباح مرحّلة / حساب تخصيص"
autocomplete=
"off"
>
<input
type=
"hidden"
class=
"wz-ap-src"
>
<div
class=
"acct-results"
></div>
</div>
</div>
<div
class=
"form-help"
style=
"color:#3730A3;"
>
و«يروح لحساب» فوق هو الصندوق/الاحتياطي اللي هياخد الفلوس.
</div>
</div>
</div>
<?php
endif
;
?>
<div
class=
"wz-after"
style=
"margin-top:8px;font-size:11.5px;color:#6B7280;"
></div>
</div>
</template>
...
...
@@ -464,6 +499,9 @@
'type'
=>
$l
[
'line_type'
],
'desc'
=>
$l
[
'description_ar'
],
'months'
=>
$l
[
'recognition_months'
],
'approp'
=>
!
empty
(
$l
[
'is_appropriation'
]),
'apRev'
=>
(
int
)
(
$l
[
'revenue_account_id'
]
??
0
),
'apSrc'
=>
(
int
)
(
$l
[
'appropriation_source_account_id'
]
??
0
),
'recAcct'
=>
(
int
)
(
$l
[
'recognized_account_id'
]
??
0
),
'recLbl'
=>
$l
[
'recognized_code'
]
?
(
$l
[
'recognized_code'
]
.
' — '
.
$l
[
'recognized_name'
])
:
''
,
'defer'
=>
$l
[
'recognition_method'
]
===
'straight_line'
,
...
...
@@ -582,9 +620,23 @@
var
type
=
el
.
querySelector
(
'.wz-type'
);
var
defer
=
el
.
querySelector
(
'.wz-defer'
);
wireSearch
(
el
.
querySelector
(
'.wz-search'
),
el
.
querySelector
(
'.wz-acct'
),
el
.
querySelector
(
'.acct-results'
));
var
results
=
el
.
querySelectorAll
(
'.acct-results'
);
wireSearch
(
el
.
querySelector
(
'.wz-search'
),
el
.
querySelector
(
'.wz-acct'
),
results
[
0
]);
wireSearch
(
el
.
querySelector
(
'.wz-rec-search'
),
el
.
querySelector
(
'.wz-rec-acct'
),
el
.
querySelectorAll
(
'.acct-results'
)[
1
],
function
()
{
return
'revenue'
;
});
results
[
1
],
function
()
{
return
'revenue'
;
});
var
approp
=
el
.
querySelector
(
'.wz-approp'
);
var
apFields
=
el
.
querySelector
(
'.wz-approp-fields'
);
var
apRev
=
el
.
querySelector
(
'.wz-ap-rev'
);
var
apSrc
=
el
.
querySelector
(
'.wz-ap-src'
);
if
(
approp
)
{
wireSearch
(
el
.
querySelector
(
'.wz-ap-rev-search'
),
apRev
,
results
[
2
],
function
()
{
return
'revenue'
;
});
wireSearch
(
el
.
querySelector
(
'.wz-ap-src-search'
),
apSrc
,
results
[
3
]);
approp
.
addEventListener
(
'change'
,
function
()
{
apFields
.
style
.
display
=
approp
.
checked
?
'block'
:
'none'
;
recalc
();
});
}
function
syncDefer
()
{
defer
.
style
.
display
=
DEFERRABLE
.
indexOf
(
type
.
value
)
!==
-
1
?
'block'
:
'none'
;
...
...
@@ -623,6 +675,12 @@
el
.
querySelector
(
'.wz-rec-acct'
).
value
=
data
.
recAcct
||
''
;
el
.
querySelector
(
'.wz-rec-search'
).
value
=
data
.
recLbl
||
''
;
}
if
(
data
.
approp
&&
approp
)
{
approp
.
checked
=
true
;
apFields
.
style
.
display
=
'block'
;
apRev
.
value
=
data
.
apRev
||
''
;
apSrc
.
value
=
data
.
apSrc
||
''
;
}
}
syncDefer
();
recalc
();
...
...
@@ -657,7 +715,12 @@
account
:
el
.
querySelector
(
'.wz-acct'
).
value
,
desc
:
el
.
querySelector
(
'.wz-desc'
).
value
,
months
:
el
.
querySelector
(
'.wz-months'
).
value
,
recAcct
:
el
.
querySelector
(
'.wz-rec-acct'
).
value
recAcct
:
el
.
querySelector
(
'.wz-rec-acct'
).
value
,
approp
:
!!
(
el
.
querySelector
(
'.wz-approp'
)
&&
el
.
querySelector
(
'.wz-approp'
).
checked
),
apRev
:
el
.
querySelector
(
'.wz-ap-rev'
)
?
el
.
querySelector
(
'.wz-ap-rev'
).
value
:
''
,
apSrc
:
el
.
querySelector
(
'.wz-ap-src'
)
?
el
.
querySelector
(
'.wz-ap-src'
).
value
:
''
,
apRevLabel
:
el
.
querySelector
(
'.wz-ap-rev-search'
)
?
el
.
querySelector
(
'.wz-ap-rev-search'
).
value
:
''
,
apSrcLabel
:
el
.
querySelector
(
'.wz-ap-src-search'
)
?
el
.
querySelector
(
'.wz-ap-src-search'
).
value
:
''
};
});
...
...
@@ -808,7 +871,10 @@
recognition_method
:
deferred
?
'straight_line'
:
'immediate'
,
recognition_months
:
deferred
?
s
.
months
:
''
,
recognized_account_id
:
deferred
?
s
.
recAcct
:
''
,
max_amount
:
''
max_amount
:
''
,
is_appropriation
:
s
.
approp
?
1
:
0
,
revenue_account_id
:
s
.
approp
?
s
.
apRev
:
''
,
appropriation_source_account_id
:
s
.
approp
?
s
.
apSrc
:
''
});
});
var
remAcct
=
document
.
getElementById
(
'wz-rem-acct'
).
value
;
...
...
@@ -844,6 +910,18 @@
&&
!
el
.
querySelector
(
'.wz-rec-acct'
).
value
)
{
msgs
.
push
(
'الجزء '
+
(
i
+
1
)
+
': إيراد مؤجل من غير حساب يترحّل له.'
);
}
var
ap
=
el
.
querySelector
(
'.wz-approp'
);
if
(
ap
&&
ap
.
checked
)
{
if
(
!
el
.
querySelector
(
'.wz-ap-rev'
).
value
)
{
msgs
.
push
(
'الجزء '
+
(
i
+
1
)
+
': تخصيص من غير حساب الإيراد اللي هيتسجّل فيه.'
);
}
if
(
!
el
.
querySelector
(
'.wz-ap-src'
).
value
)
{
msgs
.
push
(
'الجزء '
+
(
i
+
1
)
+
': تخصيص من غير حساب مصدر التخصيص.'
);
}
if
(
el
.
querySelector
(
'.wz-method'
).
value
===
'remainder'
)
{
msgs
.
push
(
'الجزء '
+
(
i
+
1
)
+
': بند الباقي ما ينفعش يكون تخصيص.'
);
}
}
});
if
(
pct
>
100
)
msgs
.
push
(
'مجموع النسب '
+
pct
+
'٪ — أكبر من ١٠٠٪.'
);
if
(
Number
(
document
.
getElementById
(
'tgt-count'
).
textContent
||
0
)
<
1
)
{
...
...
database/migrations/Phase_107_001_appropriation_and_deferral.php
0 → 100644
View file @
34bbb7fa
<?php
declare
(
strict_types
=
1
);
use
App\Core\Database
;
/**
* Two accounting controls that could not be expressed before.
*
* 1. APPROPRIATION. "20% of the membership fee goes to the facilities fund" is
* almost never a revenue classification — it is an earmark. Crediting a fund
* liability straight out of the collection understates revenue and invents a
* liability owed to nobody. A line marked as an appropriation now credits the
* REVENUE account in the main entry, and the earmark is posted as a separate
* transfer out of equity: Dr appropriation source / Cr the fund.
*
* 2. DEFERRAL POLICY. Which stream categories may not recognise revenue on the
* day cash arrives. Held in system_config so finance can change it without a
* developer.
*
* Idempotent — safe to re-run.
*/
return
static
function
(
Database
$db
)
:
void
{
$has
=
static
function
(
string
$table
,
string
$column
)
use
(
$db
)
:
bool
{
return
(
bool
)
$db
->
selectOne
(
"SELECT 1 AS ok FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?"
,
[
$table
,
$column
]
);
};
if
(
!
$has
(
'revenue_posting_rule_lines'
,
'is_appropriation'
))
{
$db
->
raw
(
"
ALTER TABLE `revenue_posting_rule_lines`
ADD COLUMN `is_appropriation` TINYINT(1) NOT NULL DEFAULT 0
COMMENT '1 = earmark: revenue is recognised in full, the share is transferred out of equity separately'
AFTER `line_type`
"
);
}
if
(
!
$has
(
'revenue_posting_rule_lines'
,
'appropriation_source_account_id'
))
{
$db
->
raw
(
"
ALTER TABLE `revenue_posting_rule_lines`
ADD COLUMN `appropriation_source_account_id` BIGINT UNSIGNED NULL
COMMENT 'Debited by the transfer — retained earnings or an appropriation account'
AFTER `is_appropriation`
"
);
}
if
(
!
$has
(
'revenue_posting_rule_lines'
,
'revenue_account_id'
))
{
$db
->
raw
(
"
ALTER TABLE `revenue_posting_rule_lines`
ADD COLUMN `revenue_account_id` BIGINT UNSIGNED NULL
COMMENT 'Appropriation lines only: the revenue account credited in the main entry'
AFTER `appropriation_source_account_id`
"
);
}
// ── Deferral policy, editable from settings ─────────────────────────
$settings
=
[
[
'config_key'
=>
'accounting.deferral_required_categories'
,
'config_value'
=>
'subscription'
,
'config_type'
=>
'string'
,
'group_name'
=>
'accounting'
,
'description_ar'
=>
'فئات الإيراد اللي ممنوع تسجّل إيرادها فورًا وقت التحصيل — لازم إيراد مؤجل. افصل بينهم بفاصلة.'
,
'description_en'
=>
'Revenue categories that may not recognise revenue immediately on collection. Comma separated.'
,
],
[
'config_key'
=>
'accounting.enforce_accrual_before_collection'
,
'config_value'
=>
'1'
,
'config_type'
=>
'boolean'
,
'group_name'
=>
'accounting'
,
'description_ar'
=>
'امنع تسجيل الإيراد وقت التحصيل لو المصدر له قاعدة استحقاق — التحصيل ساعتها تسوية ذمم.'
,
'description_en'
=>
'Block revenue lines at collection when the stream already recognises at accrual.'
,
],
];
foreach
(
$settings
as
$s
)
{
$exists
=
$db
->
selectOne
(
"SELECT id FROM system_config WHERE config_key = ?"
,
[
$s
[
'config_key'
]]);
if
(
$exists
)
{
continue
;
}
$db
->
insert
(
'system_config'
,
$s
+
[
'is_editable'
=>
1
,
'created_at'
=>
date
(
'Y-m-d H:i:s'
),
'updated_at'
=>
date
(
'Y-m-d H:i:s'
),
]);
}
};
database/migrations/Phase_107_002_reclassify_membership_advances.php
0 → 100644
View file @
34bbb7fa
<?php
declare
(
strict_types
=
1
);
use
App\Core\Database
;
/**
* Three "مقدم عضوية" accounts were opened under 4105 إيرادات النادي, so money
* received before the service is delivered has been sitting in REVENUE:
*
* 410503 مقدم عضويه 2,774,495.00
* 410505 باقى مقدم عضويه 180,263.00
* 410504 جزء من مقدم 77,500.00
* ─────────────
* 3,032,258.00
*
* An advance is a contract liability (EAS 48 / IFRS 15) — the club owes a
* service, not a sale it has made. Leaving it in revenue overstates income,
* overstates the VAT base, and is the first thing an auditor pulls. The
* correctly-typed account already exists next door: 23081115 ايرادات مدفوعة
* مقدما, under 230811 حسابات دائنة أخري.
*
* This migration mirrors the three accounts into the liability section keeping
* their separate meanings, posts ONE dated correcting entry moving the balances,
* and blocks the old accounts from further posting. It does not delete them —
* the history stays readable.
*
* Idempotent: keyed on the entry's reference number, so a re-run posts nothing.
* Reversible: the entry can be reversed from the journal screen like any other.
*/
return
static
function
(
Database
$db
)
:
void
{
$REF
=
'RECLASS-ADVANCES-001'
;
if
(
$db
->
selectOne
(
"SELECT id FROM journal_entries WHERE reference_number = ?"
,
[
$REF
]))
{
return
;
// already corrected
}
// The liability parent that already holds ايرادات مدفوعة مقدما.
$parent
=
$db
->
selectOne
(
"SELECT id, account_code, level FROM chart_of_accounts
WHERE account_code = '230811' AND is_archived = 0"
);
if
(
!
$parent
)
{
return
;
// chart differs — leave it alone
}
$parentId
=
(
int
)
$parent
[
'id'
];
$parentLevel
=
(
int
)
(
$parent
[
'level'
]
??
4
);
$map
=
[
'410503'
=>
[
'code'
=>
'23081119'
,
'name'
=>
'مقدم عضوية — مقبوض مقدمًا'
],
'410504'
=>
[
'code'
=>
'23081120'
,
'name'
=>
'جزء من مقدم عضوية — مقبوض مقدمًا'
],
'410505'
=>
[
'code'
=>
'23081121'
,
'name'
=>
'باقي مقدم عضوية — مقبوض مقدمًا'
],
];
$now
=
date
(
'Y-m-d H:i:s'
);
$lines
=
[];
$total
=
'0.00'
;
foreach
(
$map
as
$oldCode
=>
$new
)
{
$old
=
$db
->
selectOne
(
"SELECT id, account_code, name_ar, current_balance, account_type
FROM chart_of_accounts WHERE account_code = ? AND is_archived = 0"
,
[
$oldCode
]
);
if
(
!
$old
||
$old
[
'account_type'
]
!==
'revenue'
)
{
continue
;
// already fixed, or not there
}
// Mirror account in the liability section.
$target
=
$db
->
selectOne
(
"SELECT id FROM chart_of_accounts WHERE account_code = ?"
,
[
$new
[
'code'
]]);
if
(
$target
)
{
$targetId
=
(
int
)
$target
[
'id'
];
}
else
{
$targetId
=
(
int
)
$db
->
insert
(
'chart_of_accounts'
,
[
'account_code'
=>
$new
[
'code'
],
'name_ar'
=>
$new
[
'name'
],
'name_en'
=>
'Membership advance — contract liability'
,
'account_type'
=>
'liability'
,
'account_nature'
=>
'credit'
,
'parent_id'
=>
$parentId
,
'level'
=>
$parentLevel
+
1
,
'is_header'
=>
0
,
'is_active'
=>
1
,
'is_archived'
=>
0
,
'current_balance'
=>
'0.00'
,
'notes'
=>
'مُنشأ بقيد تصحيح '
.
$REF
.
' — نقل مقدمات الأعضاء من الإيرادات للالتزامات'
,
'created_at'
=>
$now
,
'updated_at'
=>
$now
,
]);
}
// A revenue account carries a credit balance; debit it back to zero.
$balance
=
(
string
)
$old
[
'current_balance'
];
if
(
bccomp
(
$balance
,
'0.00'
,
2
)
===
0
)
{
continue
;
}
$lines
[]
=
[
'account_id'
=>
(
int
)
$old
[
'id'
],
'debit'
=>
$balance
,
'credit'
=>
'0.00'
,
'description_ar'
=>
'إقفال '
.
$old
[
'name_ar'
]
.
' — إعادة تبويب من الإيرادات'
,
];
$lines
[]
=
[
'account_id'
=>
$targetId
,
'debit'
=>
'0.00'
,
'credit'
=>
$balance
,
'description_ar'
=>
'إثبات '
.
$new
[
'name'
]
.
' — التزام عقدي'
,
];
$total
=
bcadd
(
$total
,
$balance
,
2
);
}
if
(
bccomp
(
$total
,
'0.00'
,
2
)
<=
0
)
{
return
;
// nothing to move
}
// Post into the open fiscal year, dated today, through the same path as any
// other entry so the period lock and balance maintenance both apply.
\App\Core\App
::
getInstance
()
->
setDb
(
$db
);
$result
=
\App\Modules\Accounting\Services\JournalService
::
createEntry
([
'entry_date'
=>
date
(
'Y-m-d'
),
'reference_type'
=>
'reclassification'
,
'reference_number'
=>
$REF
,
'description_ar'
=>
'قيد تصحيح — إعادة تبويب مقدمات العضوية من الإيرادات إلى الالتزامات ('
.
$total
.
' جنيه)'
,
'description_en'
=>
'Reclassification of membership advances from revenue to contract liability'
,
'source_module'
=>
'accounting'
,
'is_auto_generated'
=>
1
,
'notes'
=>
'المقدم التزام عقدي وليس إيرادًا — معيار المحاسبة المصري ٤٨. '
.
'الحسابات القديمة اتقفلت ومنعت من الترحيل، والأرصدة اتنقلت لحسابات الالتزامات المقابلة.'
,
],
$lines
,
true
);
if
(
empty
(
$result
[
'success'
]))
{
throw
new
\RuntimeException
(
'فشل قيد إعادة التبويب: '
.
(
$result
[
'error'
]
??
'سبب غير معروف'
));
}
// Stop anything posting to the revenue-side accounts again. They stay
// visible with a zero balance so the history reads straight.
foreach
(
array_keys
(
$map
)
as
$oldCode
)
{
$db
->
query
(
"UPDATE chart_of_accounts
SET is_active = 0,
notes = CONCAT(COALESCE(notes, ''), ' | موقوف بقيد ', ?, ' — الرصيد اتنقل لحساب الالتزامات المقابل'),
updated_at = ?
WHERE account_code = ? AND account_type = 'revenue'"
,
[
$REF
,
$now
,
$oldCode
]
);
}
};
docs/تعلّم-النظام-المحاسبي-خطوة-بخطوة.md
0 → 100644
View file @
34bbb7fa
This diff is collapsed.
Click to expand it.
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment