181 lines
6.7 KiB
PHP
181 lines
6.7 KiB
PHP
<?php
|
|
|
|
namespace Modules\IndustrialEngineering\Services;
|
|
|
|
use App\Variation;
|
|
use Illuminate\Support\Collection;
|
|
use Modules\IndustrialEngineering\Models\Bom;
|
|
use Modules\IndustrialEngineering\Models\ItemMaster;
|
|
|
|
/**
|
|
* Live BOM costing from ERP inventory, purchase prices, and supplier catalog.
|
|
*/
|
|
class LiveBomCostingService
|
|
{
|
|
public function __construct(
|
|
protected BomService $bomService,
|
|
protected SourcingService $sourcingService,
|
|
protected ErpItemLinkService $erpItemLink
|
|
) {
|
|
}
|
|
|
|
public function analyze(Bom $bom, float $plannedQty = 1.0, ?int $locationId = null): array
|
|
{
|
|
$flat = $this->bomService->explodeLeaves($bom, $plannedQty);
|
|
$lines = [];
|
|
$totals = [
|
|
'standard_material' => 0,
|
|
'live_material' => 0,
|
|
'linked_items' => 0,
|
|
'shortage_value' => 0,
|
|
];
|
|
|
|
$aggregated = $flat->groupBy('item_master_id')->map(function ($group) {
|
|
$first = $group->first();
|
|
|
|
return [
|
|
'bom_line_id' => $first['bom_line_id'],
|
|
'item_master_id' => $first['item_master_id'],
|
|
'item_code' => $first['item_code'],
|
|
'item_name' => $first['item_name'],
|
|
'quantity' => $group->sum('quantity'),
|
|
'unit_cost' => $first['unit_cost'],
|
|
];
|
|
})->values();
|
|
|
|
$itemIds = $aggregated->pluck('item_master_id')->filter()->unique()->values()->all();
|
|
$items = ItemMaster::query()
|
|
->whereIn('id', $itemIds)
|
|
->get()
|
|
->keyBy('id');
|
|
|
|
$variationIds = $items->pluck('variation_id')->filter()->unique()->values()->all();
|
|
$variations = Variation::query()
|
|
->whereIn('id', $variationIds)
|
|
->get()
|
|
->keyBy('id');
|
|
|
|
$stockByItem = $this->preloadStock($bom->business_id, $itemIds, $locationId);
|
|
|
|
foreach ($aggregated as $row) {
|
|
$item = $items->get($row['item_master_id']);
|
|
$live = $this->resolveLiveCost($item, $variations);
|
|
$stock = $stockByItem[$row['item_master_id']] ?? ['available' => 0.0];
|
|
$requiredQty = (float) $row['quantity'];
|
|
$shortage = max(0, $requiredQty - $stock['available']);
|
|
$standardExtended = $requiredQty * (float) $row['unit_cost'];
|
|
$liveExtended = $requiredQty * $live['unit_cost'];
|
|
$shortageExtended = $shortage * $live['unit_cost'];
|
|
|
|
if ($item?->variation_id) {
|
|
$totals['linked_items']++;
|
|
}
|
|
|
|
$totals['standard_material'] += $standardExtended;
|
|
$totals['live_material'] += $liveExtended;
|
|
$totals['shortage_value'] += $shortageExtended;
|
|
|
|
$lines[] = [
|
|
'bom_line_id' => $row['bom_line_id'],
|
|
'item_master_id' => $row['item_master_id'],
|
|
'item_code' => $row['item_code'],
|
|
'item_name' => $row['item_name'],
|
|
'item_type' => $item?->item_type,
|
|
'required_qty' => $requiredQty,
|
|
'stock_available' => $stock['available'],
|
|
'shortage_qty' => $shortage,
|
|
'standard_unit_cost' => (float) $row['unit_cost'],
|
|
'live_unit_cost' => $live['unit_cost'],
|
|
'cost_source' => $live['source'],
|
|
'standard_extended' => $standardExtended,
|
|
'live_extended' => $liveExtended,
|
|
'shortage_extended' => $shortageExtended,
|
|
'variation_id' => $item?->variation_id,
|
|
'product_id' => $item?->variation_id ? ($variations->get($item->variation_id)?->product_id) : null,
|
|
'erp_label' => $this->erpItemLink->erpLabel($item),
|
|
'is_purchasable' => (bool) $item?->variation_id,
|
|
];
|
|
}
|
|
|
|
$leafLines = $this->filterLeafRequirements($lines);
|
|
|
|
return [
|
|
'planned_quantity' => $plannedQty,
|
|
'line_count' => count($lines),
|
|
'leaf_count' => count($leafLines),
|
|
'totals' => $totals,
|
|
'delta' => $totals['live_material'] - $totals['standard_material'],
|
|
'lines' => $lines,
|
|
'leaf_lines' => $leafLines,
|
|
'shortages' => collect($leafLines)->where('shortage_qty', '>', 0)->values()->all(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Leaf purchasable lines only (for MRP / PO generation).
|
|
*/
|
|
public function filterLeafRequirements(array $lines): array
|
|
{
|
|
return collect($lines)
|
|
->filter(fn ($l) => in_array($l['item_type'], ['part', 'consumable', 'tool'], true) || $l['is_purchasable'])
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
public function compareStandardVsLive(Bom $bom, float $plannedQty = 1.0, ?int $locationId = null): array
|
|
{
|
|
$analysis = $this->analyze($bom, $plannedQty, $locationId);
|
|
|
|
return [
|
|
'standard_total' => $analysis['totals']['standard_material'],
|
|
'live_total' => $analysis['totals']['live_material'],
|
|
'delta' => $analysis['delta'],
|
|
'delta_percent' => $analysis['totals']['standard_material'] > 0
|
|
? round(($analysis['delta'] / $analysis['totals']['standard_material']) * 100, 2)
|
|
: 0,
|
|
'shortage_value' => $analysis['totals']['shortage_value'],
|
|
'shortage_count' => count($analysis['shortages']),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<int, int> $itemIds
|
|
* @return array<int, array{available: float}>
|
|
*/
|
|
protected function preloadStock(int $businessId, array $itemIds, ?int $locationId): array
|
|
{
|
|
$stockByItem = array_fill_keys($itemIds, ['available' => 0.0]);
|
|
|
|
foreach ($itemIds as $itemId) {
|
|
$stock = $this->sourcingService->findStock($businessId, $itemId);
|
|
if ($locationId) {
|
|
$stock = $stock->where('location_id', $locationId);
|
|
}
|
|
$stockByItem[$itemId]['available'] = (float) $stock->sum('quantity');
|
|
}
|
|
|
|
return $stockByItem;
|
|
}
|
|
|
|
protected function resolveLiveCost(?ItemMaster $item, Collection $variations): array
|
|
{
|
|
if (! $item) {
|
|
return ['unit_cost' => 0, 'source' => 'none'];
|
|
}
|
|
|
|
if ($item->variation_id) {
|
|
$variation = $variations->get($item->variation_id);
|
|
if ($variation && (float) $variation->dpp_inc_tax > 0) {
|
|
return ['unit_cost' => (float) $variation->dpp_inc_tax, 'source' => 'erp_purchase_price'];
|
|
}
|
|
}
|
|
|
|
$supplierCost = $this->sourcingService->resolveItemCost($item);
|
|
|
|
return [
|
|
'unit_cost' => $supplierCost,
|
|
'source' => $supplierCost > 0 ? 'supplier_or_standard' : 'standard',
|
|
];
|
|
}
|
|
}
|