172 lines
5.8 KiB
PHP
172 lines
5.8 KiB
PHP
<?php
|
|
|
|
namespace Modules\IndustrialEngineering\Services;
|
|
|
|
use Illuminate\Support\Facades\DB;
|
|
use Modules\IndustrialEngineering\Models\Bom;
|
|
use Modules\IndustrialEngineering\Models\CostScenario;
|
|
use Modules\IndustrialEngineering\Models\CostScenarioLine;
|
|
use Modules\IndustrialEngineering\Models\ItemMaster;
|
|
use Modules\IndustrialEngineering\Models\LineRevision;
|
|
use Modules\IndustrialEngineering\Models\RoutingOperation;
|
|
|
|
class CostingService
|
|
{
|
|
public function __construct(
|
|
protected BomService $bomService
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* Build a cost scenario from a BOM with optional per-line alternate selections.
|
|
*
|
|
* @param array<int, int|null> $alternateSelections bom_line_id => alternate_item_id
|
|
*/
|
|
public function buildScenario(
|
|
int $businessId,
|
|
Bom $bom,
|
|
string $scenarioCode,
|
|
string $name,
|
|
array $alternateSelections = [],
|
|
string $scenarioType = 'what_if'
|
|
): CostScenario {
|
|
return DB::transaction(function () use ($businessId, $bom, $scenarioCode, $name, $alternateSelections, $scenarioType) {
|
|
$scenario = CostScenario::create([
|
|
'business_id' => $businessId,
|
|
'line_revision_id' => $bom->line_revision_id,
|
|
'bom_id' => $bom->id,
|
|
'scenario_code' => $scenarioCode,
|
|
'name' => $name,
|
|
'scenario_type' => $scenarioType,
|
|
'status' => 'draft',
|
|
'created_by' => auth()->id(),
|
|
]);
|
|
|
|
$flat = $this->bomService->explode($bom);
|
|
$materialTotal = 0;
|
|
|
|
foreach ($flat as $row) {
|
|
$bomLineId = $row['bom_line_id'];
|
|
$selectedAlternateId = $alternateSelections[$bomLineId] ?? null;
|
|
$unitCost = $row['unit_cost'];
|
|
$itemId = $row['item_master_id'];
|
|
|
|
if ($selectedAlternateId) {
|
|
$altItem = ItemMaster::find($selectedAlternateId);
|
|
if ($altItem) {
|
|
$unitCost = (float) $altItem->standard_cost;
|
|
$itemId = $altItem->id;
|
|
}
|
|
}
|
|
|
|
$extended = $row['quantity'] * $unitCost;
|
|
$materialTotal += $extended;
|
|
|
|
CostScenarioLine::create([
|
|
'cost_scenario_id' => $scenario->id,
|
|
'bom_line_id' => $bomLineId,
|
|
'item_master_id' => $itemId,
|
|
'selected_alternate_item_id' => $selectedAlternateId,
|
|
'quantity' => $row['quantity'],
|
|
'unit_cost' => $unitCost,
|
|
'extended_cost' => $extended,
|
|
'lead_time_days' => $row['lead_time_days'],
|
|
]);
|
|
}
|
|
|
|
$routingCosts = $this->calculateRoutingCosts($bom->line_revision_id);
|
|
$overhead = $materialTotal * 0.1;
|
|
|
|
$scenario->update([
|
|
'total_material_cost' => $materialTotal,
|
|
'total_labor_cost' => $routingCosts['labor'],
|
|
'total_machine_cost' => $routingCosts['machine'],
|
|
'total_overhead_cost' => $overhead,
|
|
'total_cost' => $materialTotal + $routingCosts['labor'] + $routingCosts['machine'] + $overhead,
|
|
]);
|
|
|
|
return $scenario->fresh(['lines.itemMaster', 'lines.selectedAlternateItem']);
|
|
});
|
|
}
|
|
|
|
protected function calculateRoutingCosts(?int $lineRevisionId): array
|
|
{
|
|
if (! $lineRevisionId) {
|
|
return ['labor' => 0, 'machine' => 0];
|
|
}
|
|
|
|
$operations = RoutingOperation::with('workCenter')
|
|
->where('line_revision_id', $lineRevisionId)
|
|
->get();
|
|
|
|
$labor = 0;
|
|
$machine = 0;
|
|
|
|
foreach ($operations as $op) {
|
|
$wc = $op->workCenter;
|
|
if (! $wc) {
|
|
continue;
|
|
}
|
|
$hours = (float) $op->setup_time_hours + (float) $op->run_time_hours;
|
|
$labor += $hours * (float) $wc->labor_rate_per_hour;
|
|
$machine += $hours * (float) $wc->machine_rate_per_hour;
|
|
}
|
|
|
|
return ['labor' => $labor, 'machine' => $machine];
|
|
}
|
|
|
|
/**
|
|
* Compare multiple scenarios side by side.
|
|
*/
|
|
public function compareScenarios(array $scenarioIds): array
|
|
{
|
|
$scenarios = CostScenario::with('lines.itemMaster')
|
|
->whereIn('id', $scenarioIds)
|
|
->get();
|
|
|
|
return $scenarios->map(fn (CostScenario $s) => [
|
|
'id' => $s->id,
|
|
'scenario_code' => $s->scenario_code,
|
|
'name' => $s->name,
|
|
'total_material_cost' => (float) $s->total_material_cost,
|
|
'total_labor_cost' => (float) $s->total_labor_cost,
|
|
'total_machine_cost' => (float) $s->total_machine_cost,
|
|
'total_overhead_cost' => (float) $s->total_overhead_cost,
|
|
'total_cost' => (float) $s->total_cost,
|
|
'line_count' => $s->lines->count(),
|
|
])->values()->all();
|
|
}
|
|
|
|
/**
|
|
* What-if: replace one component and recalculate.
|
|
*/
|
|
public function whatIfReplaceComponent(
|
|
CostScenario $baseScenario,
|
|
int $bomLineId,
|
|
int $alternateItemId,
|
|
string $newScenarioCode
|
|
): CostScenario {
|
|
$selections = [];
|
|
foreach ($baseScenario->lines as $line) {
|
|
if ($line->bom_line_id) {
|
|
$selections[$line->bom_line_id] = $line->selected_alternate_item_id;
|
|
}
|
|
}
|
|
$selections[$bomLineId] = $alternateItemId;
|
|
|
|
$bom = $baseScenario->bom;
|
|
if (! $bom) {
|
|
throw new \RuntimeException('Base scenario has no linked BOM.');
|
|
}
|
|
|
|
return $this->buildScenario(
|
|
$baseScenario->business_id,
|
|
$bom,
|
|
$newScenarioCode,
|
|
$baseScenario->name.' (What-if)',
|
|
$selections,
|
|
'what_if'
|
|
);
|
|
}
|
|
}
|