Commit d75ca900 authored by DevPilot's avatar DevPilot

feat(accounting,procurement,auctions,inventory): reported fixes and committee dates/attachments

Four issues raised from the demo instance:

1. GL sync preview kept listing rows that could never sync. The preview
   selected every non-voided payment while syncPayments() skips
   amount <= 0 in PHP, so zero-amount payments stayed "pending"
   forever — most visible right after a sync. Both queries now filter
   amount > 0, matching the dashboard count.

2. The five financial statements (income statement, balance sheet,
   consolidated balance sheet, cash flow, changes in equity) moved out
   of the flat Accounting menu into their own "القوائم المالية" group
   inside the المالية section.

3. Technical and financial committees — in both tenders and auctions —
   now carry تاريخ التشكيل / تاريخ الانعقاد / تاريخ البت on the
   formation screen, plus multi-file attachments per committee
   (new committee_attachments table + CommitteeAttachmentService,
   modelled on the existing Support attachment service). Dates and
   attachment links render on the tender and auction pages, with a
   permission-checked download route for each scope.

4. Asset custody screens inner-joined inventory_items, so every asset
   without a stock item — buildings, courts, machines registered
   directly, which is most of the register — was invisible there. Now
   LEFT JOIN with the same name fallback the asset card already uses.
parent 55d6ac8e
...@@ -127,7 +127,7 @@ final class GLSyncService ...@@ -127,7 +127,7 @@ final class GLSyncService
m.full_name_ar AS party_name m.full_name_ar AS party_name
FROM payments p FROM payments p
LEFT JOIN members m ON m.id = p.member_id LEFT JOIN members m ON m.id = p.member_id
WHERE p.is_voided = 0 WHERE p.is_voided = 0 AND p.amount > 0
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM journal_entries je SELECT 1 FROM journal_entries je
WHERE je.reference_type = 'payment' AND je.reference_id = p.id WHERE je.reference_type = 'payment' AND je.reference_id = p.id
...@@ -331,7 +331,7 @@ final class GLSyncService ...@@ -331,7 +331,7 @@ final class GLSyncService
"SELECT p.id, p.member_id, p.payment_type, p.amount, p.payment_method, "SELECT p.id, p.member_id, p.payment_type, p.amount, p.payment_method,
p.payment_date, p.receipt_id p.payment_date, p.receipt_id
FROM payments p FROM payments p
WHERE p.is_voided = 0 WHERE p.is_voided = 0 AND p.amount > 0
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM journal_entries je SELECT 1 FROM journal_entries je
WHERE je.reference_type = 'payment' AND je.reference_id = p.id WHERE je.reference_type = 'payment' AND je.reference_id = p.id
......
...@@ -184,11 +184,6 @@ MenuRegistry::register('accounting', [ ...@@ -184,11 +184,6 @@ MenuRegistry::register('accounting', [
['label_ar' => 'إقفال الفترات', 'label_en' => 'Period Closing', 'route' => '/accounting/period-closing', 'permission' => 'accounting.period.view', 'order' => 12], ['label_ar' => 'إقفال الفترات', 'label_en' => 'Period Closing', 'route' => '/accounting/period-closing', 'permission' => 'accounting.period.view', 'order' => 12],
['label_ar' => 'ميزان المراجعة', 'label_en' => 'Trial Balance', 'route' => '/accounting/reports/trial-balance', 'permission' => 'accounting.reports.trial_balance', 'order' => 9], ['label_ar' => 'ميزان المراجعة', 'label_en' => 'Trial Balance', 'route' => '/accounting/reports/trial-balance', 'permission' => 'accounting.reports.trial_balance', 'order' => 9],
['label_ar' => 'دفتر الأستاذ', 'label_en' => 'General Ledger', 'route' => '/accounting/reports/general-ledger', 'permission' => 'accounting.reports.general_ledger', 'order' => 10], ['label_ar' => 'دفتر الأستاذ', 'label_en' => 'General Ledger', 'route' => '/accounting/reports/general-ledger', 'permission' => 'accounting.reports.general_ledger', 'order' => 10],
['label_ar' => 'قائمة الدخل', 'label_en' => 'Income Statement', 'route' => '/accounting/reports/income-statement', 'permission' => 'accounting.reports.income_statement','order' => 11],
['label_ar' => 'الميزانية العمومية', 'label_en' => 'Balance Sheet', 'route' => '/accounting/reports/balance-sheet', 'permission' => 'accounting.reports.balance_sheet', 'order' => 12],
['label_ar' => 'ميزانية موحدة', 'label_en' => 'Consolidated BS', 'route' => '/accounting/reports/consolidated-balance-sheet', 'permission' => 'accounting.reports.consolidated', 'order' => 13],
['label_ar' => 'قائمة التدفقات النقدية', 'label_en' => 'Cash Flow Statement', 'route' => '/accounting/reports/cash-flow', 'permission' => 'accounting.reports.cash_flow', 'order' => 14],
['label_ar' => 'قائمة التغيرات في حقوق الملكية', 'label_en' => 'Statement of Changes in Equity', 'route' => '/accounting/reports/equity-statement', 'permission' => 'accounting.reports.equity_statement', 'order' => 15],
['label_ar' => 'المدينون (AR)', 'label_en' => 'Accounts Receivable', 'route' => '/accounting/reports/accounts-receivable', 'permission' => 'accounting.reports.ar', 'order' => 14], ['label_ar' => 'المدينون (AR)', 'label_en' => 'Accounts Receivable', 'route' => '/accounting/reports/accounts-receivable', 'permission' => 'accounting.reports.ar', 'order' => 14],
['label_ar' => 'الدائنون (AP)', 'label_en' => 'Accounts Payable', 'route' => '/accounting/reports/accounts-payable', 'permission' => 'accounting.reports.ap', 'order' => 15], ['label_ar' => 'الدائنون (AP)', 'label_en' => 'Accounts Payable', 'route' => '/accounting/reports/accounts-payable', 'permission' => 'accounting.reports.ap', 'order' => 15],
['label_ar' => 'كشف حساب عضو', 'label_en' => 'Member Statement', 'route' => '/accounting/reports/member-statement', 'permission' => 'accounting.reports.member_statement','order' => 16], ['label_ar' => 'كشف حساب عضو', 'label_en' => 'Member Statement', 'route' => '/accounting/reports/member-statement', 'permission' => 'accounting.reports.member_statement','order' => 16],
...@@ -508,6 +503,28 @@ EventBus::listen('treasury.deposit.confirmed', function (array $data): void { ...@@ -508,6 +503,28 @@ EventBus::listen('treasury.deposit.confirmed', function (array $data): void {
} }
}, 50); }, 50);
// ────────────────────────────────────────────────────────────
// القوائم المالية — مجموعة مستقلة تحت قسم «المالية»
// ────────────────────────────────────────────────────────────
MenuRegistry::register('financial_statements', [
'label_ar' => 'القوائم المالية',
'label_en' => 'Financial Statements',
'icon' => 'file-text',
'route' => '/accounting/reports/income-statement',
'permission' => 'accounting.reports.view',
'parent' => null,
'order' => 335,
'children' => [
['label_ar' => 'قائمة الدخل', 'label_en' => 'Income Statement', 'route' => '/accounting/reports/income-statement', 'permission' => 'accounting.reports.income_statement', 'order' => 1],
['label_ar' => 'الميزانية العمومية', 'label_en' => 'Balance Sheet', 'route' => '/accounting/reports/balance-sheet', 'permission' => 'accounting.reports.balance_sheet', 'order' => 2],
['label_ar' => 'الميزانية المجمعة', 'label_en' => 'Consolidated Balance Sheet', 'route' => '/accounting/reports/consolidated-balance-sheet', 'permission' => 'accounting.reports.consolidated', 'order' => 3],
['label_ar' => 'قائمة التدفقات النقدية', 'label_en' => 'Cash Flow Statement', 'route' => '/accounting/reports/cash-flow', 'permission' => 'accounting.reports.cash_flow', 'order' => 4],
['label_ar' => 'قائمة التغيرات في حقوق الملكية', 'label_en' => 'Statement of Changes in Equity', 'route' => '/accounting/reports/equity-statement', 'permission' => 'accounting.reports.equity_statement', 'order' => 5],
],
]);
// ── Operational postings ──────────────────────────────────── // ── Operational postings ────────────────────────────────────
// Money leaving the club, and value moving between assets. Every one of these // Money leaving the club, and value moving between assets. Every one of these
// events was already being dispatched or is dispatched now; none of them had a // events was already being dispatched or is dispatched now; none of them had a
......
...@@ -8,6 +8,7 @@ use App\Core\Request; ...@@ -8,6 +8,7 @@ use App\Core\Request;
use App\Core\Response; use App\Core\Response;
use App\Core\App; use App\Core\App;
use App\Modules\Auctions\Services\AuctionCommitteeService; use App\Modules\Auctions\Services\AuctionCommitteeService;
use App\Shared\Services\CommitteeAttachmentService;
class AuctionCommitteeController extends Controller class AuctionCommitteeController extends Controller
{ {
...@@ -42,10 +43,13 @@ class AuctionCommitteeController extends Controller ...@@ -42,10 +43,13 @@ class AuctionCommitteeController extends Controller
return $this->redirect('/auctions/' . $auctionId)->withError('نوع اللجنة غير صحيح'); return $this->redirect('/auctions/' . $auctionId)->withError('نوع اللجنة غير صحيح');
} }
$committeeId = null;
try { try {
AuctionCommitteeService::create($type, (int) $auctionId, [ $committeeId = AuctionCommitteeService::create($type, (int) $auctionId, [
'committee_name' => $request->post('committee_name'), 'committee_name' => $request->post('committee_name'),
'formed_date' => $request->post('formed_date'), 'formed_date' => $request->post('formed_date'),
'meeting_date' => $request->post('meeting_date'),
'decision_date' => $request->post('decision_date'),
'chairman_employee_id' => $request->post('chairman_employee_id'), 'chairman_employee_id' => $request->post('chairman_employee_id'),
'minutes' => $request->post('minutes'), 'minutes' => $request->post('minutes'),
], $request->post('member_ids', [])); ], $request->post('member_ids', []));
...@@ -53,6 +57,32 @@ class AuctionCommitteeController extends Controller ...@@ -53,6 +57,32 @@ class AuctionCommitteeController extends Controller
return $this->redirect('/auctions/' . $auctionId . '/committees/' . $type . '/create')->withError($e->getMessage()); return $this->redirect('/auctions/' . $auctionId . '/committees/' . $type . '/create')->withError($e->getMessage());
} }
if ($committeeId && !empty($_FILES['attachments'])) {
CommitteeAttachmentService::handleUploads($_FILES['attachments'], 'auction', $committeeId);
}
return $this->redirect('/auctions/' . $auctionId)->withSuccess('تم تشكيل اللجنة'); return $this->redirect('/auctions/' . $auctionId)->withSuccess('تم تشكيل اللجنة');
} }
/** تنزيل مرفق لجنة */
public function downloadAttachment(Request $request, string $id): Response
{
$this->authorize('auction.view');
$row = CommitteeAttachmentService::find((int) $id);
if (!$row || $row['committee_scope'] !== 'auction') {
return $this->redirect('/auctions')->withError('المرفق غير موجود');
}
$path = CommitteeAttachmentService::absolutePath($row);
if (!is_file($path)) {
return $this->redirect('/auctions')->withError('الملف غير موجود على الخادم');
}
header('Content-Type: ' . ($row['mime_type'] ?: 'application/octet-stream'));
header('Content-Disposition: attachment; filename="' . basename((string) $row['original_filename']) . '"');
header('Content-Length: ' . filesize($path));
readfile($path);
exit;
}
} }
...@@ -16,4 +16,5 @@ return [ ...@@ -16,4 +16,5 @@ return [
['GET', '/auctions/{auctionId}/committees/{type}/create', 'Auctions\Controllers\AuctionCommitteeController@create', ['auth'], 'auction.manage'], ['GET', '/auctions/{auctionId}/committees/{type}/create', 'Auctions\Controllers\AuctionCommitteeController@create', ['auth'], 'auction.manage'],
['POST', '/auctions/{auctionId}/committees/{type}', 'Auctions\Controllers\AuctionCommitteeController@store', ['auth', 'csrf'], 'auction.manage'], ['POST', '/auctions/{auctionId}/committees/{type}', 'Auctions\Controllers\AuctionCommitteeController@store', ['auth', 'csrf'], 'auction.manage'],
['GET', '/auctions/committees/attachments/{id:\d+}', 'Auctions\Controllers\AuctionCommitteeController@downloadAttachment', ['auth'], 'auction.view'],
]; ];
...@@ -5,6 +5,7 @@ namespace App\Modules\Auctions\Services; ...@@ -5,6 +5,7 @@ namespace App\Modules\Auctions\Services;
use App\Core\App; use App\Core\App;
use App\Core\Logger; use App\Core\Logger;
use App\Shared\Services\CommitteeAttachmentService;
final class AuctionCommitteeService final class AuctionCommitteeService
{ {
...@@ -33,6 +34,8 @@ final class AuctionCommitteeService ...@@ -33,6 +34,8 @@ final class AuctionCommitteeService
$type === 'technical' ? 'اللجنة الفنية' : 'اللجنة المالية' $type === 'technical' ? 'اللجنة الفنية' : 'اللجنة المالية'
), ),
'formed_date' => $data['formed_date'] ?? date('Y-m-d'), 'formed_date' => $data['formed_date'] ?? date('Y-m-d'),
'meeting_date' => !empty($data['meeting_date']) ? $data['meeting_date'] : null,
'decision_date' => !empty($data['decision_date']) ? $data['decision_date'] : null,
'chairman_employee_id' => $chairmanId, 'chairman_employee_id' => $chairmanId,
'minutes' => $data['minutes'] ?? null, 'minutes' => $data['minutes'] ?? null,
'status' => 'active', 'status' => 'active',
...@@ -78,6 +81,7 @@ final class AuctionCommitteeService ...@@ -78,6 +81,7 @@ final class AuctionCommitteeService
WHERE m.committee_id = ?", WHERE m.committee_id = ?",
[(int) $committee['id']] [(int) $committee['id']]
); );
$committee['attachments'] = CommitteeAttachmentService::getFor('auction', (int) $committee['id']);
} }
return $committees; return $committees;
......
...@@ -51,6 +51,20 @@ $typeLabels = ['sale' => 'بيع أصول', 'rental' => 'تأجير منشآت'] ...@@ -51,6 +51,20 @@ $typeLabels = ['sale' => 'بيع أصول', 'rental' => 'تأجير منشآت']
<div style="font-size:13px;color:#6B7280;margin-top:3px;"> <div style="font-size:13px;color:#6B7280;margin-top:3px;">
رئيس اللجنة: <?= e($c['chairman_name'] ?? '—') ?> — الأعضاء: <?= e(implode('، ', array_column($c['members'], 'employee_name'))) ?> رئيس اللجنة: <?= e($c['chairman_name'] ?? '—') ?> — الأعضاء: <?= e(implode('، ', array_column($c['members'], 'employee_name'))) ?>
</div> </div>
<div style="font-size:12.5px;color:#374151;margin-top:4px;">
تاريخ التشكيل: <strong><?= e($c['formed_date'] ?? '—') ?></strong> &nbsp;|&nbsp;
تاريخ الانعقاد: <strong><?= e($c['meeting_date'] ?: '—') ?></strong> &nbsp;|&nbsp;
تاريخ البت: <strong><?= e($c['decision_date'] ?: '—') ?></strong>
</div>
<?php if (!empty($c['minutes'])): ?><div style="font-size:13px;margin-top:3px;">محضر: <?= e($c['minutes']) ?></div><?php endif; ?>
<?php if (!empty($c['attachments'])): ?>
<div style="font-size:12.5px;margin-top:5px;">
المرفقات:
<?php foreach ($c['attachments'] as $att): ?>
<a href="/auctions/committees/attachments/<?= (int) $att['id'] ?>" style="margin-inline-end:10px;"><?= e($att['original_filename']) ?></a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
......
...@@ -14,17 +14,27 @@ ...@@ -14,17 +14,27 @@
</h3> </h3>
</div> </div>
<div style="padding:20px;"> <div style="padding:20px;">
<form method="POST" action="/auctions/<?= (int) $auction['id'] ?>/committees/<?= e($type) ?>"> <form method="POST" action="/auctions/<?= (int) $auction['id'] ?>/committees/<?= e($type) ?>" enctype="multipart/form-data">
<?= csrf_field() ?> <?= csrf_field() ?>
<div class="form-group"> <div class="form-group">
<label class="form-label">اسم اللجنة</label> <label class="form-label">اسم اللجنة</label>
<input type="text" name="committee_name" class="form-input" placeholder="<?= $type === 'technical' ? 'اللجنة الفنية' : 'اللجنة المالية' ?>"> <input type="text" name="committee_name" class="form-input" placeholder="<?= $type === 'technical' ? 'اللجنة الفنية' : 'اللجنة المالية' ?>">
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;"> <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:15px;">
<div class="form-group"> <div class="form-group">
<label class="form-label">تاريخ التشكيل</label> <label class="form-label">تاريخ التشكيل</label>
<input type="date" name="formed_date" class="form-input" value="<?= e(date('Y-m-d')) ?>"> <input type="date" name="formed_date" class="form-input" value="<?= e(date('Y-m-d')) ?>">
</div> </div>
<div class="form-group">
<label class="form-label">تاريخ الانعقاد</label>
<input type="date" name="meeting_date" class="form-input">
</div>
<div class="form-group">
<label class="form-label">تاريخ البت</label>
<input type="date" name="decision_date" class="form-input">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr;gap:15px;">
<div class="form-group"> <div class="form-group">
<label class="form-label">رئيس اللجنة <span style="color:#DC2626;">*</span></label> <label class="form-label">رئيس اللجنة <span style="color:#DC2626;">*</span></label>
<select name="chairman_employee_id" class="form-select" required> <select name="chairman_employee_id" class="form-select" required>
...@@ -48,6 +58,11 @@ ...@@ -48,6 +58,11 @@
<label class="form-label">محضر اللجنة (إن وجد)</label> <label class="form-label">محضر اللجنة (إن وجد)</label>
<textarea name="minutes" class="form-input" rows="4"></textarea> <textarea name="minutes" class="form-input" rows="4"></textarea>
</div> </div>
<div class="form-group">
<label class="form-label">مرفقات اللجنة</label>
<input type="file" name="attachments[]" class="form-input" multiple>
<small style="color:#6B7280;">محاضر، كشوف تقييم، صور مستندات — يمكن اختيار أكثر من ملف (PDF، صور، Word، Excel — حتى 20 ميجا للملف).</small>
</div>
<button type="submit" class="btn btn-primary">تشكيل اللجنة</button> <button type="submit" class="btn btn-primary">تشكيل اللجنة</button>
</form> </form>
</div> </div>
......
...@@ -30,9 +30,9 @@ class AssetCustodyController extends Controller ...@@ -30,9 +30,9 @@ class AssetCustodyController extends Controller
} }
$assets = $db->select( $assets = $db->select(
"SELECT a.*, i.name_ar as item_name, e.full_name_ar as custodian_name, w.name_ar as warehouse_name "SELECT a.*, COALESCE(NULLIF(a.asset_name,''), i.name_ar, a.asset_tag) as item_name, e.full_name_ar as custodian_name, w.name_ar as warehouse_name
FROM asset_register a FROM asset_register a
JOIN inventory_items i ON i.id = a.item_id LEFT JOIN inventory_items i ON i.id = a.item_id
LEFT JOIN employees e ON e.id = a.custodian_employee_id LEFT JOIN employees e ON e.id = a.custodian_employee_id
LEFT JOIN warehouses w ON w.id = a.warehouse_id LEFT JOIN warehouses w ON w.id = a.warehouse_id
WHERE {$where} WHERE {$where}
...@@ -56,9 +56,9 @@ class AssetCustodyController extends Controller ...@@ -56,9 +56,9 @@ class AssetCustodyController extends Controller
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$asset = $db->selectOne( $asset = $db->selectOne(
"SELECT a.*, i.name_ar as item_name, e.full_name_ar as current_custodian_name "SELECT a.*, COALESCE(NULLIF(a.asset_name,''), i.name_ar, a.asset_tag) as item_name, e.full_name_ar as current_custodian_name
FROM asset_register a FROM asset_register a
JOIN inventory_items i ON i.id = a.item_id LEFT JOIN inventory_items i ON i.id = a.item_id
LEFT JOIN employees e ON e.id = a.custodian_employee_id LEFT JOIN employees e ON e.id = a.custodian_employee_id
WHERE a.id = ?", WHERE a.id = ?",
[(int) $id] [(int) $id]
...@@ -124,7 +124,7 @@ class AssetCustodyController extends Controller ...@@ -124,7 +124,7 @@ class AssetCustodyController extends Controller
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$asset = $db->selectOne( $asset = $db->selectOne(
"SELECT a.*, i.name_ar as item_name FROM asset_register a JOIN inventory_items i ON i.id = a.item_id WHERE a.id = ?", "SELECT a.*, COALESCE(NULLIF(a.asset_name,''), i.name_ar, a.asset_tag) as item_name FROM asset_register a LEFT JOIN inventory_items i ON i.id = a.item_id WHERE a.id = ?",
[(int) $id] [(int) $id]
); );
......
...@@ -8,6 +8,7 @@ use App\Core\Request; ...@@ -8,6 +8,7 @@ use App\Core\Request;
use App\Core\Response; use App\Core\Response;
use App\Core\App; use App\Core\App;
use App\Modules\Procurement\Services\CommitteeService; use App\Modules\Procurement\Services\CommitteeService;
use App\Shared\Services\CommitteeAttachmentService;
class CommitteeController extends Controller class CommitteeController extends Controller
{ {
...@@ -47,10 +48,13 @@ class CommitteeController extends Controller ...@@ -47,10 +48,13 @@ class CommitteeController extends Controller
$memberIds = $request->post('member_ids', []); $memberIds = $request->post('member_ids', []);
$tenderId = (int) $request->post('tender_id', 0); $tenderId = (int) $request->post('tender_id', 0);
$committeeId = null;
try { try {
CommitteeService::create($type, (int) $requisitionId, $tenderId ?: null, [ $committeeId = CommitteeService::create($type, (int) $requisitionId, $tenderId ?: null, [
'committee_name' => $request->post('committee_name'), 'committee_name' => $request->post('committee_name'),
'formed_date' => $request->post('formed_date'), 'formed_date' => $request->post('formed_date'),
'meeting_date' => $request->post('meeting_date'),
'decision_date' => $request->post('decision_date'),
'chairman_employee_id' => $request->post('chairman_employee_id'), 'chairman_employee_id' => $request->post('chairman_employee_id'),
'minutes' => $request->post('minutes'), 'minutes' => $request->post('minutes'),
], $memberIds); ], $memberIds);
...@@ -58,7 +62,33 @@ class CommitteeController extends Controller ...@@ -58,7 +62,33 @@ class CommitteeController extends Controller
return $this->redirect('/procurement/requisitions/' . $requisitionId . '/committees/' . $type . '/create')->withError($e->getMessage()); return $this->redirect('/procurement/requisitions/' . $requisitionId . '/committees/' . $type . '/create')->withError($e->getMessage());
} }
if ($committeeId && !empty($_FILES['attachments'])) {
CommitteeAttachmentService::handleUploads($_FILES['attachments'], 'procurement', $committeeId);
}
$redirect = $tenderId ? '/procurement/tenders/' . $tenderId : '/procurement/requisitions/' . $requisitionId; $redirect = $tenderId ? '/procurement/tenders/' . $tenderId : '/procurement/requisitions/' . $requisitionId;
return $this->redirect($redirect)->withSuccess('تم تشكيل اللجنة'); return $this->redirect($redirect)->withSuccess('تم تشكيل اللجنة');
} }
/** تنزيل مرفق لجنة */
public function downloadAttachment(Request $request, string $id): Response
{
$this->authorize('procurement.tender.view');
$row = CommitteeAttachmentService::find((int) $id);
if (!$row || $row['committee_scope'] !== 'procurement') {
return $this->redirect('/procurement/tenders')->withError('المرفق غير موجود');
}
$path = CommitteeAttachmentService::absolutePath($row);
if (!is_file($path)) {
return $this->redirect('/procurement/tenders')->withError('الملف غير موجود على الخادم');
}
header('Content-Type: ' . ($row['mime_type'] ?: 'application/octet-stream'));
header('Content-Disposition: attachment; filename="' . basename((string) $row['original_filename']) . '"');
header('Content-Length: ' . filesize($path));
readfile($path);
exit;
}
} }
...@@ -31,6 +31,7 @@ return [ ...@@ -31,6 +31,7 @@ return [
['GET', '/procurement/requisitions/{requisitionId}/committees/{type}/create', 'Procurement\Controllers\CommitteeController@create', ['auth'], 'procurement.committee.manage'], ['GET', '/procurement/requisitions/{requisitionId}/committees/{type}/create', 'Procurement\Controllers\CommitteeController@create', ['auth'], 'procurement.committee.manage'],
['POST', '/procurement/requisitions/{requisitionId}/committees/{type}', 'Procurement\Controllers\CommitteeController@store', ['auth', 'csrf'], 'procurement.committee.manage'], ['POST', '/procurement/requisitions/{requisitionId}/committees/{type}', 'Procurement\Controllers\CommitteeController@store', ['auth', 'csrf'], 'procurement.committee.manage'],
['GET', '/procurement/committees/attachments/{id:\d+}', 'Procurement\Controllers\CommitteeController@downloadAttachment', ['auth'], 'procurement.tender.view'],
// ── Goods Received Notes ── // ── Goods Received Notes ──
['GET', '/procurement/grn', 'Procurement\Controllers\GoodsReceivedNoteController@index', ['auth'], 'procurement.grn.view'], ['GET', '/procurement/grn', 'Procurement\Controllers\GoodsReceivedNoteController@index', ['auth'], 'procurement.grn.view'],
......
...@@ -5,6 +5,7 @@ namespace App\Modules\Procurement\Services; ...@@ -5,6 +5,7 @@ namespace App\Modules\Procurement\Services;
use App\Core\App; use App\Core\App;
use App\Core\Logger; use App\Core\Logger;
use App\Shared\Services\CommitteeAttachmentService;
/** /**
* Technical and financial committees — same shape, different job. * Technical and financial committees — same shape, different job.
...@@ -47,6 +48,8 @@ final class CommitteeService ...@@ -47,6 +48,8 @@ final class CommitteeService
$type === 'technical' ? 'اللجنة الفنية' : 'اللجنة المالية' $type === 'technical' ? 'اللجنة الفنية' : 'اللجنة المالية'
), ),
'formed_date' => $data['formed_date'] ?? date('Y-m-d'), 'formed_date' => $data['formed_date'] ?? date('Y-m-d'),
'meeting_date' => !empty($data['meeting_date']) ? $data['meeting_date'] : null,
'decision_date' => !empty($data['decision_date']) ? $data['decision_date'] : null,
'chairman_employee_id' => $chairmanId, 'chairman_employee_id' => $chairmanId,
'minutes' => $data['minutes'] ?? null, 'minutes' => $data['minutes'] ?? null,
'status' => 'active', 'status' => 'active',
...@@ -93,6 +96,7 @@ final class CommitteeService ...@@ -93,6 +96,7 @@ final class CommitteeService
WHERE m.committee_id = ?", WHERE m.committee_id = ?",
[(int) $committee['id']] [(int) $committee['id']]
); );
$committee['attachments'] = CommitteeAttachmentService::getFor('procurement', (int) $committee['id']);
} }
return $committees; return $committees;
......
...@@ -18,7 +18,7 @@ ...@@ -18,7 +18,7 @@
</h3> </h3>
</div> </div>
<div style="padding:20px;"> <div style="padding:20px;">
<form method="POST" action="/procurement/requisitions/<?= (int) $pr['id'] ?>/committees/<?= e($type) ?>"> <form method="POST" action="/procurement/requisitions/<?= (int) $pr['id'] ?>/committees/<?= e($type) ?>" enctype="multipart/form-data">
<?= csrf_field() ?> <?= csrf_field() ?>
<?php if ($tender): ?><input type="hidden" name="tender_id" value="<?= (int) $tender['id'] ?>"><?php endif; ?> <?php if ($tender): ?><input type="hidden" name="tender_id" value="<?= (int) $tender['id'] ?>"><?php endif; ?>
...@@ -26,11 +26,21 @@ ...@@ -26,11 +26,21 @@
<label class="form-label">اسم اللجنة</label> <label class="form-label">اسم اللجنة</label>
<input type="text" name="committee_name" class="form-input" placeholder="<?= $type === 'technical' ? 'اللجنة الفنية' : 'اللجنة المالية' ?>"> <input type="text" name="committee_name" class="form-input" placeholder="<?= $type === 'technical' ? 'اللجنة الفنية' : 'اللجنة المالية' ?>">
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;"> <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:15px;">
<div class="form-group"> <div class="form-group">
<label class="form-label">تاريخ التشكيل</label> <label class="form-label">تاريخ التشكيل</label>
<input type="date" name="formed_date" class="form-input" value="<?= e(date('Y-m-d')) ?>"> <input type="date" name="formed_date" class="form-input" value="<?= e(date('Y-m-d')) ?>">
</div> </div>
<div class="form-group">
<label class="form-label">تاريخ الانعقاد</label>
<input type="date" name="meeting_date" class="form-input">
</div>
<div class="form-group">
<label class="form-label">تاريخ البت</label>
<input type="date" name="decision_date" class="form-input">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr;gap:15px;">
<div class="form-group"> <div class="form-group">
<label class="form-label">رئيس اللجنة <span style="color:#DC2626;">*</span></label> <label class="form-label">رئيس اللجنة <span style="color:#DC2626;">*</span></label>
<select name="chairman_employee_id" class="form-select" required> <select name="chairman_employee_id" class="form-select" required>
...@@ -54,6 +64,11 @@ ...@@ -54,6 +64,11 @@
<label class="form-label">محضر اللجنة (إن وجد)</label> <label class="form-label">محضر اللجنة (إن وجد)</label>
<textarea name="minutes" class="form-input" rows="4"></textarea> <textarea name="minutes" class="form-input" rows="4"></textarea>
</div> </div>
<div class="form-group">
<label class="form-label">مرفقات اللجنة</label>
<input type="file" name="attachments[]" class="form-input" multiple>
<small style="color:#6B7280;">محاضر، كشوف تقييم، صور مستندات — يمكن اختيار أكثر من ملف (PDF، صور، Word، Excel — حتى 20 ميجا للملف).</small>
</div>
<button type="submit" class="btn btn-primary">تشكيل اللجنة</button> <button type="submit" class="btn btn-primary">تشكيل اللجنة</button>
</form> </form>
</div> </div>
......
...@@ -8,6 +8,7 @@ ...@@ -8,6 +8,7 @@
<?php $__template->endSection(); ?> <?php $__template->endSection(); ?>
<?php $__template->section('content'); ?> <?php $__template->section('content'); ?>
<?php $ATT_BASE = '/procurement/committees/attachments'; ?>
<?php <?php
$statusLabels = [ $statusLabels = [
...@@ -65,7 +66,22 @@ $technicalColors = ['pending' => '#6B7280', 'accepted' => '#059669', 'rejected' ...@@ -65,7 +66,22 @@ $technicalColors = ['pending' => '#6B7280', 'accepted' => '#059669', 'rejected'
رئيس اللجنة: <?= e($c['chairman_name'] ?? '—') ?> رئيس اللجنة: <?= e($c['chairman_name'] ?? '—') ?>
الأعضاء: <?= e(implode('، ', array_column($c['members'], 'employee_name'))) ?> الأعضاء: <?= e(implode('، ', array_column($c['members'], 'employee_name'))) ?>
</div> </div>
<div style="font-size:12.5px;color:#374151;margin-top:4px;">
تاريخ التشكيل: <strong><?= e($c['formed_date'] ?? '—') ?></strong> &nbsp;|&nbsp;
تاريخ الانعقاد: <strong><?= e($c['meeting_date'] ?: '—') ?></strong> &nbsp;|&nbsp;
تاريخ البت: <strong><?= e($c['decision_date'] ?: '—') ?></strong>
</div>
<?php if (!empty($c['minutes'])): ?><div style="font-size:13px;margin-top:3px;">محضر: <?= e($c['minutes']) ?></div><?php endif; ?> <?php if (!empty($c['minutes'])): ?><div style="font-size:13px;margin-top:3px;">محضر: <?= e($c['minutes']) ?></div><?php endif; ?>
<?php if (!empty($c['attachments'])): ?>
<div style="font-size:12.5px;margin-top:5px;">
المرفقات:
<?php foreach ($c['attachments'] as $att): ?>
<a href="<?= e($ATT_BASE) ?>/<?= (int) $att['id'] ?>" style="margin-inline-end:10px;">
<?= e($att['original_filename']) ?>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
......
<?php
declare(strict_types=1);
namespace App\Shared\Services;
use App\Core\App;
/**
* مرفقات اللجان (مناقصات ومزادات) — محاضر، كشوف تقييم، صور مستندات.
*
* نفس أسلوب Support\AttachmentService: نتحقق من النوع والحجم، نخزّن باسم
* عشوائي آمن، ونسجّل صف في committee_attachments.
*/
final class CommitteeAttachmentService
{
private const ALLOWED_MIMES = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp',
'application/pdf',
'text/plain', 'text/csv',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/zip', 'application/x-rar-compressed',
];
private const MAX_FILE_SIZE = 20 * 1024 * 1024;
public static function uploadDir(): string
{
$dir = App::getInstance()->basePath() . '/storage/uploads/committees/';
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
return $dir;
}
/** @return array<int, array{id:int, filename:string}> */
public static function handleUploads(array $files, string $scope, int $committeeId): array
{
if (!in_array($scope, ['procurement', 'auction'], true)) {
return [];
}
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$uploadDir = self::uploadDir();
$saved = [];
foreach (self::normalizeFiles($files) as $file) {
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || ($file['size'] ?? 0) <= 0) continue;
if ($file['size'] > self::MAX_FILE_SIZE) continue;
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($file['tmp_name']);
if (!in_array($mimeType, self::ALLOWED_MIMES, true)) continue;
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)) ?: 'bin';
$stored = 'cmt_' . $scope . '_' . $committeeId . '_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '.' . $ext;
if (!move_uploaded_file($file['tmp_name'], $uploadDir . $stored)) continue;
$id = $db->insert('committee_attachments', [
'committee_scope' => $scope,
'committee_id' => $committeeId,
'original_filename' => $file['name'],
'stored_filename' => $stored,
'file_path' => 'storage/uploads/committees/' . $stored,
'file_size' => (int) $file['size'],
'mime_type' => $mimeType,
'uploaded_by' => $employee ? (int) $employee->id : null,
'created_at' => date('Y-m-d H:i:s'),
]);
$saved[] = ['id' => (int) $id, 'filename' => $file['name']];
}
return $saved;
}
public static function getFor(string $scope, int $committeeId): array
{
return App::getInstance()->db()->select(
"SELECT * FROM committee_attachments
WHERE committee_scope = ? AND committee_id = ?
ORDER BY created_at DESC",
[$scope, $committeeId]
);
}
public static function find(int $id): ?array
{
return App::getInstance()->db()->selectOne(
"SELECT * FROM committee_attachments WHERE id = ?",
[$id]
) ?: null;
}
public static function absolutePath(array $row): string
{
return App::getInstance()->basePath() . '/' . ltrim((string) $row['file_path'], '/');
}
public static function formatSize(int $bytes): string
{
if ($bytes >= 1048576) return round($bytes / 1048576, 1) . ' MB';
if ($bytes >= 1024) return round($bytes / 1024, 1) . ' KB';
return $bytes . ' B';
}
private static function normalizeFiles(array $files): array
{
if (isset($files['name']) && is_array($files['name'])) {
$out = [];
foreach ($files['name'] as $i => $name) {
$out[] = [
'name' => $name,
'type' => $files['type'][$i] ?? '',
'tmp_name' => $files['tmp_name'][$i] ?? '',
'error' => $files['error'][$i] ?? UPLOAD_ERR_NO_FILE,
'size' => $files['size'][$i] ?? 0,
];
}
return $out;
}
if (isset($files['name'])) return [$files];
return $files;
}
}
<?php
declare(strict_types=1);
/**
* اللجان الفنية والمالية (مناقصات ومزادات):
* - تاريخ التشكيل موجود، ونضيف تاريخ الانعقاد وتاريخ البت.
* - مرفقات لكل لجنة (محاضر، كشوف، صور مستندات).
*/
return [
'up' => "
ALTER TABLE `procurement_committees`
ADD COLUMN `meeting_date` DATE NULL AFTER `formed_date`,
ADD COLUMN `decision_date` DATE NULL AFTER `meeting_date`;
ALTER TABLE `auction_committees`
ADD COLUMN `meeting_date` DATE NULL AFTER `formed_date`,
ADD COLUMN `decision_date` DATE NULL AFTER `meeting_date`;
CREATE TABLE IF NOT EXISTS `committee_attachments` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`committee_scope` ENUM('procurement','auction') NOT NULL,
`committee_id` BIGINT UNSIGNED NOT NULL,
`original_filename` VARCHAR(255) NOT NULL,
`stored_filename` VARCHAR(255) NOT NULL,
`file_path` VARCHAR(500) NOT NULL,
`file_size` BIGINT UNSIGNED NOT NULL DEFAULT 0,
`mime_type` VARCHAR(150) NULL,
`uploaded_by` BIGINT UNSIGNED NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_comm_att_scope` (`committee_scope`, `committee_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
",
'down' => "
DROP TABLE IF EXISTS `committee_attachments`;
ALTER TABLE `auction_committees` DROP COLUMN `meeting_date`, DROP COLUMN `decision_date`;
ALTER TABLE `procurement_committees` DROP COLUMN `meeting_date`, DROP COLUMN `decision_date`;
",
];
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