421 lines
15 KiB
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 [];
|
|
}
|
|
}
|
|
}
|