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

@@ -101,4 +101,116 @@ class ProjectWorkflowService
'is_over_budget' => $poTotal > $totalEstimated || $project->rollup_capitalization > $project->rollup_contract_value,
];
}
/**
* Compute remaining estimated materials for a project taking into account:
* - Project baseline estimated quantities
* - Quantities requested on active (non-rejected/cancelled) estimated MR items
* - Missing/shortage quantities released back from delivered POs
* - Optional exclusion of a requisition ID (for editing an existing draft MR)
*/
public function getRemainingMaterialEstimates(Project $project, ?int $excludeRequisitionId = null): array
{
$project->loadMissing('materialsEstimates.material');
// Sum requested quantities in non-rejected, non-cancelled MRs
$alreadyReqMap = \Modules\MaterialLogistics\Models\MaterialRequisitionItem::whereHas('requisition', function ($q) use ($project, $excludeRequisitionId) {
$q->where('project_id', $project->id)
->whereNotIn('status', ['rejected', 'cancelled']);
if ($excludeRequisitionId) {
$q->where('id', '!=', $excludeRequisitionId);
}
})
->where('is_unestimated', false)
->select('material_id', \Illuminate\Support\Facades\DB::raw('SUM(quantity) as total_qty'))
->groupBy('material_id')
->pluck('total_qty', 'material_id')
->toArray();
// Release shortage/missing quantities from delivered POs back into the available pool
$missingPoQtyMap = \Modules\MaterialLogistics\Models\PurchaseOrderItem::whereHas('purchaseOrder', function ($q) use ($project) {
$q->where('project_id', $project->id)
->where('status', 'delivered');
})
->where('missing_quantity', '>', 0)
->select('material_id', \Illuminate\Support\Facades\DB::raw('SUM(missing_quantity) as total_missing'))
->groupBy('material_id')
->pluck('total_missing', 'material_id')
->toArray();
$estimates = [];
foreach ($project->materialsEstimates as $estimate) {
if (!$estimate->material) {
continue;
}
$alreadyReq = (float) ($alreadyReqMap[$estimate->material_id] ?? 0);
$missingFromPo = (float) ($missingPoQtyMap[$estimate->material_id] ?? 0);
$netRequested = max(0, $alreadyReq - $missingFromPo);
$remaining = max(0, (float) $estimate->estimated_qty - $netRequested);
if ($remaining > 0) {
$estimates[] = [
'material_id' => $estimate->material_id,
'material_ulid' => $estimate->material->ulid,
'material_name' => $estimate->material->name,
'unit' => $estimate->material->unit,
'quantity' => $remaining,
'unit_cost' => (float) $estimate->unit_cost,
'estimated_qty' => (float) $estimate->estimated_qty,
'remaining_qty' => $remaining,
];
}
}
return $estimates;
}
/**
* Validate that estimated items do not exceed the remaining estimated quantities.
* Returns null if valid, or an error message string if any item exceeds limits.
*/
public function validateEstimatedQuantities(Project $project, array $items, ?int $excludeRequisitionId = null): ?string
{
$remainingEstimates = $this->getRemainingMaterialEstimates($project, $excludeRequisitionId);
$remainingByUlid = collect($remainingEstimates)->keyBy('material_ulid');
$remainingById = collect($remainingEstimates)->keyBy('material_id');
// Track cumulative requested quantities in this single submission to prevent duplicates bypassing limit
$cumulativeRequested = [];
foreach ($items as $item) {
$isUnestimated = isset($item['is_unestimated']) ? (bool) $item['is_unestimated'] : false;
if ($isUnestimated) {
continue; // Unestimated/supplemental items are not restricted by estimated budget caps
}
$materialUlid = $item['material_ulid'] ?? null;
$materialId = $item['material_id'] ?? null;
$qty = (float) ($item['quantity'] ?? 0);
$estimate = $materialUlid ? ($remainingByUlid[$materialUlid] ?? null) : ($remainingById[$materialId] ?? null);
if (!$estimate) {
$materialName = $item['material_name'] ?? 'Material';
if ($materialUlid) {
$mat = \Modules\MasterData\Models\Material::where('ulid', $materialUlid)->first();
if ($mat) {
$materialName = $mat->name;
}
}
return "The material '{$materialName}' has no remaining estimated quantity on this project. Please select Unestimated Materials for supplemental requests.";
}
$key = $estimate['material_ulid'];
$cumulativeRequested[$key] = ($cumulativeRequested[$key] ?? 0) + $qty;
$maxAllowed = (float) $estimate['remaining_qty'];
if ($cumulativeRequested[$key] > $maxAllowed + 0.0001) {
return "The requested quantity (" . number_format($cumulativeRequested[$key], 2) . ") for '{$estimate['material_name']}' exceeds the project's remaining estimate of " . number_format($maxAllowed, 2) . " {$estimate['unit']}. Please reduce the quantity or choose Unestimated Materials for additional items.";
}
}
return null;
}
}