Commit 1d760560 authored by DevPilot's avatar DevPilot

feat(procurement): government tender cycle — committees, technical/financial...

feat(procurement): government tender cycle — committees, technical/financial gate, multi-vendor award

Adds the documented, auditable purchase cycle on top of the existing
PR/quote infrastructure: a tender booklet (كراسة شروط) per PR, vendor
invitations that open a quote record per vendor (rejected quotes stay
on file with their reason, never deleted), technical committee
evaluation with the 3-accepted-offer minimum before financial review
(otherwise the tender is flagged for re-tender with a reason), a
financial committee comparison scoped to technically-accepted quotes
only, and item-level award that can split one PR across several
vendors and therefore several purchase orders — replacing the old
strict PR:PO 1:1 assumption. Every PO produced carries its tender and
evaluation id back to the original PR.
parent afa29392
<?php
declare(strict_types=1);
namespace App\Modules\Procurement\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Procurement\Services\CommitteeService;
class CommitteeController extends Controller
{
public function create(Request $request, string $requisitionId, string $type): Response
{
$this->authorize('procurement.committee.manage');
if (!\in_array($type, ['technical', 'financial'], true)) {
return $this->redirect('/procurement/requisitions/' . $requisitionId)->withError('نوع اللجنة غير صحيح');
}
$db = App::getInstance()->db();
$pr = $db->selectOne("SELECT * FROM purchase_requisitions WHERE id = ?", [(int) $requisitionId]);
if (!$pr) {
return $this->redirect('/procurement/requisitions')->withError('طلب الشراء غير موجود');
}
$tender = $db->selectOne("SELECT id, tender_number FROM procurement_tenders WHERE requisition_id = ? ORDER BY id DESC LIMIT 1", [(int) $requisitionId]);
$employees = $db->select("SELECT id, full_name_ar FROM employees WHERE is_active = 1 ORDER BY full_name_ar");
return $this->view('Procurement.Views.committees.form', [
'pr' => $pr,
'tender' => $tender,
'type' => $type,
'employees' => $employees,
]);
}
public function store(Request $request, string $requisitionId, string $type): Response
{
$this->authorize('procurement.committee.manage');
if (!\in_array($type, ['technical', 'financial'], true)) {
return $this->redirect('/procurement/requisitions/' . $requisitionId)->withError('نوع اللجنة غير صحيح');
}
$memberIds = $request->post('member_ids', []);
$tenderId = (int) $request->post('tender_id', 0);
try {
CommitteeService::create($type, (int) $requisitionId, $tenderId ?: null, [
'committee_name' => $request->post('committee_name'),
'formed_date' => $request->post('formed_date'),
'chairman_employee_id' => $request->post('chairman_employee_id'),
'minutes' => $request->post('minutes'),
], $memberIds);
} catch (\Throwable $e) {
return $this->redirect('/procurement/requisitions/' . $requisitionId . '/committees/' . $type . '/create')->withError($e->getMessage());
}
$redirect = $tenderId ? '/procurement/tenders/' . $tenderId : '/procurement/requisitions/' . $requisitionId;
return $this->redirect($redirect)->withSuccess('تم تشكيل اللجنة');
}
}
This diff is collapsed.
...@@ -18,6 +18,20 @@ return [ ...@@ -18,6 +18,20 @@ return [
['POST', '/procurement/requisitions/{id}/convert', 'Procurement\Controllers\RequisitionController@convert', ['auth', 'csrf'], 'procurement.pr.convert'], ['POST', '/procurement/requisitions/{id}/convert', 'Procurement\Controllers\RequisitionController@convert', ['auth', 'csrf'], 'procurement.pr.convert'],
['POST', '/procurement/requisitions/{id}/cancel', 'Procurement\Controllers\RequisitionController@cancel', ['auth', 'csrf'], 'procurement.pr.create'], ['POST', '/procurement/requisitions/{id}/cancel', 'Procurement\Controllers\RequisitionController@cancel', ['auth', 'csrf'], 'procurement.pr.create'],
// Government tender cycle — كراسة الشروط, committees, technical/financial evaluation
['GET', '/procurement/tenders', 'Procurement\Controllers\TenderController@index', ['auth'], 'procurement.tender.view'],
['GET', '/procurement/requisitions/{requisitionId}/tender/create', 'Procurement\Controllers\TenderController@create', ['auth'], 'procurement.tender.manage'],
['POST', '/procurement/requisitions/{requisitionId}/tender', 'Procurement\Controllers\TenderController@store', ['auth', 'csrf'], 'procurement.tender.manage'],
['GET', '/procurement/tenders/{id}', 'Procurement\Controllers\TenderController@show', ['auth'], 'procurement.tender.view'],
['POST', '/procurement/tenders/{id}/invite-vendors', 'Procurement\Controllers\TenderController@inviteVendors', ['auth', 'csrf'], 'procurement.tender.manage'],
['POST', '/procurement/tenders/{id}/quotes/{quoteId}/technical-evaluate', 'Procurement\Controllers\TenderController@technicalEvaluate', ['auth', 'csrf'], 'procurement.tender.manage'],
['POST', '/procurement/tenders/{id}/check-gate', 'Procurement\Controllers\TenderController@checkGate', ['auth', 'csrf'], 'procurement.tender.manage'],
['POST', '/procurement/tenders/{id}/award', 'Procurement\Controllers\TenderController@award', ['auth', 'csrf'], 'procurement.tender.manage'],
['POST', '/procurement/tenders/{id}/convert-to-pos', 'Procurement\Controllers\TenderController@convertToPos', ['auth', 'csrf'], 'procurement.pr.convert'],
['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'],
// ── 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'],
['GET', '/procurement/grn/create', 'Procurement\Controllers\GoodsReceivedNoteController@create', ['auth'], 'procurement.grn.create'], ['GET', '/procurement/grn/create', 'Procurement\Controllers\GoodsReceivedNoteController@create', ['auth'], 'procurement.grn.create'],
......
<?php
declare(strict_types=1);
namespace App\Modules\Procurement\Services;
use App\Core\App;
use App\Core\Logger;
/**
* Technical and financial committees — same shape, different job.
*
* The technical committee judges specs against the tender/PR; the financial
* committee only ever sees quotes the technical committee already accepted.
* Both are recorded against the PR (and the tender, when there is one) so
* "who decided this and when" survives in the case file forever.
*/
final class CommitteeService
{
public static function create(
string $type,
int $requisitionId,
?int $tenderId,
array $data,
array $memberIds
): int {
if (!\in_array($type, ['technical', 'financial'], true)) {
throw new \RuntimeException('نوع اللجنة غير صحيح');
}
if (empty($memberIds)) {
throw new \RuntimeException('يجب إضافة عضو واحد على الأقل للجنة');
}
$chairmanId = (int) ($data['chairman_employee_id'] ?? 0);
if ($chairmanId <= 0) {
throw new \RuntimeException('يجب تحديد رئيس اللجنة');
}
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$db->beginTransaction();
try {
$committeeId = (int) $db->insert('procurement_committees', [
'committee_type' => $type,
'requisition_id' => $requisitionId,
'tender_id' => $tenderId,
'committee_name' => trim((string) ($data['committee_name'] ?? '')) ?: (
$type === 'technical' ? 'اللجنة الفنية' : 'اللجنة المالية'
),
'formed_date' => $data['formed_date'] ?? date('Y-m-d'),
'chairman_employee_id' => $chairmanId,
'minutes' => $data['minutes'] ?? null,
'status' => 'active',
'created_at' => date('Y-m-d H:i:s'),
'created_by' => $employee ? (int) $employee->id : null,
]);
foreach (array_unique(array_map('intval', $memberIds)) as $memberId) {
$db->insert('procurement_committee_members', [
'committee_id' => $committeeId,
'employee_id' => $memberId,
'role_title' => $data['member_roles'][$memberId] ?? null,
]);
}
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
throw $e;
}
Logger::info("Committee #{$committeeId} ({$type}) formed for PR #{$requisitionId}");
return $committeeId;
}
public static function getForRequisition(int $requisitionId): array
{
$db = App::getInstance()->db();
$committees = $db->select(
"SELECT c.*, e.full_name_ar AS chairman_name
FROM procurement_committees c
LEFT JOIN employees e ON e.id = c.chairman_employee_id
WHERE c.requisition_id = ?
ORDER BY c.created_at ASC",
[$requisitionId]
);
foreach ($committees as &$committee) {
$committee['members'] = $db->select(
"SELECT m.*, e.full_name_ar AS employee_name
FROM procurement_committee_members m
JOIN employees e ON e.id = m.employee_id
WHERE m.committee_id = ?",
[(int) $committee['id']]
);
}
return $committees;
}
public static function updateMinutes(int $committeeId, string $minutes, string $status = 'closed'): void
{
App::getInstance()->db()->update('procurement_committees', [
'minutes' => $minutes,
'status' => $status,
], 'id = ?', [$committeeId]);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Procurement\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
/**
* كراسة الشروط — the tender booklet for a high-value PR.
*
* A tender is the documented alternative to an ordinary quote request: every
* vendor invited, every quote received (including rejected ones), and the
* committees that judged them all hang off this one record, so a purchase
* can be reconstructed years later from the PR alone.
*/
final class TenderService
{
public static function createTender(int $requisitionId, array $data): int
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$pr = $db->selectOne("SELECT * FROM purchase_requisitions WHERE id = ?", [$requisitionId]);
if (!$pr) {
throw new \RuntimeException('طلب الشراء غير موجود');
}
if ($pr['status'] !== 'approved') {
throw new \RuntimeException('لا يمكن إنشاء كراسة شروط إلا لطلب شراء معتمد');
}
$tenderNumber = 'TND-' . date('Ymd') . '-' . str_pad((string) random_int(1, 9999), 4, '0', STR_PAD_LEFT);
$tenderId = (int) $db->insert('procurement_tenders', [
'tender_number' => $tenderNumber,
'requisition_id' => $requisitionId,
'title' => trim((string) ($data['title'] ?? '')) ?: ('كراسة شروط — ' . $pr['pr_number']),
'terms' => $data['terms'] ?? null,
'booklet_fee' => !empty($data['booklet_fee']) ? $data['booklet_fee'] : null,
'issue_date' => $data['issue_date'] ?? date('Y-m-d'),
'bid_deadline' => $data['bid_deadline'] ?? null,
'status' => 'draft',
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
'created_by' => $employee ? (int) $employee->id : null,
]);
Logger::info("Tender #{$tenderId} ({$tenderNumber}) created for PR #{$requisitionId}");
return $tenderId;
}
/**
* Invite vendors and open a quote record for every one of them, even
* before a single price comes back. A vendor that never responds still
* has to show up in the case file as "invited, no response".
*/
public static function inviteVendors(int $tenderId, array $supplierIds, array $feesPaid = []): void
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$tender = $db->selectOne("SELECT * FROM procurement_tenders WHERE id = ?", [$tenderId]);
if (!$tender) {
throw new \RuntimeException('كراسة الشروط غير موجودة');
}
$db->beginTransaction();
try {
foreach ($supplierIds as $supplierId) {
$supplierId = (int) $supplierId;
$existing = $db->selectOne(
"SELECT id FROM procurement_tender_vendors WHERE tender_id = ? AND supplier_id = ?",
[$tenderId, $supplierId]
);
if ($existing) {
continue;
}
$db->insert('procurement_tender_vendors', [
'tender_id' => $tenderId,
'supplier_id' => $supplierId,
'invited_at' => date('Y-m-d H:i:s'),
'booklet_fee_paid' => $feesPaid[$supplierId] ?? null,
]);
$quoteNumber = 'QR-' . date('Ymd') . '-' . str_pad((string) random_int(1, 9999), 4, '0', STR_PAD_LEFT);
$db->insert('supplier_price_quotes', [
'quote_number' => $quoteNumber,
'requisition_id' => $tender['requisition_id'],
'tender_id' => $tenderId,
'supplier_id' => $supplierId,
'request_date' => date('Y-m-d'),
'status' => 'requested',
'technical_status' => 'pending',
'created_by' => $employee ? (int) $employee->id : null,
]);
}
$db->update('procurement_tenders', [
'status' => 'published',
'published_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$tenderId]);
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
throw $e;
}
EventBus::dispatch('procurement.tender_published', ['tender_id' => $tenderId, 'supplier_count' => count($supplierIds)]);
Logger::info("Tender #{$tenderId} published to " . count($supplierIds) . ' vendors');
}
public static function getWithVendors(int $tenderId): ?array
{
$db = App::getInstance()->db();
$tender = $db->selectOne(
"SELECT t.*, pr.pr_number FROM procurement_tenders t
JOIN purchase_requisitions pr ON pr.id = t.requisition_id
WHERE t.id = ?",
[$tenderId]
);
if (!$tender) {
return null;
}
$tender['vendors'] = $db->select(
"SELECT tv.*, s.name_ar AS supplier_name, q.id AS quote_id, q.status AS quote_status,
q.technical_status, q.total_amount
FROM procurement_tender_vendors tv
JOIN suppliers s ON s.id = tv.supplier_id
LEFT JOIN supplier_price_quotes q ON q.tender_id = tv.tender_id AND q.supplier_id = tv.supplier_id
WHERE tv.tender_id = ?
ORDER BY s.name_ar",
[$tenderId]
);
return $tender;
}
}
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?><?= $type === 'technical' ? 'تشكيل اللجنة الفنية' : 'تشكيل اللجنة المالية' ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="<?= $tender ? '/procurement/tenders/' . (int) $tender['id'] : '/procurement/requisitions/' . (int) $pr['id'] ?>" class="btn btn-outline">
<i data-lucide="arrow-right" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> رجوع
</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="max-width:700px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;color:<?= $type === 'technical' ? '#D97706' : '#7C3AED' ?>;font-size:15px;">
<?= $type === 'technical' ? 'تشكيل اللجنة الفنية' : 'تشكيل اللجنة المالية' ?>
— طلب شراء <?= e($pr['pr_number']) ?>
<?php if ($tender): ?> / كراسة <?= e($tender['tender_number']) ?><?php endif; ?>
</h3>
</div>
<div style="padding:20px;">
<form method="POST" action="/procurement/requisitions/<?= (int) $pr['id'] ?>/committees/<?= e($type) ?>">
<?= csrf_field() ?>
<?php if ($tender): ?><input type="hidden" name="tender_id" value="<?= (int) $tender['id'] ?>"><?php endif; ?>
<div class="form-group">
<label class="form-label">اسم اللجنة</label>
<input type="text" name="committee_name" class="form-input" placeholder="<?= $type === 'technical' ? 'اللجنة الفنية' : 'اللجنة المالية' ?>">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div class="form-group">
<label class="form-label">تاريخ التشكيل</label>
<input type="date" name="formed_date" class="form-input" value="<?= e(date('Y-m-d')) ?>">
</div>
<div class="form-group">
<label class="form-label">رئيس اللجنة <span style="color:#DC2626;">*</span></label>
<select name="chairman_employee_id" class="form-select" required>
<option value="">— اختر —</option>
<?php foreach ($employees as $e): ?>
<option value="<?= (int) $e['id'] ?>"><?= e($e['full_name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">أعضاء اللجنة <span style="color:#DC2626;">*</span></label>
<select name="member_ids[]" class="form-select" multiple size="8" required>
<?php foreach ($employees as $e): ?>
<option value="<?= (int) $e['id'] ?>"><?= e($e['full_name_ar']) ?></option>
<?php endforeach; ?>
</select>
<small style="color:#6B7280;">اضغط Ctrl (أو Cmd) لاختيار أكثر من عضو</small>
</div>
<div class="form-group">
<label class="form-label">محضر اللجنة (إن وجد)</label>
<textarea name="minutes" class="form-input" rows="4"></textarea>
</div>
<button type="submit" class="btn btn-primary">تشكيل اللجنة</button>
</form>
</div>
</div>
<?php $__template->endSection(); ?>
...@@ -32,6 +32,11 @@ ...@@ -32,6 +32,11 @@
<i data-lucide="repeat" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> تحويل لأمر شراء <i data-lucide="repeat" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> تحويل لأمر شراء
</button> </button>
<?php endif; ?> <?php endif; ?>
<?php if (can('procurement.tender.manage')): ?>
<a href="/procurement/requisitions/<?= (int) $pr['id'] ?>/tender/create" class="btn" style="background:#7C3AED;color:#fff;border:none;">
<i data-lucide="file-text" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> إنشاء كراسة شروط (مناقصة)
</a>
<?php endif; ?>
<?php endif; ?> <?php endif; ?>
<?php if (in_array($pr['status'], ['draft', 'submitted'])): ?> <?php if (in_array($pr['status'], ['draft', 'submitted'])): ?>
<form method="POST" action="/procurement/requisitions/<?= (int) $pr['id'] ?>/cancel" style="display:inline;"> <form method="POST" action="/procurement/requisitions/<?= (int) $pr['id'] ?>/cancel" style="display:inline;">
......
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>كراسة شروط جديدة<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/procurement/requisitions/<?= (int) $pr['id'] ?>" class="btn btn-outline"><i data-lucide="arrow-right" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> طلب الشراء</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="max-width:700px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;color:#7C3AED;font-size:15px;">كراسة شروط لطلب الشراء <?= e($pr['pr_number']) ?></h3>
</div>
<div style="padding:20px;">
<form method="POST" action="/procurement/requisitions/<?= (int) $pr['id'] ?>/tender">
<?= csrf_field() ?>
<div class="form-group">
<label class="form-label">عنوان المناقصة</label>
<input type="text" name="title" class="form-input" placeholder="مثال: توريد وتركيب معدات صالة الألعاب">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:15px;">
<div class="form-group">
<label class="form-label">تاريخ الإصدار</label>
<input type="date" name="issue_date" class="form-input" value="<?= e(date('Y-m-d')) ?>">
</div>
<div class="form-group">
<label class="form-label">موعد إقفال العروض</label>
<input type="datetime-local" name="bid_deadline" class="form-input">
</div>
<div class="form-group">
<label class="form-label">رسوم الكراسة (إن وجدت)</label>
<input type="number" name="booklet_fee" class="form-input" step="0.01" min="0" style="direction:ltr;text-align:left;">
</div>
</div>
<div class="form-group">
<label class="form-label">الشروط والمواصفات</label>
<textarea name="terms" class="form-input" rows="5" placeholder="الشروط والمواصفات المطلوبة من الموردين..."></textarea>
</div>
<button type="submit" class="btn btn-primary">إنشاء كراسة الشروط</button>
</form>
</div>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>كراسات الشروط (المناقصات)<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php
$statusLabels = [
'draft' => 'مسودة', 'published' => 'منشورة', 'technical_review' => 'تقييم فني',
'needs_retender' => 'تحتاج إعادة طرح', 'financial_review' => 'تقييم مالي',
'awarded' => 'تمت الترسية', 'cancelled' => 'ملغية',
];
$statusColors = [
'draft' => ['bg' => '#F3F4F6', 'color' => '#6B7280'],
'published' => ['bg' => '#EFF6FF', 'color' => '#2563EB'],
'technical_review' => ['bg' => '#FFF7ED', 'color' => '#D97706'],
'needs_retender' => ['bg' => '#FEE2E2', 'color' => '#DC2626'],
'financial_review' => ['bg' => '#F5F3FF', 'color' => '#7C3AED'],
'awarded' => ['bg' => '#ECFDF5', 'color' => '#059669'],
'cancelled' => ['bg' => '#F3F4F6', 'color' => '#9CA3AF'],
];
?>
<?php if (!empty($tenders)): ?>
<div class="card">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>رقم الكراسة</th>
<th>طلب الشراء</th>
<th>تاريخ الإصدار</th>
<th>الموردون المدعوون</th>
<th>العروض</th>
<th>الحالة</th>
</tr>
</thead>
<tbody>
<?php foreach ($tenders as $t): ?>
<?php $sc = $statusColors[$t['status']] ?? $statusColors['draft']; ?>
<tr>
<td><a href="/procurement/tenders/<?= (int) $t['id'] ?>"><code><?= e($t['tender_number']) ?></code></a></td>
<td><?= e($t['pr_number']) ?></td>
<td><?= e($t['issue_date']) ?></td>
<td><?= (int) $t['vendor_count'] ?></td>
<td><?= (int) $t['quote_count'] ?></td>
<td><span style="display:inline-block;padding:3px 10px;border-radius:10px;font-size:12px;font-weight:600;background:<?= $sc['bg'] ?>;color:<?= $sc['color'] ?>;"><?= e($statusLabels[$t['status']] ?? $t['status']) ?></span></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php else: ?>
<div class="card" style="padding:40px;text-align:center;color:#6B7280;">
<p style="margin:0;">لا توجد كراسات شروط حتى الآن</p>
<p style="margin:5px 0 0;font-size:13px;">أنشئ كراسة شروط من صفحة أي طلب شراء معتمد</p>
</div>
<?php endif; ?>
<?php $__template->endSection(); ?>
This diff is collapsed.
...@@ -38,6 +38,9 @@ PermissionRegistry::register('procurement', [ ...@@ -38,6 +38,9 @@ PermissionRegistry::register('procurement', [
'procurement.quote.view' => ['ar' => 'عرض عروض الأسعار', 'en' => 'View Supplier Quotes'], 'procurement.quote.view' => ['ar' => 'عرض عروض الأسعار', 'en' => 'View Supplier Quotes'],
'procurement.quote.create' => ['ar' => 'إنشاء طلبات عروض أسعار', 'en' => 'Create Quote Requests'], 'procurement.quote.create' => ['ar' => 'إنشاء طلبات عروض أسعار', 'en' => 'Create Quote Requests'],
'procurement.quote.evaluate' => ['ar' => 'تقييم ومقارنة العروض', 'en' => 'Evaluate & Compare Quotes'], 'procurement.quote.evaluate' => ['ar' => 'تقييم ومقارنة العروض', 'en' => 'Evaluate & Compare Quotes'],
'procurement.tender.view' => ['ar' => 'عرض كراسات الشروط', 'en' => 'View Tenders'],
'procurement.tender.manage' => ['ar' => 'إدارة كراسات الشروط والتقييم الفني والمالي', 'en' => 'Manage Tenders & Evaluation'],
'procurement.committee.manage' => ['ar' => 'تشكيل اللجان الفنية والمالية', 'en' => 'Form Technical & Financial Committees'],
]); ]);
// ──────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────
...@@ -60,6 +63,7 @@ MenuRegistry::register('procurement', [ ...@@ -60,6 +63,7 @@ MenuRegistry::register('procurement', [
['label_ar' => 'مدفوعات الموردين', 'label_en' => 'Vendor Payments', 'route' => '/procurement/payments', 'permission' => 'procurement.payment.view', 'order' => 5], ['label_ar' => 'مدفوعات الموردين', 'label_en' => 'Vendor Payments', 'route' => '/procurement/payments', 'permission' => 'procurement.payment.view', 'order' => 5],
['label_ar' => 'مرتجعات الموردين', 'label_en' => 'Returns to Vendor', 'route' => '/procurement/rtv', 'permission' => 'procurement.rtv.view', 'order' => 6], ['label_ar' => 'مرتجعات الموردين', 'label_en' => 'Returns to Vendor', 'route' => '/procurement/rtv', 'permission' => 'procurement.rtv.view', 'order' => 6],
['label_ar' => 'عروض الأسعار', 'label_en' => 'Supplier Quotes', 'route' => '/procurement/quotes', 'permission' => 'procurement.quote.view', 'order' => 7], ['label_ar' => 'عروض الأسعار', 'label_en' => 'Supplier Quotes', 'route' => '/procurement/quotes', 'permission' => 'procurement.quote.view', 'order' => 7],
['label_ar' => 'كراسات الشروط (المناقصات)', 'label_en' => 'Tenders', 'route' => '/procurement/tenders', 'permission' => 'procurement.tender.view', 'order' => 7],
['label_ar' => 'تتبع التسليم', 'label_en' => 'Delivery Tracking', 'route' => '/procurement/reports/overdue-deliveries', 'permission' => 'procurement.report', 'order' => 8], ['label_ar' => 'تتبع التسليم', 'label_en' => 'Delivery Tracking', 'route' => '/procurement/reports/overdue-deliveries', 'permission' => 'procurement.report', 'order' => 8],
['label_ar' => 'رصيد أوامر الشراء', 'label_en' => 'PO Balance', 'route' => '/procurement/reports/po-balance', 'permission' => 'procurement.report', 'order' => 9], ['label_ar' => 'رصيد أوامر الشراء', 'label_en' => 'PO Balance', 'route' => '/procurement/reports/po-balance', 'permission' => 'procurement.report', 'order' => 9],
['label_ar' => 'تقارير المشتريات', 'label_en' => 'Procurement Reports', 'route' => '/procurement/reports/purchase-volume', 'permission' => 'procurement.report', 'order' => 10], ['label_ar' => 'تقارير المشتريات', 'label_en' => 'Procurement Reports', 'route' => '/procurement/reports/purchase-volume', 'permission' => 'procurement.report', 'order' => 10],
......
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `procurement_tenders` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`tender_number` VARCHAR(50) NOT NULL,
`requisition_id` BIGINT UNSIGNED NOT NULL,
`title` VARCHAR(300) NOT NULL,
`terms` TEXT NULL,
`booklet_fee` DECIMAL(15,2) NULL,
`issue_date` DATE NOT NULL,
`bid_deadline` DATETIME NULL,
`status` ENUM('draft','published','technical_review','needs_retender','financial_review','awarded','cancelled') NOT NULL DEFAULT 'draft',
`retender_reason` TEXT NULL,
`published_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`created_by` BIGINT UNSIGNED NULL,
UNIQUE INDEX `uq_tender_number` (`tender_number`),
INDEX `idx_tender_requisition` (`requisition_id`),
INDEX `idx_tender_status` (`status`),
CONSTRAINT `fk_tender_requisition` FOREIGN KEY (`requisition_id`) REFERENCES `purchase_requisitions`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `procurement_tender_vendors` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`tender_id` BIGINT UNSIGNED NOT NULL,
`supplier_id` BIGINT UNSIGNED NOT NULL,
`invited_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`booklet_fee_paid` DECIMAL(15,2) NULL,
UNIQUE INDEX `uq_tender_vendor` (`tender_id`, `supplier_id`),
CONSTRAINT `fk_tv_tender` FOREIGN KEY (`tender_id`) REFERENCES `procurement_tenders`(`id`) ON DELETE CASCADE,
CONSTRAINT `fk_tv_supplier` FOREIGN KEY (`supplier_id`) REFERENCES `suppliers`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `procurement_committees` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`committee_type` ENUM('technical','financial') NOT NULL,
`requisition_id` BIGINT UNSIGNED NOT NULL,
`tender_id` BIGINT UNSIGNED NULL,
`committee_name` VARCHAR(200) NOT NULL,
`formed_date` DATE NOT NULL,
`chairman_employee_id` BIGINT UNSIGNED NOT NULL,
`minutes` TEXT NULL,
`status` ENUM('active','closed') NOT NULL DEFAULT 'active',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`created_by` BIGINT UNSIGNED NULL,
INDEX `idx_committee_requisition` (`requisition_id`),
INDEX `idx_committee_type` (`committee_type`),
CONSTRAINT `fk_committee_requisition` FOREIGN KEY (`requisition_id`) REFERENCES `purchase_requisitions`(`id`),
CONSTRAINT `fk_committee_tender` FOREIGN KEY (`tender_id`) REFERENCES `procurement_tenders`(`id`),
CONSTRAINT `fk_committee_chairman` FOREIGN KEY (`chairman_employee_id`) REFERENCES `employees`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `procurement_committee_members` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`committee_id` BIGINT UNSIGNED NOT NULL,
`employee_id` BIGINT UNSIGNED NOT NULL,
`role_title` VARCHAR(150) NULL,
INDEX `idx_cm_committee` (`committee_id`),
CONSTRAINT `fk_cm_committee` FOREIGN KEY (`committee_id`) REFERENCES `procurement_committees`(`id`) ON DELETE CASCADE,
CONSTRAINT `fk_cm_employee` FOREIGN KEY (`employee_id`) REFERENCES `employees`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
ALTER TABLE `supplier_price_quotes`
ADD COLUMN `tender_id` BIGINT UNSIGNED NULL AFTER `requisition_id`,
ADD COLUMN `technical_status` ENUM('pending','accepted','rejected') NOT NULL DEFAULT 'pending' AFTER `status`,
ADD COLUMN `technical_notes` TEXT NULL AFTER `technical_status`,
ADD COLUMN `rejection_reason` TEXT NULL AFTER `technical_notes`,
ADD COLUMN `technical_evaluated_by` BIGINT UNSIGNED NULL AFTER `rejection_reason`,
ADD COLUMN `technical_evaluated_at` DATETIME NULL AFTER `technical_evaluated_by`,
ADD INDEX `idx_spq_tender` (`tender_id`);
ALTER TABLE `purchase_requisition_items`
ADD COLUMN `awarded_supplier_id` BIGINT UNSIGNED NULL AFTER `preferred_supplier_id`,
ADD COLUMN `awarded_quote_id` BIGINT UNSIGNED NULL AFTER `awarded_supplier_id`,
ADD COLUMN `awarded_po_id` BIGINT UNSIGNED NULL AFTER `awarded_quote_id`;
ALTER TABLE `purchase_orders`
ADD COLUMN `tender_id` BIGINT UNSIGNED NULL AFTER `requisition_id`,
ADD COLUMN `quote_evaluation_id` BIGINT UNSIGNED NULL AFTER `tender_id`;
ALTER TABLE `quote_evaluations`
ADD COLUMN `financial_committee_id` BIGINT UNSIGNED NULL AFTER `evaluation_date`,
ADD COLUMN `is_multi_award` TINYINT(1) NOT NULL DEFAULT 0 AFTER `financial_committee_id`;
",
'down' => "
ALTER TABLE `quote_evaluations` DROP COLUMN `financial_committee_id`, DROP COLUMN `is_multi_award`;
ALTER TABLE `purchase_orders` DROP COLUMN `tender_id`, DROP COLUMN `quote_evaluation_id`;
ALTER TABLE `purchase_requisition_items` DROP COLUMN `awarded_supplier_id`, DROP COLUMN `awarded_quote_id`, DROP COLUMN `awarded_po_id`;
ALTER TABLE `supplier_price_quotes` DROP COLUMN `tender_id`, DROP COLUMN `technical_status`, DROP COLUMN `technical_notes`, DROP COLUMN `rejection_reason`, DROP COLUMN `technical_evaluated_by`, DROP COLUMN `technical_evaluated_at`;
DROP TABLE IF EXISTS `procurement_committee_members`;
DROP TABLE IF EXISTS `procurement_committees`;
DROP TABLE IF EXISTS `procurement_tender_vendors`;
DROP TABLE IF EXISTS `procurement_tenders`;
",
];
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