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('تم تشكيل اللجنة');
}
}
<?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\TenderService;
use App\Modules\Procurement\Services\QuoteService;
use App\Modules\Procurement\Services\CommitteeService;
class TenderController extends Controller
{
public function index(Request $request): Response
{
$this->authorize('procurement.tender.view');
$db = App::getInstance()->db();
$tenders = $db->select(
"SELECT t.*, pr.pr_number,
(SELECT COUNT(*) FROM procurement_tender_vendors WHERE tender_id = t.id) AS vendor_count,
(SELECT COUNT(*) FROM supplier_price_quotes WHERE tender_id = t.id AND status IN ('received','accepted','rejected')) AS quote_count
FROM procurement_tenders t
JOIN purchase_requisitions pr ON pr.id = t.requisition_id
ORDER BY t.created_at DESC"
);
return $this->view('Procurement.Views.tenders.index', ['tenders' => $tenders]);
}
public function create(Request $request, string $requisitionId): Response
{
$this->authorize('procurement.tender.manage');
$db = App::getInstance()->db();
$pr = $db->selectOne("SELECT * FROM purchase_requisitions WHERE id = ?", [(int) $requisitionId]);
if (!$pr) {
return $this->redirect('/procurement/requisitions')->withError('طلب الشراء غير موجود');
}
return $this->view('Procurement.Views.tenders.form', ['pr' => $pr]);
}
public function store(Request $request, string $requisitionId): Response
{
$this->authorize('procurement.tender.manage');
try {
$tenderId = TenderService::createTender((int) $requisitionId, [
'title' => $request->post('title'),
'terms' => $request->post('terms'),
'booklet_fee' => $request->post('booklet_fee'),
'issue_date' => $request->post('issue_date'),
'bid_deadline' => $request->post('bid_deadline'),
]);
} catch (\Throwable $e) {
return $this->redirect('/procurement/requisitions/' . $requisitionId)->withError($e->getMessage());
}
return $this->redirect('/procurement/tenders/' . $tenderId)->withSuccess('تم إنشاء كراسة الشروط');
}
public function show(Request $request, string $id): Response
{
$this->authorize('procurement.tender.view');
$tender = TenderService::getWithVendors((int) $id);
if (!$tender) {
return $this->redirect('/procurement/tenders')->withError('كراسة الشروط غير موجودة');
}
$db = App::getInstance()->db();
$suppliers = $db->select("SELECT id, name_ar, code FROM suppliers WHERE is_archived = 0 AND is_active = 1 ORDER BY name_ar");
$gate = null;
if ($tender['status'] === 'technical_review' || $tender['status'] === 'needs_retender' || $tender['status'] === 'financial_review') {
$gate = QuoteService::checkTechnicalGate((int) $id);
}
$committees = CommitteeService::getForRequisition((int) $tender['requisition_id']);
$financialComparison = $tender['status'] === 'financial_review' || $tender['status'] === 'awarded'
? QuoteService::getFinancialComparison((int) $id)
: null;
$prItems = $db->select(
"SELECT pri.*, i.name_ar AS item_name
FROM purchase_requisition_items pri
LEFT JOIN inventory_items i ON i.id = pri.item_id
WHERE pri.requisition_id = ?",
[(int) $tender['requisition_id']]
);
$warehouses = $db->select("SELECT id, name_ar FROM warehouses WHERE is_active = 1 ORDER BY name_ar");
$employees = $db->select("SELECT id, full_name_ar FROM employees WHERE is_active = 1 ORDER BY full_name_ar");
return $this->view('Procurement.Views.tenders.show', [
'tender' => $tender,
'suppliers' => $suppliers,
'gate' => $gate,
'committees' => $committees,
'financial' => $financialComparison,
'prItems' => $prItems,
'warehouses' => $warehouses,
'employees' => $employees,
]);
}
public function inviteVendors(Request $request, string $id): Response
{
$this->authorize('procurement.tender.manage');
$supplierIds = $request->post('supplier_ids', []);
if (empty($supplierIds)) {
return $this->redirect('/procurement/tenders/' . $id)->withError('يجب اختيار مورد واحد على الأقل');
}
try {
TenderService::inviteVendors((int) $id, $supplierIds);
} catch (\Throwable $e) {
return $this->redirect('/procurement/tenders/' . $id)->withError($e->getMessage());
}
return $this->redirect('/procurement/tenders/' . $id)->withSuccess('تم دعوة الموردين وفتح ملفات العروض');
}
public function technicalEvaluate(Request $request, string $id, string $quoteId): Response
{
$this->authorize('procurement.tender.manage');
$status = (string) $request->post('technical_status', '');
$notes = (string) $request->post('technical_notes', '');
$reason = $request->post('rejection_reason');
try {
QuoteService::evaluateTechnical((int) $quoteId, $status, $notes, $reason ? (string) $reason : null);
} catch (\Throwable $e) {
return $this->redirect('/procurement/tenders/' . $id)->withError($e->getMessage());
}
return $this->redirect('/procurement/tenders/' . $id)->withSuccess('تم تسجيل التقييم الفني');
}
public function checkGate(Request $request, string $id): Response
{
$this->authorize('procurement.tender.manage');
$result = QuoteService::checkTechnicalGate((int) $id);
if ($result['passed'] === null) {
return $this->redirect('/procurement/tenders/' . $id)->withError('لا يزال هناك عروض بدون تقييم فني — ' . $result['pending'] . ' عرض معلق');
}
if ($result['passed'] === false) {
return $this->redirect('/procurement/tenders/' . $id)->withError($result['reason']);
}
return $this->redirect('/procurement/tenders/' . $id)->withSuccess('تجاوزت العملية الحد الأدنى (' . $result['accepted_count'] . ' عروض مقبولة فنيًا) — انتقلت للجنة المالية');
}
public function award(Request $request, string $id): Response
{
$this->authorize('procurement.tender.manage');
$db = App::getInstance()->db();
$tender = $db->selectOne("SELECT * FROM procurement_tenders WHERE id = ?", [(int) $id]);
if (!$tender) {
return $this->redirect('/procurement/tenders')->withError('كراسة الشروط غير موجودة');
}
$itemIds = $request->post('requisition_item_id', []);
$supplierIds = $request->post('award_supplier_id', []);
$quoteIds = $request->post('award_quote_id', []);
$justification = (string) $request->post('justification', '');
$financialCommitteeId = (int) $request->post('financial_committee_id', 0);
$awards = [];
for ($i = 0; $i < count($itemIds); $i++) {
if (empty($supplierIds[$i])) {
continue;
}
$awards[] = [
'requisition_item_id' => (int) $itemIds[$i],
'supplier_id' => (int) $supplierIds[$i],
'quote_id' => (int) $quoteIds[$i],
];
}
if (empty($awards)) {
return $this->redirect('/procurement/tenders/' . $id)->withError('يجب ترسية صنف واحد على الأقل');
}
$employee = App::getInstance()->currentEmployee();
$evalNumber = 'EVAL-' . date('Ymd') . '-' . str_pad((string) random_int(1, 9999), 4, '0', STR_PAD_LEFT);
$evaluationId = (int) $db->insert('quote_evaluations', [
'evaluation_number' => $evalNumber,
'requisition_id' => $tender['requisition_id'],
'evaluation_date' => date('Y-m-d'),
'financial_committee_id' => $financialCommitteeId ?: null,
'status' => 'approved',
'justification' => $justification,
'evaluated_by' => $employee ? (int) $employee->id : null,
'approved_by' => $employee ? (int) $employee->id : null,
'approved_at' => date('Y-m-d H:i:s'),
]);
try {
QuoteService::awardItems((int) $tender['requisition_id'], $evaluationId, $awards);
} catch (\Throwable $e) {
return $this->redirect('/procurement/tenders/' . $id)->withError($e->getMessage());
}
$db->update('procurement_tenders', ['status' => 'awarded', 'updated_at' => date('Y-m-d H:i:s')], 'id = ?', [(int) $id]);
return $this->redirect('/procurement/tenders/' . $id)->withSuccess('تم تسجيل الترسية — يمكن الآن إنشاء أوامر الشراء');
}
public function convertToPos(Request $request, string $id): Response
{
$this->authorize('procurement.pr.convert');
$db = App::getInstance()->db();
$tender = $db->selectOne("SELECT * FROM procurement_tenders WHERE id = ?", [(int) $id]);
if (!$tender) {
return $this->redirect('/procurement/tenders')->withError('كراسة الشروط غير موجودة');
}
$evaluation = $db->selectOne(
"SELECT id FROM quote_evaluations WHERE requisition_id = ? ORDER BY id DESC LIMIT 1",
[$tender['requisition_id']]
);
if (!$evaluation) {
return $this->redirect('/procurement/tenders/' . $id)->withError('لا يوجد قرار ترسية بعد');
}
$warehouseId = (int) $request->post('warehouse_id', 0);
if ($warehouseId <= 0) {
return $this->redirect('/procurement/tenders/' . $id)->withError('يجب تحديد المخزن');
}
$result = QuoteService::convertToMultiplePOs((int) $tender['requisition_id'], (int) $evaluation['id'], $warehouseId);
if (!$result['success']) {
return $this->redirect('/procurement/tenders/' . $id)->withError($result['error']);
}
return $this->redirect('/procurement/tenders/' . $id)->withSuccess(
'تم إنشاء ' . count($result['po_ids']) . ' أمر شراء — رقم(أرقام): ' . implode(', ', $result['po_ids'])
);
}
}
......@@ -18,6 +18,20 @@ return [
['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'],
// 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 ──
['GET', '/procurement/grn', 'Procurement\Controllers\GoodsReceivedNoteController@index', ['auth'], 'procurement.grn.view'],
['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]);
}
}
......@@ -5,6 +5,7 @@ namespace App\Modules\Procurement\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
final class QuoteService
{
......@@ -205,6 +206,248 @@ final class QuoteService
}
}
/**
* Technical committee verdict on one vendor's quote. Rejected quotes are
* never deleted — they stay on the requisition with their reason, because
* a reviewer a year later needs to see who was excluded and why.
*/
public static function evaluateTechnical(int $quoteId, string $status, string $notes, ?string $rejectionReason = null): void
{
if (!\in_array($status, ['accepted', 'rejected'], true)) {
throw new \RuntimeException('حالة التقييم الفني غير صحيحة');
}
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$quote = $db->selectOne("SELECT * FROM supplier_price_quotes WHERE id = ?", [$quoteId]);
if (!$quote) {
throw new \RuntimeException('عرض السعر غير موجود');
}
$db->update('supplier_price_quotes', [
'technical_status' => $status,
'technical_notes' => $notes,
'rejection_reason' => $status === 'rejected' ? $rejectionReason : null,
'technical_evaluated_by' => $employee ? (int) $employee->id : null,
'technical_evaluated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$quoteId]);
}
/**
* After the technical committee finishes every quote on a tender: three
* or more technically-accepted offers move to the financial committee.
* Fewer than three and the whole exercise has to be re-tendered — that
* gate, and the reason it failed, has to be visible on the tender itself.
*/
public static function checkTechnicalGate(int $tenderId): array
{
$db = App::getInstance()->db();
$tender = $db->selectOne("SELECT * FROM procurement_tenders WHERE id = ?", [$tenderId]);
if (!$tender) {
throw new \RuntimeException('كراسة الشروط غير موجودة');
}
$counts = $db->selectOne(
"SELECT
SUM(CASE WHEN technical_status = 'pending' THEN 1 ELSE 0 END) AS pending,
SUM(CASE WHEN technical_status = 'accepted' THEN 1 ELSE 0 END) AS accepted,
SUM(CASE WHEN technical_status = 'rejected' THEN 1 ELSE 0 END) AS rejected
FROM supplier_price_quotes WHERE tender_id = ?",
[$tenderId]
);
$pending = (int) ($counts['pending'] ?? 0);
$accepted = (int) ($counts['accepted'] ?? 0);
if ($pending > 0) {
return ['passed' => null, 'accepted_count' => $accepted, 'pending' => $pending, 'reason' => null];
}
if ($accepted >= 3) {
$db->update('procurement_tenders', [
'status' => 'financial_review',
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$tenderId]);
return ['passed' => true, 'accepted_count' => $accepted, 'pending' => 0, 'reason' => null];
}
$reason = "عدد العروض المقبولة فنيًا ({$accepted}) أقل من الحد الأدنى المطلوب (3) — يلزم إعادة الطرح";
$db->update('procurement_tenders', [
'status' => 'needs_retender',
'retender_reason' => $reason,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$tenderId]);
return ['passed' => false, 'accepted_count' => $accepted, 'pending' => 0, 'reason' => $reason];
}
/** Financial comparison — technically-accepted quotes only, per the gate above. */
public static function getFinancialComparison(int $tenderId): array
{
$db = App::getInstance()->db();
$quotes = $db->select(
"SELECT q.*, s.name_ar AS supplier_name, s.code AS supplier_code
FROM supplier_price_quotes q
JOIN suppliers s ON s.id = q.supplier_id
WHERE q.tender_id = ? AND q.technical_status = 'accepted'
ORDER BY q.total_amount ASC",
[$tenderId]
);
return ['quotes' => $quotes];
}
/**
* Item-level award. The tender/PR need not go to one vendor — different
* line items can be won by different vendors, so the award is recorded
* per requisition item, not per requisition.
*
* @param array $awards Each: ['requisition_item_id' => int, 'supplier_id' => int, 'quote_id' => int]
*/
public static function awardItems(int $requisitionId, int $evaluationId, array $awards): void
{
$db = App::getInstance()->db();
$db->beginTransaction();
try {
foreach ($awards as $award) {
$itemId = (int) ($award['requisition_item_id'] ?? 0);
if ($itemId <= 0) {
continue;
}
$db->update('purchase_requisition_items', [
'awarded_supplier_id' => (int) $award['supplier_id'],
'awarded_quote_id' => (int) $award['quote_id'],
], 'id = ? AND requisition_id = ?', [$itemId, $requisitionId]);
}
$db->update('quote_evaluations', [
'is_multi_award' => count(array_unique(array_column($awards, 'supplier_id'))) > 1 ? 1 : 0,
], 'id = ?', [$evaluationId]);
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
throw $e;
}
Logger::info("PR #{$requisitionId} items awarded under evaluation #{$evaluationId}");
}
/**
* One PR can spawn several POs — one per winning vendor — instead of the
* single-vendor conversion the ordinary quote flow uses. Every PO carries
* the requisition, the tender, and the evaluation it came from, so a PO
* found later traces straight back to the committee decision that made it.
*/
public static function convertToMultiplePOs(int $requisitionId, int $evaluationId, int $warehouseId): array
{
$db = App::getInstance()->db();
$pr = $db->selectOne("SELECT * FROM purchase_requisitions WHERE id = ?", [$requisitionId]);
if (!$pr) {
return ['success' => false, 'error' => 'طلب الشراء غير موجود'];
}
$eval = $db->selectOne("SELECT * FROM quote_evaluations WHERE id = ?", [$evaluationId]);
if (!$eval) {
return ['success' => false, 'error' => 'التقييم المالي غير موجود'];
}
$items = $db->select(
"SELECT * FROM purchase_requisition_items WHERE requisition_id = ? AND awarded_supplier_id IS NOT NULL",
[$requisitionId]
);
if (empty($items)) {
return ['success' => false, 'error' => 'لا توجد أصناف مرساة على مورد بعد'];
}
$bySupplier = [];
foreach ($items as $item) {
$bySupplier[(int) $item['awarded_supplier_id']][] = $item;
}
$poIds = [];
try {
foreach ($bySupplier as $supplierId => $supplierItems) {
$poItems = [];
foreach ($supplierItems as $pri) {
if (empty($pri['item_id'])) {
continue; // free-text specification line — nothing to receive into stock
}
$quote = $db->selectOne("SELECT * FROM supplier_price_quotes WHERE id = ?", [(int) $pri['awarded_quote_id']]);
$quoteItem = $quote ? $db->selectOne(
"SELECT * FROM supplier_quote_items WHERE quote_id = ? AND item_id = ?",
[(int) $quote['id'], (int) $pri['item_id']]
) : null;
$poItems[] = [
'item_id' => (int) $pri['item_id'],
'quantity_ordered' => (string) $pri['quantity'],
'unit_price' => (string) ($quoteItem['unit_price'] ?? $pri['estimated_unit_cost'] ?? '0'),
'notes' => $pri['specifications'] ?? null,
];
}
if (empty($poItems)) {
continue;
}
$poId = \App\Modules\Inventory\Services\PurchaseOrderService::createPO([
'supplier_id' => $supplierId,
'warehouse_id' => $warehouseId,
'expected_delivery_date' => $pr['required_date'],
'notes' => 'ترسية من تقييم عروض #' . $evaluationId . ' — طلب شراء ' . $pr['pr_number'],
], $poItems);
$tenderRow = $db->selectOne(
"SELECT tender_id FROM supplier_price_quotes WHERE requisition_id = ? AND supplier_id = ? LIMIT 1",
[$requisitionId, $supplierId]
);
$db->update('purchase_orders', [
'requisition_id' => $requisitionId,
'tender_id' => $tenderRow['tender_id'] ?? null,
'quote_evaluation_id' => $evaluationId,
], 'id = ?', [$poId]);
foreach ($supplierItems as $pri) {
$db->update('purchase_requisition_items', [
'awarded_po_id' => $poId,
], 'id = ?', [(int) $pri['id']]);
}
$poIds[] = $poId;
EventBus::dispatch('purchase_order.created', ['po_id' => $poId, 'from_evaluation' => $evaluationId]);
}
// Fully awarded only once every item has a PO — a PR can be split
// across several rounds of committee work before that is true.
$unawarded = $db->selectOne(
"SELECT COUNT(*) AS c FROM purchase_requisition_items WHERE requisition_id = ? AND awarded_po_id IS NULL",
[$requisitionId]
);
if ((int) ($unawarded['c'] ?? 0) === 0) {
$db->update('purchase_requisitions', [
'status' => 'converted',
'converted_po_id' => $poIds[0] ?? null,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$requisitionId]);
}
} catch (\Throwable $e) {
return ['success' => false, 'error' => $e->getMessage()];
}
Logger::info("PR #{$requisitionId} awarded across " . count($poIds) . ' purchase order(s)');
return ['success' => true, 'po_ids' => $poIds];
}
public static function getComparisonData(int $requisitionId): array
{
$db = App::getInstance()->db();
......
<?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 @@
<i data-lucide="repeat" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> تحويل لأمر شراء
</button>
<?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 if (in_array($pr['status'], ['draft', 'submitted'])): ?>
<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(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>كراسة شروط <?= e($tender['tender_number']) ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/procurement/requisitions/<?= (int) $tender['requisition_id'] ?>" class="btn btn-outline">
<i data-lucide="arrow-right" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> طلب الشراء <?= e($tender['pr_number']) ?>
</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php
$statusLabels = [
'draft' => 'مسودة', 'published' => 'منشورة', 'technical_review' => 'تقييم فني',
'needs_retender' => 'تحتاج إعادة طرح', 'financial_review' => 'تقييم مالي',
'awarded' => 'تمت الترسية', 'cancelled' => 'ملغية',
];
$technicalLabels = ['pending' => 'معلق', 'accepted' => 'مقبول فنيًا', 'rejected' => 'مرفوض فنيًا'];
$technicalColors = ['pending' => '#6B7280', 'accepted' => '#059669', 'rejected' => '#DC2626'];
?>
<!-- Tender Header -->
<div class="card" style="margin-bottom:20px;padding:20px;">
<div style="display:flex;justify-content:space-between;flex-wrap:wrap;gap:10px;">
<div>
<h3 style="margin:0;"><?= e($tender['title']) ?></h3>
<p style="color:#6B7280;margin:5px 0 0;font-size:13px;">
رقم الكراسة: <code><?= e($tender['tender_number']) ?></code>
تاريخ الإصدار: <?= e($tender['issue_date']) ?>
<?php if ($tender['bid_deadline']): ?> — إقفال العروض: <?= e($tender['bid_deadline']) ?><?php endif; ?>
</p>
</div>
<span style="display:inline-block;padding:5px 14px;border-radius:10px;font-size:13px;font-weight:700;background:#F5F3FF;color:#7C3AED;height:fit-content;">
<?= e($statusLabels[$tender['status']] ?? $tender['status']) ?>
</span>
</div>
<?php if (!empty($tender['terms'])): ?>
<div style="margin-top:12px;padding:12px;background:#F9FAFB;border-radius:6px;font-size:13px;color:#374151;white-space:pre-line;"><?= e($tender['terms']) ?></div>
<?php endif; ?>
<?php if ($tender['status'] === 'needs_retender'): ?>
<div style="margin-top:12px;padding:12px;background:#FEE2E2;border-radius:6px;font-size:13px;color:#991B1B;font-weight:600;">
<?= e($tender['retender_reason']) ?>
</div>
<?php endif; ?>
</div>
<!-- Committees -->
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;">
<h3 style="margin:0;color:#374151;font-size:15px;">اللجان</h3>
<?php if (can('procurement.committee.manage')): ?>
<div style="display:flex;gap:8px;">
<a href="/procurement/requisitions/<?= (int) $tender['requisition_id'] ?>/committees/technical/create" class="btn btn-sm btn-outline">+ لجنة فنية</a>
<a href="/procurement/requisitions/<?= (int) $tender['requisition_id'] ?>/committees/financial/create" class="btn btn-sm btn-outline">+ لجنة مالية</a>
</div>
<?php endif; ?>
</div>
<?php if (!empty($committees)): ?>
<div style="padding:15px 20px;">
<?php foreach ($committees as $c): ?>
<div style="padding:10px 0;border-bottom:1px solid #F3F4F6;">
<strong><?= e($c['committee_name']) ?></strong>
<span style="color:#6B7280;font-size:12px;">(<?= $c['committee_type'] === 'technical' ? 'فنية' : 'مالية' ?><?= e($c['formed_date']) ?>)</span>
<div style="font-size:13px;color:#6B7280;margin-top:3px;">
رئيس اللجنة: <?= e($c['chairman_name'] ?? '—') ?>
الأعضاء: <?= e(implode('، ', array_column($c['members'], 'employee_name'))) ?>
</div>
<?php if (!empty($c['minutes'])): ?><div style="font-size:13px;margin-top:3px;">محضر: <?= e($c['minutes']) ?></div><?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<div style="padding:20px;text-align:center;color:#9CA3AF;font-size:13px;">لم تشكل أي لجنة بعد</div>
<?php endif; ?>
</div>
<!-- Invite Vendors -->
<?php if (can('procurement.tender.manage') && in_array($tender['status'], ['draft', 'published'])): ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;color:#2563EB;font-size:15px;">دعوة الموردين</h3>
</div>
<div style="padding:20px;">
<form method="POST" action="/procurement/tenders/<?= (int) $tender['id'] ?>/invite-vendors">
<?= csrf_field() ?>
<div class="form-group">
<select name="supplier_ids[]" class="form-select" multiple size="8" required>
<?php foreach ($suppliers as $s): ?>
<option value="<?= (int) $s['id'] ?>"><?= e($s['name_ar']) ?></option>
<?php endforeach; ?>
</select>
<small style="color:#6B7280;">اضغط Ctrl (أو Cmd) لاختيار أكثر من مورد — يمكن الدعوة على أكثر من مرة</small>
</div>
<button type="submit" class="btn btn-primary">دعوة الموردين وفتح ملفات العروض</button>
</form>
</div>
</div>
<?php endif; ?>
<!-- Vendors & Quotes / Technical Evaluation -->
<?php if (!empty($tender['vendors'])): ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;">
<h3 style="margin:0;color:#374151;font-size:15px;">الموردون المدعوون والعروض (<?= count($tender['vendors']) ?>)</h3>
<?php if (can('procurement.tender.manage') && in_array($tender['status'], ['published', 'technical_review'])): ?>
<form method="POST" action="/procurement/tenders/<?= (int) $tender['id'] ?>/check-gate">
<?= csrf_field() ?>
<button type="submit" class="btn btn-sm" style="background:#D97706;color:#fff;border:none;">فحص الحد الأدنى وإرسال للجنة المالية</button>
</form>
<?php endif; ?>
</div>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>المورد</th>
<th>حالة العرض</th>
<th>إجمالي العرض</th>
<th>التقييم الفني</th>
<?php if (can('procurement.tender.manage')): ?><th>إجراء</th><?php endif; ?>
</tr>
</thead>
<tbody>
<?php foreach ($tender['vendors'] as $v): ?>
<tr>
<td><?= e($v['supplier_name']) ?></td>
<td><?= e($v['quote_status'] ?? 'لم يرد عرض') ?></td>
<td style="direction:ltr;text-align:left;"><?= $v['total_amount'] ? money($v['total_amount']) : '—' ?></td>
<td>
<span style="color:<?= $technicalColors[$v['technical_status'] ?? 'pending'] ?>;font-weight:600;">
<?= e($technicalLabels[$v['technical_status'] ?? 'pending'] ?? '—') ?>
</span>
</td>
<?php if (can('procurement.tender.manage')): ?>
<td>
<?php if ($v['quote_id'] && ($v['technical_status'] ?? 'pending') === 'pending'): ?>
<button type="button" class="btn btn-sm btn-outline" onclick="document.getElementById('techEval<?= (int) $v['quote_id'] ?>').style.display='block';">تقييم فني</button>
<div id="techEval<?= (int) $v['quote_id'] ?>" style="display:none;margin-top:8px;">
<form method="POST" action="/procurement/tenders/<?= (int) $tender['id'] ?>/quotes/<?= (int) $v['quote_id'] ?>/technical-evaluate">
<?= csrf_field() ?>
<select name="technical_status" class="form-select" style="margin-bottom:6px;" required>
<option value="accepted">مطابق فنيًا</option>
<option value="rejected">غير مطابق فنيًا</option>
</select>
<input type="text" name="rejection_reason" class="form-input" placeholder="سبب الرفض (إن وجد)" style="margin-bottom:6px;">
<textarea name="technical_notes" class="form-input" placeholder="ملاحظات فنية" rows="2" style="margin-bottom:6px;"></textarea>
<button type="submit" class="btn btn-sm btn-primary">حفظ التقييم</button>
</form>
</div>
<?php endif; ?>
</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<!-- Financial Comparison & Award -->
<?php if ($financial && !empty($financial['quotes'])): ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;color:#7C3AED;font-size:15px;">المقارنة المالية (العروض المقبولة فنيًا فقط)</h3>
</div>
<div class="table-responsive">
<table class="data-table">
<thead><tr><th>المورد</th><th>الإجمالي</th><th>شروط الدفع</th><th>شروط التوريد</th></tr></thead>
<tbody>
<?php foreach ($financial['quotes'] as $q): ?>
<tr>
<td><?= e($q['supplier_name']) ?></td>
<td style="direction:ltr;text-align:left;font-weight:700;"><?= money($q['total_amount']) ?></td>
<td><?= e($q['payment_terms'] ?? '—') ?></td>
<td><?= e($q['delivery_terms'] ?? '—') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php if (can('procurement.tender.manage') && $tender['status'] === 'financial_review'): ?>
<div style="padding:20px;border-top:1px solid #E5E7EB;">
<h4 style="margin:0 0 10px;font-size:14px;">الترسية حسب الصنف — يمكن أن يفوز أكثر من مورد بأصناف مختلفة</h4>
<form method="POST" action="/procurement/tenders/<?= (int) $tender['id'] ?>/award">
<?= csrf_field() ?>
<table class="data-table" style="margin-bottom:15px;">
<thead><tr><th>الصنف</th><th>الكمية</th><th>المورد الفائز</th></tr></thead>
<tbody>
<?php foreach ($prItems as $item): ?>
<tr>
<td><?= e($item['item_name'] ?? $item['description_ar'] ?? '—') ?>
<input type="hidden" name="requisition_item_id[]" value="<?= (int) $item['id'] ?>"></td>
<td><?= e($item['quantity']) ?></td>
<td>
<select name="award_supplier_id[]" class="form-select" onchange="
var opt = this.options[this.selectedIndex];
this.form.querySelector('input[name=\'award_quote_id[]\']:last-of-type')?.remove();
var inp = document.createElement('input'); inp.type='hidden'; inp.name='award_quote_id[]'; inp.value = opt.dataset.quoteId || '';
this.parentNode.appendChild(inp);
">
<option value="">— بدون ترسية —</option>
<?php foreach ($financial['quotes'] as $q): ?>
<option value="<?= (int) $q['supplier_id'] ?>" data-quote-id="<?= (int) $q['id'] ?>"><?= e($q['supplier_name']) ?> (<?= money($q['total_amount']) ?>)</option>
<?php endforeach; ?>
</select>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<div class="form-group">
<label class="form-label">لجنة مالية مرتبطة (اختياري)</label>
<input type="number" name="financial_committee_id" class="form-input" style="max-width:150px;">
</div>
<div class="form-group">
<label class="form-label">سبب الاختيار / محضر الترسية</label>
<textarea name="justification" class="form-input" rows="3" required></textarea>
</div>
<button type="submit" class="btn btn-primary">تسجيل الترسية</button>
</form>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<!-- Convert to POs -->
<?php if (can('procurement.pr.convert') && $tender['status'] === 'awarded'): ?>
<div class="card" style="padding:20px;">
<h3 style="margin:0 0 12px;color:#059669;font-size:15px;">إنشاء أوامر الشراء من الترسية</h3>
<form method="POST" action="/procurement/tenders/<?= (int) $tender['id'] ?>/convert-to-pos" style="display:flex;gap:10px;align-items:end;">
<?= csrf_field() ?>
<div class="form-group" style="margin:0;">
<label class="form-label">المخزن</label>
<select name="warehouse_id" class="form-select" required>
<option value="">— اختر —</option>
<?php foreach ($warehouses as $w): ?>
<option value="<?= (int) $w['id'] ?>"><?= e($w['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn btn-primary">إنشاء أوامر الشراء (قد يكون أكثر من أمر)</button>
</form>
</div>
<?php endif; ?>
<?php $__template->endSection(); ?>
......@@ -38,6 +38,9 @@ PermissionRegistry::register('procurement', [
'procurement.quote.view' => ['ar' => 'عرض عروض الأسعار', 'en' => 'View Supplier Quotes'],
'procurement.quote.create' => ['ar' => 'إنشاء طلبات عروض أسعار', 'en' => 'Create Quote Requests'],
'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', [
['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' => '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' => '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],
......
<?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