feat: implement comprehensive modules for daily reports, project management, material logistics, and approval workflows

This commit is contained in:
Ajjj
2026-08-26 15:48:41 +08:00
parent d4e2b468ff
commit d6efc46624
52 changed files with 5275 additions and 932 deletions

View File

@@ -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.