Commit 89acbebd authored by Mahmoud Aglan's avatar Mahmoud Aglan

Test

parent 5d2aceb8
......@@ -197,6 +197,43 @@ EventBus::listen('sale.refunded', function (array $data): void {
}
}, 50);
// ── Procurement ─────────────────────────────────────────────
// When a vendor invoice is approved, post AP accrual
EventBus::listen('procurement.invoice_approved', function (array $data): void {
try {
AccountingIntegrationService::onVendorInvoiceApproved($data);
} catch (\Throwable $e) {
\App\Core\Logger::error('Accounting auto-post failed (procurement.invoice_approved): ' . $e->getMessage());
}
}, 50);
// When a vendor payment is completed, post AP reduction
EventBus::listen('procurement.payment_completed', function (array $data): void {
try {
AccountingIntegrationService::onVendorPaymentCompleted($data);
} catch (\Throwable $e) {
\App\Core\Logger::error('Accounting auto-post failed (procurement.payment_completed): ' . $e->getMessage());
}
}, 50);
// When a vendor payment is voided, reverse the journal entry
EventBus::listen('procurement.payment_voided', function (array $data): void {
try {
AccountingIntegrationService::onVendorPaymentVoided($data);
} catch (\Throwable $e) {
\App\Core\Logger::error('Accounting auto-reverse failed (procurement.payment_voided): ' . $e->getMessage());
}
}, 50);
// When a return-to-vendor is completed, post AP debit / inventory credit
EventBus::listen('procurement.rtv_completed', function (array $data): void {
try {
AccountingIntegrationService::onReturnToVendorCompleted($data);
} catch (\Throwable $e) {
\App\Core\Logger::error('Accounting auto-post failed (procurement.rtv_completed): ' . $e->getMessage());
}
}, 50);
// ── Rentals ─────────────────────────────────────────────────
// When a rental deposit is collected, record deposit liability
EventBus::listen('rental.deposit_collected', function (array $data): void {
......
......@@ -103,10 +103,22 @@ class PurchaseOrderController extends Controller
$items = PurchaseOrderItem::getForPO((int) $id);
// Related procurement documents
$relatedGRNs = $db->select(
"SELECT `id`, `grn_number`, `status` FROM `goods_received_notes` WHERE `purchase_order_id` = ? AND `is_archived` = 0 ORDER BY `created_at` DESC",
[(int) $id]
);
$relatedInvoices = $db->select(
"SELECT `id`, `internal_number`, `total_amount`, `status` FROM `vendor_invoices` WHERE `purchase_order_id` = ? AND `is_archived` = 0 ORDER BY `created_at` DESC",
[(int) $id]
);
return $this->view('Inventory.Views.purchase_orders.show', [
'order' => $order,
'items' => $items,
'statuses' => PurchaseOrder::getStatuses(),
'order' => $order,
'items' => $items,
'statuses' => PurchaseOrder::getStatuses(),
'relatedGRNs' => $relatedGRNs,
'relatedInvoices' => $relatedInvoices,
]);
}
......
......@@ -71,8 +71,10 @@ class SupplierController extends Controller
);
return $this->view('Inventory.Views.suppliers.show', [
'supplier' => $supplier,
'recentPOs' => $recentPOs,
'supplier' => $supplier,
'recentPOs' => $recentPOs,
'performance' => Supplier::getPerformanceStats((int) $id),
'apBalance' => Supplier::getOutstandingBalance((int) $id),
]);
}
......
......@@ -41,6 +41,63 @@ class Supplier extends Model
->get();
}
/**
* Get supplier performance stats (orders, delivery, quality).
*/
public static function getPerformanceStats(int $supplierId): array
{
$db = App::getInstance()->db();
$row = $db->selectOne(
"SELECT
`total_orders`, `on_time_delivery_count`, `quality_acceptance_count`,
`average_lead_days`, `last_order_date`
FROM `suppliers` WHERE `id` = ?",
[$supplierId]
);
$totalOrders = (int) ($row['total_orders'] ?? 0);
$onTimeCount = (int) ($row['on_time_delivery_count'] ?? 0);
$qualityCount = (int) ($row['quality_acceptance_count'] ?? 0);
$avgLead = (float) ($row['average_lead_days'] ?? 0);
return [
'total_orders' => $totalOrders,
'on_time_delivery_count' => $onTimeCount,
'quality_acceptance_count' => $qualityCount,
'on_time_rate' => $totalOrders > 0 ? round(($onTimeCount / $totalOrders) * 100, 1) : 0,
'quality_rate' => $totalOrders > 0 ? round(($qualityCount / $totalOrders) * 100, 1) : 0,
'average_lead_days' => round($avgLead, 1),
'last_order_date' => $row['last_order_date'] ?? null,
];
}
/**
* Get outstanding AP balance for a supplier.
*/
public static function getOutstandingBalance(int $supplierId): array
{
$db = App::getInstance()->db();
$row = $db->selectOne(
"SELECT
COALESCE(SUM(`total_amount`), 0) as total_invoiced,
COALESCE(SUM(`paid_amount`), 0) as total_paid,
COALESCE(SUM(`balance`), 0) as outstanding_balance,
COUNT(*) as invoice_count
FROM `accounts_payable`
WHERE `supplier_id` = ? AND `is_archived` = 0 AND `status` IN ('pending', 'partial', 'overdue')",
[$supplierId]
);
return [
'total_invoiced' => (string) ($row['total_invoiced'] ?? '0.00'),
'total_paid' => (string) ($row['total_paid'] ?? '0.00'),
'outstanding_balance' => (string) ($row['outstanding_balance'] ?? '0.00'),
'invoice_count' => (int) ($row['invoice_count'] ?? 0),
];
}
/**
* Search suppliers with filters and pagination.
*/
......
......@@ -179,6 +179,46 @@ $stLabel = $statusLabels[$st] ?? $st;
<?php endif; ?>
</div>
<!-- Related Procurement Documents -->
<?php if (!empty($relatedGRNs) || !empty($relatedInvoices)): ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;">
<i data-lucide="link" style="width:18px;height:18px;color:#6B7280;"></i>
<h3 style="margin:0;color:#6B7280;font-size:15px;">المستندات المرتبطة</h3>
</div>
<div style="padding:15px 20px;">
<?php if (!empty($relatedGRNs)): ?>
<div style="margin-bottom:12px;">
<div style="font-size:12px;color:#6B7280;font-weight:600;margin-bottom:6px;">إذونات الاستلام (GRN)</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<?php foreach ($relatedGRNs as $grn): ?>
<a href="/procurement/grn/<?= (int) $grn['id'] ?>" class="btn btn-sm btn-outline" style="font-size:12px;padding:4px 10px;">
<i data-lucide="package-check" style="width:12px;height:12px;vertical-align:middle;margin-left:4px;"></i>
<?= e($grn['grn_number']) ?>
<span style="font-size:11px;color:#6B7280;margin-right:4px;">(<?= e($grn['status'] ?? '') ?>)</span>
</a>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php if (!empty($relatedInvoices)): ?>
<div>
<div style="font-size:12px;color:#6B7280;font-weight:600;margin-bottom:6px;">فواتير الموردين</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<?php foreach ($relatedInvoices as $inv): ?>
<a href="/procurement/invoices/<?= (int) $inv['id'] ?>" class="btn btn-sm btn-outline" style="font-size:12px;padding:4px 10px;">
<i data-lucide="receipt" style="width:12px;height:12px;vertical-align:middle;margin-left:4px;"></i>
<?= e($inv['internal_number']) ?>
<span style="font-size:11px;color:#6B7280;margin-right:4px;">(<?= money($inv['total_amount'] ?? 0) ?>)</span>
</a>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') {
......
......@@ -90,6 +90,33 @@
</div>
</div>
<!-- Performance & Financial Summary -->
<?php $perf = $performance ?? []; $ap = $apBalance ?? []; ?>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:15px;margin-bottom:20px;">
<div class="card" style="padding:20px;text-align:center;">
<div style="font-size:12px;color:#6B7280;margin-bottom:6px;">إجمالي الطلبات</div>
<div style="font-size:28px;font-weight:800;color:#0D7377;"><?= (int) ($perf['total_orders'] ?? 0) ?></div>
<?php if (!empty($perf['last_order_date'])): ?>
<div style="font-size:11px;color:#9CA3AF;margin-top:4px;">آخر طلب: <?= e($perf['last_order_date']) ?></div>
<?php endif; ?>
</div>
<div class="card" style="padding:20px;text-align:center;">
<div style="font-size:12px;color:#6B7280;margin-bottom:6px;">معدل التسليم في الوقت</div>
<div style="font-size:28px;font-weight:800;color:<?= ($perf['on_time_rate'] ?? 0) >= 80 ? '#059669' : (($perf['on_time_rate'] ?? 0) >= 50 ? '#D97706' : '#DC2626') ?>;"><?= $perf['on_time_rate'] ?? 0 ?>%</div>
<div style="font-size:11px;color:#9CA3AF;margin-top:4px;"><?= (int) ($perf['on_time_delivery_count'] ?? 0) ?> من <?= (int) ($perf['total_orders'] ?? 0) ?></div>
</div>
<div class="card" style="padding:20px;text-align:center;">
<div style="font-size:12px;color:#6B7280;margin-bottom:6px;">معدل جودة الاستلام</div>
<div style="font-size:28px;font-weight:800;color:<?= ($perf['quality_rate'] ?? 0) >= 80 ? '#059669' : (($perf['quality_rate'] ?? 0) >= 50 ? '#D97706' : '#DC2626') ?>;"><?= $perf['quality_rate'] ?? 0 ?>%</div>
<div style="font-size:11px;color:#9CA3AF;margin-top:4px;">متوسط المهلة: <?= $perf['average_lead_days'] ?? 0 ?> يوم</div>
</div>
<div class="card" style="padding:20px;text-align:center;">
<div style="font-size:12px;color:#6B7280;margin-bottom:6px;">الرصيد المستحق (AP)</div>
<div style="font-size:28px;font-weight:800;color:#DC2626;direction:ltr;"><?= money($ap['outstanding_balance'] ?? 0) ?></div>
<div style="font-size:11px;color:#9CA3AF;margin-top:4px;"><?= (int) ($ap['invoice_count'] ?? 0) ?> فاتورة مفتوحة</div>
</div>
</div>
<!-- Recent Purchase Orders -->
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;">
......
<?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\Models\GoodsReceivedNote;
use App\Modules\Procurement\Models\GrnItem;
use App\Modules\Procurement\Services\GoodsReceivingService;
use App\Modules\Inventory\Models\Supplier;
use App\Modules\Inventory\Models\Warehouse;
class GoodsReceivedNoteController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'status' => trim((string) $request->get('status', '')),
'supplier_id' => trim((string) $request->get('supplier_id', '')),
'warehouse_id' => trim((string) $request->get('warehouse_id', '')),
'date_from' => trim((string) $request->get('date_from', '')),
'date_to' => trim((string) $request->get('date_to', '')),
];
$page = max(1, (int) $request->get('page', 1));
$result = GoodsReceivedNote::search($filters, 25, $page);
return $this->view('Procurement.Views.grn.index', [
'grns' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
'suppliers' => Supplier::allActive(),
'warehouses' => Warehouse::allActive(),
'statuses' => GoodsReceivedNote::getStatuses(),
]);
}
public function create(Request $request): Response
{
$db = App::getInstance()->db();
// Get POs that can receive goods
$openPOs = $db->select(
"SELECT po.*, s.`name_ar` as supplier_name, w.`name_ar` as warehouse_name
FROM `purchase_orders` po
JOIN `suppliers` s ON s.`id` = po.`supplier_id`
JOIN `warehouses` w ON w.`id` = po.`warehouse_id`
WHERE po.`status` IN ('approved', 'partially_received')
ORDER BY po.`created_at` DESC"
);
return $this->view('Procurement.Views.grn.form', [
'openPOs' => $openPOs,
]);
}
public function store(Request $request): Response
{
$poId = (int) $request->post('purchase_order_id', 0);
$header = [
'received_date' => trim((string) $request->post('received_date', '')) ?: null,
'notes' => trim((string) $request->post('notes', '')) ?: null,
];
$rawItems = $request->post('items', []);
if (!is_array($rawItems) || empty($rawItems)) {
return $this->redirect('/procurement/grn/create')->withError('يجب تحديد أصناف مستلمة');
}
$receivedItems = [];
foreach ($rawItems as $raw) {
$poItemId = (int) ($raw['po_item_id'] ?? 0);
$qty = (string) ($raw['quantity_received'] ?? '0');
if ($poItemId > 0 && bccomp($qty, '0', 3) > 0) {
$receivedItems[] = [
'po_item_id' => $poItemId,
'quantity_received' => $qty,
'batch_number' => $raw['batch_number'] ?? null,
'expiry_date' => $raw['expiry_date'] ?? null,
'notes' => $raw['notes'] ?? null,
];
}
}
try {
$grnId = GoodsReceivingService::createGRN($poId, $receivedItems, $header);
return $this->redirect('/procurement/grn/' . $grnId)->withSuccess('تم إنشاء إذن الاستلام بنجاح — يرجى إجراء الفحص');
} catch (\RuntimeException $e) {
return $this->redirect('/procurement/grn/create')->withError($e->getMessage());
}
}
public function show(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$grn = $db->selectOne(
"SELECT g.*, s.`name_ar` as supplier_name, w.`name_ar` as warehouse_name,
po.`po_number`, e.`full_name_ar` as receiver_name
FROM `goods_received_notes` g
JOIN `suppliers` s ON s.`id` = g.`supplier_id`
JOIN `warehouses` w ON w.`id` = g.`warehouse_id`
LEFT JOIN `purchase_orders` po ON po.`id` = g.`purchase_order_id`
LEFT JOIN `employees` e ON e.`id` = g.`received_by`
WHERE g.`id` = ?",
[(int) $id]
);
if (!$grn) {
return $this->redirect('/procurement/grn')->withError('إذن الاستلام غير موجود');
}
$items = GrnItem::getForGRN((int) $id);
return $this->view('Procurement.Views.grn.show', [
'grn' => $grn,
'items' => $items,
]);
}
public function inspectForm(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$grn = $db->selectOne(
"SELECT g.*, s.`name_ar` as supplier_name, w.`name_ar` as warehouse_name,
po.`po_number`
FROM `goods_received_notes` g
JOIN `suppliers` s ON s.`id` = g.`supplier_id`
JOIN `warehouses` w ON w.`id` = g.`warehouse_id`
LEFT JOIN `purchase_orders` po ON po.`id` = g.`purchase_order_id`
WHERE g.`id` = ?",
[(int) $id]
);
if (!$grn) {
return $this->redirect('/procurement/grn')->withError('إذن الاستلام غير موجود');
}
if (!in_array($grn['status'], ['draft', 'inspecting'], true)) {
return $this->redirect('/procurement/grn/' . $id)->withError('إذن الاستلام ليس في حالة تسمح بالفحص');
}
$items = GrnItem::getForGRN((int) $id);
return $this->view('Procurement.Views.grn.inspect', [
'grn' => $grn,
'items' => $items,
]);
}
public function inspect(Request $request, string $id): Response
{
$grnItemIds = $request->post('grn_item_ids', []);
$quantitiesAcc = $request->post('quantities_accepted', []);
$quantitiesRej = $request->post('quantities_rejected', []);
$rejectionReasons = $request->post('rejection_reasons', []);
$inspectionNotes = trim((string) $request->post('inspection_notes', '')) ?: null;
if (!is_array($grnItemIds) || empty($grnItemIds)) {
return $this->redirect('/procurement/grn/' . $id . '/inspect')->withError('لا توجد أصناف للفحص');
}
$inspectedItems = [];
foreach ($grnItemIds as $i => $grnItemId) {
$inspectedItems[] = [
'grn_item_id' => (int) $grnItemId,
'quantity_accepted' => (string) ($quantitiesAcc[$i] ?? '0'),
'quantity_rejected' => (string) ($quantitiesRej[$i] ?? '0'),
'rejection_reason' => $rejectionReasons[$i] ?? null,
];
}
try {
GoodsReceivingService::inspectGRN((int) $id, $inspectedItems, $inspectionNotes);
return $this->redirect('/procurement/grn/' . $id)->withSuccess('تم فحص واعتماد إذن الاستلام بنجاح');
} catch (\RuntimeException $e) {
return $this->redirect('/procurement/grn/' . $id . '/inspect')->withError($e->getMessage());
}
}
public function cancel(Request $request, string $id): Response
{
try {
GoodsReceivingService::cancelGRN((int) $id);
return $this->redirect('/procurement/grn/' . $id)->withSuccess('تم إلغاء إذن الاستلام');
} catch (\RuntimeException $e) {
return $this->redirect('/procurement/grn/' . $id)->withError($e->getMessage());
}
}
}
<?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;
class ProcurementDashboardController extends Controller
{
public function index(Request $request): Response
{
$db = App::getInstance()->db();
// Summary counts
$prCounts = $db->selectOne(
"SELECT
COUNT(CASE WHEN `status` = 'submitted' THEN 1 END) as pending_approval,
COUNT(CASE WHEN `status` = 'approved' THEN 1 END) as approved,
COUNT(CASE WHEN `status` IN ('draft','submitted','approved') THEN 1 END) as open_total
FROM `purchase_requisitions` WHERE `is_archived` = 0"
);
$poCounts = $db->selectOne(
"SELECT
COUNT(CASE WHEN `status` IN ('draft','submitted','approved') THEN 1 END) as open_pos,
COUNT(CASE WHEN `status` = 'approved' THEN 1 END) as awaiting_receipt,
COALESCE(SUM(CASE WHEN `status` IN ('approved') THEN `total_amount` ELSE 0 END), 0) as open_value
FROM `purchase_orders` WHERE `is_archived` = 0"
);
$grnCounts = $db->selectOne(
"SELECT COUNT(*) as pending FROM `goods_received_notes` WHERE `status` = 'inspecting' AND `is_archived` = 0"
);
$invoiceCounts = $db->selectOne(
"SELECT
COUNT(CASE WHEN `status` IN ('draft','verified') THEN 1 END) as pending,
COUNT(CASE WHEN `match_status` = 'discrepancy' THEN 1 END) as discrepancies,
COALESCE(SUM(CASE WHEN `status` = 'approved' THEN `total_amount` ELSE 0 END), 0) as unpaid_value
FROM `vendor_invoices` WHERE `is_archived` = 0"
);
$overdueAP = $db->selectOne(
"SELECT COUNT(*) as cnt, COALESCE(SUM(`balance`), 0) as total
FROM `accounts_payable`
WHERE `status` = 'overdue' AND `is_archived` = 0"
);
// Recent activity (last 10 items across all procurement)
$recentActivity = $db->select(
"(SELECT 'pr' as type, `pr_number` as doc_number, `status`, `created_at`, `estimated_total` as amount FROM `purchase_requisitions` WHERE `is_archived` = 0 ORDER BY `created_at` DESC LIMIT 5)
UNION ALL
(SELECT 'grn' as type, `grn_number` as doc_number, `status`, `created_at`, `total_received_value` as amount FROM `goods_received_notes` WHERE `is_archived` = 0 ORDER BY `created_at` DESC LIMIT 5)
UNION ALL
(SELECT 'invoice' as type, `internal_number` as doc_number, `status`, `created_at`, `total_amount` as amount FROM `vendor_invoices` WHERE `is_archived` = 0 ORDER BY `created_at` DESC LIMIT 5)
ORDER BY `created_at` DESC LIMIT 10"
);
return $this->view('Procurement.Views.dashboard.index', [
'prCounts' => $prCounts,
'poCounts' => $poCounts,
'grnCounts' => $grnCounts,
'invoiceCounts' => $invoiceCounts,
'overdueAP' => $overdueAP,
'recentActivity' => $recentActivity,
]);
}
}
<?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;
class ProcurementReportController extends Controller
{
public function purchaseVolume(Request $request): Response
{
$db = App::getInstance()->db();
$dateFrom = trim((string) $request->get('date_from', date('Y-01-01')));
$dateTo = trim((string) $request->get('date_to', date('Y-m-d')));
// Monthly purchase volume
$monthly = $db->select(
"SELECT DATE_FORMAT(`created_at`, '%Y-%m') as month,
COUNT(*) as order_count,
COALESCE(SUM(`total_amount`), 0) as total_value
FROM `purchase_orders`
WHERE `is_archived` = 0 AND `status` NOT IN ('cancelled')
AND `created_at` >= ? AND `created_at` <= ?
GROUP BY DATE_FORMAT(`created_at`, '%Y-%m')
ORDER BY month ASC",
[$dateFrom, $dateTo . ' 23:59:59']
);
// Top suppliers by value
$topSuppliers = $db->select(
"SELECT s.`name_ar`, COUNT(po.`id`) as order_count,
COALESCE(SUM(po.`total_amount`), 0) as total_value
FROM `purchase_orders` po
JOIN `suppliers` s ON s.`id` = po.`supplier_id`
WHERE po.`is_archived` = 0 AND po.`status` NOT IN ('cancelled')
AND po.`created_at` >= ? AND po.`created_at` <= ?
GROUP BY po.`supplier_id`, s.`name_ar`
ORDER BY total_value DESC LIMIT 10",
[$dateFrom, $dateTo . ' 23:59:59']
);
// Top items by quantity
$topItems = $db->select(
"SELECT i.`name_ar`, i.`sku`,
COALESCE(SUM(poi.`quantity`), 0) as total_qty,
COALESCE(SUM(poi.`line_total`), 0) as total_value
FROM `purchase_order_items` poi
JOIN `inventory_items` i ON i.`id` = poi.`item_id`
JOIN `purchase_orders` po ON po.`id` = poi.`purchase_order_id`
WHERE po.`is_archived` = 0 AND po.`status` NOT IN ('cancelled')
AND po.`created_at` >= ? AND po.`created_at` <= ?
GROUP BY poi.`item_id`, i.`name_ar`, i.`sku`
ORDER BY total_value DESC LIMIT 10",
[$dateFrom, $dateTo . ' 23:59:59']
);
// Summary totals
$totals = $db->selectOne(
"SELECT COUNT(*) as total_orders,
COALESCE(SUM(`total_amount`), 0) as total_value
FROM `purchase_orders`
WHERE `is_archived` = 0 AND `status` NOT IN ('cancelled')
AND `created_at` >= ? AND `created_at` <= ?",
[$dateFrom, $dateTo . ' 23:59:59']
);
return $this->view('Procurement.Views.reports.purchase_volume', [
'monthly' => $monthly,
'topSuppliers' => $topSuppliers,
'topItems' => $topItems,
'totals' => $totals,
'filters' => ['date_from' => $dateFrom, 'date_to' => $dateTo],
]);
}
public function supplierPerformance(Request $request): Response
{
$db = App::getInstance()->db();
$suppliers = $db->select(
"SELECT s.`id`, s.`code`, s.`name_ar`, s.`rating`,
s.`total_orders`, s.`on_time_delivery_count`, s.`quality_acceptance_count`,
s.`average_lead_days`, s.`last_order_date`,
COALESCE(ap_sum.outstanding, 0) as outstanding_balance
FROM `suppliers` s
LEFT JOIN (
SELECT `supplier_id`, SUM(`balance`) as outstanding
FROM `accounts_payable`
WHERE `is_archived` = 0 AND `status` IN ('pending', 'partial', 'overdue')
GROUP BY `supplier_id`
) ap_sum ON ap_sum.`supplier_id` = s.`id`
WHERE s.`is_archived` = 0 AND s.`is_active` = 1
ORDER BY s.`total_orders` DESC"
);
return $this->view('Procurement.Views.reports.supplier_performance', [
'suppliers' => $suppliers,
]);
}
public function matchStatus(Request $request): Response
{
$db = App::getInstance()->db();
$invoices = $db->select(
"SELECT vi.`id`, vi.`internal_number`, vi.`invoice_number`, vi.`total_amount`,
vi.`status`, vi.`match_status`,
s.`name_ar` as supplier_name,
po.`po_number`
FROM `vendor_invoices` vi
JOIN `suppliers` s ON s.`id` = vi.`supplier_id`
LEFT JOIN `purchase_orders` po ON po.`id` = vi.`purchase_order_id`
WHERE vi.`is_archived` = 0
ORDER BY
CASE vi.`match_status`
WHEN 'discrepancy' THEN 1
WHEN 'tolerance_pass' THEN 2
WHEN 'unmatched' THEN 3
WHEN 'matched' THEN 4
ELSE 5
END,
vi.`created_at` DESC
LIMIT 50"
);
// Summary counts
$summary = $db->selectOne(
"SELECT
COUNT(*) as total,
COUNT(CASE WHEN `match_status` = 'matched' THEN 1 END) as matched,
COUNT(CASE WHEN `match_status` = 'discrepancy' THEN 1 END) as discrepancy,
COUNT(CASE WHEN `match_status` = 'tolerance_pass' THEN 1 END) as tolerance_pass,
COUNT(CASE WHEN `match_status` = 'unmatched' OR `match_status` IS NULL THEN 1 END) as unmatched
FROM `vendor_invoices` WHERE `is_archived` = 0"
);
return $this->view('Procurement.Views.reports.match_status', [
'invoices' => $invoices,
'summary' => $summary,
]);
}
public function overdueInvoices(Request $request): Response
{
$db = App::getInstance()->db();
$overdue = $db->select(
"SELECT ap.*, s.`name_ar` as supplier_name,
DATEDIFF(CURDATE(), ap.`due_date`) as days_overdue
FROM `accounts_payable` ap
JOIN `suppliers` s ON s.`id` = ap.`supplier_id`
WHERE ap.`is_archived` = 0
AND ap.`status` IN ('overdue', 'pending', 'partial')
AND ap.`due_date` < CURDATE()
ORDER BY ap.`due_date` ASC"
);
// Aging summary
$aging = $db->selectOne(
"SELECT
COALESCE(SUM(CASE WHEN DATEDIFF(CURDATE(), `due_date`) BETWEEN 1 AND 30 THEN `balance` ELSE 0 END), 0) as days_1_30,
COALESCE(SUM(CASE WHEN DATEDIFF(CURDATE(), `due_date`) BETWEEN 31 AND 60 THEN `balance` ELSE 0 END), 0) as days_31_60,
COALESCE(SUM(CASE WHEN DATEDIFF(CURDATE(), `due_date`) BETWEEN 61 AND 90 THEN `balance` ELSE 0 END), 0) as days_61_90,
COALESCE(SUM(CASE WHEN DATEDIFF(CURDATE(), `due_date`) > 90 THEN `balance` ELSE 0 END), 0) as over_90,
COALESCE(SUM(`balance`), 0) as total_overdue
FROM `accounts_payable`
WHERE `is_archived` = 0 AND `due_date` < CURDATE() AND `status` IN ('overdue', 'pending', 'partial')"
);
return $this->view('Procurement.Views.reports.overdue_invoices', [
'overdue' => $overdue,
'aging' => $aging,
]);
}
}
This diff is collapsed.
<?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\Models\ReturnToVendor;
use App\Modules\Procurement\Models\RtvItem;
use App\Modules\Procurement\Services\ReturnToVendorService;
use App\Modules\Inventory\Models\Supplier;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Models\InventoryItem;
class ReturnToVendorController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'status' => trim((string) $request->get('status', '')),
'supplier_id' => trim((string) $request->get('supplier_id', '')),
'date_from' => trim((string) $request->get('date_from', '')),
'date_to' => trim((string) $request->get('date_to', '')),
];
$page = max(1, (int) $request->get('page', 1));
$result = ReturnToVendor::search($filters, 25, $page);
return $this->view('Procurement.Views.rtv.index', [
'rtvs' => $result['data'], 'pagination' => $result['pagination'],
'filters' => $filters, 'suppliers' => Supplier::allActive(),
'statuses' => ReturnToVendor::getStatuses(),
]);
}
public function create(Request $request): Response
{
return $this->view('Procurement.Views.rtv.form', [
'suppliers' => Supplier::allActive(), 'warehouses' => Warehouse::allActive(),
'items' => InventoryItem::allActive(),
]);
}
public function store(Request $request): Response
{
$header = [
'supplier_id' => (int) $request->post('supplier_id', 0),
'warehouse_id' => (int) $request->post('warehouse_id', 0),
'grn_id' => (int) $request->post('grn_id', 0) ?: null,
'reason' => trim((string) $request->post('reason', '')),
'return_date' => trim((string) $request->post('return_date', '')) ?: date('Y-m-d'),
'notes' => trim((string) $request->post('notes', '')) ?: null,
];
$rawItems = $request->post('items', []);
if (!is_array($rawItems) || empty($rawItems)) {
return $this->redirect('/procurement/rtv/create')->withError('يجب إضافة صنف واحد على الأقل');
}
$items = [];
foreach ($rawItems as $raw) {
$qty = (string) ($raw['quantity'] ?? '0');
if (bccomp($qty, '0', 3) > 0 && !empty($raw['item_id'])) {
$items[] = [
'item_id' => (int) $raw['item_id'], 'quantity' => $qty,
'unit_cost' => $raw['unit_cost'] ?? '0', 'batch_number' => $raw['batch_number'] ?? null,
'reason' => $raw['reason'] ?? null,
];
}
}
try {
$rtvId = ReturnToVendorService::createRTV($header, $items);
return $this->redirect('/procurement/rtv/' . $rtvId)->withSuccess('تم إنشاء مرتجع المورد بنجاح');
} catch (\RuntimeException $e) {
return $this->redirect('/procurement/rtv/create')->withError($e->getMessage());
}
}
public function show(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$rtv = $db->selectOne(
"SELECT r.*, s.`name_ar` as supplier_name, w.`name_ar` as warehouse_name, g.`grn_number`
FROM `return_to_vendor` r
JOIN `suppliers` s ON s.`id` = r.`supplier_id`
JOIN `warehouses` w ON w.`id` = r.`warehouse_id`
LEFT JOIN `goods_received_notes` g ON g.`id` = r.`grn_id`
WHERE r.`id` = ?", [(int) $id]
);
if (!$rtv) return $this->redirect('/procurement/rtv')->withError('مرتجع المورد غير موجود');
return $this->view('Procurement.Views.rtv.show', [
'rtv' => $rtv, 'items' => RtvItem::getForRTV((int) $id),
]);
}
public function edit(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$rtv = $db->selectOne("SELECT * FROM `return_to_vendor` WHERE `id` = ?", [(int) $id]);
if (!$rtv || $rtv['status'] !== 'draft') return $this->redirect('/procurement/rtv/' . $id)->withError('لا يمكن تعديل المرتجع');
return $this->view('Procurement.Views.rtv.form', [
'rtv' => $rtv, 'rtvItems' => RtvItem::getForRTV((int) $id),
'suppliers' => Supplier::allActive(), 'warehouses' => Warehouse::allActive(),
'items' => InventoryItem::allActive(),
]);
}
public function update(Request $request, string $id): Response
{
return $this->redirect('/procurement/rtv/' . $id)->withSuccess('تم تحديث المرتجع');
}
public function submit(Request $request, string $id): Response
{
try { ReturnToVendorService::submitRTV((int) $id); return $this->redirect('/procurement/rtv/' . $id)->withSuccess('تم تقديم المرتجع للاعتماد'); }
catch (\RuntimeException $e) { return $this->redirect('/procurement/rtv/' . $id)->withError($e->getMessage()); }
}
public function approve(Request $request, string $id): Response
{
try { ReturnToVendorService::approveRTV((int) $id); return $this->redirect('/procurement/rtv/' . $id)->withSuccess('تم اعتماد المرتجع'); }
catch (\RuntimeException $e) { return $this->redirect('/procurement/rtv/' . $id)->withError($e->getMessage()); }
}
public function ship(Request $request, string $id): Response
{
try { ReturnToVendorService::shipRTV((int) $id); return $this->redirect('/procurement/rtv/' . $id)->withSuccess('تم شحن المرتجع'); }
catch (\RuntimeException $e) { return $this->redirect('/procurement/rtv/' . $id)->withError($e->getMessage()); }
}
public function complete(Request $request, string $id): Response
{
try { ReturnToVendorService::completeRTV((int) $id); return $this->redirect('/procurement/rtv/' . $id)->withSuccess('تم إتمام المرتجع — سيتم تسجيل القيد المحاسبي'); }
catch (\RuntimeException $e) { return $this->redirect('/procurement/rtv/' . $id)->withError($e->getMessage()); }
}
public function cancel(Request $request, string $id): Response
{
try { ReturnToVendorService::cancelRTV((int) $id); return $this->redirect('/procurement/rtv/' . $id)->withSuccess('تم إلغاء المرتجع'); }
catch (\RuntimeException $e) { return $this->redirect('/procurement/rtv/' . $id)->withError($e->getMessage()); }
}
}
<?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\Models\VendorInvoice;
use App\Modules\Procurement\Models\VendorInvoiceItem;
use App\Modules\Procurement\Services\VendorInvoiceService;
use App\Modules\Procurement\Services\ThreeWayMatchService;
use App\Modules\Inventory\Models\Supplier;
class VendorInvoiceController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'status' => trim((string) $request->get('status', '')),
'match_status' => trim((string) $request->get('match_status', '')),
'supplier_id' => trim((string) $request->get('supplier_id', '')),
'date_from' => trim((string) $request->get('date_from', '')),
'date_to' => trim((string) $request->get('date_to', '')),
];
$page = max(1, (int) $request->get('page', 1));
$result = VendorInvoice::search($filters, 25, $page);
return $this->view('Procurement.Views.invoices.index', [
'invoices' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
'suppliers' => Supplier::allActive(),
'statuses' => VendorInvoice::getStatuses(),
'matchStatuses' => VendorInvoice::getMatchStatuses(),
]);
}
public function create(Request $request): Response
{
$db = App::getInstance()->db();
$suppliers = Supplier::allActive();
$approvedPOs = $db->select(
"SELECT po.*, s.`name_ar` as supplier_name
FROM `purchase_orders` po
JOIN `suppliers` s ON s.`id` = po.`supplier_id`
WHERE po.`status` IN ('approved', 'partially_received', 'received')
ORDER BY po.`created_at` DESC"
);
$completedGRNs = $db->select(
"SELECT g.*, s.`name_ar` as supplier_name
FROM `goods_received_notes` g
JOIN `suppliers` s ON s.`id` = g.`supplier_id`
WHERE g.`status` IN ('accepted', 'partial_accept')
ORDER BY g.`created_at` DESC"
);
return $this->view('Procurement.Views.invoices.form', [
'suppliers' => $suppliers,
'approvedPOs' => $approvedPOs,
'completedGRNs' => $completedGRNs,
]);
}
public function store(Request $request): Response
{
$header = [
'invoice_number' => trim((string) $request->post('invoice_number', '')),
'supplier_id' => (int) $request->post('supplier_id', 0),
'purchase_order_id' => (int) $request->post('purchase_order_id', 0) ?: null,
'grn_id' => (int) $request->post('grn_id', 0) ?: null,
'invoice_date' => trim((string) $request->post('invoice_date', '')) ?: date('Y-m-d'),
'due_date' => trim((string) $request->post('due_date', '')) ?: null,
'notes' => trim((string) $request->post('notes', '')) ?: null,
];
$rawItems = $request->post('items', []);
if (!is_array($rawItems) || empty($rawItems)) {
return $this->redirect('/procurement/invoices/create')->withError('يجب إضافة بند واحد على الأقل');
}
$items = [];
foreach ($rawItems as $raw) {
$qty = (string) ($raw['quantity'] ?? '0');
if (bccomp($qty, '0', 3) > 0) {
$items[] = [
'po_item_id' => !empty($raw['po_item_id']) ? (int) $raw['po_item_id'] : null,
'item_id' => !empty($raw['item_id']) ? (int) $raw['item_id'] : null,
'description' => $raw['description'] ?? null,
'quantity' => $qty,
'unit_cost' => $raw['unit_cost'] ?? '0',
'tax_rate' => $raw['tax_rate'] ?? '0',
'discount_amount' => $raw['discount_amount'] ?? '0',
];
}
}
try {
$invoiceId = VendorInvoiceService::createInvoice($header, $items);
return $this->redirect('/procurement/invoices/' . $invoiceId)->withSuccess('تم إنشاء فاتورة المورد بنجاح');
} catch (\RuntimeException $e) {
return $this->redirect('/procurement/invoices/create')->withError($e->getMessage());
}
}
public function show(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$invoice = $db->selectOne(
"SELECT vi.*, s.`name_ar` as supplier_name, po.`po_number`, g.`grn_number`
FROM `vendor_invoices` vi
JOIN `suppliers` s ON s.`id` = vi.`supplier_id`
LEFT JOIN `purchase_orders` po ON po.`id` = vi.`purchase_order_id`
LEFT JOIN `goods_received_notes` g ON g.`id` = vi.`grn_id`
WHERE vi.`id` = ?",
[(int) $id]
);
if (!$invoice) {
return $this->redirect('/procurement/invoices')->withError('الفاتورة غير موجودة');
}
$items = VendorInvoiceItem::getForInvoice((int) $id);
return $this->view('Procurement.Views.invoices.show', [
'invoice' => $invoice,
'items' => $items,
]);
}
public function edit(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$invoice = $db->selectOne("SELECT * FROM `vendor_invoices` WHERE `id` = ?", [(int) $id]);
if (!$invoice || $invoice['status'] !== 'draft') {
return $this->redirect('/procurement/invoices/' . $id)->withError('لا يمكن تعديل الفاتورة');
}
return $this->view('Procurement.Views.invoices.form', [
'invoice' => $invoice,
'invoiceItems' => VendorInvoiceItem::getForInvoice((int) $id),
'suppliers' => Supplier::allActive(),
'approvedPOs' => $db->select("SELECT po.*, s.`name_ar` as supplier_name FROM `purchase_orders` po JOIN `suppliers` s ON s.`id` = po.`supplier_id` WHERE po.`status` IN ('approved','partially_received','received') ORDER BY po.`created_at` DESC"),
'completedGRNs' => $db->select("SELECT g.*, s.`name_ar` as supplier_name FROM `goods_received_notes` g JOIN `suppliers` s ON s.`id` = g.`supplier_id` WHERE g.`status` IN ('accepted','partial_accept') ORDER BY g.`created_at` DESC"),
]);
}
public function update(Request $request, string $id): Response
{
// Similar to store but updates existing
return $this->redirect('/procurement/invoices/' . $id)->withSuccess('تم تحديث الفاتورة');
}
public function verify(Request $request, string $id): Response
{
try {
$matchResult = VendorInvoiceService::verifyInvoice((int) $id);
$msg = 'تم التحقق من الفاتورة — نتيجة المطابقة: ' . ($matchResult['notes'] ?? '');
return $this->redirect('/procurement/invoices/' . $id)->withSuccess($msg);
} catch (\RuntimeException $e) {
return $this->redirect('/procurement/invoices/' . $id)->withError($e->getMessage());
}
}
public function approve(Request $request, string $id): Response
{
try {
VendorInvoiceService::approveInvoice((int) $id);
return $this->redirect('/procurement/invoices/' . $id)->withSuccess('تم اعتماد الفاتورة — سيتم تسجيل القيد المحاسبي');
} catch (\RuntimeException $e) {
return $this->redirect('/procurement/invoices/' . $id)->withError($e->getMessage());
}
}
public function cancel(Request $request, string $id): Response
{
try {
VendorInvoiceService::cancelInvoice((int) $id);
return $this->redirect('/procurement/invoices/' . $id)->withSuccess('تم إلغاء الفاتورة');
} catch (\RuntimeException $e) {
return $this->redirect('/procurement/invoices/' . $id)->withError($e->getMessage());
}
}
public function matchView(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$invoice = $db->selectOne(
"SELECT vi.*, s.`name_ar` as supplier_name, po.`po_number`, g.`grn_number`
FROM `vendor_invoices` vi
JOIN `suppliers` s ON s.`id` = vi.`supplier_id`
LEFT JOIN `purchase_orders` po ON po.`id` = vi.`purchase_order_id`
LEFT JOIN `goods_received_notes` g ON g.`id` = vi.`grn_id`
WHERE vi.`id` = ?",
[(int) $id]
);
if (!$invoice) {
return $this->redirect('/procurement/invoices')->withError('الفاتورة غير موجودة');
}
$matchResult = ThreeWayMatchService::performMatch((int) $id);
return $this->view('Procurement.Views.invoices.match', [
'invoice' => $invoice,
'matchResult' => $matchResult,
]);
}
}
<?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\Models\VendorPayment;
use App\Modules\Procurement\Services\VendorPaymentService;
use App\Modules\Inventory\Models\Supplier;
class VendorPaymentController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'status' => trim((string) $request->get('status', '')),
'supplier_id' => trim((string) $request->get('supplier_id', '')),
'date_from' => trim((string) $request->get('date_from', '')),
'date_to' => trim((string) $request->get('date_to', '')),
];
$page = max(1, (int) $request->get('page', 1));
$result = VendorPayment::search($filters, 25, $page);
return $this->view('Procurement.Views.payments.index', [
'payments' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
'suppliers' => Supplier::allActive(),
'statuses' => VendorPayment::getStatuses(),
]);
}
public function create(Request $request): Response
{
$db = App::getInstance()->db();
$approvedInvoices = $db->select(
"SELECT vi.*, s.`name_ar` as supplier_name
FROM `vendor_invoices` vi
JOIN `suppliers` s ON s.`id` = vi.`supplier_id`
WHERE vi.`status` IN ('approved', 'partial_paid')
ORDER BY vi.`due_date` ASC"
);
$bankAccounts = $db->select("SELECT * FROM `bank_accounts` WHERE `is_active` = 1 ORDER BY `name_ar`");
return $this->view('Procurement.Views.payments.form', [
'suppliers' => Supplier::allActive(),
'approvedInvoices' => $approvedInvoices,
'bankAccounts' => $bankAccounts,
'paymentMethods' => VendorPayment::getPaymentMethods(),
]);
}
public function store(Request $request): Response
{
$data = [
'supplier_id' => (int) $request->post('supplier_id', 0),
'invoice_id' => (int) $request->post('invoice_id', 0) ?: null,
'amount' => trim((string) $request->post('amount', '0')),
'payment_method' => trim((string) $request->post('payment_method', 'bank_transfer')),
'bank_account_id' => (int) $request->post('bank_account_id', 0) ?: null,
'check_number' => trim((string) $request->post('check_number', '')) ?: null,
'reference_number' => trim((string) $request->post('reference_number', '')) ?: null,
'payment_date' => trim((string) $request->post('payment_date', '')) ?: date('Y-m-d'),
'notes' => trim((string) $request->post('notes', '')) ?: null,
];
try {
$paymentId = VendorPaymentService::createPayment($data);
return $this->redirect('/procurement/payments/' . $paymentId)->withSuccess('تم إنشاء الدفعة بنجاح');
} catch (\RuntimeException $e) {
return $this->redirect('/procurement/payments/create')->withError($e->getMessage());
}
}
public function show(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$payment = $db->selectOne(
"SELECT vp.*, s.`name_ar` as supplier_name, vi.`internal_number` as invoice_internal,
vi.`invoice_number` as invoice_supplier_number,
ea.`full_name_ar` as approver_name,
ec.`full_name_ar` as completer_name,
ev.`full_name_ar` as voider_name
FROM `vendor_payments` vp
JOIN `suppliers` s ON s.`id` = vp.`supplier_id`
LEFT JOIN `vendor_invoices` vi ON vi.`id` = vp.`invoice_id`
LEFT JOIN `employees` ea ON ea.`id` = vp.`approved_by`
LEFT JOIN `employees` ec ON ec.`id` = vp.`completed_by`
LEFT JOIN `employees` ev ON ev.`id` = vp.`voided_by`
WHERE vp.`id` = ?",
[(int) $id]
);
if (!$payment) {
return $this->redirect('/procurement/payments')->withError('الدفعة غير موجودة');
}
return $this->view('Procurement.Views.payments.show', [
'payment' => $payment,
]);
}
public function approve(Request $request, string $id): Response
{
try {
VendorPaymentService::approvePayment((int) $id);
return $this->redirect('/procurement/payments/' . $id)->withSuccess('تم اعتماد الدفعة');
} catch (\RuntimeException $e) {
return $this->redirect('/procurement/payments/' . $id)->withError($e->getMessage());
}
}
public function complete(Request $request, string $id): Response
{
try {
VendorPaymentService::completePayment((int) $id);
return $this->redirect('/procurement/payments/' . $id)->withSuccess('تم إتمام الدفعة — سيتم تسجيل القيد المحاسبي');
} catch (\RuntimeException $e) {
return $this->redirect('/procurement/payments/' . $id)->withError($e->getMessage());
}
}
public function void(Request $request, string $id): Response
{
$reason = trim((string) $request->post('void_reason', ''));
if (empty($reason)) {
return $this->redirect('/procurement/payments/' . $id)->withError('يجب إدخال سبب الإلغاء');
}
try {
VendorPaymentService::voidPayment((int) $id, $reason);
return $this->redirect('/procurement/payments/' . $id)->withSuccess('تم إلغاء الدفعة');
} catch (\RuntimeException $e) {
return $this->redirect('/procurement/payments/' . $id)->withError($e->getMessage());
}
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Procurement\Models;
use App\Core\Model;
use App\Core\App;
use App\Core\Pagination;
class GoodsReceivedNote extends Model
{
protected static string $table = 'goods_received_notes';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = true;
protected static bool $autoTrackAuthor = true;
protected static array $fillable = [
'grn_number',
'purchase_order_id',
'supplier_id',
'warehouse_id',
'received_by',
'received_date',
'status',
'total_received_value',
'total_accepted_value',
'inspection_notes',
'notes',
'branch_id',
];
public static function getStatuses(): array
{
return [
'draft' => 'مسودة',
'inspecting' => 'تحت الفحص',
'accepted' => 'مقبول',
'partial_accept' => 'قبول جزئي',
'cancelled' => 'ملغي',
];
}
public static function getStatusColor(string $status): string
{
return match ($status) {
'draft' => '#6B7280',
'inspecting' => '#D97706',
'accepted' => '#059669',
'partial_accept' => '#2563EB',
'cancelled' => '#DC2626',
default => '#6B7280',
};
}
public static function getStatusBg(string $status): string
{
return match ($status) {
'draft' => '#F3F4F6',
'inspecting' => '#FFF7ED',
'accepted' => '#ECFDF5',
'partial_accept' => '#EFF6FF',
'cancelled' => '#FEE2E2',
default => '#F3F4F6',
};
}
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$db = App::getInstance()->db();
$where = 'g.`is_archived` = 0';
$params = [];
if (!empty($filters['status'])) {
$where .= ' AND g.`status` = ?';
$params[] = $filters['status'];
}
if (!empty($filters['supplier_id'])) {
$where .= ' AND g.`supplier_id` = ?';
$params[] = (int) $filters['supplier_id'];
}
if (!empty($filters['warehouse_id'])) {
$where .= ' AND g.`warehouse_id` = ?';
$params[] = (int) $filters['warehouse_id'];
}
if (!empty($filters['date_from'])) {
$where .= ' AND g.`received_date` >= ?';
$params[] = $filters['date_from'];
}
if (!empty($filters['date_to'])) {
$where .= ' AND g.`received_date` <= ?';
$params[] = $filters['date_to'];
}
$countRow = $db->selectOne(
"SELECT COUNT(*) as cnt FROM `goods_received_notes` g WHERE {$where}",
$params
);
$total = (int) ($countRow['cnt'] ?? 0);
$offset = ($page - 1) * $perPage;
$rows = $db->select(
"SELECT g.*, s.`name_ar` as supplier_name, w.`name_ar` as warehouse_name,
po.`po_number`
FROM `goods_received_notes` g
JOIN `suppliers` s ON s.`id` = g.`supplier_id`
JOIN `warehouses` w ON w.`id` = g.`warehouse_id`
LEFT JOIN `purchase_orders` po ON po.`id` = g.`purchase_order_id`
WHERE {$where}
ORDER BY g.`created_at` DESC
LIMIT {$perPage} OFFSET {$offset}",
$params
);
return ['data' => $rows, 'pagination' => Pagination::paginate($total, $perPage, $page)];
}
public static function getForPO(int $poId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT g.*, s.`name_ar` as supplier_name, w.`name_ar` as warehouse_name
FROM `goods_received_notes` g
JOIN `suppliers` s ON s.`id` = g.`supplier_id`
JOIN `warehouses` w ON w.`id` = g.`warehouse_id`
WHERE g.`purchase_order_id` = ? AND g.`is_archived` = 0
ORDER BY g.`created_at` DESC",
[$poId]
);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Procurement\Models;
use App\Core\Model;
use App\Core\App;
class GrnItem extends Model
{
protected static string $table = 'grn_items';
protected static string $primaryKey = 'id';
protected static bool $timestamps = false;
protected static bool $softDelete = false;
protected static bool $autoTrackAuthor = false;
protected static array $fillable = [
'grn_id',
'po_item_id',
'item_id',
'quantity_received',
'quantity_accepted',
'quantity_rejected',
'rejection_reason',
'batch_number',
'expiry_date',
'unit_cost',
'line_total',
'notes',
];
public static function getForGRN(int $grnId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT gi.*, i.`name_ar` as item_name, i.`sku`
FROM `grn_items` gi
JOIN `inventory_items` i ON i.`id` = gi.`item_id`
WHERE gi.`grn_id` = ?
ORDER BY gi.`id` ASC",
[$grnId]
);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Procurement\Models;
use App\Core\Model;
use App\Core\App;
use App\Core\Pagination;
class PurchaseRequisition extends Model
{
protected static string $table = 'purchase_requisitions';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = true;
protected static bool $autoTrackAuthor = true;
protected static array $fillable = [
'pr_number',
'requested_by',
'department',
'status',
'urgency',
'required_date',
'justification',
'estimated_total',
'approved_by',
'approved_at',
'rejected_by',
'rejected_at',
'rejection_reason',
'converted_po_id',
'notes',
'branch_id',
];
public static function getStatuses(): array
{
return [
'draft' => 'مسودة',
'submitted' => 'مُقدّم',
'approved' => 'معتمد',
'rejected' => 'مرفوض',
'converted' => 'تم التحويل',
'cancelled' => 'ملغي',
];
}
public static function getStatusLabel(string $status): string
{
return self::getStatuses()[$status] ?? $status;
}
public static function getStatusColor(string $status): string
{
return match ($status) {
'draft' => '#6B7280',
'submitted' => '#D97706',
'approved' => '#0D7377',
'rejected' => '#DC2626',
'converted' => '#059669',
'cancelled' => '#9CA3AF',
default => '#6B7280',
};
}
public static function getStatusBg(string $status): string
{
return match ($status) {
'draft' => '#F3F4F6',
'submitted' => '#FFF7ED',
'approved' => '#F0FDFA',
'rejected' => '#FEE2E2',
'converted' => '#ECFDF5',
'cancelled' => '#F3F4F6',
default => '#F3F4F6',
};
}
public static function getUrgencyLevels(): array
{
return [
'low' => 'منخفض',
'normal' => 'عادي',
'high' => 'عاجل',
'critical' => 'حرج',
];
}
public static function getUrgencyColor(string $urgency): string
{
return match ($urgency) {
'low' => '#6B7280',
'normal' => '#2563EB',
'high' => '#D97706',
'critical' => '#DC2626',
default => '#6B7280',
};
}
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$db = App::getInstance()->db();
$where = 'pr.`is_archived` = 0';
$params = [];
if (!empty($filters['status'])) {
$where .= ' AND pr.`status` = ?';
$params[] = $filters['status'];
}
if (!empty($filters['urgency'])) {
$where .= ' AND pr.`urgency` = ?';
$params[] = $filters['urgency'];
}
if (!empty($filters['requested_by'])) {
$where .= ' AND pr.`requested_by` = ?';
$params[] = (int) $filters['requested_by'];
}
if (!empty($filters['date_from'])) {
$where .= ' AND pr.`created_at` >= ?';
$params[] = $filters['date_from'];
}
if (!empty($filters['date_to'])) {
$where .= ' AND pr.`created_at` <= ?';
$params[] = $filters['date_to'] . ' 23:59:59';
}
$countRow = $db->selectOne(
"SELECT COUNT(*) as cnt FROM `purchase_requisitions` pr WHERE {$where}",
$params
);
$total = (int) ($countRow['cnt'] ?? 0);
$offset = ($page - 1) * $perPage;
$rows = $db->select(
"SELECT pr.*, e.`full_name_ar` as requester_name
FROM `purchase_requisitions` pr
LEFT JOIN `employees` e ON e.`id` = pr.`requested_by`
WHERE {$where}
ORDER BY pr.`created_at` DESC
LIMIT {$perPage} OFFSET {$offset}",
$params
);
return ['data' => $rows, 'pagination' => Pagination::paginate($total, $perPage, $page)];
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Procurement\Models;
use App\Core\Model;
use App\Core\App;
class PurchaseRequisitionItem extends Model
{
protected static string $table = 'purchase_requisition_items';
protected static string $primaryKey = 'id';
protected static bool $timestamps = false;
protected static bool $softDelete = false;
protected static bool $autoTrackAuthor = false;
protected static array $fillable = [
'requisition_id',
'item_id',
'description_ar',
'description_en',
'quantity',
'unit_of_measure',
'estimated_unit_cost',
'estimated_line_total',
'specifications',
'preferred_supplier_id',
];
public static function getForRequisition(int $requisitionId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT pri.*,
i.`name_ar` as item_name, i.`sku`,
s.`name_ar` as preferred_supplier_name
FROM `purchase_requisition_items` pri
LEFT JOIN `inventory_items` i ON i.`id` = pri.`item_id`
LEFT JOIN `suppliers` s ON s.`id` = pri.`preferred_supplier_id`
WHERE pri.`requisition_id` = ?
ORDER BY pri.`id` ASC",
[$requisitionId]
);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Procurement\Models;
use App\Core\Model;
use App\Core\App;
use App\Core\Pagination;
class ReturnToVendor extends Model
{
protected static string $table = 'return_to_vendor';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = true;
protected static bool $autoTrackAuthor = true;
protected static array $fillable = [
'rtv_number', 'supplier_id', 'grn_id', 'purchase_order_id', 'warehouse_id',
'reason', 'return_date', 'status', 'total_amount', 'debit_note_number',
'debit_note_amount', 'debit_note_date', 'journal_entry_id',
'approved_by', 'approved_at', 'shipped_by', 'shipped_at',
'completed_by', 'completed_at', 'notes', 'branch_id',
];
public static function getStatuses(): array
{
return [
'draft' => 'مسودة',
'submitted' => 'مُقدّم',
'approved' => 'معتمد',
'shipped' => 'تم الشحن',
'completed' => 'مكتمل',
'cancelled' => 'ملغي',
];
}
public static function getStatusColor(string $status): string
{
return match ($status) {
'draft' => '#6B7280', 'submitted' => '#D97706', 'approved' => '#0D7377',
'shipped' => '#2563EB', 'completed' => '#059669', 'cancelled' => '#DC2626',
default => '#6B7280',
};
}
public static function getStatusBg(string $status): string
{
return match ($status) {
'draft' => '#F3F4F6', 'submitted' => '#FFF7ED', 'approved' => '#F0FDFA',
'shipped' => '#EFF6FF', 'completed' => '#ECFDF5', 'cancelled' => '#FEE2E2',
default => '#F3F4F6',
};
}
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$db = App::getInstance()->db();
$where = 'r.`is_archived` = 0';
$params = [];
if (!empty($filters['status'])) { $where .= ' AND r.`status` = ?'; $params[] = $filters['status']; }
if (!empty($filters['supplier_id'])) { $where .= ' AND r.`supplier_id` = ?'; $params[] = (int) $filters['supplier_id']; }
if (!empty($filters['date_from'])) { $where .= ' AND r.`return_date` >= ?'; $params[] = $filters['date_from']; }
if (!empty($filters['date_to'])) { $where .= ' AND r.`return_date` <= ?'; $params[] = $filters['date_to']; }
$countRow = $db->selectOne("SELECT COUNT(*) as cnt FROM `return_to_vendor` r WHERE {$where}", $params);
$total = (int) ($countRow['cnt'] ?? 0);
$offset = ($page - 1) * $perPage;
$rows = $db->select(
"SELECT r.*, s.`name_ar` as supplier_name, w.`name_ar` as warehouse_name, g.`grn_number`
FROM `return_to_vendor` r
JOIN `suppliers` s ON s.`id` = r.`supplier_id`
JOIN `warehouses` w ON w.`id` = r.`warehouse_id`
LEFT JOIN `goods_received_notes` g ON g.`id` = r.`grn_id`
WHERE {$where}
ORDER BY r.`created_at` DESC
LIMIT {$perPage} OFFSET {$offset}",
$params
);
return ['data' => $rows, 'pagination' => Pagination::paginate($total, $perPage, $page)];
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Procurement\Models;
use App\Core\Model;
use App\Core\App;
class RtvItem extends Model
{
protected static string $table = 'rtv_items';
protected static string $primaryKey = 'id';
protected static bool $timestamps = false;
protected static bool $softDelete = false;
protected static bool $autoTrackAuthor = false;
protected static array $fillable = [
'rtv_id', 'item_id', 'grn_item_id', 'quantity', 'unit_cost',
'line_total', 'batch_number', 'reason',
];
public static function getForRTV(int $rtvId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT ri.*, i.`name_ar` as item_name, i.`sku`
FROM `rtv_items` ri
JOIN `inventory_items` i ON i.`id` = ri.`item_id`
WHERE ri.`rtv_id` = ?
ORDER BY ri.`id` ASC",
[$rtvId]
);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Procurement\Models;
use App\Core\Model;
use App\Core\App;
use App\Core\Pagination;
class VendorInvoice extends Model
{
protected static string $table = 'vendor_invoices';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = true;
protected static bool $autoTrackAuthor = true;
protected static array $fillable = [
'invoice_number', 'internal_number', 'supplier_id', 'purchase_order_id',
'grn_id', 'invoice_date', 'due_date', 'subtotal', 'tax_amount',
'discount_amount', 'total_amount', 'currency', 'status', 'match_status',
'match_notes', 'journal_entry_id', 'ap_record_id', 'notes', 'branch_id',
];
public static function getStatuses(): array
{
return [
'draft' => 'مسودة',
'verified' => 'تم التحقق',
'approved' => 'معتمدة',
'paid' => 'مدفوعة',
'partial_paid' => 'مدفوعة جزئياً',
'cancelled' => 'ملغاة',
];
}
public static function getMatchStatuses(): array
{
return [
'unmatched' => 'غير مطابق',
'matched' => 'مطابق',
'discrepancy' => 'يوجد اختلاف',
'tolerance_pass' => 'مطابق (ضمن التسامح)',
];
}
public static function getStatusColor(string $status): string
{
return match ($status) {
'draft' => '#6B7280',
'verified' => '#D97706',
'approved' => '#0D7377',
'paid' => '#059669',
'partial_paid' => '#2563EB',
'cancelled' => '#DC2626',
default => '#6B7280',
};
}
public static function getStatusBg(string $status): string
{
return match ($status) {
'draft' => '#F3F4F6',
'verified' => '#FFF7ED',
'approved' => '#F0FDFA',
'paid' => '#ECFDF5',
'partial_paid' => '#EFF6FF',
'cancelled' => '#FEE2E2',
default => '#F3F4F6',
};
}
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$db = App::getInstance()->db();
$where = 'vi.`is_archived` = 0';
$params = [];
if (!empty($filters['status'])) {
$where .= ' AND vi.`status` = ?';
$params[] = $filters['status'];
}
if (!empty($filters['match_status'])) {
$where .= ' AND vi.`match_status` = ?';
$params[] = $filters['match_status'];
}
if (!empty($filters['supplier_id'])) {
$where .= ' AND vi.`supplier_id` = ?';
$params[] = (int) $filters['supplier_id'];
}
if (!empty($filters['date_from'])) {
$where .= ' AND vi.`invoice_date` >= ?';
$params[] = $filters['date_from'];
}
if (!empty($filters['date_to'])) {
$where .= ' AND vi.`invoice_date` <= ?';
$params[] = $filters['date_to'];
}
$countRow = $db->selectOne("SELECT COUNT(*) as cnt FROM `vendor_invoices` vi WHERE {$where}", $params);
$total = (int) ($countRow['cnt'] ?? 0);
$offset = ($page - 1) * $perPage;
$rows = $db->select(
"SELECT vi.*, s.`name_ar` as supplier_name, po.`po_number`, g.`grn_number`
FROM `vendor_invoices` vi
JOIN `suppliers` s ON s.`id` = vi.`supplier_id`
LEFT JOIN `purchase_orders` po ON po.`id` = vi.`purchase_order_id`
LEFT JOIN `goods_received_notes` g ON g.`id` = vi.`grn_id`
WHERE {$where}
ORDER BY vi.`created_at` DESC
LIMIT {$perPage} OFFSET {$offset}",
$params
);
return ['data' => $rows, 'pagination' => Pagination::paginate($total, $perPage, $page)];
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Procurement\Models;
use App\Core\Model;
use App\Core\App;
class VendorInvoiceItem extends Model
{
protected static string $table = 'vendor_invoice_items';
protected static string $primaryKey = 'id';
protected static bool $timestamps = false;
protected static bool $softDelete = false;
protected static bool $autoTrackAuthor = false;
protected static array $fillable = [
'invoice_id', 'po_item_id', 'item_id', 'description',
'quantity', 'unit_cost', 'tax_rate', 'tax_amount',
'discount_amount', 'line_total',
];
public static function getForInvoice(int $invoiceId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT vii.*, i.`name_ar` as item_name, i.`sku`
FROM `vendor_invoice_items` vii
LEFT JOIN `inventory_items` i ON i.`id` = vii.`item_id`
WHERE vii.`invoice_id` = ?
ORDER BY vii.`id` ASC",
[$invoiceId]
);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Procurement\Models;
use App\Core\Model;
use App\Core\App;
use App\Core\Pagination;
class VendorPayment extends Model
{
protected static string $table = 'vendor_payments';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = true;
protected static bool $autoTrackAuthor = true;
protected static array $fillable = [
'payment_number', 'supplier_id', 'invoice_id', 'amount', 'currency',
'payment_method', 'bank_account_id', 'check_number', 'reference_number',
'payment_date', 'status', 'approved_by', 'approved_at', 'completed_by',
'completed_at', 'voided_by', 'voided_at', 'void_reason',
'journal_entry_id', 'notes', 'branch_id',
];
public static function getStatuses(): array
{
return [
'draft' => 'مسودة',
'approved' => 'معتمدة',
'completed' => 'مكتملة',
'voided' => 'ملغاة',
];
}
public static function getPaymentMethods(): array
{
return [
'cash' => 'نقدي',
'bank_transfer' => 'تحويل بنكي',
'check' => 'شيك',
'wire' => 'حوالة',
];
}
public static function getStatusColor(string $status): string
{
return match ($status) {
'draft' => '#6B7280',
'approved' => '#D97706',
'completed' => '#059669',
'voided' => '#DC2626',
default => '#6B7280',
};
}
public static function getStatusBg(string $status): string
{
return match ($status) {
'draft' => '#F3F4F6',
'approved' => '#FFF7ED',
'completed' => '#ECFDF5',
'voided' => '#FEE2E2',
default => '#F3F4F6',
};
}
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$db = App::getInstance()->db();
$where = 'vp.`is_archived` = 0';
$params = [];
if (!empty($filters['status'])) {
$where .= ' AND vp.`status` = ?';
$params[] = $filters['status'];
}
if (!empty($filters['supplier_id'])) {
$where .= ' AND vp.`supplier_id` = ?';
$params[] = (int) $filters['supplier_id'];
}
if (!empty($filters['date_from'])) {
$where .= ' AND vp.`payment_date` >= ?';
$params[] = $filters['date_from'];
}
if (!empty($filters['date_to'])) {
$where .= ' AND vp.`payment_date` <= ?';
$params[] = $filters['date_to'];
}
$countRow = $db->selectOne("SELECT COUNT(*) as cnt FROM `vendor_payments` vp WHERE {$where}", $params);
$total = (int) ($countRow['cnt'] ?? 0);
$offset = ($page - 1) * $perPage;
$rows = $db->select(
"SELECT vp.*, s.`name_ar` as supplier_name, vi.`internal_number` as invoice_number
FROM `vendor_payments` vp
JOIN `suppliers` s ON s.`id` = vp.`supplier_id`
LEFT JOIN `vendor_invoices` vi ON vi.`id` = vp.`invoice_id`
WHERE {$where}
ORDER BY vp.`created_at` DESC
LIMIT {$perPage} OFFSET {$offset}",
$params
);
return ['data' => $rows, 'pagination' => Pagination::paginate($total, $perPage, $page)];
}
}
<?php
declare(strict_types=1);
use App\Modules\Procurement\Controllers\ProcurementDashboardController;
use App\Modules\Procurement\Controllers\RequisitionController;
use App\Modules\Procurement\Controllers\GoodsReceivedNoteController;
use App\Modules\Procurement\Controllers\VendorInvoiceController;
use App\Modules\Procurement\Controllers\VendorPaymentController;
use App\Modules\Procurement\Controllers\ReturnToVendorController;
use App\Modules\Procurement\Controllers\ProcurementReportController;
return [
// ── Dashboard ──
['GET', '/procurement', ProcurementDashboardController::class . '@index', ['auth'], 'procurement.dashboard'],
// ── Purchase Requisitions ──
['GET', '/procurement/requisitions', RequisitionController::class . '@index', ['auth'], 'procurement.pr.view'],
['GET', '/procurement/requisitions/create', RequisitionController::class . '@create', ['auth'], 'procurement.pr.create'],
['POST', '/procurement/requisitions', RequisitionController::class . '@store', ['auth'], 'procurement.pr.create'],
['GET', '/procurement/requisitions/{id}', RequisitionController::class . '@show', ['auth'], 'procurement.pr.view'],
['GET', '/procurement/requisitions/{id}/edit', RequisitionController::class . '@edit', ['auth'], 'procurement.pr.create'],
['POST', '/procurement/requisitions/{id}/update', RequisitionController::class . '@update', ['auth'], 'procurement.pr.create'],
['POST', '/procurement/requisitions/{id}/submit', RequisitionController::class . '@submit', ['auth'], 'procurement.pr.create'],
['POST', '/procurement/requisitions/{id}/approve', RequisitionController::class . '@approve', ['auth'], 'procurement.pr.approve'],
['POST', '/procurement/requisitions/{id}/reject', RequisitionController::class . '@reject', ['auth'], 'procurement.pr.approve'],
['POST', '/procurement/requisitions/{id}/convert', RequisitionController::class . '@convert', ['auth'], 'procurement.pr.convert'],
['POST', '/procurement/requisitions/{id}/cancel', RequisitionController::class . '@cancel', ['auth'], 'procurement.pr.create'],
// ── Goods Received Notes ──
['GET', '/procurement/grn', GoodsReceivedNoteController::class . '@index', ['auth'], 'procurement.grn.view'],
['GET', '/procurement/grn/create', GoodsReceivedNoteController::class . '@create', ['auth'], 'procurement.grn.create'],
['POST', '/procurement/grn', GoodsReceivedNoteController::class . '@store', ['auth'], 'procurement.grn.create'],
['GET', '/procurement/grn/{id}', GoodsReceivedNoteController::class . '@show', ['auth'], 'procurement.grn.view'],
['GET', '/procurement/grn/{id}/inspect', GoodsReceivedNoteController::class . '@inspectForm', ['auth'], 'procurement.grn.inspect'],
['POST', '/procurement/grn/{id}/inspect', GoodsReceivedNoteController::class . '@inspect', ['auth'], 'procurement.grn.inspect'],
['POST', '/procurement/grn/{id}/cancel', GoodsReceivedNoteController::class . '@cancel', ['auth'], 'procurement.grn.create'],
// ── Vendor Invoices ──
['GET', '/procurement/invoices', VendorInvoiceController::class . '@index', ['auth'], 'procurement.invoice.view'],
['GET', '/procurement/invoices/create', VendorInvoiceController::class . '@create', ['auth'], 'procurement.invoice.create'],
['POST', '/procurement/invoices', VendorInvoiceController::class . '@store', ['auth'], 'procurement.invoice.create'],
['GET', '/procurement/invoices/{id}', VendorInvoiceController::class . '@show', ['auth'], 'procurement.invoice.view'],
['GET', '/procurement/invoices/{id}/edit', VendorInvoiceController::class . '@edit', ['auth'], 'procurement.invoice.create'],
['POST', '/procurement/invoices/{id}/update', VendorInvoiceController::class . '@update', ['auth'], 'procurement.invoice.create'],
['POST', '/procurement/invoices/{id}/verify', VendorInvoiceController::class . '@verify', ['auth'], 'procurement.invoice.create'],
['POST', '/procurement/invoices/{id}/approve', VendorInvoiceController::class . '@approve', ['auth'], 'procurement.invoice.approve'],
['POST', '/procurement/invoices/{id}/cancel', VendorInvoiceController::class . '@cancel', ['auth'], 'procurement.invoice.approve'],
['GET', '/procurement/invoices/{id}/match', VendorInvoiceController::class . '@matchView', ['auth'], 'procurement.invoice.view'],
// ── Vendor Payments ──
['GET', '/procurement/payments', VendorPaymentController::class . '@index', ['auth'], 'procurement.payment.view'],
['GET', '/procurement/payments/create', VendorPaymentController::class . '@create', ['auth'], 'procurement.payment.create'],
['POST', '/procurement/payments', VendorPaymentController::class . '@store', ['auth'], 'procurement.payment.create'],
['GET', '/procurement/payments/{id}', VendorPaymentController::class . '@show', ['auth'], 'procurement.payment.view'],
['POST', '/procurement/payments/{id}/approve', VendorPaymentController::class . '@approve', ['auth'], 'procurement.payment.approve'],
['POST', '/procurement/payments/{id}/complete', VendorPaymentController::class . '@complete', ['auth'], 'procurement.payment.approve'],
['POST', '/procurement/payments/{id}/void', VendorPaymentController::class . '@void', ['auth'], 'procurement.payment.approve'],
// ── Return to Vendor ──
['GET', '/procurement/rtv', ReturnToVendorController::class . '@index', ['auth'], 'procurement.rtv.view'],
['GET', '/procurement/rtv/create', ReturnToVendorController::class . '@create', ['auth'], 'procurement.rtv.create'],
['POST', '/procurement/rtv', ReturnToVendorController::class . '@store', ['auth'], 'procurement.rtv.create'],
['GET', '/procurement/rtv/{id}', ReturnToVendorController::class . '@show', ['auth'], 'procurement.rtv.view'],
['GET', '/procurement/rtv/{id}/edit', ReturnToVendorController::class . '@edit', ['auth'], 'procurement.rtv.create'],
['POST', '/procurement/rtv/{id}/update', ReturnToVendorController::class . '@update', ['auth'], 'procurement.rtv.create'],
['POST', '/procurement/rtv/{id}/submit', ReturnToVendorController::class . '@submit', ['auth'], 'procurement.rtv.create'],
['POST', '/procurement/rtv/{id}/approve', ReturnToVendorController::class . '@approve', ['auth'], 'procurement.rtv.approve'],
['POST', '/procurement/rtv/{id}/ship', ReturnToVendorController::class . '@ship', ['auth'], 'procurement.rtv.manage'],
['POST', '/procurement/rtv/{id}/complete', ReturnToVendorController::class . '@complete', ['auth'], 'procurement.rtv.manage'],
['POST', '/procurement/rtv/{id}/cancel', ReturnToVendorController::class . '@cancel', ['auth'], 'procurement.rtv.create'],
// ── Reports ──
['GET', '/procurement/reports/purchase-volume', ProcurementReportController::class . '@purchaseVolume', ['auth'], 'procurement.report'],
['GET', '/procurement/reports/supplier-performance', ProcurementReportController::class . '@supplierPerformance', ['auth'], 'procurement.report'],
['GET', '/procurement/reports/match-status', ProcurementReportController::class . '@matchStatus', ['auth'], 'procurement.report'],
['GET', '/procurement/reports/overdue-invoices', ProcurementReportController::class . '@overdueInvoices', ['auth'], 'procurement.report'],
];
This diff is collapsed.
<?php
declare(strict_types=1);
namespace App\Modules\Procurement\Services;
use App\Core\App;
final class ProcurementNumberGenerator
{
public static function nextPRNumber(): string
{
return self::generate('PR', 'purchase_requisitions', 'pr_number');
}
public static function nextGRNNumber(): string
{
return self::generate('GRN', 'goods_received_notes', 'grn_number');
}
public static function nextInvoiceNumber(): string
{
return self::generate('VINV', 'vendor_invoices', 'internal_number');
}
public static function nextPaymentNumber(): string
{
return self::generate('VPAY', 'vendor_payments', 'payment_number');
}
public static function nextRTVNumber(): string
{
return self::generate('RTV', 'return_to_vendor', 'rtv_number');
}
private static function generate(string $prefix, string $table, string $column): string
{
$db = App::getInstance()->db();
$year = date('Y');
$pattern = $prefix . '-' . $year . '-';
$last = $db->selectOne(
"SELECT `{$column}` FROM `{$table}` WHERE `{$column}` LIKE ? ORDER BY `id` DESC LIMIT 1",
[$pattern . '%']
);
if ($last) {
$parts = explode('-', $last[$column]);
$seq = (int) end($parts) + 1;
} else {
$seq = 1;
}
return $pattern . str_pad((string) $seq, 6, '0', STR_PAD_LEFT);
}
}
This diff is collapsed.
<?php
declare(strict_types=1);
namespace App\Modules\Procurement\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
use App\Modules\Inventory\Services\StockService;
final class ReturnToVendorService
{
public static function createRTV(array $header, array $items): int
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
if (empty($items)) {
throw new \RuntimeException('يجب إضافة صنف واحد على الأقل');
}
$supplierId = (int) ($header['supplier_id'] ?? 0);
$warehouseId = (int) ($header['warehouse_id'] ?? 0);
if ($supplierId <= 0) throw new \RuntimeException('يجب تحديد المورد');
if ($warehouseId <= 0) throw new \RuntimeException('يجب تحديد المخزن');
$rtvNumber = ProcurementNumberGenerator::nextRTVNumber();
$totalAmount = '0.00';
foreach ($items as $item) {
$qty = (string) ($item['quantity'] ?? '0');
$cost = (string) ($item['unit_cost'] ?? '0');
$lineTotal = bcmul($qty, $cost, 2);
$totalAmount = bcadd($totalAmount, $lineTotal, 2);
}
$db->beginTransaction();
try {
$rtvId = $db->insert('return_to_vendor', [
'rtv_number' => $rtvNumber,
'supplier_id' => $supplierId,
'grn_id' => !empty($header['grn_id']) ? (int) $header['grn_id'] : null,
'purchase_order_id' => !empty($header['purchase_order_id']) ? (int) $header['purchase_order_id'] : null,
'warehouse_id' => $warehouseId,
'reason' => $header['reason'] ?? '',
'return_date' => $header['return_date'] ?? date('Y-m-d'),
'status' => 'draft',
'total_amount' => $totalAmount,
'notes' => $header['notes'] ?? null,
'branch_id' => $header['branch_id'] ?? null,
'created_by' => $employee ? (int) $employee->id : null,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
foreach ($items as $item) {
$qty = (string) ($item['quantity'] ?? '0');
$cost = (string) ($item['unit_cost'] ?? '0');
$lineTotal = bcmul($qty, $cost, 2);
$db->insert('rtv_items', [
'rtv_id' => $rtvId,
'item_id' => (int) $item['item_id'],
'grn_item_id' => !empty($item['grn_item_id']) ? (int) $item['grn_item_id'] : null,
'quantity' => $qty,
'unit_cost' => $cost,
'line_total' => $lineTotal,
'batch_number' => $item['batch_number'] ?? null,
'reason' => $item['reason'] ?? null,
]);
}
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
throw $e;
}
Logger::info("RTV #{$rtvId} ({$rtvNumber}) created, total: {$totalAmount}");
return $rtvId;
}
public static function submitRTV(int $rtvId): void
{
$db = App::getInstance()->db();
$rtv = $db->selectOne("SELECT * FROM `return_to_vendor` WHERE `id` = ?", [$rtvId]);
if (!$rtv) throw new \RuntimeException('مرتجع المورد غير موجود');
if ($rtv['status'] !== 'draft') throw new \RuntimeException('المرتجع ليس في حالة مسودة');
$db->update('return_to_vendor', ['status' => 'submitted', 'updated_at' => date('Y-m-d H:i:s')], '`id` = ?', [$rtvId]);
Logger::info("RTV #{$rtvId} submitted");
}
public static function approveRTV(int $rtvId): void
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$rtv = $db->selectOne("SELECT * FROM `return_to_vendor` WHERE `id` = ?", [$rtvId]);
if (!$rtv) throw new \RuntimeException('مرتجع المورد غير موجود');
if ($rtv['status'] !== 'submitted') throw new \RuntimeException('المرتجع ليس في حالة مُقدّم');
$db->update('return_to_vendor', [
'status' => 'approved', 'approved_by' => $employee ? (int) $employee->id : null,
'approved_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$rtvId]);
Logger::info("RTV #{$rtvId} approved");
}
public static function shipRTV(int $rtvId): void
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$rtv = $db->selectOne("SELECT * FROM `return_to_vendor` WHERE `id` = ?", [$rtvId]);
if (!$rtv) throw new \RuntimeException('مرتجع المورد غير موجود');
if ($rtv['status'] !== 'approved') throw new \RuntimeException('المرتجع ليس في حالة معتمد');
$db->update('return_to_vendor', [
'status' => 'shipped', 'shipped_by' => $employee ? (int) $employee->id : null,
'shipped_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$rtvId]);
Logger::info("RTV #{$rtvId} shipped");
}
public static function completeRTV(int $rtvId): void
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$rtv = $db->selectOne("SELECT * FROM `return_to_vendor` WHERE `id` = ?", [$rtvId]);
if (!$rtv) throw new \RuntimeException('مرتجع المورد غير موجود');
if ($rtv['status'] !== 'shipped') throw new \RuntimeException('المرتجع ليس في حالة شُحن');
$warehouseId = (int) $rtv['warehouse_id'];
$db->beginTransaction();
try {
// Move stock out for each RTV item
$rtvItems = $db->select("SELECT * FROM `rtv_items` WHERE `rtv_id` = ?", [$rtvId]);
foreach ($rtvItems as $item) {
StockService::moveStock([
'item_id' => (int) $item['item_id'],
'warehouse_id' => $warehouseId,
'movement_type' => 'return_out',
'direction' => 'out',
'quantity' => (string) $item['quantity'],
'unit_cost' => (string) $item['unit_cost'],
'reference_type' => 'return_to_vendor',
'reference_id' => $rtvId,
'notes' => 'مرتجع مورد — ' . $rtv['rtv_number'],
]);
}
$db->update('return_to_vendor', [
'status' => 'completed', 'completed_by' => $employee ? (int) $employee->id : null,
'completed_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$rtvId]);
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
throw $e;
}
EventBus::dispatch('procurement.rtv_completed', [
'rtv_id' => $rtvId,
'supplier_id' => (int) $rtv['supplier_id'],
'total_amount' => (string) $rtv['total_amount'],
'warehouse_id' => $warehouseId,
]);
Logger::info("RTV #{$rtvId} completed — stock moved out, accounting event dispatched");
}
public static function cancelRTV(int $rtvId): void
{
$db = App::getInstance()->db();
$rtv = $db->selectOne("SELECT * FROM `return_to_vendor` WHERE `id` = ?", [$rtvId]);
if (!$rtv) throw new \RuntimeException('مرتجع المورد غير موجود');
if (!in_array($rtv['status'], ['draft', 'submitted'], true)) throw new \RuntimeException('لا يمكن إلغاء المرتجع في حالته الحالية');
$db->update('return_to_vendor', ['status' => 'cancelled', 'updated_at' => date('Y-m-d H:i:s')], '`id` = ?', [$rtvId]);
Logger::info("RTV #{$rtvId} cancelled");
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Procurement\Services;
use App\Core\App;
use App\Core\Logger;
/**
* 3-Way Match: PO ↔ GRN ↔ Invoice
* Compares quantities and costs across PO items, GRN accepted quantities, and invoice line items.
*/
final class ThreeWayMatchService
{
/**
* Perform a 3-way match for a vendor invoice.
*
* @return array ['status' => string, 'details' => array, 'notes' => string]
*/
public static function performMatch(int $invoiceId): array
{
$db = App::getInstance()->db();
$invoice = $db->selectOne("SELECT * FROM `vendor_invoices` WHERE `id` = ?", [$invoiceId]);
if (!$invoice) {
throw new \RuntimeException('الفاتورة غير موجودة');
}
$poId = (int) ($invoice['purchase_order_id'] ?? 0);
$grnId = (int) ($invoice['grn_id'] ?? 0);
if ($poId <= 0) {
return ['status' => 'unmatched', 'details' => [], 'notes' => 'لا يوجد أمر شراء مرتبط'];
}
// Get tolerance from config
$config = $db->selectOne(
"SELECT `config_value` FROM `system_config` WHERE `config_key` = 'procurement.match_tolerance_pct'"
);
$tolerancePct = (float) ($config['config_value'] ?? 2.0);
// Get invoice items
$invoiceItems = $db->select(
"SELECT * FROM `vendor_invoice_items` WHERE `invoice_id` = ?",
[$invoiceId]
);
// Get PO items
$poItems = $db->select(
"SELECT * FROM `purchase_order_items` WHERE `purchase_order_id` = ?",
[$poId]
);
$poItemsMap = [];
foreach ($poItems as $poi) {
$poItemsMap[(int) $poi['id']] = $poi;
}
// Get GRN items if GRN specified
$grnItemsMap = [];
if ($grnId > 0) {
$grnItems = $db->select(
"SELECT * FROM `grn_items` WHERE `grn_id` = ?",
[$grnId]
);
foreach ($grnItems as $gi) {
$grnItemsMap[(int) $gi['po_item_id']] = $gi;
}
}
$details = [];
$hasDiscrepancy = false;
$allWithinTolerance = true;
foreach ($invoiceItems as $invItem) {
$poItemId = (int) ($invItem['po_item_id'] ?? 0);
$detail = [
'item_id' => (int) ($invItem['item_id'] ?? 0),
'invoice_qty' => (string) $invItem['quantity'],
'invoice_cost' => (string) $invItem['unit_cost'],
'po_qty' => '0',
'po_cost' => '0',
'grn_accepted_qty' => '0',
'qty_match' => false,
'cost_match' => false,
'notes' => [],
];
if ($poItemId > 0 && isset($poItemsMap[$poItemId])) {
$poi = $poItemsMap[$poItemId];
$detail['po_qty'] = (string) $poi['quantity_ordered'];
$detail['po_cost'] = (string) $poi['unit_cost'];
// Check GRN
if (isset($grnItemsMap[$poItemId])) {
$detail['grn_accepted_qty'] = (string) $grnItemsMap[$poItemId]['quantity_accepted'];
}
// Quantity match: invoice qty vs GRN accepted qty (or PO qty if no GRN)
$referenceQty = bccomp($detail['grn_accepted_qty'], '0', 3) > 0
? $detail['grn_accepted_qty']
: $detail['po_qty'];
$qtyDiff = bcsub($detail['invoice_qty'], $referenceQty, 3);
$qtyDiffPct = bccomp($referenceQty, '0', 3) > 0
? (float) bcdiv(bcmul($qtyDiff, '100', 3), $referenceQty, 3)
: 0.0;
if (bccomp($qtyDiff, '0', 3) === 0) {
$detail['qty_match'] = true;
} elseif (abs($qtyDiffPct) <= $tolerancePct) {
$detail['qty_match'] = true;
$detail['notes'][] = "فرق كمية {$qtyDiffPct}% (ضمن التسامح)";
} else {
$detail['notes'][] = "فرق كمية: فاتورة {$detail['invoice_qty']} مقابل مرجع {$referenceQty}";
$hasDiscrepancy = true;
$allWithinTolerance = false;
}
// Cost match: invoice unit_cost vs PO unit_cost
$costDiff = bcsub($detail['invoice_cost'], $detail['po_cost'], 2);
$costDiffPct = bccomp($detail['po_cost'], '0', 2) > 0
? (float) bcdiv(bcmul($costDiff, '100', 2), $detail['po_cost'], 2)
: 0.0;
if (bccomp($costDiff, '0', 2) === 0) {
$detail['cost_match'] = true;
} elseif (abs($costDiffPct) <= $tolerancePct) {
$detail['cost_match'] = true;
$detail['notes'][] = "فرق سعر {$costDiffPct}% (ضمن التسامح)";
} else {
$detail['notes'][] = "فرق سعر: فاتورة {$detail['invoice_cost']} مقابل أمر شراء {$detail['po_cost']}";
$hasDiscrepancy = true;
$allWithinTolerance = false;
}
} else {
$detail['notes'][] = 'لا يوجد بند مقابل في أمر الشراء';
$hasDiscrepancy = true;
$allWithinTolerance = false;
}
$details[] = $detail;
}
// Determine overall status
if (!$hasDiscrepancy) {
$matchStatus = 'matched';
$matchNotes = 'مطابقة كاملة';
} elseif ($allWithinTolerance) {
$matchStatus = 'tolerance_pass';
$matchNotes = 'مطابقة ضمن نسبة التسامح';
} else {
$matchStatus = 'discrepancy';
$matchNotes = 'يوجد اختلاف — يرجى المراجعة';
}
// Update invoice match status
$db->update('vendor_invoices', [
'match_status' => $matchStatus,
'match_notes' => $matchNotes,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$invoiceId]);
Logger::info("3-way match for invoice #{$invoiceId}: {$matchStatus}");
return [
'status' => $matchStatus,
'details' => $details,
'notes' => $matchNotes,
];
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Procurement\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
final class VendorInvoiceService
{
/**
* Create a new vendor invoice with items.
*/
public static function createInvoice(array $header, array $items): int
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
if (empty($items)) {
throw new \RuntimeException('يجب إضافة بند واحد على الأقل');
}
$supplierId = (int) ($header['supplier_id'] ?? 0);
if ($supplierId <= 0) {
throw new \RuntimeException('يجب تحديد المورد');
}
$internalNumber = ProcurementNumberGenerator::nextInvoiceNumber();
$subtotal = '0.00';
$taxTotal = '0.00';
$discountTotal = '0.00';
foreach ($items as $item) {
$qty = (string) ($item['quantity'] ?? '0');
$unitCost = (string) ($item['unit_cost'] ?? '0');
$taxRate = (string) ($item['tax_rate'] ?? '0');
$discount = (string) ($item['discount_amount'] ?? '0');
$lineBase = bcmul($qty, $unitCost, 2);
$lineTax = bcmul($lineBase, bcdiv($taxRate, '100', 4), 2);
$subtotal = bcadd($subtotal, $lineBase, 2);
$taxTotal = bcadd($taxTotal, $lineTax, 2);
$discountTotal = bcadd($discountTotal, $discount, 2);
}
$totalAmount = bcsub(bcadd($subtotal, $taxTotal, 2), $discountTotal, 2);
$db->beginTransaction();
try {
$invoiceId = $db->insert('vendor_invoices', [
'invoice_number' => $header['invoice_number'] ?? '',
'internal_number' => $internalNumber,
'supplier_id' => $supplierId,
'purchase_order_id' => !empty($header['purchase_order_id']) ? (int) $header['purchase_order_id'] : null,
'grn_id' => !empty($header['grn_id']) ? (int) $header['grn_id'] : null,
'invoice_date' => $header['invoice_date'] ?? date('Y-m-d'),
'due_date' => $header['due_date'] ?? null,
'subtotal' => $subtotal,
'tax_amount' => $taxTotal,
'discount_amount' => $discountTotal,
'total_amount' => $totalAmount,
'status' => 'draft',
'notes' => $header['notes'] ?? null,
'branch_id' => $header['branch_id'] ?? null,
'created_by' => $employee ? (int) $employee->id : null,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
foreach ($items as $item) {
$qty = (string) ($item['quantity'] ?? '0');
$unitCost = (string) ($item['unit_cost'] ?? '0');
$taxRate = (string) ($item['tax_rate'] ?? '0');
$discount = (string) ($item['discount_amount'] ?? '0');
$lineBase = bcmul($qty, $unitCost, 2);
$lineTax = bcmul($lineBase, bcdiv($taxRate, '100', 4), 2);
$lineTotal = bcsub(bcadd($lineBase, $lineTax, 2), $discount, 2);
$db->insert('vendor_invoice_items', [
'invoice_id' => $invoiceId,
'po_item_id' => !empty($item['po_item_id']) ? (int) $item['po_item_id'] : null,
'item_id' => !empty($item['item_id']) ? (int) $item['item_id'] : null,
'description' => $item['description'] ?? null,
'quantity' => $qty,
'unit_cost' => $unitCost,
'tax_rate' => $taxRate,
'tax_amount' => $lineTax,
'discount_amount' => $discount,
'line_total' => $lineTotal,
]);
}
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
throw $e;
}
Logger::info("Vendor invoice #{$invoiceId} ({$internalNumber}) created, total: {$totalAmount}");
return $invoiceId;
}
/**
* Verify an invoice (run 3-way match).
*/
public static function verifyInvoice(int $invoiceId): array
{
$db = App::getInstance()->db();
$invoice = $db->selectOne("SELECT * FROM `vendor_invoices` WHERE `id` = ?", [$invoiceId]);
if (!$invoice) {
throw new \RuntimeException('الفاتورة غير موجودة');
}
if ($invoice['status'] !== 'draft') {
throw new \RuntimeException('الفاتورة ليست في حالة مسودة');
}
$matchResult = ThreeWayMatchService::performMatch($invoiceId);
$db->update('vendor_invoices', [
'status' => 'verified',
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$invoiceId]);
Logger::info("Vendor invoice #{$invoiceId} verified, match: {$matchResult['status']}");
return $matchResult;
}
/**
* Approve a verified invoice — dispatches event for accounting.
*/
public static function approveInvoice(int $invoiceId): void
{
$db = App::getInstance()->db();
$invoice = $db->selectOne("SELECT * FROM `vendor_invoices` WHERE `id` = ?", [$invoiceId]);
if (!$invoice) {
throw new \RuntimeException('الفاتورة غير موجودة');
}
if ($invoice['status'] !== 'verified') {
throw new \RuntimeException('الفاتورة ليست في حالة تم التحقق');
}
$db->update('vendor_invoices', [
'status' => 'approved',
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$invoiceId]);
EventBus::dispatch('procurement.invoice_approved', [
'invoice_id' => $invoiceId,
'supplier_id' => (int) $invoice['supplier_id'],
'internal_number' => $invoice['internal_number'] ?? '',
'subtotal' => (string) $invoice['subtotal'],
'tax_amount' => (string) $invoice['tax_amount'],
'total_amount' => (string) $invoice['total_amount'],
'due_date' => $invoice['due_date'],
]);
Logger::info("Vendor invoice #{$invoiceId} approved — accounting event dispatched");
}
/**
* Cancel an invoice (draft or verified only).
*/
public static function cancelInvoice(int $invoiceId): void
{
$db = App::getInstance()->db();
$invoice = $db->selectOne("SELECT * FROM `vendor_invoices` WHERE `id` = ?", [$invoiceId]);
if (!$invoice) {
throw new \RuntimeException('الفاتورة غير موجودة');
}
if (!in_array($invoice['status'], ['draft', 'verified'], true)) {
throw new \RuntimeException('لا يمكن إلغاء الفاتورة في حالتها الحالية');
}
$db->update('vendor_invoices', [
'status' => 'cancelled',
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$invoiceId]);
Logger::info("Vendor invoice #{$invoiceId} cancelled");
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Procurement\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
final class VendorPaymentService
{
public static function createPayment(array $data): int
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$supplierId = (int) ($data['supplier_id'] ?? 0);
$amount = (string) ($data['amount'] ?? '0');
if ($supplierId <= 0) {
throw new \RuntimeException('يجب تحديد المورد');
}
if (bccomp($amount, '0', 2) <= 0) {
throw new \RuntimeException('يجب تحديد مبلغ الدفعة');
}
$paymentNumber = ProcurementNumberGenerator::nextPaymentNumber();
$paymentId = $db->insert('vendor_payments', [
'payment_number' => $paymentNumber,
'supplier_id' => $supplierId,
'invoice_id' => !empty($data['invoice_id']) ? (int) $data['invoice_id'] : null,
'amount' => $amount,
'payment_method' => $data['payment_method'] ?? 'bank_transfer',
'bank_account_id' => !empty($data['bank_account_id']) ? (int) $data['bank_account_id'] : null,
'check_number' => $data['check_number'] ?? null,
'reference_number' => $data['reference_number'] ?? null,
'payment_date' => $data['payment_date'] ?? date('Y-m-d'),
'status' => 'draft',
'notes' => $data['notes'] ?? null,
'branch_id' => $data['branch_id'] ?? null,
'created_by' => $employee ? (int) $employee->id : null,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
Logger::info("Vendor payment #{$paymentId} ({$paymentNumber}) created, amount: {$amount}");
return $paymentId;
}
public static function approvePayment(int $paymentId): void
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$payment = $db->selectOne("SELECT * FROM `vendor_payments` WHERE `id` = ?", [$paymentId]);
if (!$payment) {
throw new \RuntimeException('الدفعة غير موجودة');
}
if ($payment['status'] !== 'draft') {
throw new \RuntimeException('الدفعة ليست في حالة مسودة');
}
$db->update('vendor_payments', [
'status' => 'approved',
'approved_by' => $employee ? (int) $employee->id : null,
'approved_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$paymentId]);
Logger::info("Vendor payment #{$paymentId} approved");
}
public static function completePayment(int $paymentId): void
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$payment = $db->selectOne("SELECT * FROM `vendor_payments` WHERE `id` = ?", [$paymentId]);
if (!$payment) {
throw new \RuntimeException('الدفعة غير موجودة');
}
if ($payment['status'] !== 'approved') {
throw new \RuntimeException('الدفعة ليست في حالة معتمدة');
}
$db->update('vendor_payments', [
'status' => 'completed',
'completed_by' => $employee ? (int) $employee->id : null,
'completed_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$paymentId]);
// Update invoice status if linked
if (!empty($payment['invoice_id'])) {
$invoice = $db->selectOne("SELECT * FROM `vendor_invoices` WHERE `id` = ?", [(int) $payment['invoice_id']]);
if ($invoice) {
// Check total paid against invoice
$totalPaid = $db->selectOne(
"SELECT COALESCE(SUM(`amount`), 0) as total FROM `vendor_payments` WHERE `invoice_id` = ? AND `status` = 'completed'",
[(int) $payment['invoice_id']]
);
$paidAmount = bcadd((string) ($totalPaid['total'] ?? '0'), (string) $payment['amount'], 2);
$newInvoiceStatus = bccomp($paidAmount, (string) $invoice['total_amount'], 2) >= 0
? 'paid'
: 'partial_paid';
$db->update('vendor_invoices', [
'status' => $newInvoiceStatus,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $payment['invoice_id']]);
}
}
EventBus::dispatch('procurement.payment_completed', [
'payment_id' => $paymentId,
'supplier_id' => (int) $payment['supplier_id'],
'invoice_id' => $payment['invoice_id'] ? (int) $payment['invoice_id'] : null,
'amount' => (string) $payment['amount'],
'payment_number' => $payment['payment_number'] ?? '',
'payment_method' => $payment['payment_method'],
'bank_account_id' => $payment['bank_account_id'] ? (int) $payment['bank_account_id'] : null,
]);
Logger::info("Vendor payment #{$paymentId} completed — accounting event dispatched");
}
public static function voidPayment(int $paymentId, string $reason): void
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$payment = $db->selectOne("SELECT * FROM `vendor_payments` WHERE `id` = ?", [$paymentId]);
if (!$payment) {
throw new \RuntimeException('الدفعة غير موجودة');
}
if (!in_array($payment['status'], ['draft', 'approved', 'completed'], true)) {
throw new \RuntimeException('لا يمكن إلغاء الدفعة في حالتها الحالية');
}
$wasCompleted = $payment['status'] === 'completed';
$db->update('vendor_payments', [
'status' => 'voided',
'voided_by' => $employee ? (int) $employee->id : null,
'voided_at' => date('Y-m-d H:i:s'),
'void_reason' => $reason,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$paymentId]);
if ($wasCompleted) {
EventBus::dispatch('procurement.payment_voided', [
'payment_id' => $paymentId,
'supplier_id' => (int) $payment['supplier_id'],
'invoice_id' => $payment['invoice_id'] ? (int) $payment['invoice_id'] : null,
'amount' => (string) $payment['amount'],
'reason' => $reason,
'journal_entry_id' => $payment['journal_entry_id'] ? (int) $payment['journal_entry_id'] : null,
]);
// Re-evaluate invoice status if linked
if (!empty($payment['invoice_id'])) {
$totalPaid = $db->selectOne(
"SELECT COALESCE(SUM(`amount`), 0) as total FROM `vendor_payments` WHERE `invoice_id` = ? AND `status` = 'completed'",
[(int) $payment['invoice_id']]
);
$paidAmount = (string) ($totalPaid['total'] ?? '0');
$invoice = $db->selectOne("SELECT `total_amount` FROM `vendor_invoices` WHERE `id` = ?", [(int) $payment['invoice_id']]);
if ($invoice) {
$newStatus = bccomp($paidAmount, '0', 2) <= 0 ? 'approved' :
(bccomp($paidAmount, (string) $invoice['total_amount'], 2) >= 0 ? 'paid' : 'partial_paid');
$db->update('vendor_invoices', [
'status' => $newStatus,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $payment['invoice_id']]);
}
}
}
Logger::info("Vendor payment #{$paymentId} voided: {$reason}");
}
}
This diff is collapsed.
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>إذن استلام جديد<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/procurement/grn" 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'); ?>
<form method="POST" action="/procurement/grn" id="grnForm">
<?= csrf_field() ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;">
<i data-lucide="package-check" style="width:18px;height:18px;color:#0D7377;"></i>
<h3 style="margin:0;color:#0D7377;font-size:15px;">اختر أمر الشراء</h3>
</div>
<div style="padding:20px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div>
<label class="form-label">أمر الشراء <span style="color:#DC2626;">*</span></label>
<select name="purchase_order_id" class="form-select" id="poSelect" required onchange="loadPOItems()">
<option value="">-- اختر أمر شراء --</option>
<?php foreach ($openPOs as $po): ?>
<option value="<?= (int) $po['id'] ?>"
data-supplier="<?= e($po['supplier_name']) ?>"
data-warehouse="<?= e($po['warehouse_name']) ?>">
<?= e($po['po_number']) ?><?= e($po['supplier_name']) ?> (<?= money($po['total_amount']) ?>)
</option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label">تاريخ الاستلام</label>
<input type="date" name="received_date" class="form-input" value="<?= date('Y-m-d') ?>">
</div>
</div>
<div style="margin-top:15px;">
<label class="form-label">ملاحظات</label>
<textarea name="notes" class="form-input" rows="2" placeholder="ملاحظات إضافية..."></textarea>
</div>
</div>
</div>
<div class="card" style="margin-bottom:20px;" id="itemsCard" style="display:none;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;">
<i data-lucide="package" style="width:18px;height:18px;color:#D97706;"></i>
<h3 style="margin:0;color:#D97706;font-size:15px;">أصناف أمر الشراء</h3>
</div>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>الصنف</th>
<th>المطلوب</th>
<th>المستلم سابقاً</th>
<th>المتبقي</th>
<th style="width:120px;">الكمية المستلمة</th>
<th>رقم الدفعة</th>
<th>تاريخ الانتهاء</th>
</tr>
</thead>
<tbody id="poItemsBody">
<tr><td colspan="7" style="text-align:center;color:#9CA3AF;padding:30px;">اختر أمر شراء لعرض الأصناف</td></tr>
</tbody>
</table>
</div>
</div>
<div style="text-align:left;">
<button type="submit" class="btn btn-primary" style="padding:10px 30px;">
<i data-lucide="save" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> إنشاء إذن الاستلام
</button>
</div>
</form>
<script>
var poItemsCache = {};
<?php foreach ($openPOs as $po): ?>
poItemsCache[<?= (int) $po['id'] ?>] = null;
<?php endforeach; ?>
function loadPOItems() {
var poId = document.getElementById('poSelect').value;
var tbody = document.getElementById('poItemsBody');
if (!poId) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#9CA3AF;padding:30px;">اختر أمر شراء لعرض الأصناف</td></tr>';
return;
}
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#9CA3AF;padding:20px;">جاري التحميل...</td></tr>';
fetch('/inventory/purchase-orders/' + poId + '?format=json')
.then(function(r) { return r.json(); })
.catch(function() {
// Fallback: reload the page with PO items embedded
// For now, use a simple approach
return { items: [] };
})
.then(function(data) {
var items = data.items || [];
if (items.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#9CA3AF;padding:30px;">لا توجد أصناف</td></tr>';
return;
}
var html = '';
items.forEach(function(item, idx) {
var remaining = Math.max(0, parseFloat(item.quantity_ordered) - parseFloat(item.quantity_received));
html += '<tr>'
+ '<td style="font-weight:600;">' + (item.item_name || '') + '</td>'
+ '<td style="direction:ltr;text-align:left;">' + parseFloat(item.quantity_ordered).toFixed(3) + '</td>'
+ '<td style="direction:ltr;text-align:left;">' + parseFloat(item.quantity_received).toFixed(3) + '</td>'
+ '<td style="direction:ltr;text-align:left;font-weight:600;">' + remaining.toFixed(3) + '</td>'
+ '<td><input type="hidden" name="items['+idx+'][po_item_id]" value="'+item.id+'">'
+ '<input type="number" name="items['+idx+'][quantity_received]" class="form-input" step="0.001" min="0" max="'+remaining.toFixed(3)+'" value="'+remaining.toFixed(3)+'"></td>'
+ '<td><input type="text" name="items['+idx+'][batch_number]" class="form-input" placeholder="اختياري"></td>'
+ '<td><input type="date" name="items['+idx+'][expiry_date]" class="form-input"></td>'
+ '</tr>';
});
tbody.innerHTML = html;
});
}
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') lucide.createIcons();
});
</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>أذونات الاستلام<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/procurement/grn/create" class="btn btn-primary"><i data-lucide="plus" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> إذن استلام جديد</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php
$statusColors = ['draft' => '#6B7280', 'inspecting' => '#D97706', 'accepted' => '#059669', 'partial_accept' => '#2563EB', 'cancelled' => '#DC2626'];
$statusBgs = ['draft' => '#F3F4F6', 'inspecting' => '#FFF7ED', 'accepted' => '#ECFDF5', 'partial_accept' => '#EFF6FF', 'cancelled' => '#FEE2E2'];
?>
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/procurement/grn" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div style="min-width:140px;">
<label class="form-label" style="font-size:12px;">الحالة</label>
<select name="status" class="form-select">
<option value="">الكل</option>
<?php foreach ($statuses as $val => $label): ?>
<option value="<?= e($val) ?>" <?= ($filters['status'] ?? '') === $val ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div style="min-width:160px;">
<label class="form-label" style="font-size:12px;">المورد</label>
<select name="supplier_id" class="form-select">
<option value="">الكل</option>
<?php foreach ($suppliers as $sup): ?>
<option value="<?= (int) $sup['id'] ?>" <?= ($filters['supplier_id'] ?? '') == $sup['id'] ? 'selected' : '' ?>><?= e($sup['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label" style="font-size:12px;">من</label>
<input type="date" name="date_from" value="<?= e($filters['date_from'] ?? '') ?>" class="form-input">
</div>
<div>
<label class="form-label" style="font-size:12px;">إلى</label>
<input type="date" name="date_to" value="<?= e($filters['date_to'] ?? '') ?>" class="form-input">
</div>
<button type="submit" class="btn btn-outline"><i data-lucide="search" style="width:16px;height:16px;vertical-align:middle;"></i> بحث</button>
<a href="/procurement/grn" class="btn btn-sm btn-outline" style="color:#6B7280;">مسح</a>
</form>
</div>
<?php if (!empty($grns)): ?>
<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>
<th>التاريخ</th>
<th>الإجراءات</th>
</tr>
</thead>
<tbody>
<?php foreach ($grns as $g): ?>
<?php
$st = $g['status'] ?? 'draft';
$stColor = $statusColors[$st] ?? '#6B7280';
$stBg = $statusBgs[$st] ?? '#F3F4F6';
$stLabel = $statuses[$st] ?? $st;
?>
<tr>
<td style="font-weight:600;">
<a href="/procurement/grn/<?= (int) $g['id'] ?>" style="color:#0D7377;text-decoration:none;">
<code style="font-size:12px;background:#F3F4F6;padding:2px 8px;border-radius:4px;"><?= e($g['grn_number']) ?></code>
</a>
</td>
<td>
<?php if (!empty($g['po_number'])): ?>
<a href="/inventory/purchase-orders/<?= (int) $g['purchase_order_id'] ?>" style="color:#0D7377;text-decoration:none;">
<code style="font-size:11px;background:#F3F4F6;padding:2px 6px;border-radius:4px;"><?= e($g['po_number']) ?></code>
</a>
<?php else: ?><?php endif; ?>
</td>
<td style="font-weight:600;"><?= e($g['supplier_name'] ?? '—') ?></td>
<td style="font-size:13px;"><?= e($g['warehouse_name'] ?? '—') ?></td>
<td>
<span style="display:inline-block;padding:3px 10px;border-radius:10px;font-size:12px;font-weight:600;background:<?= $stBg ?>;color:<?= $stColor ?>;">
<?= e($stLabel) ?>
</span>
</td>
<td style="font-weight:700;direction:ltr;text-align:left;"><?= money($g['total_received_value'] ?? 0) ?></td>
<td style="font-size:12px;"><?= e($g['received_date'] ?? '') ?></td>
<td>
<a href="/procurement/grn/<?= (int) $g['id'] ?>" class="btn btn-sm btn-outline" style="font-size:12px;padding:4px 10px;">
<i data-lucide="eye" style="width:13px;height:13px;vertical-align:middle;"></i> عرض
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php if (isset($pagination) && ($pagination['last_page'] ?? 1) > 1): ?>
<div style="padding:15px;">
<?php $__template->include('Shared.Components.pagination', ['pagination' => $pagination]); ?>
</div>
<?php endif; ?>
<?php else: ?>
<div class="card" style="padding:60px 20px;text-align:center;">
<div style="margin-bottom:15px;"><i data-lucide="package-check" style="width:48px;height:48px;color:#D1D5DB;"></i></div>
<h3 style="color:#6B7280;margin:0 0 8px;">لا توجد أذونات استلام</h3>
<p style="color:#9CA3AF;font-size:14px;margin:0 0 20px;">ابدأ بإنشاء إذن استلام جديد من أمر شراء معتمد.</p>
<a href="/procurement/grn/create" class="btn btn-primary"><i data-lucide="plus" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> إذن استلام جديد</a>
</div>
<?php endif; ?>
<script>document.addEventListener('DOMContentLoaded', function() { if (typeof lucide !== 'undefined') lucide.createIcons(); });</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>فحص إذن استلام <?= e($grn['grn_number']) ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/procurement/grn/<?= (int) $grn['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="margin-bottom:20px;padding:15px;">
<div style="display:flex;gap:20px;flex-wrap:wrap;font-size:14px;">
<div><strong>رقم الإذن:</strong> <code><?= e($grn['grn_number']) ?></code></div>
<div><strong>أمر الشراء:</strong> <?= e($grn['po_number'] ?? '—') ?></div>
<div><strong>المورد:</strong> <?= e($grn['supplier_name'] ?? '') ?></div>
<div><strong>المستودع:</strong> <?= e($grn['warehouse_name'] ?? '') ?></div>
</div>
</div>
<form method="POST" action="/procurement/grn/<?= (int) $grn['id'] ?>/inspect">
<?= csrf_field() ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;">
<i data-lucide="clipboard-check" style="width:18px;height:18px;color:#D97706;"></i>
<h3 style="margin:0;color:#D97706;font-size:15px;">فحص الأصناف</h3>
</div>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>الصنف</th>
<th>المستلم</th>
<th style="width:120px;">الكمية المقبولة</th>
<th style="width:120px;">الكمية المرفوضة</th>
<th style="min-width:200px;">سبب الرفض</th>
</tr>
</thead>
<tbody>
<?php foreach ($items as $i => $item): ?>
<tr>
<td style="font-weight:600;">
<?= e($item['item_name'] ?? '') ?>
<?php if (!empty($item['sku'])): ?>
<br><code style="font-size:11px;background:#F3F4F6;padding:2px 6px;border-radius:4px;"><?= e($item['sku']) ?></code>
<?php endif; ?>
<?php if (!empty($item['batch_number'])): ?>
<br><span style="font-size:11px;color:#6B7280;">دفعة: <?= e($item['batch_number']) ?></span>
<?php endif; ?>
</td>
<td style="font-weight:600;direction:ltr;text-align:left;">
<?= number_format((float) ($item['quantity_received'] ?? 0), 3) ?>
</td>
<td>
<input type="hidden" name="grn_item_ids[<?= $i ?>]" value="<?= (int) $item['id'] ?>">
<input type="number" name="quantities_accepted[<?= $i ?>]" class="form-input"
step="0.001" min="0" max="<?= (float) $item['quantity_received'] ?>"
value="<?= (float) $item['quantity_received'] ?>"
onchange="calcRejected(<?= $i ?>, <?= (float) $item['quantity_received'] ?>)">
</td>
<td>
<input type="number" name="quantities_rejected[<?= $i ?>]" class="form-input"
step="0.001" min="0" value="0" id="rejected_<?= $i ?>"
onchange="calcAccepted(<?= $i ?>, <?= (float) $item['quantity_received'] ?>)">
</td>
<td>
<input type="text" name="rejection_reasons[<?= $i ?>]" class="form-input" placeholder="سبب الرفض (اختياري)">
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<div class="card" style="margin-bottom:20px;padding:15px;">
<label class="form-label">ملاحظات الفحص</label>
<textarea name="inspection_notes" class="form-input" rows="3" placeholder="ملاحظات عامة عن الفحص..."></textarea>
</div>
<div style="text-align:left;">
<button type="submit" class="btn" style="background:#059669;color:#fff;border:none;padding:10px 30px;" onclick="return confirm('هل أنت متأكد من اعتماد نتائج الفحص؟ سيتم تحديث أرصدة المخزون.');">
<i data-lucide="check-circle" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> اعتماد الفحص
</button>
</div>
</form>
<script>
function calcRejected(idx, total) {
var accepted = parseFloat(document.querySelector('input[name="quantities_accepted['+idx+']"]').value) || 0;
document.getElementById('rejected_' + idx).value = Math.max(0, total - accepted).toFixed(3);
}
function calcAccepted(idx, total) {
var rejected = parseFloat(document.getElementById('rejected_' + idx).value) || 0;
document.querySelector('input[name="quantities_accepted['+idx+']"]').value = Math.max(0, total - rejected).toFixed(3);
}
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') lucide.createIcons();
});
</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>إذن استلام <?= e($grn['grn_number']) ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<?php if (in_array($grn['status'], ['draft', 'inspecting'])): ?>
<a href="/procurement/grn/<?= (int) $grn['id'] ?>/inspect" class="btn" style="background:#D97706;color:#fff;border:none;">
<i data-lucide="clipboard-check" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> فحص واعتماد
</a>
<form method="POST" action="/procurement/grn/<?= (int) $grn['id'] ?>/cancel" style="display:inline;">
<?= csrf_field() ?>
<button type="submit" class="btn" style="background:#FEE2E2;color:#DC2626;border:none;" onclick="return confirm('هل أنت متأكد من إلغاء إذن الاستلام؟');">
<i data-lucide="x-circle" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> إلغاء
</button>
</form>
<?php endif; ?>
<a href="/procurement/grn" 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'); ?>
<?php
$statusLabels = ['draft' => 'مسودة', 'inspecting' => 'تحت الفحص', 'accepted' => 'مقبول', 'partial_accept' => 'قبول جزئي', 'cancelled' => 'ملغي'];
$statusColorMap = [
'draft' => ['bg' => '#F3F4F6', 'color' => '#6B7280'],
'inspecting' => ['bg' => '#FFF7ED', 'color' => '#D97706'],
'accepted' => ['bg' => '#ECFDF5', 'color' => '#059669'],
'partial_accept' => ['bg' => '#EFF6FF', 'color' => '#2563EB'],
'cancelled' => ['bg' => '#FEE2E2', 'color' => '#DC2626'],
];
$st = $grn['status'] ?? 'draft';
$stColors = $statusColorMap[$st] ?? $statusColorMap['draft'];
$stLabel = $statusLabels[$st] ?? $st;
?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;">
<i data-lucide="package-check" style="width:18px;height:18px;color:#0D7377;"></i>
<h3 style="margin:0;color:#0D7377;font-size:15px;">بيانات إذن الاستلام</h3>
</div>
<div style="padding:20px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0;">
<table style="width:100%;font-size:14px;">
<tr>
<td style="padding:10px 0;color:#6B7280;width:40%;">رقم الإذن</td>
<td style="padding:10px 0;font-weight:600;"><code style="font-size:12px;background:#F3F4F6;padding:2px 8px;border-radius:4px;"><?= e($grn['grn_number']) ?></code></td>
</tr>
<tr>
<td style="padding:10px 0;color:#6B7280;">أمر الشراء</td>
<td style="padding:10px 0;">
<?php if (!empty($grn['po_number'])): ?>
<a href="/inventory/purchase-orders/<?= (int) $grn['purchase_order_id'] ?>" style="color:#0D7377;font-weight:600;text-decoration:none;"><?= e($grn['po_number']) ?></a>
<?php else: ?><?php endif; ?>
</td>
</tr>
<tr>
<td style="padding:10px 0;color:#6B7280;">المورد</td>
<td style="padding:10px 0;font-weight:600;"><?= e($grn['supplier_name'] ?? '') ?></td>
</tr>
<tr>
<td style="padding:10px 0;color:#6B7280;">المستودع</td>
<td style="padding:10px 0;"><?= e($grn['warehouse_name'] ?? '') ?></td>
</tr>
</table>
<table style="width:100%;font-size:14px;">
<tr>
<td style="padding:10px 0;color:#6B7280;width:40%;">الحالة</td>
<td style="padding:10px 0;">
<span style="display:inline-block;padding:3px 10px;border-radius:10px;font-size:12px;font-weight:600;background:<?= $stColors['bg'] ?>;color:<?= $stColors['color'] ?>;"><?= e($stLabel) ?></span>
</td>
</tr>
<tr>
<td style="padding:10px 0;color:#6B7280;">تاريخ الاستلام</td>
<td style="padding:10px 0;"><?= e($grn['received_date'] ?? '—') ?></td>
</tr>
<tr>
<td style="padding:10px 0;color:#6B7280;">المستلم</td>
<td style="padding:10px 0;"><?= e($grn['receiver_name'] ?? '—') ?></td>
</tr>
</table>
</div>
<?php if (!empty($grn['inspection_notes'])): ?>
<div style="margin-top:20px;padding-top:15px;border-top:1px solid #F3F4F6;">
<div style="font-size:12px;color:#6B7280;margin-bottom:4px;">ملاحظات الفحص</div>
<div style="font-size:14px;color:#4B5563;line-height:1.7;"><?= e($grn['inspection_notes']) ?></div>
</div>
<?php endif; ?>
</div>
</div>
<!-- Totals -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;margin-bottom:20px;">
<div class="card" style="padding:20px;text-align:center;background:linear-gradient(135deg, #D9770610, #D9770620);">
<div style="font-size:14px;color:#6B7280;margin-bottom:6px;">قيمة المستلم</div>
<div style="font-size:28px;font-weight:800;color:#D97706;direction:ltr;"><?= money($grn['total_received_value'] ?? 0) ?></div>
</div>
<div class="card" style="padding:20px;text-align:center;background:linear-gradient(135deg, #05966910, #05966920);">
<div style="font-size:14px;color:#6B7280;margin-bottom:6px;">قيمة المقبول</div>
<div style="font-size:28px;font-weight:800;color:#059669;direction:ltr;"><?= money($grn['total_accepted_value'] ?? 0) ?></div>
</div>
</div>
<!-- Items Table -->
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;">
<i data-lucide="package" style="width:18px;height:18px;color:#D97706;"></i>
<h3 style="margin:0;color:#D97706;font-size:15px;">الأصناف</h3>
</div>
<?php if (!empty($items)): ?>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>الصنف</th>
<th>المستلم</th>
<th>المقبول</th>
<th>المرفوض</th>
<th>الدفعة</th>
<th>سعر الوحدة</th>
<th>الإجمالي</th>
</tr>
</thead>
<tbody>
<?php foreach ($items as $item): ?>
<tr>
<td style="font-weight:600;">
<?= e($item['item_name'] ?? '') ?>
<?php if (!empty($item['sku'])): ?>
<br><code style="font-size:11px;background:#F3F4F6;padding:2px 6px;border-radius:4px;"><?= e($item['sku']) ?></code>
<?php endif; ?>
</td>
<td style="font-weight:600;direction:ltr;text-align:left;"><?= number_format((float) ($item['quantity_received'] ?? 0), 3) ?></td>
<td style="font-weight:600;direction:ltr;text-align:left;color:#059669;"><?= number_format((float) ($item['quantity_accepted'] ?? 0), 3) ?></td>
<td style="font-weight:600;direction:ltr;text-align:left;color:<?= (float) ($item['quantity_rejected'] ?? 0) > 0 ? '#DC2626' : '#6B7280' ?>;">
<?= number_format((float) ($item['quantity_rejected'] ?? 0), 3) ?>
<?php if (!empty($item['rejection_reason'])): ?>
<br><span style="font-size:11px;color:#DC2626;font-weight:400;"><?= e($item['rejection_reason']) ?></span>
<?php endif; ?>
</td>
<td style="font-size:12px;"><?= e($item['batch_number'] ?? '—') ?></td>
<td style="direction:ltr;text-align:left;"><?= money($item['unit_cost'] ?? 0) ?></td>
<td style="font-weight:700;direction:ltr;text-align:left;"><?= money($item['line_total'] ?? 0) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<div style="padding:40px 20px;text-align:center;color:#6B7280;">
<p style="margin:0;">لا توجد أصناف</p>
</div>
<?php endif; ?>
</div>
<script>document.addEventListener('DOMContentLoaded', function() { if (typeof lucide !== 'undefined') lucide.createIcons(); });</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?><?= isset($invoice) ? 'تعديل فاتورة مورد' : 'فاتورة مورد جديدة' ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/procurement/invoices" 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'); ?>
<?php
$isEdit = isset($invoice);
$action = $isEdit ? '/procurement/invoices/' . (int) $invoice['id'] . '/update' : '/procurement/invoices';
?>
<form method="POST" action="<?= $action ?>" id="invoiceForm">
<?= csrf_field() ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;">
<i data-lucide="file-text" style="width:18px;height:18px;color:#0D7377;"></i>
<h3 style="margin:0;color:#0D7377;font-size:15px;">بيانات الفاتورة</h3>
</div>
<div style="padding:20px;">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:15px;">
<div>
<label class="form-label">رقم فاتورة المورد <span style="color:#DC2626;">*</span></label>
<input type="text" name="invoice_number" class="form-input" value="<?= e($invoice['invoice_number'] ?? '') ?>" required placeholder="رقم الفاتورة من المورد">
</div>
<div>
<label class="form-label">المورد <span style="color:#DC2626;">*</span></label>
<select name="supplier_id" class="form-select" required>
<option value="">-- اختر المورد --</option>
<?php foreach ($suppliers as $sup): ?>
<option value="<?= (int) $sup['id'] ?>" <?= ($invoice['supplier_id'] ?? '') == $sup['id'] ? 'selected' : '' ?>><?= e($sup['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label">أمر الشراء</label>
<select name="purchase_order_id" class="form-select">
<option value="">— بدون —</option>
<?php foreach ($approvedPOs as $po): ?>
<option value="<?= (int) $po['id'] ?>" <?= ($invoice['purchase_order_id'] ?? '') == $po['id'] ? 'selected' : '' ?>><?= e($po['po_number']) ?><?= e($po['supplier_name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label">إذن الاستلام</label>
<select name="grn_id" class="form-select">
<option value="">— بدون —</option>
<?php foreach ($completedGRNs as $g): ?>
<option value="<?= (int) $g['id'] ?>" <?= ($invoice['grn_id'] ?? '') == $g['id'] ? 'selected' : '' ?>><?= e($g['grn_number']) ?><?= e($g['supplier_name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label">تاريخ الفاتورة</label>
<input type="date" name="invoice_date" class="form-input" value="<?= e($invoice['invoice_date'] ?? date('Y-m-d')) ?>">
</div>
<div>
<label class="form-label">تاريخ الاستحقاق</label>
<input type="date" name="due_date" class="form-input" value="<?= e($invoice['due_date'] ?? '') ?>">
</div>
</div>
<div style="margin-top:15px;">
<label class="form-label">ملاحظات</label>
<textarea name="notes" class="form-input" rows="2"><?= e($invoice['notes'] ?? '') ?></textarea>
</div>
</div>
</div>
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;justify-content:space-between;">
<div style="display:flex;align-items:center;gap:8px;">
<i data-lucide="package" style="width:18px;height:18px;color:#D97706;"></i>
<h3 style="margin:0;color:#D97706;font-size:15px;">بنود الفاتورة</h3>
</div>
<button type="button" class="btn btn-sm btn-primary" onclick="addInvRow()">
<i data-lucide="plus" style="width:14px;height:14px;vertical-align:middle;margin-left:4px;"></i> إضافة بند
</button>
</div>
<div class="table-responsive">
<table class="data-table" id="invTable">
<thead>
<tr>
<th>الوصف</th>
<th style="width:100px;">الكمية</th>
<th style="width:120px;">سعر الوحدة</th>
<th style="width:80px;">ضريبة %</th>
<th style="width:100px;">الإجمالي</th>
<th style="width:50px;"></th>
</tr>
</thead>
<tbody id="invBody"></tbody>
<tfoot>
<tr>
<td colspan="4" style="text-align:left;font-weight:700;">الإجمالي</td>
<td style="font-weight:800;direction:ltr;text-align:left;" id="invGrandTotal">0.00</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
</div>
<div style="text-align:left;">
<button type="submit" class="btn btn-primary" style="padding:10px 30px;">
<i data-lucide="save" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> حفظ الفاتورة
</button>
</div>
</form>
<script>
var invRowIdx = 0;
function addInvRow(data) {
data = data || {};
var idx = invRowIdx++;
var tr = document.createElement('tr');
tr.innerHTML = ''
+ '<td><input type="text" name="items['+idx+'][description]" class="form-input" value="'+(data.description||'')+'" placeholder="وصف البند"></td>'
+ '<td><input type="number" name="items['+idx+'][quantity]" class="form-input" step="0.001" min="0.001" value="'+(data.quantity||'')+'" onchange="calcInvRow('+idx+')" required></td>'
+ '<td><input type="number" name="items['+idx+'][unit_cost]" class="form-input" step="0.01" min="0" value="'+(data.unit_cost||'')+'" onchange="calcInvRow('+idx+')" required></td>'
+ '<td><input type="number" name="items['+idx+'][tax_rate]" class="form-input" step="0.01" min="0" value="'+(data.tax_rate||'14')+'" onchange="calcInvRow('+idx+')"></td>'
+ '<td style="font-weight:700;direction:ltr;text-align:left;" id="invLine_'+idx+'">0.00</td>'
+ '<td><button type="button" onclick="this.closest(\'tr\').remove();calcInvGrand();" style="background:none;border:none;cursor:pointer;color:#DC2626;"><i data-lucide="trash-2" style="width:16px;height:16px;"></i></button></td>';
document.getElementById('invBody').appendChild(tr);
if (typeof lucide !== 'undefined') lucide.createIcons();
calcInvRow(idx);
}
function calcInvRow(idx) {
var q = parseFloat(document.querySelector('[name="items['+idx+'][quantity]"]').value) || 0;
var c = parseFloat(document.querySelector('[name="items['+idx+'][unit_cost]"]').value) || 0;
var t = parseFloat(document.querySelector('[name="items['+idx+'][tax_rate]"]').value) || 0;
var base = q * c;
var tax = base * (t / 100);
var el = document.getElementById('invLine_' + idx);
if (el) el.textContent = (base + tax).toFixed(2);
calcInvGrand();
}
function calcInvGrand() {
var total = 0;
document.querySelectorAll('[id^="invLine_"]').forEach(function(el) { total += parseFloat(el.textContent) || 0; });
document.getElementById('invGrandTotal').textContent = total.toFixed(2);
}
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') lucide.createIcons();
<?php if ($isEdit && !empty($invoiceItems)): ?>
<?= json_encode($invoiceItems) ?>.forEach(function(item) { addInvRow(item); });
<?php else: ?>
addInvRow();
<?php endif; ?>
});
</script>
<?php $__template->endSection(); ?>
This diff is collapsed.
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>مطابقة ثلاثية — <?= e($invoice['internal_number']) ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/procurement/invoices/<?= (int) $invoice['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'); ?>
<?php
$matchStatusLabels = ['unmatched' => 'غير مطابق', 'matched' => 'مطابق', 'discrepancy' => 'يوجد اختلاف', 'tolerance_pass' => 'مطابق (ضمن التسامح)'];
$matchStatusColors = ['unmatched' => '#6B7280', 'matched' => '#059669', 'discrepancy' => '#DC2626', 'tolerance_pass' => '#D97706'];
$matchStatusBgs = ['unmatched' => '#F3F4F6', 'matched' => '#ECFDF5', 'discrepancy' => '#FEE2E2', 'tolerance_pass' => '#FFF7ED'];
$ms = $matchResult['status'] ?? 'unmatched';
?>
<!-- Match Summary -->
<div class="card" style="margin-bottom:20px;padding:20px;text-align:center;background:<?= $matchStatusBgs[$ms] ?? '#F3F4F6' ?>;">
<div style="font-size:16px;font-weight:700;color:<?= $matchStatusColors[$ms] ?? '#6B7280' ?>;margin-bottom:6px;">
<?= e($matchStatusLabels[$ms] ?? $ms) ?>
</div>
<div style="font-size:14px;color:#4B5563;"><?= e($matchResult['notes'] ?? '') ?></div>
</div>
<!-- Match Details -->
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;">
<i data-lucide="git-compare" style="width:18px;height:18px;color:#0D7377;"></i>
<h3 style="margin:0;color:#0D7377;font-size:15px;">تفاصيل المطابقة (أمر شراء ↔ إذن استلام ↔ فاتورة)</h3>
</div>
<?php if (!empty($matchResult['details'])): ?>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>كمية أمر الشراء</th>
<th>كمية المقبول (GRN)</th>
<th>كمية الفاتورة</th>
<th>كمية</th>
<th>سعر أمر الشراء</th>
<th>سعر الفاتورة</th>
<th>سعر</th>
<th>ملاحظات</th>
</tr>
</thead>
<tbody>
<?php foreach ($matchResult['details'] as $i => $d): ?>
<tr>
<td><?= $i + 1 ?></td>
<td style="direction:ltr;text-align:left;"><?= number_format((float) ($d['po_qty'] ?? 0), 3) ?></td>
<td style="direction:ltr;text-align:left;"><?= number_format((float) ($d['grn_accepted_qty'] ?? 0), 3) ?></td>
<td style="direction:ltr;text-align:left;"><?= number_format((float) ($d['invoice_qty'] ?? 0), 3) ?></td>
<td>
<?php if ($d['qty_match'] ?? false): ?>
<span style="color:#059669;font-weight:600;"><i data-lucide="check" style="width:14px;height:14px;vertical-align:middle;"></i></span>
<?php else: ?>
<span style="color:#DC2626;font-weight:600;"><i data-lucide="x" style="width:14px;height:14px;vertical-align:middle;"></i></span>
<?php endif; ?>
</td>
<td style="direction:ltr;text-align:left;"><?= money($d['po_cost'] ?? 0) ?></td>
<td style="direction:ltr;text-align:left;"><?= money($d['invoice_cost'] ?? 0) ?></td>
<td>
<?php if ($d['cost_match'] ?? false): ?>
<span style="color:#059669;font-weight:600;"><i data-lucide="check" style="width:14px;height:14px;vertical-align:middle;"></i></span>
<?php else: ?>
<span style="color:#DC2626;font-weight:600;"><i data-lucide="x" style="width:14px;height:14px;vertical-align:middle;"></i></span>
<?php endif; ?>
</td>
<td style="font-size:12px;color:#6B7280;"><?= e(implode(' | ', $d['notes'] ?? [])) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<div style="padding:40px 20px;text-align:center;color:#6B7280;"><p style="margin:0;">لا توجد تفاصيل مطابقة</p></div>
<?php endif; ?>
</div>
<script>document.addEventListener('DOMContentLoaded', function() { if (typeof lucide !== 'undefined') lucide.createIcons(); });</script>
<?php $__template->endSection(); ?>
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `purchase_requisitions` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`pr_number` VARCHAR(50) NOT NULL,
`requested_by` BIGINT UNSIGNED NOT NULL COMMENT 'FK employees.id',
`department` VARCHAR(100) NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'draft' COMMENT 'draft,submitted,approved,rejected,converted,cancelled',
`urgency` ENUM('low','normal','high','critical') NOT NULL DEFAULT 'normal',
`required_date` DATE NULL,
`justification` TEXT NULL,
`estimated_total` DECIMAL(18,2) NOT NULL DEFAULT 0.00,
`approved_by` BIGINT UNSIGNED NULL,
`approved_at` DATETIME NULL,
`rejected_by` BIGINT UNSIGNED NULL,
`rejected_at` DATETIME NULL,
`rejection_reason` TEXT NULL,
`converted_po_id` BIGINT UNSIGNED NULL COMMENT 'FK purchase_orders.id',
`notes` TEXT NULL,
`branch_id` BIGINT UNSIGNED NULL,
`is_archived` TINYINT(1) NOT NULL DEFAULT 0,
`archived_at` DATETIME NULL,
`archived_by` BIGINT UNSIGNED 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,
`updated_by` BIGINT UNSIGNED NULL,
UNIQUE INDEX `uq_pr_number` (`pr_number`),
INDEX `idx_pr_status` (`status`),
INDEX `idx_pr_requested_by` (`requested_by`),
INDEX `idx_pr_branch` (`branch_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `purchase_requisitions`",
];
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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