ultimatepos/Modules/IndustrialEngineering/Services/MrpService.php

421 lines
15 KiB
PHP

<?php
namespace Modules\IndustrialEngineering\Services;
use App\BusinessLocation;
use App\Contact;
use App\Transaction;
use App\Utils\ProductUtil;
use App\Utils\TransactionUtil;
use App\Variation;
use Illuminate\Support\Facades\DB;
use Modules\IndustrialEngineering\Models\Bom;
use Modules\IndustrialEngineering\Models\ItemMaster;
use Modules\IndustrialEngineering\Models\LineRevision;
use Modules\IndustrialEngineering\Models\ManufacturingWorkOrder;
use Modules\IndustrialEngineering\Models\MrpRun;
use Modules\IndustrialEngineering\Models\ProcurementTask;
use Modules\IndustrialEngineering\Models\ReleasePackage;
use Modules\IndustrialEngineering\Models\SupplierPartNumber;
use Modules\IndustrialEngineering\Support\ActorResolver;
class MrpService
{
public function __construct(
protected LiveBomCostingService $liveCosting,
protected BomService $bomService,
protected ReleaseService $releaseService,
protected SourcingService $sourcingService,
protected ConnectionGateService $connectionGate,
protected ProductUtil $productUtil,
protected TransactionUtil $transactionUtil
) {
}
/**
* Explode BOM leaves for MRP analysis (legacy entry point).
*
* @return array<string, mixed>
*/
public function explodeBOM(int|string $bomId): array
{
$bom = Bom::with(['lines.itemMaster', 'lineRevision'])->findOrFail((int) $bomId);
return $this->liveCosting->analyze($bom, 1.0);
}
public function run(
int $businessId,
Bom $bom,
LineRevision $revision,
float $plannedQty = 1,
?int $locationId = null,
bool $createPurchaseOrders = true,
bool $createWorkOrder = true,
?int $supplierContactId = null,
bool $forceMrp = false
): MrpRun {
$this->connectionGate->assertMrpAllowed($bom, $plannedQty, $forceMrp);
return DB::transaction(function () use ($businessId, $bom, $revision, $plannedQty, $locationId, $createPurchaseOrders, $createWorkOrder, $supplierContactId) {
$analysis = $this->liveCosting->analyze($bom, $plannedQty, $locationId);
$analysis = $this->applySupplyChainDemandToAnalysis($businessId, $analysis);
$runCode = 'MRP-'.now()->format('Ymd-His');
$purchaseOrderIds = [];
if ($createPurchaseOrders && ! empty($analysis['shortages'])) {
$purchaseOrderIds = $this->createPurchaseOrdersForShortages(
$businessId,
$analysis['shortages'],
$locationId,
$supplierContactId
);
}
$workOrder = null;
if ($createWorkOrder) {
$workOrder = $this->createProductionWorkOrder($businessId, $revision, $bom, $plannedQty, $locationId);
}
$run = MrpRun::create([
'business_id' => $businessId,
'line_revision_id' => $revision->id,
'bom_id' => $bom->id,
'run_code' => $runCode,
'planned_quantity' => $plannedQty,
'location_id' => $locationId,
'status' => 'completed',
'shortage_analysis' => $analysis,
'purchase_order_ids' => $purchaseOrderIds,
'work_order_id' => $workOrder?->id,
'created_by' => ActorResolver::id($businessId),
'notes' => __('industrialengineering::lang.mrp_auto_run'),
]);
$this->notifySupplyChainAfterMrpRun($run);
return $run;
});
}
protected function notifySupplyChainAfterMrpRun(MrpRun $run): void
{
try {
$moduleUtil = app(\App\Utils\ModuleUtil::class);
if ($moduleUtil->isModuleInstalled('SupplyChain')) {
$moduleUtil->getModuleData('after_mrp_run', $run, ['SupplyChain']);
}
} catch (\Throwable $e) {
\Log::warning('MRP SupplyChain hook failed: '.$e->getMessage());
}
}
/**
* @param array<string, mixed> $analysis
* @return array<string, mixed>
*/
protected function applySupplyChainDemandToAnalysis(int $businessId, array $analysis): array
{
try {
if (! class_exists(\Modules\SupplyChain\Services\SupplyChainIntegrationService::class)) {
return $analysis;
}
$moduleUtil = app(\App\Utils\ModuleUtil::class);
if (! $moduleUtil->isModuleInstalled('SupplyChain')) {
return $analysis;
}
return app(\Modules\SupplyChain\Services\SupplyChainIntegrationService::class)
->enrichMrpAnalysis($businessId, $analysis);
} catch (\Throwable $e) {
\Log::warning('MRP SupplyChain demand enrichment failed: '.$e->getMessage());
return $analysis;
}
}
/**
* @param array<int, array<string, mixed>> $shortages
* @return array<int, int>
*/
public function createPurchaseOrdersForShortages(
int $businessId,
array $shortages,
?int $locationId,
?int $defaultSupplierId = null
): array {
$locationId = $locationId ?? BusinessLocation::where('business_id', $businessId)->value('id');
if (! $locationId) {
throw new \RuntimeException(__('industrialengineering::lang.no_business_location'));
}
$supplierCache = $this->buildSupplierCache($businessId, $shortages, $defaultSupplierId);
$grouped = collect($shortages)
->filter(fn ($s) => ! empty($s['variation_id']) && (float) $s['shortage_qty'] > 0)
->groupBy(fn ($s) => $this->resolveSupplierIdFromCache(
(int) $s['item_master_id'],
$defaultSupplierId,
$supplierCache
));
$createdIds = [];
foreach ($grouped as $supplierId => $items) {
if (! $supplierId) {
foreach ($items as $item) {
$this->createProcurementTask($businessId, $item);
}
continue;
}
$transaction = $this->createPurchaseOrderTransaction(
$businessId,
(int) $supplierId,
(int) $locationId,
$items->all()
);
$createdIds[] = $transaction->id;
foreach ($items as $item) {
ProcurementTask::create([
'business_id' => $businessId,
'item_master_id' => $item['item_master_id'],
'purchase_requisition_id' => $transaction->id,
'task_type' => 'create_po',
'title' => __('industrialengineering::lang.po_line_for', ['item' => $item['item_name']]),
'status' => 'completed',
'assigned_to' => ActorResolver::id($businessId),
'completed_at' => now(),
'notes' => 'MRP auto-generated PO #'.$transaction->ref_no,
]);
}
}
return $createdIds;
}
public function createProductionWorkOrder(
int $businessId,
LineRevision $revision,
Bom $mbom,
float $plannedQty,
?int $locationId
): ManufacturingWorkOrder {
$releasePackage = $revision->releasePackages()->where('status', 'released')->latest('id')->first();
if (! $releasePackage) {
$releasePackage = ReleasePackage::create([
'business_id' => $businessId,
'line_revision_id' => $revision->id,
'mbom_id' => $mbom->id,
'package_code' => 'MRP-REL-'.now()->format('YmdHis'),
'name' => $revision->name.' — MRP Auto Release',
'status' => 'released',
'mbom_snapshot' => $this->bomService->snapshot($mbom),
'released_by' => ActorResolver::id($businessId),
'released_at' => now(),
]);
}
$woNumber = $this->nextWorkOrderNumber($businessId);
return ManufacturingWorkOrder::create([
'business_id' => $businessId,
'release_package_id' => $releasePackage->id,
'line_revision_id' => $revision->id,
'location_id' => $locationId,
'work_order_number' => $woNumber,
'planned_quantity' => $plannedQty,
'status' => 'planned',
'bom_snapshot' => $this->bomService->snapshot($mbom),
'routing_snapshot' => $this->safeRoutingSnapshot($revision),
'assigned_to' => ActorResolver::id($businessId),
]);
}
protected function createPurchaseOrderTransaction(
int $businessId,
int $supplierId,
int $locationId,
array $items
): Transaction {
$currency = $this->transactionUtil->purchaseCurrencyDetails($businessId);
$purchases = [];
$totalBeforeTax = 0;
$variationIds = collect($items)->pluck('variation_id')->filter()->unique()->values()->all();
$variations = Variation::with('product')->whereIn('id', $variationIds)->get()->keyBy('id');
foreach ($items as $row) {
$variation = $variations->get($row['variation_id']);
if (! $variation) {
continue;
}
$qty = (float) $row['shortage_qty'];
$unitCost = (float) $row['live_unit_cost'];
$lineTotal = $qty * $unitCost;
$totalBeforeTax += $lineTotal;
$purchases[] = [
'product_id' => $variation->product_id,
'product_unit_id' => $variation->product->unit_id,
'variation_id' => $variation->id,
'quantity' => $qty,
'pp_without_discount' => $unitCost,
'purchase_price' => $unitCost,
'purchase_price_inc_tax' => $unitCost,
'item_tax' => 0,
'tax_id' => null,
'purchase_line_tax_id' => null,
'discount_percent' => 0,
];
}
if (empty($purchases)) {
throw new \RuntimeException(__('industrialengineering::lang.no_purchasable_shortages'));
}
$refCount = $this->productUtil->setAndGetReferenceCount('purchase_order');
$refNo = $this->productUtil->generateReferenceNumber('purchase_order', $refCount);
$transaction = Transaction::create([
'business_id' => $businessId,
'location_id' => $locationId,
'type' => 'purchase_order',
'status' => 'ordered',
'contact_id' => $supplierId,
'transaction_date' => now()->toDateTimeString(),
'total_before_tax' => $totalBeforeTax,
'tax_amount' => 0,
'discount_type' => 'fixed',
'discount_amount' => 0,
'final_total' => $totalBeforeTax,
'created_by' => ActorResolver::id($businessId),
'ref_no' => $refNo,
'additional_notes' => __('industrialengineering::lang.mrp_po_note'),
]);
$this->productUtil->createOrUpdatePurchaseLines($transaction, $purchases, $currency, false);
$this->transactionUtil->activityLog($transaction, 'added');
return $transaction->fresh(['purchase_lines']);
}
protected function resolveSupplierId(int $itemMasterId, ?int $defaultSupplierId): ?int
{
return $this->resolveSupplierIdFromCache($itemMasterId, $defaultSupplierId, []);
}
/**
* @param array<int, int|null> $cache
*/
protected function resolveSupplierIdFromCache(int $itemMasterId, ?int $defaultSupplierId, array $cache): ?int
{
if (array_key_exists($itemMasterId, $cache)) {
return $cache[$itemMasterId];
}
if ($defaultSupplierId) {
return $defaultSupplierId;
}
$spn = SupplierPartNumber::with('supplier')
->where('item_master_id', $itemMasterId)
->where('is_approved', true)
->orderByDesc('is_preferred')
->first();
if ($spn?->supplier?->contact_id) {
return (int) $spn->supplier->contact_id;
}
$businessId = ItemMaster::where('id', $itemMasterId)->value('business_id');
return Contact::where('type', 'supplier')
->where('business_id', $businessId)
->value('id');
}
/**
* @param array<int, array<string, mixed>> $shortages
* @return array<int, int|null>
*/
protected function buildSupplierCache(int $businessId, array $shortages, ?int $defaultSupplierId): array
{
if ($defaultSupplierId) {
return [];
}
$itemMasterIds = collect($shortages)->pluck('item_master_id')->filter()->unique()->values()->all();
$cache = array_fill_keys($itemMasterIds, null);
$preferredSuppliers = SupplierPartNumber::with('supplier')
->whereIn('item_master_id', $itemMasterIds)
->where('is_approved', true)
->orderByDesc('is_preferred')
->get()
->groupBy('item_master_id');
foreach ($itemMasterIds as $itemMasterId) {
$spn = $preferredSuppliers->get($itemMasterId)?->first();
if ($spn?->supplier?->contact_id) {
$cache[$itemMasterId] = (int) $spn->supplier->contact_id;
}
}
$fallbackSupplierId = Contact::where('type', 'supplier')
->where('business_id', $businessId)
->value('id');
foreach ($cache as $itemMasterId => $supplierId) {
if ($supplierId === null && $fallbackSupplierId) {
$cache[$itemMasterId] = (int) $fallbackSupplierId;
}
}
return $cache;
}
protected function nextWorkOrderNumber(int $businessId): string
{
$prefix = 'WO-'.now()->format('Ymd').'-';
$latest = ManufacturingWorkOrder::forBusiness($businessId)
->where('work_order_number', 'like', $prefix.'%')
->lockForUpdate()
->orderByDesc('id')
->value('work_order_number');
$sequence = 1;
if ($latest && preg_match('/-(\d+)$/', (string) $latest, $matches)) {
$sequence = ((int) $matches[1]) + 1;
}
return $prefix.str_pad((string) $sequence, 4, '0', STR_PAD_LEFT);
}
protected function createProcurementTask(int $businessId, array $item): void
{
$this->sourcingService->createSourcingCase(
$businessId,
(int) $item['item_master_id'],
(float) $item['shortage_qty'],
null,
now()->addDays(14)->toDateString(),
'high'
);
}
protected function safeRoutingSnapshot(LineRevision $revision): array
{
try {
return $revision->routingOperations()->with('workCenter')->get()->values()->all();
} catch (\Throwable) {
return [];
}
}
}