chore: update document approval workflow and bug fixes
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\MaterialLogistics\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Modules\MaterialLogistics\Models\InventoryMovement;
|
||||
use Modules\MaterialLogistics\Models\Warehouse;
|
||||
use Modules\ProjectManagement\Models\Project;
|
||||
|
||||
class InventoryMovementController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = InventoryMovement::with([
|
||||
'material:id,ulid,name,sku,unit',
|
||||
'warehouse:id,ulid,name,code',
|
||||
'project:id,ulid,name,code',
|
||||
'task:id,ulid,name',
|
||||
'performer:id,ulid,name',
|
||||
]);
|
||||
|
||||
if ($materialId = $request->material_id) {
|
||||
$query->where('material_id', $materialId);
|
||||
}
|
||||
|
||||
if ($warehouseId = $request->warehouse_id) {
|
||||
$query->where('warehouse_id', $warehouseId);
|
||||
}
|
||||
|
||||
if ($projectId = $request->project_id) {
|
||||
$query->where('project_id', $projectId);
|
||||
}
|
||||
|
||||
if ($type = $request->movement_type) {
|
||||
$query->where('movement_type', $type);
|
||||
}
|
||||
|
||||
if ($from = $request->date_from) {
|
||||
$query->where('created_at', '>=', $from);
|
||||
}
|
||||
|
||||
if ($to = $request->date_to) {
|
||||
$query->where('created_at', '<=', $to . ' 23:59:59');
|
||||
}
|
||||
|
||||
$movements = $query->latest()->paginate(20)->withQueryString();
|
||||
|
||||
$warehouses = Warehouse::select('id', 'ulid', 'name', 'code')->active()->get();
|
||||
$projects = Project::select('id', 'ulid', 'name', 'code')->latest()->get();
|
||||
|
||||
return Inertia::render('MaterialLogistics::Inventory/Movements', [
|
||||
'movements' => $movements,
|
||||
'warehouses' => $warehouses,
|
||||
'projects' => $projects,
|
||||
'filters' => $request->only(['material_id', 'warehouse_id', 'project_id', 'movement_type', 'date_from', 'date_to']),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\MaterialLogistics\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
use Modules\MasterData\Models\Material;
|
||||
use Modules\MaterialLogistics\Models\ProjectInventory;
|
||||
use Modules\MaterialLogistics\Models\Warehouse;
|
||||
use Modules\MaterialLogistics\Models\WarehouseStock;
|
||||
use Modules\MaterialLogistics\Services\WarehouseService;
|
||||
use Modules\ProjectManagement\Models\Project;
|
||||
use Modules\ProjectManagement\Models\Task;
|
||||
use Modules\MaterialLogistics\Models\InventoryBatch;
|
||||
|
||||
class MaterialController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private WarehouseService $warehouseService,
|
||||
) {}
|
||||
|
||||
// --- Inventory Dashboard ---
|
||||
public function inventory(Request $request)
|
||||
{
|
||||
$warehouses = Warehouse::active()->select('id', 'ulid', 'name', 'code', 'type')
|
||||
->with(['stock' => function ($query) {
|
||||
$query->where('quantity', '>', 0);
|
||||
}])
|
||||
->get()
|
||||
->map(function ($w) {
|
||||
return [
|
||||
'id' => $w->id,
|
||||
'ulid' => $w->ulid,
|
||||
'name' => $w->name,
|
||||
'code' => $w->code,
|
||||
'type' => $w->type,
|
||||
'item_count' => $w->stock->count(),
|
||||
'total_value' => $w->stock->sum(fn ($s) => (float) $s->quantity * (float) $s->unit_cost),
|
||||
];
|
||||
});
|
||||
|
||||
$projects = Project::select('id', 'ulid', 'name', 'code', 'status')
|
||||
->with(['inventories' => function ($query) {
|
||||
$query->where('on_hand_qty', '>', 0);
|
||||
}])
|
||||
->latest()
|
||||
->get()
|
||||
->map(function ($p) {
|
||||
return [
|
||||
'id' => $p->id,
|
||||
'ulid' => $p->ulid,
|
||||
'name' => $p->name,
|
||||
'code' => $p->code,
|
||||
'status' => $p->status,
|
||||
'item_count' => $p->inventories->count(),
|
||||
'total_value' => $p->inventories->sum(fn ($s) => (float) $s->on_hand_qty * (float) $s->unit_cost),
|
||||
];
|
||||
});
|
||||
|
||||
// Warehouse stock
|
||||
$warehouseStockQuery = WarehouseStock::with([
|
||||
'warehouse:id,ulid,name,code',
|
||||
'material' => function ($query) {
|
||||
$query->select('id', 'ulid', 'name', 'sku', 'unit', 'category', 'type')
|
||||
->with('components.component:id,name,unit');
|
||||
},
|
||||
]);
|
||||
|
||||
if ($warehouseId = $request->warehouse_id) {
|
||||
$warehouseStockQuery->where('warehouse_id', $warehouseId);
|
||||
}
|
||||
|
||||
$warehouseStock = $warehouseStockQuery->where('quantity', '>', 0)
|
||||
->paginate(20, ['*'], 'wh_page')
|
||||
->withQueryString();
|
||||
|
||||
// Project site stock
|
||||
$siteStockQuery = ProjectInventory::with([
|
||||
'project:id,ulid,name,code',
|
||||
'material' => function ($query) {
|
||||
$query->select('id', 'ulid', 'name', 'sku', 'unit', 'category', 'type')
|
||||
->with('components.component:id,name,unit');
|
||||
},
|
||||
]);
|
||||
|
||||
if ($projectId = $request->project_id) {
|
||||
$siteStockQuery->where('project_id', $projectId);
|
||||
}
|
||||
|
||||
$siteStock = $siteStockQuery->paginate(20, ['*'], 'site_page')
|
||||
->withQueryString();
|
||||
|
||||
// Summary stats
|
||||
$summary = [
|
||||
'total_warehouse' => WarehouseStock::sum('quantity'),
|
||||
|
||||
'total_onsite' => ProjectInventory::sum('on_hand_qty'),
|
||||
'total_reserved' => ProjectInventory::sum('allocated_qty'),
|
||||
'total_consumed' => ProjectInventory::sum('consumed_qty'),
|
||||
];
|
||||
|
||||
return Inertia::render('MaterialLogistics::Inventory/Index', [
|
||||
'warehouseStock' => $warehouseStock,
|
||||
'siteStock' => $siteStock,
|
||||
'warehouses' => $warehouses,
|
||||
'projects' => $projects,
|
||||
'summary' => $summary,
|
||||
'filters' => $request->only(['warehouse_id', 'project_id']),
|
||||
]);
|
||||
}
|
||||
|
||||
// --- Inventory Operations Form Page ---
|
||||
public function operations(Request $request, string $type)
|
||||
{
|
||||
$warehouses = Warehouse::active()->select('id', 'ulid', 'name', 'code')->get();
|
||||
$projects = Project::select('id', 'ulid', 'name', 'code')->latest()->get();
|
||||
|
||||
// Materials with their warehouse/site stocks to enforce limits
|
||||
$materials = Material::select('id', 'ulid', 'name', 'sku', 'unit', 'unit_cost')
|
||||
->with(['warehouseStocks', 'projectInventories'])
|
||||
->get();
|
||||
|
||||
$projectManagers = \App\Models\User::role('project_manager')->select('id', 'name')->get();
|
||||
|
||||
return Inertia::render('MaterialLogistics::Inventory/OperationsForm', [
|
||||
'warehouses' => $warehouses,
|
||||
'projects' => $projects,
|
||||
'materials' => $materials,
|
||||
'projectManagers' => $projectManagers,
|
||||
'defaultType' => $type,
|
||||
]);
|
||||
}
|
||||
|
||||
// --- Dispatch from Warehouse to Project ---
|
||||
public function dispatchFromWarehouse(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'warehouse_ulid' => 'required|string',
|
||||
'project_ulid' => 'required|string',
|
||||
'handler_id' => 'required|integer|exists:users,id',
|
||||
'notes' => 'nullable|string|max:500',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.inventory_batch_ulid' => 'nullable|string',
|
||||
'items.*.material_ulid' => 'required|string',
|
||||
'items.*.quantity' => 'required|numeric|min:0.01',
|
||||
]);
|
||||
|
||||
$warehouse = Warehouse::where('ulid', $validated['warehouse_ulid'])->firstOrFail();
|
||||
$project = Project::where('ulid', $validated['project_ulid'])->firstOrFail();
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
foreach ($validated['items'] as $item) {
|
||||
$material = Material::where('ulid', $item['material_ulid'])->firstOrFail();
|
||||
$batchId = null;
|
||||
|
||||
if (!empty($item['inventory_batch_ulid'])) {
|
||||
$batchId = InventoryBatch::where('ulid', $item['inventory_batch_ulid'])->firstOrFail()->id;
|
||||
}
|
||||
|
||||
$this->warehouseService->dispatchToProject(
|
||||
warehouse: $warehouse,
|
||||
project: $project,
|
||||
material: $material,
|
||||
quantity: $item['quantity'],
|
||||
inventoryBatchId: $batchId,
|
||||
performedBy: auth()->id(),
|
||||
dispatchedBy: $validated['handler_id'],
|
||||
notes: $validated['notes'] ?? null,
|
||||
);
|
||||
}
|
||||
DB::commit();
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
return back()->with('success', "Materials dispatched to {$project->name}.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
// --- Reserve for Task ---
|
||||
public function reserveForTask(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'project_ulid' => 'required|string',
|
||||
'task_ulid' => 'required|string',
|
||||
'material_ulid' => 'required|string',
|
||||
'quantity' => 'required|numeric|min:0.01',
|
||||
]);
|
||||
|
||||
$project = Project::where('ulid', $validated['project_ulid'])->firstOrFail();
|
||||
$task = Task::where('ulid', $validated['task_ulid'])->firstOrFail();
|
||||
$material = Material::where('ulid', $validated['material_ulid'])->firstOrFail();
|
||||
|
||||
try {
|
||||
$this->warehouseService->reserveForTask(
|
||||
$project, $task, $material,
|
||||
$validated['quantity'],
|
||||
auth()->id(),
|
||||
);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
return back()->with('success', 'Materials reserved for task.');
|
||||
}
|
||||
|
||||
// --- Consume at Task ---
|
||||
public function consumeAtTask(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'project_ulid' => 'required|string',
|
||||
'task_ulid' => 'required|string',
|
||||
'material_ulid' => 'required|string',
|
||||
'quantity' => 'required|numeric|min:0.01',
|
||||
'notes' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
$project = Project::where('ulid', $validated['project_ulid'])->firstOrFail();
|
||||
$task = Task::where('ulid', $validated['task_ulid'])->firstOrFail();
|
||||
$material = Material::where('ulid', $validated['material_ulid'])->firstOrFail();
|
||||
|
||||
try {
|
||||
$this->warehouseService->consumeAtTask(
|
||||
$project, $task, $material,
|
||||
$validated['quantity'],
|
||||
auth()->id(),
|
||||
$validated['notes'] ?? null,
|
||||
);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
return back()->with('success', 'Materials consumed.');
|
||||
}
|
||||
|
||||
// --- Return to Warehouse ---
|
||||
public function returnToWarehouse(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'project_ulid' => 'required|string',
|
||||
'warehouse_ulid' => 'required|string',
|
||||
'material_ulid' => 'required|string',
|
||||
'quantity' => 'required|numeric|min:0.01',
|
||||
'notes' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
$project = Project::where('ulid', $validated['project_ulid'])->firstOrFail();
|
||||
$warehouse = Warehouse::where('ulid', $validated['warehouse_ulid'])->firstOrFail();
|
||||
$material = Material::where('ulid', $validated['material_ulid'])->firstOrFail();
|
||||
|
||||
try {
|
||||
$this->warehouseService->returnToWarehouse(
|
||||
$project, $warehouse, $material,
|
||||
$validated['quantity'],
|
||||
auth()->id(),
|
||||
$validated['notes'] ?? null,
|
||||
);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
return back()->with('success', 'Materials returned to warehouse.');
|
||||
}
|
||||
|
||||
// --- Adjust Stock ---
|
||||
public function adjustStock(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'location_type' => 'required|in:warehouse,project',
|
||||
'location_ulid' => 'required|string',
|
||||
'material_ulid' => 'required|string',
|
||||
'quantity' => 'required|numeric|min:0.01',
|
||||
'direction' => 'required|in:in,out',
|
||||
'reason' => 'required|string|max:500',
|
||||
]);
|
||||
|
||||
$material = Material::where('ulid', $validated['material_ulid'])->firstOrFail();
|
||||
|
||||
if ($validated['location_type'] === 'warehouse') {
|
||||
$location = Warehouse::where('ulid', $validated['location_ulid'])->firstOrFail();
|
||||
$locationId = $location->id;
|
||||
} else {
|
||||
$location = Project::where('ulid', $validated['location_ulid'])->firstOrFail();
|
||||
$locationId = $location->id;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->warehouseService->adjustStock(
|
||||
$validated['location_type'], $locationId, $material,
|
||||
$validated['quantity'],
|
||||
$validated['direction'],
|
||||
$validated['reason'],
|
||||
auth()->id(),
|
||||
);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
return back()->with('success', 'Stock adjusted.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\MaterialLogistics\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class MaterialLogisticsController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
return view('materiallogistics::index');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new resource.
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
return view('materiallogistics::create');
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*/
|
||||
public function store(Request $request) {}
|
||||
|
||||
/**
|
||||
* Show the specified resource.
|
||||
*/
|
||||
public function show($id)
|
||||
{
|
||||
return view('materiallogistics::show');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified resource.
|
||||
*/
|
||||
public function edit($id)
|
||||
{
|
||||
return view('materiallogistics::edit');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*/
|
||||
public function update(Request $request, $id) {}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*/
|
||||
public function destroy($id) {}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\MaterialLogistics\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
use Modules\ApprovalWorkflow\Services\ApprovalService;
|
||||
use Modules\MasterData\Models\Material;
|
||||
use Modules\MaterialLogistics\Models\MaterialRequisition;
|
||||
use Modules\MasterData\Models\MaterialGroup;
|
||||
use Modules\MaterialLogistics\Services\DocumentNumberService;
|
||||
use Modules\ProjectManagement\Models\Project;
|
||||
|
||||
class MaterialRequisitionController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$requisitions = MaterialRequisition::with([
|
||||
'requester:id,ulid,name',
|
||||
'approver:id,ulid,name',
|
||||
'items:id,material_requisition_id,quantity,unit_cost' // Need items for frontend line total
|
||||
])
|
||||
->latest()
|
||||
->paginate(20);
|
||||
|
||||
return Inertia::render('MaterialLogistics::Requisitions/Index', [
|
||||
'requisitions' => $requisitions,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$materials = Material::select('id', 'ulid', 'name', 'unit', 'unit_cost', 'sku', 'category', 'status')
|
||||
->where('status', 'active')
|
||||
->orderBy('category')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$materialGroups = Material::with('components.component:id,ulid,name,sku,category,unit,unit_cost')
|
||||
->whereIn('type', ['kit', 'assembly'])
|
||||
->where('status', 'active')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(function ($kit) {
|
||||
return [
|
||||
'id' => $kit->id,
|
||||
'ulid' => $kit->ulid,
|
||||
'name' => $kit->name,
|
||||
'description' => $kit->description ?? '',
|
||||
'status' => $kit->status,
|
||||
'materials' => $kit->components->map(function ($comp) {
|
||||
return array_merge($comp->component->toArray(), [
|
||||
'pivot' => ['quantity' => $comp->quantity]
|
||||
]);
|
||||
})->toArray()
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('MaterialLogistics::Requisitions/Form', [
|
||||
'materials' => $materials,
|
||||
'materialGroups' => $materialGroups,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.material_ulid' => 'required|string',
|
||||
'items.*.quantity' => 'required|numeric|min:0.01',
|
||||
'items.*.unit_cost' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
$docService = new DocumentNumberService();
|
||||
|
||||
$mr = DB::transaction(function () use ($validated, $docService) {
|
||||
$mr = MaterialRequisition::create([
|
||||
'document_number' => $docService->nextMrNumber(),
|
||||
'status' => 'draft',
|
||||
'requested_by' => auth()->id(),
|
||||
'notes' => $validated['notes'] ?? null,
|
||||
]);
|
||||
|
||||
foreach ($validated['items'] as $item) {
|
||||
$material = Material::where('ulid', $item['material_ulid'])->firstOrFail();
|
||||
$mr->items()->create([
|
||||
'material_id' => $material->id,
|
||||
'quantity' => $item['quantity'],
|
||||
'unit_cost' => $item['unit_cost'],
|
||||
]);
|
||||
}
|
||||
|
||||
return $mr;
|
||||
});
|
||||
|
||||
return redirect()->route('requisitions.show', $mr)->with('success', 'Material Requisition created.');
|
||||
}
|
||||
|
||||
public function edit(MaterialRequisition $requisition)
|
||||
{
|
||||
if ($requisition->status !== 'draft') {
|
||||
return redirect()->back()->with('error', 'Only draft requisitions can be edited.');
|
||||
}
|
||||
|
||||
$requisition->load('items.material:id,ulid,name,unit,unit_cost');
|
||||
|
||||
$materials = Material::select('id', 'ulid', 'name', 'sku', 'category', 'unit', 'unit_cost', 'status')
|
||||
->where('status', 'active')
|
||||
->orderBy('category')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$materialGroups = Material::with('components.component:id,ulid,name,sku,category,unit,unit_cost')
|
||||
->whereIn('type', ['kit', 'assembly'])
|
||||
->where('status', 'active')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(function ($kit) {
|
||||
return [
|
||||
'id' => $kit->id,
|
||||
'ulid' => $kit->ulid,
|
||||
'name' => $kit->name,
|
||||
'description' => $kit->description ?? '',
|
||||
'status' => $kit->status,
|
||||
'materials' => $kit->components->map(function ($comp) {
|
||||
return array_merge($comp->component->toArray(), [
|
||||
'pivot' => ['quantity' => $comp->quantity]
|
||||
]);
|
||||
})->toArray()
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('MaterialLogistics::Requisitions/Form', [
|
||||
'requisition' => $requisition,
|
||||
'materials' => $materials,
|
||||
'materialGroups' => $materialGroups,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, MaterialRequisition $requisition)
|
||||
{
|
||||
if ($requisition->status !== 'draft') {
|
||||
return redirect()->back()->with('error', 'Only draft requisitions can be updated.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.material_ulid' => 'required|string',
|
||||
'items.*.quantity' => 'required|numeric|min:0.01',
|
||||
'items.*.unit_cost' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($validated, $requisition) {
|
||||
$requisition->update([
|
||||
'notes' => $validated['notes'] ?? null,
|
||||
]);
|
||||
|
||||
$requisition->items()->delete(); // remove old items
|
||||
|
||||
foreach ($validated['items'] as $item) {
|
||||
$material = Material::where('ulid', $item['material_ulid'])->firstOrFail();
|
||||
$requisition->items()->create([
|
||||
'material_id' => $material->id,
|
||||
'quantity' => $item['quantity'],
|
||||
'unit_cost' => $item['unit_cost'],
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
return redirect()->route('requisitions.show', $requisition)->with('success', 'Material Requisition updated.');
|
||||
}
|
||||
|
||||
public function show(MaterialRequisition $requisition)
|
||||
{
|
||||
$requisition->load([
|
||||
'requester:id,ulid,name',
|
||||
'approver:id,ulid,name',
|
||||
'items.material' => function ($query) {
|
||||
$query->select('id', 'ulid', 'name', 'unit', 'unit_cost', 'type')
|
||||
->with('components.component:id,name,unit');
|
||||
},
|
||||
'approvalChains.steps.approver:id,ulid,name',
|
||||
]);
|
||||
|
||||
return Inertia::render('MaterialLogistics::Requisitions/Show', [
|
||||
'requisition' => $requisition,
|
||||
]);
|
||||
}
|
||||
|
||||
public function submitForApproval(MaterialRequisition $requisition)
|
||||
{
|
||||
if ($requisition->status !== 'draft') {
|
||||
return back()->with('error', 'Only draft requisitions can be submitted.');
|
||||
}
|
||||
|
||||
// Find personnel with approve_mr permission across the whole system
|
||||
$approverIds = \App\Models\User::permission('approve_mr')
|
||||
->pluck('id')
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
if (empty($approverIds)) {
|
||||
return back()->with('error', 'No approvers found in the system for Material Requests.');
|
||||
}
|
||||
|
||||
$approvalService = app(ApprovalService::class);
|
||||
$approvalService->createChain(
|
||||
$requisition,
|
||||
$approverIds,
|
||||
'material_requisition',
|
||||
auth()->id(),
|
||||
);
|
||||
|
||||
$requisition->update(['status' => 'submitted']);
|
||||
|
||||
return back()->with('success', 'Material Requisition submitted for approval.');
|
||||
}
|
||||
|
||||
public function downloadPdf(MaterialRequisition $requisition)
|
||||
{
|
||||
$requisition->load([
|
||||
'requester:id,name',
|
||||
'approver:id,name',
|
||||
'items.material:id,name,unit',
|
||||
'approvalChains.steps.approver:id,name',
|
||||
]);
|
||||
|
||||
// Global approver roles
|
||||
$approverDetails = [];
|
||||
if ($requisition->approvalChains->isNotEmpty()) {
|
||||
$chain = $requisition->approvalChains->first();
|
||||
foreach ($chain->steps as $step) {
|
||||
if ($step->status === 'approved' && $step->approver) {
|
||||
$approverDetails[] = [
|
||||
'name' => $step->approver->name,
|
||||
'role' => 'Approver',
|
||||
'date' => $step->acted_at?->format('M d, Y'),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($approverDetails) && $requisition->status === 'approved' && $requisition->approver) {
|
||||
$approverDetails[] = [
|
||||
'name' => $requisition->approver->name,
|
||||
'role' => 'Approver',
|
||||
'date' => $requisition->approved_at ? $requisition->approved_at->format('M d, Y') : null,
|
||||
];
|
||||
}
|
||||
|
||||
$pdf = Pdf::loadView('materiallogistics::pdf.material-requisition', [
|
||||
'requisition' => $requisition,
|
||||
'approverDetails' => $approverDetails,
|
||||
]);
|
||||
|
||||
return $pdf->download("MR-{$requisition->document_number}.pdf");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\MaterialLogistics\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
use Modules\MasterData\Models\Material;
|
||||
use Modules\MaterialLogistics\Enums\MaterialTransferStatus;
|
||||
use Modules\MaterialLogistics\Enums\MovementType;
|
||||
use Modules\MaterialLogistics\Models\InventoryMovement;
|
||||
use Modules\MaterialLogistics\Models\MaterialTransfer;
|
||||
use Modules\MaterialLogistics\Models\MaterialTransferItem;
|
||||
use Modules\ProjectManagement\Models\Project;
|
||||
|
||||
class MaterialTransferController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$transfers = MaterialTransfer::with(['fromProject', 'toProject', 'requester'])
|
||||
->latest()
|
||||
->paginate(15);
|
||||
|
||||
return Inertia::render('MaterialLogistics::Transfers/Index', [
|
||||
'transfers' => $transfers
|
||||
]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$projects = Project::select('id', 'name')->get();
|
||||
|
||||
return Inertia::render('MaterialLogistics::Transfers/Create', [
|
||||
'projects' => $projects,
|
||||
]);
|
||||
}
|
||||
|
||||
public function projectInventory($projectId)
|
||||
{
|
||||
$inventory = \Modules\MaterialLogistics\Models\ProjectInventory::with('material:id,name,sku,unit')
|
||||
->where('project_id', $projectId)
|
||||
->where('on_hand_qty', '>', 0)
|
||||
->get()
|
||||
->map(function ($inv) {
|
||||
return [
|
||||
'id' => $inv->id,
|
||||
'material_id' => $inv->material_id,
|
||||
'material_name' => $inv->material->name,
|
||||
'material_sku' => $inv->material->sku,
|
||||
'material_unit' => $inv->material->unit,
|
||||
'available_qty' => $inv->available_qty,
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json($inventory);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'from_project_id' => 'required|exists:projects,id|different:to_project_id',
|
||||
'to_project_id' => 'required|exists:projects,id',
|
||||
'notes' => 'nullable|string',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.material_id' => 'required|exists:materials,id',
|
||||
'items.*.requested_quantity' => 'required|numeric|min:0.01',
|
||||
'items.*.notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($validated, $request) {
|
||||
$transfer = MaterialTransfer::create([
|
||||
'transfer_number' => MaterialTransfer::generateTransferNumber(),
|
||||
'from_project_id' => $validated['from_project_id'],
|
||||
'to_project_id' => $validated['to_project_id'],
|
||||
'status' => MaterialTransferStatus::Draft,
|
||||
'requested_by' => $request->user()->id,
|
||||
'notes' => $validated['notes'] ?? null,
|
||||
]);
|
||||
|
||||
foreach ($validated['items'] as $item) {
|
||||
$transfer->items()->create([
|
||||
'material_id' => $item['material_id'],
|
||||
'requested_quantity' => $item['requested_quantity'],
|
||||
'notes' => $item['notes'] ?? null,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
return redirect()->route('material-transfers.index')->with('success', 'Transfer draft created successfully.');
|
||||
}
|
||||
|
||||
public function show(MaterialTransfer $materialTransfer)
|
||||
{
|
||||
$materialTransfer->load(['fromProject', 'toProject', 'requester', 'approver', 'dispatcher', 'receiver', 'items.material']);
|
||||
|
||||
return Inertia::render('MaterialLogistics::Transfers/Show', [
|
||||
'transfer' => $materialTransfer
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(MaterialTransfer $materialTransfer)
|
||||
{
|
||||
abort_if($materialTransfer->status !== MaterialTransferStatus::Draft, 403, 'Only draft transfers can be edited.');
|
||||
|
||||
$materialTransfer->load('items.material');
|
||||
$projects = Project::select('id', 'name')->get();
|
||||
$materials = Material::select('id', 'name', 'sku', 'unit')->get();
|
||||
|
||||
return Inertia::render('MaterialLogistics::Transfers/Edit', [
|
||||
'transfer' => $materialTransfer,
|
||||
'projects' => $projects,
|
||||
'materials' => $materials,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, MaterialTransfer $materialTransfer)
|
||||
{
|
||||
abort_if($materialTransfer->status !== MaterialTransferStatus::Draft, 403, 'Only draft transfers can be edited.');
|
||||
|
||||
$validated = $request->validate([
|
||||
'from_project_id' => 'required|exists:projects,id|different:to_project_id',
|
||||
'to_project_id' => 'required|exists:projects,id',
|
||||
'notes' => 'nullable|string',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.id' => 'nullable|exists:material_transfer_items,id',
|
||||
'items.*.material_id' => 'required|exists:materials,id',
|
||||
'items.*.requested_quantity' => 'required|numeric|min:0.01',
|
||||
'items.*.notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($validated, $materialTransfer) {
|
||||
$materialTransfer->update([
|
||||
'from_project_id' => $validated['from_project_id'],
|
||||
'to_project_id' => $validated['to_project_id'],
|
||||
'notes' => $validated['notes'] ?? null,
|
||||
]);
|
||||
|
||||
// Keep track of which items are updated to delete removed ones
|
||||
$keptItemIds = [];
|
||||
|
||||
foreach ($validated['items'] as $itemData) {
|
||||
if (isset($itemData['id'])) {
|
||||
$item = $materialTransfer->items()->find($itemData['id']);
|
||||
$item->update([
|
||||
'material_id' => $itemData['material_id'],
|
||||
'requested_quantity' => $itemData['requested_quantity'],
|
||||
'notes' => $itemData['notes'] ?? null,
|
||||
]);
|
||||
$keptItemIds[] = $item->id;
|
||||
} else {
|
||||
$newItem = $materialTransfer->items()->create([
|
||||
'material_id' => $itemData['material_id'],
|
||||
'requested_quantity' => $itemData['requested_quantity'],
|
||||
'notes' => $itemData['notes'] ?? null,
|
||||
]);
|
||||
$keptItemIds[] = $newItem->id;
|
||||
}
|
||||
}
|
||||
|
||||
$materialTransfer->items()->whereNotIn('id', $keptItemIds)->delete();
|
||||
});
|
||||
|
||||
return redirect()->route('material-transfers.show', $materialTransfer)->with('success', 'Transfer draft updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(MaterialTransfer $materialTransfer)
|
||||
{
|
||||
abort_if($materialTransfer->status !== MaterialTransferStatus::Draft, 403, 'Only draft transfers can be deleted.');
|
||||
|
||||
$materialTransfer->delete();
|
||||
|
||||
return redirect()->route('material-transfers.index')->with('success', 'Transfer deleted successfully.');
|
||||
}
|
||||
|
||||
// --- Action Methods ---
|
||||
|
||||
public function submit(Request $request, MaterialTransfer $materialTransfer, \Modules\ApprovalWorkflow\Services\ApprovalService $approvalService)
|
||||
{
|
||||
abort_if($materialTransfer->status !== MaterialTransferStatus::Draft, 403, 'Only draft transfers can be submitted.');
|
||||
|
||||
$approverIds = \App\Models\User::role('project_manager')->pluck('id')->toArray();
|
||||
if (empty($approverIds)) {
|
||||
$approverIds = [1]; // Fallback to admin/first user
|
||||
}
|
||||
|
||||
$approvalService->createChain(
|
||||
$materialTransfer,
|
||||
$approverIds,
|
||||
'material_transfer',
|
||||
$request->user()->id,
|
||||
);
|
||||
|
||||
$materialTransfer->update([
|
||||
'status' => MaterialTransferStatus::PendingApproval,
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Transfer submitted for approval.');
|
||||
}
|
||||
|
||||
public function dispatch(Request $request, MaterialTransfer $materialTransfer)
|
||||
{
|
||||
abort_if($materialTransfer->status !== MaterialTransferStatus::Approved, 403, 'Only approved transfers can be dispatched.');
|
||||
|
||||
$validated = $request->validate([
|
||||
'items' => 'required|array',
|
||||
'items.*.id' => 'required|exists:material_transfer_items,id',
|
||||
'items.*.dispatched_quantity' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($validated, $materialTransfer, $request) {
|
||||
foreach ($validated['items'] as $itemData) {
|
||||
$item = $materialTransfer->items()->find($itemData['id']);
|
||||
$item->update([
|
||||
'dispatched_quantity' => $itemData['dispatched_quantity'],
|
||||
]);
|
||||
|
||||
if ($itemData['dispatched_quantity'] > 0) {
|
||||
// Generate Dispatch Inventory Movement (OUT from from_project_id)
|
||||
// Wait, we need the current balance to calculate balance_after. For simplicity, setting 0 here, or we can look it up.
|
||||
// Assuming inventory system handles balance calculation via observers or similar.
|
||||
InventoryMovement::create([
|
||||
'material_id' => $item->material_id,
|
||||
'project_id' => $materialTransfer->from_project_id,
|
||||
'movement_type' => MovementType::Dispatch,
|
||||
'direction' => 'out',
|
||||
'quantity' => $itemData['dispatched_quantity'],
|
||||
'from_location_type' => 'project',
|
||||
'from_location_id' => $materialTransfer->from_project_id,
|
||||
'to_location_type' => 'project',
|
||||
'to_location_id' => $materialTransfer->to_project_id,
|
||||
'reference_type' => MaterialTransfer::class,
|
||||
'reference_id' => $materialTransfer->id,
|
||||
'dispatched_by' => $request->user()->id,
|
||||
'performed_by' => $request->user()->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$materialTransfer->update([
|
||||
'status' => MaterialTransferStatus::InTransit,
|
||||
'dispatched_by' => $request->user()->id,
|
||||
]);
|
||||
});
|
||||
|
||||
return back()->with('success', 'Transfer dispatched successfully.');
|
||||
}
|
||||
|
||||
public function receive(Request $request, MaterialTransfer $materialTransfer)
|
||||
{
|
||||
abort_if($materialTransfer->status !== MaterialTransferStatus::InTransit, 403, 'Only in-transit transfers can be received.');
|
||||
|
||||
$validated = $request->validate([
|
||||
'items' => 'required|array',
|
||||
'items.*.id' => 'required|exists:material_transfer_items,id',
|
||||
'items.*.received_quantity' => 'required|numeric|min:0',
|
||||
'items.*.damaged_quantity' => 'required|numeric|min:0',
|
||||
'items.*.lost_quantity' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($validated, $materialTransfer, $request) {
|
||||
foreach ($validated['items'] as $itemData) {
|
||||
$item = $materialTransfer->items()->find($itemData['id']);
|
||||
|
||||
// Validate that dispatched == received + damaged + lost
|
||||
$totalAccounted = $itemData['received_quantity'] + $itemData['damaged_quantity'] + $itemData['lost_quantity'];
|
||||
// Not enforcing strict sum here to allow flexibility, but ideally $totalAccounted <= $item->dispatched_quantity
|
||||
|
||||
$item->update([
|
||||
'received_quantity' => $itemData['received_quantity'],
|
||||
'damaged_quantity' => $itemData['damaged_quantity'],
|
||||
'lost_quantity' => $itemData['lost_quantity'],
|
||||
]);
|
||||
|
||||
if ($itemData['received_quantity'] > 0) {
|
||||
// Generate Receipt Inventory Movement (IN to to_project_id)
|
||||
InventoryMovement::create([
|
||||
'material_id' => $item->material_id,
|
||||
'project_id' => $materialTransfer->to_project_id,
|
||||
'movement_type' => MovementType::SiteReceipt,
|
||||
'direction' => 'in',
|
||||
'quantity' => $itemData['received_quantity'],
|
||||
'from_location_type' => 'project',
|
||||
'from_location_id' => $materialTransfer->from_project_id,
|
||||
'to_location_type' => 'project',
|
||||
'to_location_id' => $materialTransfer->to_project_id,
|
||||
'reference_type' => MaterialTransfer::class,
|
||||
'reference_id' => $materialTransfer->id,
|
||||
'received_by' => $request->user()->id,
|
||||
'performed_by' => $request->user()->id,
|
||||
]);
|
||||
}
|
||||
|
||||
$lostOrDamaged = $itemData['damaged_quantity'] + $itemData['lost_quantity'];
|
||||
if ($lostOrDamaged > 0) {
|
||||
// Technically it left Project A, but never arrived in Project B.
|
||||
// Project A's inventory is already deducted.
|
||||
// We generate an explicit adjustment to log the loss for Project B or transit account.
|
||||
// For now, we will associate it with Project B as an 'out' adjustment to balance any 'in' that was expected, or simply log it.
|
||||
// Actually, since it never entered Project B, no inventory is strictly deducted from B.
|
||||
// Just logging an Adjustment record for tracking.
|
||||
InventoryMovement::create([
|
||||
'material_id' => $item->material_id,
|
||||
'project_id' => $materialTransfer->to_project_id,
|
||||
'movement_type' => MovementType::Adjustment,
|
||||
'direction' => 'out', // represents loss
|
||||
'quantity' => $lostOrDamaged,
|
||||
'from_location_type' => 'project',
|
||||
'from_location_id' => $materialTransfer->to_project_id,
|
||||
'to_location_type' => 'external', // Lost/Damaged
|
||||
'to_location_id' => 0,
|
||||
'reference_type' => MaterialTransfer::class,
|
||||
'reference_id' => $materialTransfer->id,
|
||||
'notes' => 'Lost or Damaged during transfer from Project A',
|
||||
'adjusted_by' => $request->user()->id,
|
||||
'performed_by' => $request->user()->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$materialTransfer->update([
|
||||
'status' => MaterialTransferStatus::Received,
|
||||
'received_by' => $request->user()->id,
|
||||
]);
|
||||
});
|
||||
|
||||
return back()->with('success', 'Transfer received successfully.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\MaterialLogistics\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
use Modules\ApprovalWorkflow\Services\ApprovalService;
|
||||
use Modules\MaterialLogistics\Events\PurchaseOrderPaid;
|
||||
use Modules\MasterData\Models\Material;
|
||||
use Modules\MaterialLogistics\Models\MaterialRequisition;
|
||||
use Modules\MaterialLogistics\Models\PurchaseOrder;
|
||||
use Modules\MaterialLogistics\Models\Warehouse;
|
||||
use Modules\MaterialLogistics\Services\DocumentNumberService;
|
||||
use Modules\ProjectManagement\Models\Project;
|
||||
|
||||
class PurchaseOrderController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$purchaseOrders = PurchaseOrder::with([
|
||||
'requester:id,ulid,name',
|
||||
'approver:id,ulid,name',
|
||||
'requisitions:id,ulid,document_number',
|
||||
'items:id,purchase_order_id,quantity,unit_cost'
|
||||
])
|
||||
->latest()
|
||||
->paginate(20);
|
||||
return Inertia::render('MaterialLogistics::PurchaseOrders/Index', [
|
||||
'purchaseOrders' => $purchaseOrders,
|
||||
'warehouses' => Warehouse::active()->select('id', 'ulid', 'name', 'code')->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
|
||||
$materials = Material::select('id', 'ulid', 'name', 'unit', 'unit_cost')
|
||||
->where('status', 'active')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
$approvedRequisitions = MaterialRequisition::with(['items.material:id,ulid,name,unit,sku', 'requester:id,name'])
|
||||
->where('status', 'approved')
|
||||
->doesntHave('purchaseOrders')
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
|
||||
// Find personnel with approve_po permission across the whole system
|
||||
$approverIds = \App\Models\User::permission('approve_po')
|
||||
->pluck('id')
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
if (empty($approverIds)) {
|
||||
return back()->with('error', 'No approvers found in the system for Purchase Orders.');
|
||||
}
|
||||
|
||||
return Inertia::render('MaterialLogistics::PurchaseOrders/Form', [
|
||||
'materials' => $materials,
|
||||
'approvedRequisitions' => $approvedRequisitions,
|
||||
'warehouses' => Inertia::lazy(fn () => Warehouse::active()
|
||||
->whereDoesntHave('project', function ($q) {
|
||||
$q->whereIn('status', ['closed', 'completed']);
|
||||
})
|
||||
->with('project:id,ulid,name')
|
||||
->select('id', 'ulid', 'name', 'code', 'project_id')
|
||||
->get()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'target_warehouse_ulid' => 'required|string',
|
||||
'supplier' => 'nullable|string|max:255',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
'requisition_ulids' => 'nullable|array',
|
||||
'requisition_ulids.*' => 'string',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.material_ulid' => 'required|string',
|
||||
'items.*.quantity' => 'required|numeric|min:0.01',
|
||||
'items.*.unit_cost' => 'required|numeric|min:0',
|
||||
'items.*.requisition_item_id' => 'nullable|integer',
|
||||
]);
|
||||
|
||||
$targetWarehouse = Warehouse::where('ulid', $validated['target_warehouse_ulid'])->firstOrFail();
|
||||
$docService = new DocumentNumberService();
|
||||
|
||||
$po = DB::transaction(function () use ($validated, $targetWarehouse, $docService) {
|
||||
$po = PurchaseOrder::create([
|
||||
'target_warehouse_id' => $targetWarehouse->id,
|
||||
'document_number' => $docService->nextPoNumber(),
|
||||
'supplier' => $validated['supplier'] ?? null,
|
||||
'status' => 'draft',
|
||||
'requested_by' => auth()->id(),
|
||||
'notes' => $validated['notes'] ?? null,
|
||||
]);
|
||||
|
||||
foreach ($validated['items'] as $item) {
|
||||
$material = Material::where('ulid', $item['material_ulid'])->firstOrFail();
|
||||
$po->items()->create([
|
||||
'material_id' => $material->id,
|
||||
'material_requisition_item_id' => $item['requisition_item_id'] ?? null,
|
||||
'quantity' => $item['quantity'],
|
||||
'unit_cost' => $item['unit_cost'],
|
||||
]);
|
||||
}
|
||||
|
||||
// Link to MRs if provided
|
||||
if (!empty($validated['requisition_ulids'])) {
|
||||
$mrIds = MaterialRequisition::whereIn('ulid', $validated['requisition_ulids'])
|
||||
->pluck('id');
|
||||
$po->requisitions()->attach($mrIds);
|
||||
}
|
||||
|
||||
return $po;
|
||||
});
|
||||
|
||||
return redirect()->route('purchase-orders.show', $po)->with('success', 'Purchase Order created.');
|
||||
}
|
||||
|
||||
public function edit(PurchaseOrder $purchaseOrder)
|
||||
{
|
||||
if ($purchaseOrder->status !== 'draft') {
|
||||
return redirect()->back()->with('error', 'Only draft POs can be edited.');
|
||||
}
|
||||
|
||||
$purchaseOrder->load('items.material:id,ulid,name,unit,unit_cost', 'requisitions:id,ulid,document_number', 'targetWarehouse:id,ulid,name,code');
|
||||
|
||||
$materials = Material::select('id', 'ulid', 'name', 'unit', 'unit_cost')
|
||||
->where('status', 'active')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
$approvedRequisitions = MaterialRequisition::with(['items.material:id,ulid,name,unit,sku', 'requester:id,name'])
|
||||
->where('status', 'approved')
|
||||
->where(function ($query) use ($purchaseOrder) {
|
||||
$query->doesntHave('purchaseOrders')
|
||||
->orWhereHas('purchaseOrders', function ($q) use ($purchaseOrder) {
|
||||
$q->where('purchase_orders.id', $purchaseOrder->id);
|
||||
});
|
||||
})
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
|
||||
return Inertia::render('MaterialLogistics::PurchaseOrders/Form', [
|
||||
'purchaseOrder' => $purchaseOrder,
|
||||
'materials' => $materials,
|
||||
'approvedRequisitions' => $approvedRequisitions,
|
||||
'warehouses' => Inertia::lazy(fn () => Warehouse::active()
|
||||
->whereDoesntHave('project', function ($q) {
|
||||
$q->whereIn('status', ['closed', 'completed']);
|
||||
})
|
||||
->with('project:id,ulid,name')
|
||||
->select('id', 'ulid', 'name', 'code', 'project_id')
|
||||
->get()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, PurchaseOrder $purchaseOrder)
|
||||
{
|
||||
if ($purchaseOrder->status !== 'draft') {
|
||||
return redirect()->back()->with('error', 'Only draft POs can be updated.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'target_warehouse_ulid' => 'required|string',
|
||||
'supplier' => 'nullable|string|max:255',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
'requisition_ulids' => 'nullable|array',
|
||||
'requisition_ulids.*' => 'string',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.material_ulid' => 'required|string',
|
||||
'items.*.quantity' => 'required|numeric|min:0.01',
|
||||
'items.*.unit_cost' => 'required|numeric|min:0',
|
||||
'items.*.requisition_item_id' => 'nullable|integer',
|
||||
]);
|
||||
|
||||
$targetWarehouse = Warehouse::where('ulid', $validated['target_warehouse_ulid'])->firstOrFail();
|
||||
|
||||
DB::transaction(function () use ($validated, $targetWarehouse, $purchaseOrder) {
|
||||
$purchaseOrder->update([
|
||||
'target_warehouse_id' => $targetWarehouse->id,
|
||||
'supplier' => $validated['supplier'] ?? null,
|
||||
'notes' => $validated['notes'] ?? null,
|
||||
]);
|
||||
|
||||
$purchaseOrder->items()->delete();
|
||||
|
||||
foreach ($validated['items'] as $item) {
|
||||
$material = Material::where('ulid', $item['material_ulid'])->firstOrFail();
|
||||
$purchaseOrder->items()->create([
|
||||
'material_id' => $material->id,
|
||||
'material_requisition_item_id' => $item['requisition_item_id'] ?? null,
|
||||
'quantity' => $item['quantity'],
|
||||
'unit_cost' => $item['unit_cost'],
|
||||
]);
|
||||
}
|
||||
|
||||
$purchaseOrder->requisitions()->detach();
|
||||
if (!empty($validated['requisition_ulids'])) {
|
||||
$mrIds = MaterialRequisition::whereIn('ulid', $validated['requisition_ulids'])
|
||||
->pluck('id');
|
||||
$purchaseOrder->requisitions()->attach($mrIds);
|
||||
}
|
||||
});
|
||||
|
||||
return redirect()->route('purchase-orders.show', $purchaseOrder)->with('success', 'Purchase Order updated.');
|
||||
}
|
||||
|
||||
public function show(PurchaseOrder $purchaseOrder)
|
||||
{
|
||||
$purchaseOrder->load([
|
||||
'targetWarehouse:id,ulid,name',
|
||||
'requester:id,ulid,name',
|
||||
'approver:id,ulid,name',
|
||||
'items.material' => function ($query) {
|
||||
$query->select('id', 'ulid', 'name', 'unit', 'unit_cost', 'type')
|
||||
->with('components.component:id,name,unit');
|
||||
},
|
||||
'requisitions:id,ulid,document_number',
|
||||
'approvalChains.steps.approver:id,ulid,name',
|
||||
]);
|
||||
|
||||
$warehouses = Warehouse::active()->select('id', 'ulid', 'name', 'code')->get();
|
||||
|
||||
return Inertia::render('MaterialLogistics::PurchaseOrders/Show', [
|
||||
'purchaseOrder' => $purchaseOrder,
|
||||
'warehouses' => $warehouses,
|
||||
]);
|
||||
}
|
||||
|
||||
public function submitForApproval(PurchaseOrder $purchaseOrder)
|
||||
{
|
||||
if ($purchaseOrder->status !== 'draft') {
|
||||
return back()->with('error', 'Only draft purchase orders can be submitted.');
|
||||
}
|
||||
|
||||
// Find personnel with approve_po permission across the whole system
|
||||
$approverIds = \App\Models\User::permission('approve_po')
|
||||
->pluck('id')
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
if (empty($approverIds)) {
|
||||
return back()->with('error', 'No approvers found in the system for Purchase Orders.');
|
||||
}
|
||||
|
||||
$approvalService = app(ApprovalService::class);
|
||||
$approvalService->createChain(
|
||||
$purchaseOrder,
|
||||
$approverIds,
|
||||
'purchase_order',
|
||||
auth()->id(),
|
||||
);
|
||||
|
||||
$purchaseOrder->update(['status' => 'submitted']);
|
||||
|
||||
return back()->with('success', 'Purchase Order submitted for approval.');
|
||||
}
|
||||
|
||||
public function downloadPdf(PurchaseOrder $purchaseOrder)
|
||||
{
|
||||
$purchaseOrder->load([
|
||||
'requester:id,name',
|
||||
'approver:id,name',
|
||||
'items.material:id,name,unit',
|
||||
'requisitions:id,document_number',
|
||||
'approvalChains.steps.approver:id,name',
|
||||
]);
|
||||
|
||||
$approverDetails = [];
|
||||
if ($purchaseOrder->approvalChains->isNotEmpty()) {
|
||||
$chain = $purchaseOrder->approvalChains->first();
|
||||
foreach ($chain->steps as $step) {
|
||||
if ($step->status === 'approved' && $step->approver) {
|
||||
$approverDetails[] = [
|
||||
'name' => $step->approver->name,
|
||||
'role' => 'Approver',
|
||||
'date' => $step->acted_at?->format('M d, Y'),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($approverDetails) && $purchaseOrder->status === 'approved' && $purchaseOrder->approver) {
|
||||
$approverDetails[] = [
|
||||
'name' => $purchaseOrder->approver->name,
|
||||
'role' => 'Approver',
|
||||
'date' => $purchaseOrder->approved_at ? $purchaseOrder->approved_at->format('M d, Y') : null,
|
||||
];
|
||||
}
|
||||
|
||||
$pdf = Pdf::loadView('materiallogistics::pdf.purchase-order', [
|
||||
'purchaseOrder' => $purchaseOrder,
|
||||
'approverDetails' => $approverDetails,
|
||||
]);
|
||||
|
||||
return $pdf->download("PO-{$purchaseOrder->document_number}.pdf");
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON API: Search approved POs for the PO Lookup component.
|
||||
*/
|
||||
public function searchApproved(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'q' => 'nullable|string|max:100',
|
||||
]);
|
||||
|
||||
$query = PurchaseOrder::where('status', 'approved')
|
||||
->with('items.material:id,ulid,name,unit,unit_cost');
|
||||
|
||||
if ($request->filled('q')) {
|
||||
$query->where('document_number', 'like', "%{$request->q}%");
|
||||
}
|
||||
|
||||
$results = $query->select('id', 'ulid', 'document_number', 'supplier')
|
||||
->latest()
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
return response()->json($results);
|
||||
}
|
||||
|
||||
public function markAsPaid(Request $request, PurchaseOrder $purchaseOrder)
|
||||
{
|
||||
if ($purchaseOrder->status !== 'approved') {
|
||||
return back()->with('error', 'Only approved purchase orders can be marked as paid.');
|
||||
}
|
||||
|
||||
if ($purchaseOrder->isPaid()) {
|
||||
return back()->with('error', 'This purchase order is already paid.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'receipt' => 'required|file|mimes:pdf,jpg,jpeg,png|max:10240',
|
||||
'warehouse_ulid' => 'nullable|string',
|
||||
]);
|
||||
|
||||
// Store receipt file
|
||||
$file = $request->file('receipt');
|
||||
$path = $file->store("po-receipts/{$purchaseOrder->id}", 'public');
|
||||
|
||||
DB::transaction(function () use ($purchaseOrder, $path, $file, $request) {
|
||||
$purchaseOrder->update([
|
||||
'payment_status' => 'paid',
|
||||
'paid_at' => now(),
|
||||
'receipt_path' => $path,
|
||||
'receipt_original_name' => $file->getClientOriginalName(),
|
||||
]);
|
||||
|
||||
if ($request->filled('warehouse_ulid')) {
|
||||
$warehouse = Warehouse::where('ulid', $request->warehouse_ulid)->firstOrFail();
|
||||
$purchaseOrder->update([
|
||||
'status' => 'delivered',
|
||||
'target_warehouse_id' => $warehouse->id,
|
||||
]);
|
||||
|
||||
$warehouseService = app(\Modules\MaterialLogistics\Services\WarehouseService::class);
|
||||
$purchaseOrder->load('items.material', 'items.requisitionItem');
|
||||
|
||||
foreach ($purchaseOrder->items as $item) {
|
||||
$warehouseService->receiveStock(
|
||||
warehouse: $warehouse,
|
||||
material: $item->material,
|
||||
quantity: (float) $item->quantity,
|
||||
unitCost: (float) $item->unit_cost,
|
||||
purchaseOrder: $purchaseOrder,
|
||||
materialRequisitionId: $item->requisitionItem?->material_requisition_id,
|
||||
performedBy: auth()->id(),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$message = $request->filled('warehouse_ulid')
|
||||
? 'Purchase Order marked as paid and materials delivered to warehouse.'
|
||||
: 'Purchase Order marked as paid.';
|
||||
|
||||
return back()->with('success', $message);
|
||||
}
|
||||
|
||||
public function markAsDelivered(PurchaseOrder $purchaseOrder)
|
||||
{
|
||||
if ($purchaseOrder->status !== 'approved') {
|
||||
return back()->with('error', 'Only approved purchase orders can be marked as delivered.');
|
||||
}
|
||||
|
||||
if (!$purchaseOrder->target_warehouse_id) {
|
||||
return back()->with('error', 'No target warehouse assigned to this Purchase Order.');
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($purchaseOrder) {
|
||||
$purchaseOrder->update(['status' => 'delivered']);
|
||||
|
||||
// Dispatch event or call service to inject inventory
|
||||
$warehouseService = app(\Modules\MaterialLogistics\Services\WarehouseService::class);
|
||||
$warehouse = $purchaseOrder->targetWarehouse;
|
||||
$purchaseOrder->load('items.material', 'items.requisitionItem');
|
||||
|
||||
foreach ($purchaseOrder->items as $item) {
|
||||
$warehouseService->receiveStock(
|
||||
warehouse: $warehouse,
|
||||
material: $item->material,
|
||||
quantity: (float) $item->quantity,
|
||||
unitCost: (float) $item->unit_cost,
|
||||
purchaseOrder: $purchaseOrder,
|
||||
materialRequisitionId: $item->requisitionItem?->material_requisition_id,
|
||||
performedBy: auth()->id(),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return back()->with('success', 'Purchase Order marked as delivered. Materials are now in the warehouse.');
|
||||
}
|
||||
|
||||
public function downloadReceipt(PurchaseOrder $purchaseOrder)
|
||||
{
|
||||
if (!$purchaseOrder->hasReceipt()) {
|
||||
abort(404, 'No receipt uploaded.');
|
||||
}
|
||||
|
||||
$disk = \Storage::disk('public');
|
||||
if (!$disk->exists($purchaseOrder->receipt_path)) {
|
||||
abort(404, 'Receipt file not found.');
|
||||
}
|
||||
|
||||
return $disk->download(
|
||||
$purchaseOrder->receipt_path,
|
||||
$purchaseOrder->receipt_original_name
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\MaterialLogistics\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Modules\MaterialLogistics\Models\InventoryMovement;
|
||||
use Modules\MaterialLogistics\Models\Warehouse;
|
||||
|
||||
class WarehouseController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = Warehouse::query();
|
||||
|
||||
if ($search = $request->search) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('code', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$warehouses = $query->withCount('stock')
|
||||
->latest()
|
||||
->paginate(15)
|
||||
->withQueryString()
|
||||
->through(fn ($w) => $w->append(['total_value', 'item_count']));
|
||||
|
||||
return Inertia::render('MaterialLogistics::Warehouses/Index', [
|
||||
'warehouses' => $warehouses,
|
||||
'filters' => $request->only(['search']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
return Inertia::render('MaterialLogistics::Warehouses/Form');
|
||||
}
|
||||
|
||||
public function show(Request $request, Warehouse $warehouse)
|
||||
{
|
||||
// Paginated stock with search & category filter
|
||||
$stockQuery = $warehouse->stock()->with([
|
||||
'material:id,ulid,name,sku,unit,category,type',
|
||||
'material.components.component:id,name,unit'
|
||||
]);
|
||||
|
||||
if ($search = $request->search) {
|
||||
$stockQuery->whereHas('material', fn ($q) =>
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('sku', 'like', "%{$search}%")
|
||||
);
|
||||
}
|
||||
if ($category = $request->category) {
|
||||
$stockQuery->whereHas('material', fn ($q) => $q->where('category', $category));
|
||||
}
|
||||
|
||||
$stock = $stockQuery->paginate(25)->withQueryString();
|
||||
|
||||
// Append is_low_stock to each stock item
|
||||
$stock->getCollection()->each(fn ($s) => $s->append('is_low_stock'));
|
||||
|
||||
// Summary stats from full warehouse stock (not filtered)
|
||||
$allStock = $warehouse->stock()->with('material:id,category')->get();
|
||||
$summary = [
|
||||
'total_items' => $allStock->count(),
|
||||
'total_value' => $allStock->sum(fn ($s) => (float) $s->quantity * (float) $s->unit_cost),
|
||||
'categories' => $allStock->pluck('material.category')->filter()->unique()->count(),
|
||||
'low_stock_count' => $allStock->filter(fn ($s) => $s->is_low_stock)->count(),
|
||||
];
|
||||
|
||||
// Distinct categories for filter dropdown
|
||||
$categories = $allStock->pluck('material.category')
|
||||
->filter()
|
||||
->unique()
|
||||
->sort()
|
||||
->values();
|
||||
|
||||
// Recent movements for this warehouse
|
||||
$movements = InventoryMovement::forWarehouse($warehouse->id)
|
||||
->with(['material:id,ulid,name,sku,unit', 'performer:id,name'])
|
||||
->latest()
|
||||
->paginate(15, ['*'], 'movements_page')
|
||||
->withQueryString();
|
||||
|
||||
return Inertia::render('MaterialLogistics::Warehouses/Show', [
|
||||
'warehouse' => $warehouse,
|
||||
'stock' => $stock,
|
||||
'summary' => $summary,
|
||||
'categories' => $categories,
|
||||
'movements' => $movements,
|
||||
'filters' => $request->only(['search', 'category']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'code' => 'required|string|max:20|unique:warehouses',
|
||||
'address' => 'nullable|string',
|
||||
'type' => 'required|in:main,yard,staging',
|
||||
]);
|
||||
|
||||
Warehouse::create($validated);
|
||||
|
||||
return redirect()->route('warehouses.index')->with('success', 'Warehouse created.');
|
||||
}
|
||||
|
||||
public function edit(Warehouse $warehouse)
|
||||
{
|
||||
return Inertia::render('MaterialLogistics::Warehouses/Form', [
|
||||
'warehouse' => $warehouse,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Warehouse $warehouse)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'code' => "required|string|max:20|unique:warehouses,code,{$warehouse->id}",
|
||||
'address' => 'nullable|string',
|
||||
'type' => 'required|in:main,yard,staging',
|
||||
'status' => 'nullable|in:active,inactive',
|
||||
]);
|
||||
|
||||
$warehouse->update($validated);
|
||||
|
||||
return redirect()->route('warehouses.index')->with('success', 'Warehouse updated.');
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON API: warehouse stock for selects and dispatching.
|
||||
*/
|
||||
public function stockSummary(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'warehouse_ulid' => 'required|string',
|
||||
]);
|
||||
|
||||
$warehouse = Warehouse::where('ulid', $request->warehouse_ulid)->firstOrFail();
|
||||
|
||||
$stock = $warehouse->stock()
|
||||
->with([
|
||||
'material:id,ulid,name,sku,unit,category,type',
|
||||
'material.components.component:id,name,unit'
|
||||
])
|
||||
->where('quantity', '>', 0)
|
||||
->get()
|
||||
->map(fn ($s) => [
|
||||
'material' => $s->material,
|
||||
'quantity' => $s->quantity,
|
||||
'reserved_qty' => $s->reserved_qty,
|
||||
'available_qty' => $s->available_qty,
|
||||
'unit_cost' => $s->unit_cost,
|
||||
]);
|
||||
|
||||
return response()->json($stock);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user