ultimatepos/Modules/IndustrialEngineering/Services/LiveBomCostingService.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',
];
}
}