feat: implement comprehensive modules for daily reports, project management, material logistics, and approval workflows
This commit is contained in:
@@ -11,6 +11,7 @@ use Modules\ApprovalWorkflow\Services\ApprovalService;
|
||||
use Modules\MaterialLogistics\Events\PurchaseOrderPaid;
|
||||
use Modules\MasterData\Models\Material;
|
||||
use Modules\MaterialLogistics\Models\MaterialRequisition;
|
||||
use Modules\MaterialLogistics\Models\MaterialRequisitionItem;
|
||||
use Modules\MaterialLogistics\Models\PurchaseOrder;
|
||||
use Modules\MaterialLogistics\Models\Warehouse;
|
||||
use Modules\MaterialLogistics\Services\DocumentNumberService;
|
||||
@@ -154,6 +155,11 @@ class PurchaseOrderController extends Controller
|
||||
return redirect()->back()->with('error', 'Cannot create Purchase Order: Project workflow state does not permit PO creation.');
|
||||
}
|
||||
|
||||
$validationError = $this->validatePoItemsAgainstRequisitions($validated['items'], $validated['requisition_ulids'] ?? []);
|
||||
if ($validationError) {
|
||||
return redirect()->back()->withInput()->with('error', $validationError);
|
||||
}
|
||||
|
||||
$docService = new DocumentNumberService();
|
||||
|
||||
$po = DB::transaction(function () use ($validated, $project, $targetWarehouse, $docService) {
|
||||
@@ -262,6 +268,11 @@ class PurchaseOrderController extends Controller
|
||||
return redirect()->back()->with('error', 'Cannot update Purchase Order: Project workflow state does not permit PO updates.');
|
||||
}
|
||||
|
||||
$validationError = $this->validatePoItemsAgainstRequisitions($validated['items'], $validated['requisition_ulids'] ?? []);
|
||||
if ($validationError) {
|
||||
return redirect()->back()->withInput()->with('error', $validationError);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($validated, $project, $targetWarehouse, $purchaseOrder) {
|
||||
$purchaseOrder->update([
|
||||
'project_id' => $project->id,
|
||||
@@ -308,6 +319,26 @@ class PurchaseOrderController extends Controller
|
||||
'approvalChains.steps.approver:id,ulid,name',
|
||||
]);
|
||||
|
||||
if ($purchaseOrder->status === 'submitted' && $purchaseOrder->approvalChains->isEmpty()) {
|
||||
$approverIds = \App\Models\User::withoutGlobalScopes()
|
||||
->where('user_type', '!=', 'contractor')
|
||||
->where(function ($query) {
|
||||
$query->whereHas('roles', function ($roleQuery) {
|
||||
$roleQuery->whereIn('name', ['Project Manager', 'project_manager', 'Super Admin', 'admin', 'Main Contractor Admin']);
|
||||
})->orWhere('user_type', 'admin');
|
||||
})
|
||||
->where('status', 'active')
|
||||
->pluck('id')
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
if (!empty($approverIds)) {
|
||||
$approvalService = app(\Modules\ApprovalWorkflow\Services\ApprovalService::class);
|
||||
$approvalService->createChain($purchaseOrder, $approverIds, 'purchase_order', $purchaseOrder->created_by);
|
||||
$purchaseOrder->load('approvalChains.steps.approver:id,ulid,name');
|
||||
}
|
||||
}
|
||||
|
||||
$warehouses = Warehouse::active()->select('id', 'ulid', 'name', 'code')->get();
|
||||
|
||||
$budgetAnalysis = null;
|
||||
@@ -531,7 +562,7 @@ class PurchaseOrderController extends Controller
|
||||
return back()->with('success', $message);
|
||||
}
|
||||
|
||||
public function markAsDelivered(PurchaseOrder $purchaseOrder)
|
||||
public function markAsDelivered(Request $request, PurchaseOrder $purchaseOrder)
|
||||
{
|
||||
if ($purchaseOrder->status !== 'approved') {
|
||||
return back()->with('error', 'Only approved purchase orders can be marked as delivered.');
|
||||
@@ -541,28 +572,66 @@ class PurchaseOrderController extends Controller
|
||||
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
|
||||
$request->validate([
|
||||
'delivery_notes' => 'nullable|string|max:1000',
|
||||
'items' => 'nullable|array',
|
||||
'items.*.id' => 'required_with:items|integer',
|
||||
'items.*.delivered_quantity' => 'required_with:items|numeric|min:0',
|
||||
]);
|
||||
|
||||
$submittedItems = collect($request->input('items', []))->keyBy('id');
|
||||
$hasShortages = false;
|
||||
$totalMissingCount = 0;
|
||||
|
||||
DB::transaction(function () use ($purchaseOrder, $request, $submittedItems, &$hasShortages, &$totalMissingCount) {
|
||||
$purchaseOrder->load('items.material', 'items.requisitionItem');
|
||||
$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(),
|
||||
);
|
||||
$orderedQty = (float) $item->quantity;
|
||||
if ($submittedItems->has($item->id)) {
|
||||
$deliveredQty = min($orderedQty, max(0, (float) $submittedItems[$item->id]['delivered_quantity']));
|
||||
} else {
|
||||
$deliveredQty = $orderedQty;
|
||||
}
|
||||
|
||||
$missingQty = max(0, $orderedQty - $deliveredQty);
|
||||
if ($missingQty > 0) {
|
||||
$hasShortages = true;
|
||||
$totalMissingCount += $missingQty;
|
||||
}
|
||||
|
||||
$item->update([
|
||||
'delivered_quantity' => $deliveredQty,
|
||||
'missing_quantity' => $missingQty,
|
||||
]);
|
||||
|
||||
if ($deliveredQty > 0) {
|
||||
$warehouseService->receiveStock(
|
||||
warehouse: $warehouse,
|
||||
material: $item->material,
|
||||
quantity: $deliveredQty,
|
||||
unitCost: (float) $item->unit_cost,
|
||||
purchaseOrder: $purchaseOrder,
|
||||
materialRequisitionId: $item->requisitionItem?->material_requisition_id,
|
||||
performedBy: auth()->id(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$purchaseOrder->update([
|
||||
'status' => 'delivered',
|
||||
'delivered_at' => now(),
|
||||
'delivery_notes' => $request->input('delivery_notes'),
|
||||
]);
|
||||
});
|
||||
|
||||
return back()->with('success', 'Purchase Order marked as delivered. Materials are now in the warehouse.');
|
||||
$message = $hasShortages
|
||||
? "Purchase Order marked as delivered. Shortage of {$totalMissingCount} unit(s) recorded and available for project re-requisition."
|
||||
: 'Purchase Order marked as delivered. Materials are now in the warehouse.';
|
||||
|
||||
return back()->with('success', $message);
|
||||
}
|
||||
|
||||
public function downloadReceipt(PurchaseOrder $purchaseOrder)
|
||||
@@ -599,6 +668,131 @@ class PurchaseOrderController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function approve(Request $request, PurchaseOrder $purchaseOrder)
|
||||
{
|
||||
$user = $request->user();
|
||||
$isAdminOrPm = $user->user_type === 'admin'
|
||||
|| $user->hasRole(['Super Admin', 'admin', 'Project Manager', 'project_manager', 'Main Contractor Admin'])
|
||||
|| $user->can('approve_po');
|
||||
|
||||
if (!$isAdminOrPm) {
|
||||
return back()->with('error', 'Unauthorized to approve purchase orders.');
|
||||
}
|
||||
|
||||
$request->validate(['notes' => 'nullable|string|max:1000']);
|
||||
|
||||
$chain = $purchaseOrder->approvalChains()->whereIn('status', ['pending', 'in_review'])->first();
|
||||
if ($chain) {
|
||||
$approvalService = app(\Modules\ApprovalWorkflow\Services\ApprovalService::class);
|
||||
$approvalService->approve($chain, $user, $request->notes);
|
||||
} else {
|
||||
$purchaseOrder->update([
|
||||
'status' => 'approved',
|
||||
'approved_by' => $user->id,
|
||||
'approved_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Purchase Order approved successfully.');
|
||||
}
|
||||
|
||||
public function reject(Request $request, PurchaseOrder $purchaseOrder)
|
||||
{
|
||||
$user = $request->user();
|
||||
$isAdminOrPm = $user->user_type === 'admin'
|
||||
|| $user->hasRole(['Super Admin', 'admin', 'Project Manager', 'project_manager', 'Main Contractor Admin'])
|
||||
|| $user->can('approve_po');
|
||||
|
||||
if (!$isAdminOrPm) {
|
||||
return back()->with('error', 'Unauthorized to reject purchase orders.');
|
||||
}
|
||||
|
||||
$request->validate(['notes' => 'required|string|max:1000']);
|
||||
|
||||
$chain = $purchaseOrder->approvalChains()->whereIn('status', ['pending', 'in_review'])->first();
|
||||
if ($chain) {
|
||||
$approvalService = app(\Modules\ApprovalWorkflow\Services\ApprovalService::class);
|
||||
$approvalService->reject($chain, $user, $request->notes);
|
||||
} else {
|
||||
$purchaseOrder->update([
|
||||
'status' => 'rejected',
|
||||
'notes' => $purchaseOrder->notes ? $purchaseOrder->notes . "\n[Rejection Reason]: " . $request->notes : "[Rejection Reason]: " . $request->notes,
|
||||
]);
|
||||
$purchaseOrder->requisitions()->detach();
|
||||
}
|
||||
|
||||
return back()->with('success', 'Purchase Order rejected.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that purchase order items do not exceed the requested quantities from linked Material Requisitions.
|
||||
*/
|
||||
protected function validatePoItemsAgainstRequisitions(array $items, array $requisitionUlids = []): ?string
|
||||
{
|
||||
$hasReqUlids = !empty($requisitionUlids);
|
||||
$reqItemIds = array_values(array_filter(array_column($items, 'requisition_item_id')));
|
||||
$hasReqItemIds = !empty($reqItemIds);
|
||||
|
||||
if (!$hasReqUlids && !$hasReqItemIds) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Load linked MR items
|
||||
$mrItems = MaterialRequisitionItem::with('material')
|
||||
->where(function ($query) use ($requisitionUlids, $reqItemIds, $hasReqUlids, $hasReqItemIds) {
|
||||
if ($hasReqUlids) {
|
||||
$query->whereHas('requisition', function ($q) use ($requisitionUlids) {
|
||||
$q->whereIn('ulid', $requisitionUlids);
|
||||
});
|
||||
}
|
||||
if ($hasReqItemIds) {
|
||||
if ($hasReqUlids) {
|
||||
$query->orWhereIn('id', $reqItemIds);
|
||||
} else {
|
||||
$query->whereIn('id', $reqItemIds);
|
||||
}
|
||||
}
|
||||
})
|
||||
->get();
|
||||
|
||||
$mrItemsById = $mrItems->keyBy('id');
|
||||
$totalRequestedByMaterial = $mrItems->groupBy('material_id')->map(fn($group) => (float) $group->sum('quantity'));
|
||||
|
||||
$totalOrderedByMaterial = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$material = Material::where('ulid', $item['material_ulid'])->first();
|
||||
if (!$material) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$orderQty = (float) $item['quantity'];
|
||||
$totalOrderedByMaterial[$material->id] = ($totalOrderedByMaterial[$material->id] ?? 0) + $orderQty;
|
||||
|
||||
// Direct item-level check
|
||||
if (!empty($item['requisition_item_id']) && isset($mrItemsById[$item['requisition_item_id']])) {
|
||||
$mrItem = $mrItemsById[$item['requisition_item_id']];
|
||||
$maxAllowed = (float) $mrItem->quantity;
|
||||
if ($orderQty > $maxAllowed + 0.0001) {
|
||||
return "Ordered quantity (" . number_format($orderQty, 2) . ") for '{$material->name}' exceeds the requested quantity of " . number_format($maxAllowed, 2) . " {$material->unit} on the linked Material Requisition.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregated material check
|
||||
foreach ($totalOrderedByMaterial as $materialId => $totalOrdered) {
|
||||
$maxRequested = (float) ($totalRequestedByMaterial[$materialId] ?? 0);
|
||||
if ($maxRequested > 0 && $totalOrdered > $maxRequested + 0.0001) {
|
||||
$material = Material::find($materialId);
|
||||
$materialName = $material ? $material->name : 'Material';
|
||||
$unit = $material ? $material->unit : 'unit';
|
||||
return "Total ordered quantity (" . number_format($totalOrdered, 2) . ") for '{$materialName}' exceeds the total requested quantity of " . number_format($maxRequested, 2) . " {$unit} across linked Material Requisitions.";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function availableProjectsQuery()
|
||||
{
|
||||
// Project's TenantScope is the source of truth for project visibility.
|
||||
|
||||
Reference in New Issue
Block a user