chore: initial commit of existing codebase
This commit is contained in:
commit
11e94889b2
292 files changed
+38586
No files matched your search
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Purchase;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\GoodsReceiptNote;
|
||||
use App\Models\GrnItem;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\StockLevel;
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\Warehouse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class GoodsReceiptNoteController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$grns = GoodsReceiptNote::with(['purchaseOrder.supplier', 'warehouse'])->paginate(15);
|
||||
|
||||
return view('purchase.grns.index', compact('grns'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$purchaseOrders = PurchaseOrder::whereIn('status', ['sent', 'partial'])->with('supplier')->get();
|
||||
$warehouses = Warehouse::all();
|
||||
|
||||
return view('purchase.grns.create', compact('purchaseOrders', 'warehouses'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'purchase_order_id' => 'required|exists:purchase_orders,id',
|
||||
'warehouse_id' => 'required|exists:warehouses,id',
|
||||
'received_date' => 'required|date',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.item_id' => 'required|exists:items,id',
|
||||
'items.*.quantity' => 'required|numeric|min:1',
|
||||
]);
|
||||
|
||||
$grnNumber = 'GRN-' . str_pad(GoodsReceiptNote::max('id') + 1, 5, '0', STR_PAD_LEFT);
|
||||
|
||||
$po = PurchaseOrder::findOrFail($request->purchase_order_id);
|
||||
|
||||
$grn = GoodsReceiptNote::create([
|
||||
'grn_number' => $grnNumber,
|
||||
'purchase_order_id' => $request->purchase_order_id,
|
||||
'supplier_id' => $po->supplier_id,
|
||||
'warehouse_id' => $request->warehouse_id,
|
||||
'received_date' => $request->received_date,
|
||||
'status' => 'draft',
|
||||
'received_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
foreach ($request->items as $item) {
|
||||
GrnItem::create([
|
||||
'goods_receipt_note_id' => $grn->id,
|
||||
'purchase_order_item_id' => $item['po_item_id'] ?? $po->items()->where('item_id', $item['item_id'])->first()?->id ?? 0,
|
||||
'item_id' => $item['item_id'],
|
||||
'quantity_received' => $item['quantity'],
|
||||
'unit_cost' => $item['unit_cost'] ?? 0,
|
||||
'type' => in_array($item['type'] ?? '', ['inventory', 'consumable']) ? $item['type'] : 'inventory',
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('purchase.grns.show', $grn)->with('success', 'GRN created successfully.');
|
||||
}
|
||||
|
||||
public function show(GoodsReceiptNote $grn)
|
||||
{
|
||||
$grn->load(['purchaseOrder.supplier', 'warehouse', 'items.item']);
|
||||
|
||||
return view('purchase.grns.show', compact('grn'));
|
||||
}
|
||||
|
||||
public function edit(GoodsReceiptNote $grn)
|
||||
{
|
||||
$warehouses = Warehouse::all();
|
||||
|
||||
return view('purchase.grns.edit', compact('grn', 'warehouses'));
|
||||
}
|
||||
|
||||
public function update(Request $request, GoodsReceiptNote $grn)
|
||||
{
|
||||
$request->validate([
|
||||
'received_date' => 'required|date',
|
||||
'warehouse_id' => 'required|exists:warehouses,id',
|
||||
]);
|
||||
|
||||
$grn->update($request->only('received_date', 'warehouse_id'));
|
||||
|
||||
return redirect()->route('purchase.grns.show', $grn)->with('success', 'GRN updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(GoodsReceiptNote $grn)
|
||||
{
|
||||
$grn->delete();
|
||||
|
||||
return redirect()->route('purchase.grns.index')->with('success', 'GRN deleted successfully.');
|
||||
}
|
||||
|
||||
public function confirm(GoodsReceiptNote $grn)
|
||||
{
|
||||
if ($grn->status === 'confirmed') {
|
||||
return redirect()->back()->with('error', 'GRN is already confirmed.');
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($grn) {
|
||||
$grn->load('items');
|
||||
|
||||
foreach ($grn->items as $grnItem) {
|
||||
$stockLevel = StockLevel::firstOrCreate(
|
||||
['item_id' => $grnItem->item_id, 'warehouse_id' => $grn->warehouse_id],
|
||||
['quantity' => 0]
|
||||
);
|
||||
$stockLevel->increment('quantity', $grnItem->quantity_received);
|
||||
|
||||
StockMovement::create([
|
||||
'item_id' => $grnItem->item_id,
|
||||
'warehouse_id' => $grn->warehouse_id,
|
||||
'type' => 'in',
|
||||
'quantity' => $grnItem->quantity_received,
|
||||
'reference_type' => 'GoodsReceiptNote',
|
||||
'reference_id' => $grn->id,
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
$grn->purchaseOrder->items()
|
||||
->where('item_id', $grnItem->item_id)
|
||||
->increment('quantity_received', $grnItem->quantity_received);
|
||||
}
|
||||
|
||||
$grn->update(['status' => 'confirmed']);
|
||||
|
||||
// Check if all PO items have been fully received
|
||||
$po = $grn->purchaseOrder->fresh(['items']);
|
||||
$allFullyReceived = $po->items->every(
|
||||
fn($poItem) => $poItem->quantity_received >= $poItem->quantity
|
||||
);
|
||||
|
||||
if ($allFullyReceived) {
|
||||
$po->update(['status' => 'received']);
|
||||
}
|
||||
});
|
||||
|
||||
return redirect()->back()->with('success', 'GRN confirmed and stock updated.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Purchase;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Item;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\PurchaseOrderItem;
|
||||
use App\Models\PurchaseRequest;
|
||||
use App\Models\Supplier;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PurchaseOrderController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$orders = PurchaseOrder::with('supplier')->paginate(15);
|
||||
|
||||
return view('purchase.orders.index', compact('orders'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$suppliers = Supplier::all();
|
||||
$items = Item::all();
|
||||
$purchaseRequests = PurchaseRequest::where('status', 'approved')->get();
|
||||
|
||||
return view('purchase.orders.create', compact('suppliers', 'items', 'purchaseRequests'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'supplier_id' => 'required|exists:suppliers,id',
|
||||
'po_date' => 'required|date',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.item_id' => 'required|exists:items,id',
|
||||
'items.*.quantity' => 'required|numeric|min:1',
|
||||
'items.*.rate' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
$poNumber = 'PO-' . str_pad(PurchaseOrder::max('id') + 1, 5, '0', STR_PAD_LEFT);
|
||||
|
||||
$totalAmount = collect($request->items)->sum(fn($item) => $item['quantity'] * $item['rate']);
|
||||
|
||||
$order = PurchaseOrder::create([
|
||||
'po_number' => $poNumber,
|
||||
'supplier_id' => $request->supplier_id,
|
||||
'po_date' => $request->po_date,
|
||||
'total_amount' => $totalAmount,
|
||||
'status' => 'draft',
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
foreach ($request->items as $item) {
|
||||
PurchaseOrderItem::create([
|
||||
'purchase_order_id' => $order->id,
|
||||
'item_id' => $item['item_id'],
|
||||
'quantity' => $item['quantity'],
|
||||
'rate' => $item['rate'],
|
||||
'amount' => $item['quantity'] * $item['rate'],
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('purchase.orders.show', $order)->with('success', 'Purchase order created successfully.');
|
||||
}
|
||||
|
||||
public function show(PurchaseOrder $purchaseOrder)
|
||||
{
|
||||
$purchaseOrder->load(['supplier', 'items.item']);
|
||||
|
||||
return view('purchase.orders.show', compact('purchaseOrder'));
|
||||
}
|
||||
|
||||
public function edit(PurchaseOrder $purchaseOrder)
|
||||
{
|
||||
$suppliers = Supplier::all();
|
||||
$items = Item::all();
|
||||
|
||||
return view('purchase.orders.edit', compact('purchaseOrder', 'suppliers', 'items'));
|
||||
}
|
||||
|
||||
public function update(Request $request, PurchaseOrder $purchaseOrder)
|
||||
{
|
||||
$request->validate([
|
||||
'supplier_id' => 'required|exists:suppliers,id',
|
||||
'po_date' => 'required|date',
|
||||
]);
|
||||
|
||||
$purchaseOrder->update($request->only('supplier_id', 'po_date', 'status'));
|
||||
|
||||
return redirect()->route('purchase.orders.show', $purchaseOrder)->with('success', 'Purchase order updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(PurchaseOrder $purchaseOrder)
|
||||
{
|
||||
$purchaseOrder->delete();
|
||||
|
||||
return redirect()->route('purchase.orders.index')->with('success', 'Purchase order deleted successfully.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Purchase;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PurchaseRequest;
|
||||
use App\Models\Supplier;
|
||||
use App\Services\PurchaseStageService;
|
||||
|
||||
class PurchasePipelineController extends Controller
|
||||
{
|
||||
private function withRelations()
|
||||
{
|
||||
return PurchaseRequest::with([
|
||||
'requestedBy',
|
||||
'signature.signedBy',
|
||||
'rfqInvitations.supplier',
|
||||
'supplierQuotes',
|
||||
'awardedQuote.supplier',
|
||||
]);
|
||||
}
|
||||
|
||||
public function index(PurchaseStageService $stages)
|
||||
{
|
||||
$active = $this->withRelations()->where('stage', '!=', 'complete')->latest()->get();
|
||||
$completed = $this->withRelations()->where('stage', 'complete')->latest()->get();
|
||||
|
||||
return view('purchase.pipeline.index', compact('active', 'completed', 'stages'));
|
||||
}
|
||||
|
||||
public function show(PurchaseRequest $purchaseRequest, PurchaseStageService $stages)
|
||||
{
|
||||
$purchaseRequest->load([
|
||||
'requestedBy',
|
||||
'items',
|
||||
'signature.signedBy',
|
||||
'rfqInvitations.supplier',
|
||||
'supplierQuotes',
|
||||
'awardedQuote.supplier',
|
||||
]);
|
||||
|
||||
$suppliers = Supplier::where('is_active', true)->orderBy('name')->get();
|
||||
|
||||
return view('purchase.pipeline.show', [
|
||||
'pr' => $purchaseRequest,
|
||||
'stages' => $stages,
|
||||
'suppliers' => $suppliers,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Purchase;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PurchaseRequest;
|
||||
use App\Models\PurchaseRequestItem;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class PurchaseRequestController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$requests = PurchaseRequest::with(['requestedBy', 'items'])->latest()->paginate(15);
|
||||
|
||||
return view('purchase.requests.index', compact('requests'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
return view('purchase.requests.create');
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'date' => 'required|date',
|
||||
'project_name' => 'required|string|max:255',
|
||||
'department' => 'nullable|string|max:255',
|
||||
'requested_by_name' => 'required|string|max:255',
|
||||
'required_date_text' => 'nullable|string|max:100',
|
||||
'location' => 'nullable|string|max:255',
|
||||
'remarks' => 'nullable|string',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.description' => 'required|string',
|
||||
'items.*.unit' => 'nullable|string|max:50',
|
||||
'items.*.quantity_required' => 'required|numeric|min:0.01',
|
||||
'items.*.purpose_use' => 'nullable|string|max:255',
|
||||
'items.*.required_date' => 'nullable|date',
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($request) {
|
||||
$year = now()->format('y');
|
||||
$next = PurchaseRequest::max('id') + 1;
|
||||
$mprNumber = 'MPR' . $year . '-' . str_pad($next, 4, '0', STR_PAD_LEFT);
|
||||
|
||||
$pr = PurchaseRequest::create([
|
||||
'request_number' => $mprNumber,
|
||||
'date' => $request->date,
|
||||
'project_name' => $request->project_name,
|
||||
'department' => $request->department,
|
||||
'requested_by_name' => $request->requested_by_name,
|
||||
'required_date_text' => $request->required_date_text,
|
||||
'location' => $request->location,
|
||||
'remarks' => $request->remarks,
|
||||
'status' => 'pending',
|
||||
'requested_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
foreach ($request->items as $item) {
|
||||
if (empty(trim($item['description']))) {
|
||||
continue;
|
||||
}
|
||||
PurchaseRequestItem::create([
|
||||
'purchase_request_id' => $pr->id,
|
||||
'description' => $item['description'],
|
||||
'unit' => $item['unit'] ?? null,
|
||||
'quantity_required' => $item['quantity_required'],
|
||||
'purpose_use' => $item['purpose_use'] ?? null,
|
||||
'required_date' => $item['required_date'] ?? null,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
return redirect()->route('purchase.requests.index')->with('success', 'Purchase request submitted successfully.');
|
||||
}
|
||||
|
||||
public function show(PurchaseRequest $purchaseRequest)
|
||||
{
|
||||
$purchaseRequest->load(['items', 'requestedBy', 'approvedBy']);
|
||||
|
||||
return view('purchase.requests.show', compact('purchaseRequest'));
|
||||
}
|
||||
|
||||
public function edit(PurchaseRequest $purchaseRequest)
|
||||
{
|
||||
$purchaseRequest->load('items');
|
||||
|
||||
return view('purchase.requests.edit', compact('purchaseRequest'));
|
||||
}
|
||||
|
||||
public function update(Request $request, PurchaseRequest $purchaseRequest)
|
||||
{
|
||||
$request->validate([
|
||||
'date' => 'required|date',
|
||||
'project_name' => 'required|string|max:255',
|
||||
'department' => 'nullable|string|max:255',
|
||||
'requested_by_name' => 'required|string|max:255',
|
||||
'required_date_text' => 'nullable|string|max:100',
|
||||
'location' => 'nullable|string|max:255',
|
||||
'remarks' => 'nullable|string',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.description' => 'required|string',
|
||||
'items.*.unit' => 'nullable|string|max:50',
|
||||
'items.*.quantity_required' => 'required|numeric|min:0.01',
|
||||
'items.*.purpose_use' => 'nullable|string|max:255',
|
||||
'items.*.required_date' => 'nullable|date',
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($request, $purchaseRequest) {
|
||||
$purchaseRequest->update([
|
||||
'date' => $request->date,
|
||||
'project_name' => $request->project_name,
|
||||
'department' => $request->department,
|
||||
'requested_by_name' => $request->requested_by_name,
|
||||
'required_date_text' => $request->required_date_text,
|
||||
'location' => $request->location,
|
||||
'remarks' => $request->remarks,
|
||||
]);
|
||||
|
||||
$purchaseRequest->items()->delete();
|
||||
|
||||
foreach ($request->items as $item) {
|
||||
if (empty(trim($item['description']))) {
|
||||
continue;
|
||||
}
|
||||
PurchaseRequestItem::create([
|
||||
'purchase_request_id' => $purchaseRequest->id,
|
||||
'description' => $item['description'],
|
||||
'unit' => $item['unit'] ?? null,
|
||||
'quantity_required' => $item['quantity_required'],
|
||||
'purpose_use' => $item['purpose_use'] ?? null,
|
||||
'required_date' => $item['required_date'] ?? null,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
return redirect()->route('purchase.requests.show', $purchaseRequest)->with('success', 'Purchase request updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(PurchaseRequest $purchaseRequest)
|
||||
{
|
||||
$purchaseRequest->delete();
|
||||
|
||||
return redirect()->route('purchase.requests.index')->with('success', 'Purchase request deleted.');
|
||||
}
|
||||
|
||||
public function approve(PurchaseRequest $purchaseRequest)
|
||||
{
|
||||
$purchaseRequest->update([
|
||||
'status' => 'approved',
|
||||
'approved_by' => auth()->id(),
|
||||
'approved_at' => now(),
|
||||
]);
|
||||
|
||||
return redirect()->back()->with('success', 'Purchase request approved.');
|
||||
}
|
||||
|
||||
public function reject(PurchaseRequest $purchaseRequest)
|
||||
{
|
||||
$purchaseRequest->update(['status' => 'rejected']);
|
||||
|
||||
return redirect()->back()->with('success', 'Purchase request rejected.');
|
||||
}
|
||||
|
||||
public function print(PurchaseRequest $purchaseRequest)
|
||||
{
|
||||
$purchaseRequest->load(['items', 'requestedBy', 'approvedBy']);
|
||||
|
||||
return view('purchase.requests.print', compact('purchaseRequest'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Purchase;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PurchaseRequest;
|
||||
use App\Models\PurchaseSignature;
|
||||
use App\Services\PurchaseStageService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PurchaseSignatureController extends Controller
|
||||
{
|
||||
public function show(PurchaseRequest $purchaseRequest)
|
||||
{
|
||||
$purchaseRequest->load('signature.signedBy');
|
||||
return view('purchase.signature.show', ['request' => $purchaseRequest]);
|
||||
}
|
||||
|
||||
public function store(Request $request, PurchaseRequest $purchaseRequest, PurchaseStageService $stages)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'signature_image' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
if ($purchaseRequest->signature) {
|
||||
return back()->with('error', 'This request has already been signed.');
|
||||
}
|
||||
|
||||
PurchaseSignature::create([
|
||||
'purchase_request_id' => $purchaseRequest->id,
|
||||
'signed_by' => auth()->id(),
|
||||
'signature_image' => $validated['signature_image'],
|
||||
'signed_at' => now(),
|
||||
'ip_address' => $request->ip(),
|
||||
]);
|
||||
|
||||
$stages->advance($purchaseRequest);
|
||||
|
||||
return redirect()->route('purchase.pipeline.index')
|
||||
->with('success', 'Signature saved. Request moved to RFQ stage.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Purchase;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PurchaseRequest;
|
||||
use App\Models\Supplier;
|
||||
use App\Services\PurchaseStageService;
|
||||
use App\Services\RfqInvitationService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class RfqController extends Controller
|
||||
{
|
||||
public function show(PurchaseRequest $purchaseRequest)
|
||||
{
|
||||
$suppliers = Supplier::where('is_active', true)->orderBy('name')->get();
|
||||
$invitations = $purchaseRequest->rfqInvitations()->with('supplier', 'quote')->get();
|
||||
return view('purchase.rfq.show', ['request' => $purchaseRequest, 'suppliers' => $suppliers, 'invitations' => $invitations]);
|
||||
}
|
||||
|
||||
public function selectSuppliers(Request $request, PurchaseRequest $purchaseRequest, RfqInvitationService $service, PurchaseStageService $stages)
|
||||
{
|
||||
$mode = $request->input('mode', 'global');
|
||||
|
||||
$alreadySelected = $purchaseRequest->rfqInvitations()->pluck('supplier_id')->toArray();
|
||||
$added = 0;
|
||||
|
||||
if ($mode === 'by_item') {
|
||||
// item_suppliers[item_id][] = supplier_id → invert to supplier_id → [item_ids]
|
||||
$itemSuppliers = $request->input('item_suppliers', []);
|
||||
$supplierItems = [];
|
||||
foreach ($itemSuppliers as $itemId => $supplierIds) {
|
||||
foreach ((array) $supplierIds as $supplierId) {
|
||||
$supplierItems[$supplierId][] = (int) $itemId;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($supplierItems)) {
|
||||
return redirect()->back()->with('error', 'Please assign at least one supplier to an item.');
|
||||
}
|
||||
|
||||
foreach ($supplierItems as $supplierId => $itemIds) {
|
||||
if (in_array($supplierId, $alreadySelected)) {
|
||||
continue;
|
||||
}
|
||||
$channel = $request->input('channel_' . $supplierId, 'email');
|
||||
$supplier = Supplier::findOrFail($supplierId);
|
||||
$service->select($purchaseRequest, $supplier, $channel, $itemIds);
|
||||
$added++;
|
||||
}
|
||||
} else {
|
||||
$validated = $request->validate([
|
||||
'supplier_ids' => ['required', 'array', 'min:1'],
|
||||
'supplier_ids.*' => ['required', 'exists:suppliers,id'],
|
||||
]);
|
||||
|
||||
foreach ($validated['supplier_ids'] as $supplierId) {
|
||||
if (in_array($supplierId, $alreadySelected)) {
|
||||
continue;
|
||||
}
|
||||
$channel = $request->input('channel_' . $supplierId, 'email');
|
||||
$supplier = Supplier::findOrFail($supplierId);
|
||||
$service->select($purchaseRequest, $supplier, $channel);
|
||||
$added++;
|
||||
}
|
||||
}
|
||||
|
||||
$stages->setStage($purchaseRequest, 'rfq');
|
||||
|
||||
return redirect()->route('purchase.pipeline.show', $purchaseRequest)
|
||||
->with('success', $added . ' supplier(s) added. Now send them the quote request links.');
|
||||
}
|
||||
|
||||
public function sendAll(PurchaseRequest $purchaseRequest, RfqInvitationService $service, PurchaseStageService $stages)
|
||||
{
|
||||
$pending = $purchaseRequest->rfqInvitations()->where('status', 'pending')->with('supplier')->get();
|
||||
|
||||
if ($pending->isEmpty()) {
|
||||
return redirect()->back()->with('error', 'No unsent invitations. Select suppliers first.');
|
||||
}
|
||||
|
||||
foreach ($pending as $invitation) {
|
||||
$service->sendInvitation($invitation);
|
||||
}
|
||||
|
||||
$stages->setStage($purchaseRequest, 'quoting');
|
||||
|
||||
return redirect()->route('purchase.pipeline.show', $purchaseRequest)
|
||||
->with('success', $pending->count() . ' supplier(s) notified. Waiting for quotes.');
|
||||
}
|
||||
|
||||
public function store(Request $request, PurchaseRequest $purchaseRequest, RfqInvitationService $service, PurchaseStageService $stages)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'supplier_ids' => ['required', 'array', 'min:1'],
|
||||
'supplier_ids.*' => ['required', 'exists:suppliers,id'],
|
||||
]);
|
||||
|
||||
$alreadyInvited = $purchaseRequest->rfqInvitations()->pluck('supplier_id')->toArray();
|
||||
|
||||
$sent = 0;
|
||||
foreach ($validated['supplier_ids'] as $supplierId) {
|
||||
if (in_array($supplierId, $alreadyInvited)) {
|
||||
continue;
|
||||
}
|
||||
$channel = $request->input('channel_' . $supplierId, 'both');
|
||||
$supplier = Supplier::findOrFail($supplierId);
|
||||
$service->invite($purchaseRequest, $supplier, $channel);
|
||||
$sent++;
|
||||
}
|
||||
|
||||
$stages->setStage($purchaseRequest, 'quoting');
|
||||
|
||||
return redirect()->route('purchase.requests.rfq', $purchaseRequest)
|
||||
->with('success', $sent . ' invitation(s) sent. Waiting for supplier quotes.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Purchase;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\RfqInvitation;
|
||||
use App\Models\SupplierQuote;
|
||||
use App\Models\SupplierQuoteItem;
|
||||
use App\Services\PurchaseStageService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class RfqPortalController extends Controller
|
||||
{
|
||||
private function resolve(string $token): RfqInvitation
|
||||
{
|
||||
return RfqInvitation::where('token', $token)
|
||||
->with(['purchaseRequest.items', 'supplier'])
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
public function show(string $token)
|
||||
{
|
||||
$invitation = $this->resolve($token);
|
||||
|
||||
if ($invitation->isSubmitted()) {
|
||||
return view('rfq.submitted', compact('invitation'));
|
||||
}
|
||||
|
||||
if ($invitation->isExpired()) {
|
||||
return view('rfq.expired', compact('invitation'));
|
||||
}
|
||||
|
||||
if ($invitation->status === 'sent') {
|
||||
$invitation->update(['status' => 'opened', 'opened_at' => now()]);
|
||||
}
|
||||
|
||||
$purchaseRequest = $invitation->purchaseRequest;
|
||||
$items = $purchaseRequest->items;
|
||||
|
||||
return view('rfq.show', compact('invitation', 'purchaseRequest', 'items'));
|
||||
}
|
||||
|
||||
public function submit(Request $request, string $token)
|
||||
{
|
||||
$invitation = $this->resolve($token);
|
||||
|
||||
if ($invitation->isSubmitted() || $invitation->isExpired()) {
|
||||
abort(403, 'This link is no longer valid.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'lead_time_days' => ['nullable', 'integer', 'min:0'],
|
||||
'payment_terms' => ['nullable', 'string', 'max:200'],
|
||||
'notes' => ['nullable', 'string', 'max:1000'],
|
||||
'items' => ['required', 'array'],
|
||||
'items.*.unit_price' => ['required', 'numeric', 'min:0'],
|
||||
]);
|
||||
|
||||
$purchaseItems = $invitation->purchaseRequest->items;
|
||||
|
||||
$quote = SupplierQuote::create([
|
||||
'rfq_invitation_id' => $invitation->id,
|
||||
'purchase_request_id' => $invitation->purchase_request_id,
|
||||
'supplier_id' => $invitation->supplier_id,
|
||||
'submitted_at' => now(),
|
||||
'lead_time_days' => $validated['lead_time_days'],
|
||||
'payment_terms' => $validated['payment_terms'],
|
||||
'notes' => $validated['notes'],
|
||||
'total_amount' => 0,
|
||||
]);
|
||||
|
||||
$total = 0;
|
||||
foreach ($purchaseItems as $i => $item) {
|
||||
$unitPrice = (float)($validated['items'][$i]['unit_price'] ?? 0);
|
||||
$qty = (float)$item->quantity;
|
||||
$totalPrice = round($unitPrice * $qty, 3);
|
||||
$total += $totalPrice;
|
||||
|
||||
SupplierQuoteItem::create([
|
||||
'supplier_quote_id' => $quote->id,
|
||||
'description' => $item->description,
|
||||
'unit' => $item->unit ?? '',
|
||||
'quantity' => $qty,
|
||||
'unit_price' => $unitPrice,
|
||||
'total_price' => $totalPrice,
|
||||
]);
|
||||
}
|
||||
|
||||
$quote->update(['total_amount' => round($total, 3)]);
|
||||
$invitation->update(['status' => 'submitted']);
|
||||
|
||||
// If at least 1 quote is in, move to comparison stage
|
||||
$pr = $invitation->purchaseRequest;
|
||||
if ($pr->stage === 'quoting') {
|
||||
app(PurchaseStageService::class)->setStage($pr, 'comparison');
|
||||
}
|
||||
|
||||
return view('rfq.submitted', compact('invitation'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Purchase;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Supplier;
|
||||
use App\Services\SupplierImportService;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
class SupplierController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$suppliers = Supplier::orderBy('name')->get();
|
||||
|
||||
$stats = [
|
||||
'total' => $suppliers->count(),
|
||||
'active' => $suppliers->where('is_active', true)->count(),
|
||||
'inactive' => $suppliers->where('is_active', false)->count(),
|
||||
'with_email' => $suppliers->filter(fn($s) => !empty($s->email))->count(),
|
||||
'categories' => $suppliers->whereNotNull('category')->groupBy('category')->count(),
|
||||
];
|
||||
|
||||
$categories = Supplier::whereNotNull('category')
|
||||
->where('category', '!=', '')
|
||||
->distinct()
|
||||
->orderBy('category')
|
||||
->pluck('category');
|
||||
|
||||
return view('purchase.suppliers.index', compact('suppliers', 'stats', 'categories'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
return view('purchase.suppliers.create');
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'credit_days' => 'nullable|integer|min:0',
|
||||
]);
|
||||
|
||||
Supplier::create(array_merge(
|
||||
$request->only([
|
||||
'supplier_code', 'name', 'category', 'contact_person',
|
||||
'email', 'secondary_email', 'phone', 'phone2', 'whatsapp',
|
||||
'address', 'website', 'tax_number', 'credit_terms', 'credit_days', 'remarks',
|
||||
]),
|
||||
['is_active' => (bool) $request->input('is_active', 1)]
|
||||
));
|
||||
|
||||
return redirect()->route('purchase.suppliers.index')->with('success', 'Supplier created successfully.');
|
||||
}
|
||||
|
||||
public function show(Supplier $supplier)
|
||||
{
|
||||
return view('purchase.suppliers.show', compact('supplier'));
|
||||
}
|
||||
|
||||
public function edit(Supplier $supplier)
|
||||
{
|
||||
return view('purchase.suppliers.edit', compact('supplier'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Supplier $supplier)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'credit_days' => 'nullable|integer|min:0',
|
||||
'is_active' => 'nullable',
|
||||
]);
|
||||
|
||||
$supplier->update(array_merge(
|
||||
$request->only([
|
||||
'supplier_code', 'name', 'category', 'contact_person',
|
||||
'email', 'secondary_email', 'phone', 'phone2', 'whatsapp',
|
||||
'address', 'website', 'tax_number', 'credit_terms', 'credit_days', 'remarks',
|
||||
]),
|
||||
['is_active' => (bool) $request->input('is_active', 0)]
|
||||
));
|
||||
|
||||
return redirect()->route('purchase.suppliers.index')->with('success', 'Supplier updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(Supplier $supplier)
|
||||
{
|
||||
$supplier->delete();
|
||||
|
||||
return redirect()->route('purchase.suppliers.index')->with('success', 'Supplier deleted successfully.');
|
||||
}
|
||||
|
||||
public function import(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'file' => 'required|file|mimes:xlsx,xls|max:10240',
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = app(SupplierImportService::class)->import(
|
||||
$request->file('file')->getPathname()
|
||||
);
|
||||
|
||||
$msg = "Import complete: {$result['imported']} added, {$result['updated']} updated"
|
||||
. ($result['skipped'] > 0 ? ", {$result['skipped']} skipped." : '.');
|
||||
|
||||
return redirect()->route('purchase.suppliers.index')->with('success', $msg);
|
||||
} catch (\Exception $e) {
|
||||
return redirect()->route('purchase.suppliers.index')
|
||||
->with('error', 'Import failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function downloadTemplate()
|
||||
{
|
||||
$path = storage_path('app/suppliers_template.xlsx');
|
||||
|
||||
if (!file_exists($path)) {
|
||||
Artisan::call('suppliers:template');
|
||||
}
|
||||
|
||||
return response()->download($path, 'suppliers_import_template.xlsx');
|
||||
}
|
||||
|
||||
public function exportPdf()
|
||||
{
|
||||
$suppliers = Supplier::orderBy('name')->get();
|
||||
|
||||
$pdf = Pdf::loadView('purchase.suppliers.pdf', compact('suppliers'))
|
||||
->setPaper('a4', 'landscape');
|
||||
|
||||
return $pdf->download('suppliers_' . now()->format('Y-m-d') . '.pdf');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Purchase;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\GoodsReceiptNote;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\SupplierInvoice;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SupplierInvoiceController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$invoices = SupplierInvoice::with('supplier')->paginate(15);
|
||||
|
||||
return view('purchase.invoices.index', compact('invoices'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$suppliers = Supplier::all();
|
||||
$purchaseOrders = PurchaseOrder::where('status', 'received')->with('supplier')->get();
|
||||
$grns = GoodsReceiptNote::where('status', 'confirmed')->with('purchaseOrder.supplier')->get();
|
||||
|
||||
return view('purchase.invoices.create', compact('suppliers', 'purchaseOrders', 'grns'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'supplier_id' => 'required|exists:suppliers,id',
|
||||
'invoice_number' => 'required|string|max:255',
|
||||
'invoice_date' => 'required|date',
|
||||
'subtotal' => 'required|numeric|min:0',
|
||||
'vat_amount' => 'required|numeric|min:0',
|
||||
'total_amount' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
SupplierInvoice::create(array_merge($request->all(), [
|
||||
'status' => 'unpaid',
|
||||
'paid_amount' => 0,
|
||||
]));
|
||||
|
||||
return redirect()->route('purchase.invoices.index')->with('success', 'Supplier invoice created successfully.');
|
||||
}
|
||||
|
||||
public function show(SupplierInvoice $supplierInvoice)
|
||||
{
|
||||
$supplierInvoice->load(['supplier', 'purchaseOrder']);
|
||||
|
||||
return view('purchase.invoices.show', compact('supplierInvoice'));
|
||||
}
|
||||
|
||||
public function edit(SupplierInvoice $supplierInvoice)
|
||||
{
|
||||
$suppliers = Supplier::all();
|
||||
|
||||
return view('purchase.invoices.edit', compact('supplierInvoice', 'suppliers'));
|
||||
}
|
||||
|
||||
public function update(Request $request, SupplierInvoice $supplierInvoice)
|
||||
{
|
||||
$request->validate([
|
||||
'supplier_id' => 'required|exists:suppliers,id',
|
||||
'invoice_number' => 'required|string|max:255',
|
||||
'invoice_date' => 'required|date',
|
||||
'subtotal' => 'required|numeric|min:0',
|
||||
'vat_amount' => 'required|numeric|min:0',
|
||||
'total_amount' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
$supplierInvoice->update($request->all());
|
||||
|
||||
return redirect()->route('purchase.invoices.index')->with('success', 'Supplier invoice updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(SupplierInvoice $supplierInvoice)
|
||||
{
|
||||
$supplierInvoice->delete();
|
||||
|
||||
return redirect()->route('purchase.invoices.index')->with('success', 'Supplier invoice deleted successfully.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Purchase;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SupplierInvoice;
|
||||
use App\Models\SupplierPayment;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SupplierPaymentController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$payments = SupplierPayment::with('supplierInvoice.supplier')->paginate(15);
|
||||
|
||||
return view('purchase.payments.index', compact('payments'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$invoices = SupplierInvoice::whereIn('status', ['unpaid', 'partial'])->with('supplier')->get();
|
||||
|
||||
return view('purchase.payments.create', compact('invoices'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'supplier_invoice_id' => 'required|exists:supplier_invoices,id',
|
||||
'payment_date' => 'required|date',
|
||||
'amount' => 'required|numeric|min:0.01',
|
||||
'payment_method' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
$payment = SupplierPayment::create(array_merge($request->all(), [
|
||||
'created_by' => auth()->id(),
|
||||
]));
|
||||
|
||||
// Refresh invoice and recompute paid status
|
||||
$invoice = SupplierInvoice::find($request->supplier_invoice_id);
|
||||
$newPaidAmount = $invoice->paid_amount + $request->amount;
|
||||
$status = $newPaidAmount >= $invoice->total_amount ? 'paid' : 'partial';
|
||||
|
||||
$invoice->update([
|
||||
'paid_amount' => $newPaidAmount,
|
||||
'status' => $status,
|
||||
]);
|
||||
|
||||
return redirect()->route('purchase.payments.index')->with('success', 'Payment recorded successfully.');
|
||||
}
|
||||
|
||||
public function show(SupplierPayment $supplierPayment)
|
||||
{
|
||||
$supplierPayment->load('supplierInvoice.supplier');
|
||||
|
||||
return view('purchase.payments.show', compact('supplierPayment'));
|
||||
}
|
||||
|
||||
public function edit(SupplierPayment $supplierPayment)
|
||||
{
|
||||
$invoices = SupplierInvoice::whereIn('status', ['unpaid', 'partial'])->with('supplier')->get();
|
||||
|
||||
return view('purchase.payments.edit', compact('supplierPayment', 'invoices'));
|
||||
}
|
||||
|
||||
public function update(Request $request, SupplierPayment $supplierPayment)
|
||||
{
|
||||
$request->validate([
|
||||
'payment_date' => 'required|date',
|
||||
'amount' => 'required|numeric|min:0.01',
|
||||
'payment_method' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
$supplierPayment->update($request->all());
|
||||
|
||||
return redirect()->route('purchase.payments.index')->with('success', 'Payment updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(SupplierPayment $supplierPayment)
|
||||
{
|
||||
$supplierPayment->delete();
|
||||
|
||||
return redirect()->route('purchase.payments.index')->with('success', 'Payment deleted successfully.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Purchase;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PurchaseRequest;
|
||||
use App\Models\SupplierQuote;
|
||||
use App\Services\PurchaseStageService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SupplierQuoteController extends Controller
|
||||
{
|
||||
public function index(PurchaseRequest $purchaseRequest)
|
||||
{
|
||||
$quotes = $purchaseRequest->supplierQuotes()->with('supplier', 'items')->get();
|
||||
return view('purchase.quotes.index', ['request' => $purchaseRequest, 'quotes' => $quotes]);
|
||||
}
|
||||
|
||||
public function compare(PurchaseRequest $purchaseRequest)
|
||||
{
|
||||
$quotes = $purchaseRequest->supplierQuotes()->with('supplier', 'items')->get();
|
||||
$items = $purchaseRequest->items;
|
||||
return view('purchase.quotes.compare', ['request' => $purchaseRequest, 'quotes' => $quotes, 'items' => $items]);
|
||||
}
|
||||
|
||||
public function award(Request $request, PurchaseRequest $purchaseRequest, SupplierQuote $quote, PurchaseStageService $stages)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'award_reason' => ['required', 'string', 'min:5'],
|
||||
]);
|
||||
|
||||
if ($purchaseRequest->awardedQuote) {
|
||||
return back()->with('error', 'A quote has already been awarded for this request.');
|
||||
}
|
||||
|
||||
$quote->update([
|
||||
'is_awarded' => true,
|
||||
'award_reason' => $validated['award_reason'],
|
||||
'awarded_at' => now(),
|
||||
'awarded_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
$stages->setStage($purchaseRequest, 'lpo');
|
||||
|
||||
return redirect()->route('purchase.pipeline.index')
|
||||
->with('success', 'Quote awarded to ' . $quote->supplier->name . '. Ready to issue LPO.');
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user