Files
Ajjj c13f418517
Some checks failed
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled
Tests / PHP 8.5 (push) Has been cancelled
feat: implement inventory management controllers, dispatch service logic, and frontend inventory tracking dashboard
2026-08-05 16:23:57 +08:00

399 lines
15 KiB
PHP

<?php
namespace Modules\MaterialLogistics\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Modules\MasterData\Models\Material;
use Modules\MaterialLogistics\Models\InventoryBatch;
use Modules\MaterialLogistics\Models\ProjectInventory;
use Modules\MaterialLogistics\Models\Warehouse;
use Modules\MaterialLogistics\Models\WarehouseStock;
use Modules\MaterialLogistics\Models\PurchaseOrder;
use Modules\MaterialLogistics\Services\WarehouseService;
use Modules\ProjectManagement\Models\Project;
use Modules\ProjectManagement\Models\Task;
class MaterialController extends Controller
{
public function __construct(
private WarehouseService $warehouseService,
) {}
// --- Inventory Dashboard ---
public function inventory(Request $request)
{
$warehouses = Warehouse::active()
->whereIn('id', $this->visibleWarehouseIdsQuery())
->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::whereIn('warehouse_id', $this->visibleWarehouseIdsQuery())
->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::whereIn('project_id', $this->visibleProjectIdsQuery())
->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::whereIn('warehouse_id', $this->visibleWarehouseIdsQuery())->sum('quantity'),
'total_onsite' => ProjectInventory::whereIn('project_id', $this->visibleProjectIdsQuery())->sum('on_hand_qty'),
'total_reserved' => ProjectInventory::whereIn('project_id', $this->visibleProjectIdsQuery())->sum('allocated_qty'),
'total_consumed' => ProjectInventory::whereIn('project_id', $this->visibleProjectIdsQuery())->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()
->whereIn('id', $this->visibleWarehouseIdsQuery())
->select('id', 'ulid', 'name', 'code')->get();
$projects = Project::select('id', 'ulid', 'name', 'code')
->with(['personnel' => function ($query) {
$query->wherePivot('role', 'pm')->select('users.id', 'users.name');
}])
->latest()
->get()
->map(function ($p) {
$pm = $p->personnel->first();
return [
'id' => $p->id,
'ulid' => $p->ulid,
'name' => $p->name,
'code' => $p->code,
'pm' => $pm ? [
'id' => $pm->id,
'name' => $pm->name,
] : null,
];
});
// Materials with their warehouse/site stocks to enforce limits
$materials = Material::select('id', 'ulid', 'name', 'sku', 'unit', 'unit_cost')
->with(['warehouseStocks', 'projectInventories'])
->get();
$projectManagers = User::whereIn('user_type', ['admin', 'employee', 'contractor'])
->where(function ($q) {
$q->whereIn('user_type', ['admin'])
->orWhereHas('roles', function ($rq) {
$rq->whereIn('name', ['Project Manager', 'Main Contractor Admin', 'Super Admin', 'admin', 'Construction Supervisor', 'Site Technical']);
});
})
->select('id', 'name', 'email')
->get();
return Inertia::render('MaterialLogistics::Inventory/OperationsForm', [
'warehouses' => $warehouses,
'projects' => $projects,
'materials' => $materials,
'projectManagers' => $projectManagers,
'defaultType' => $type,
]);
}
// --- Fetch materials filtered by operation parameters (e.g. PO for dispatch) ---
public function getMaterialsForOperation(Request $request)
{
$request->validate([
'purchase_order_ulid' => 'nullable|string',
'warehouse_ulid' => 'nullable|string',
'project_ulid' => 'nullable|string',
'type' => 'required|in:dispatch,return,adjust',
]);
$query = Material::select('id', 'ulid', 'name', 'sku', 'unit', 'unit_cost')
->with(['warehouseStocks', 'projectInventories']);
if ($request->type === 'dispatch') {
if ($request->filled('purchase_order_ulid')) {
$po = PurchaseOrder::where('ulid', $request->purchase_order_ulid)->first();
if ($po) {
$materialIds = $po->items()->pluck('material_id');
$query->whereIn('id', $materialIds);
} else {
$query->whereRaw('1 = 0');
}
} else {
$query->whereRaw('1 = 0');
}
}
$materials = $query->get();
return response()->json($materials);
}
// --- Dispatch from Warehouse to Project ---
public function dispatchFromWarehouse(Request $request)
{
$validated = $request->validate([
'warehouse_ulid' => 'required|string',
'project_ulid' => 'required|string',
'purchase_order_ulid' => 'required|string|exists:purchase_orders,ulid',
'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();
$po = PurchaseOrder::where('ulid', $validated['purchase_order_ulid'])->firstOrFail();
if ($po->project_id !== $project->id || $po->target_warehouse_id !== $warehouse->id) {
return back()->with('error', 'The selected Purchase Order is not associated with this project and warehouse.');
}
$poMaterialIds = $po->items()->pluck('material_id')->toArray();
DB::beginTransaction();
try {
foreach ($validated['items'] as $item) {
$material = Material::where('ulid', $item['material_ulid'])->firstOrFail();
if (!in_array($material->id, $poMaterialIds)) {
throw new \InvalidArgumentException("Material {$material->name} is not listed in the selected Purchase Order.");
}
$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.');
}
private function visibleProjectIdsQuery()
{
return Project::query()->select('projects.id');
}
private function visibleWarehouseIdsQuery()
{
return Warehouse::query()
->whereIn('project_id', $this->visibleProjectIdsQuery())
->select('warehouses.id');
}
}