ultimatepos/Modules/IndustrialEngineering/Services/ConnectionResolutionService.php

246 lines
9.3 KiB
PHP

<?php
namespace Modules\IndustrialEngineering\Services;
use App\Business;
use App\BusinessLocation;
use App\Product;
use App\Unit;
use App\Variation;
use App\Utils\ProductUtil;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Modules\IndustrialEngineering\Models\Bom;
use Modules\IndustrialEngineering\Models\ItemMaster;
use Modules\IndustrialEngineering\Models\LineTemplate;
use Modules\IndustrialEngineering\Support\ActorResolver;
use Modules\IndustrialEngineering\Support\CmmsAvailability;
/**
* Resolves ERP/CMMS gaps: match existing products or create them in inventory.
*/
class ConnectionResolutionService
{
public function __construct(
protected ErpItemLinkService $erpItemLink,
protected ConnectionGateService $connectionGate,
protected IntegrationBridgeService $integrationBridge,
protected ProductUtil $productUtil
) {
}
public function resolveLine(LineTemplate $line, Bom $mbom, bool $syncCmms = true): array
{
$result = $this->resolveBom($mbom, true);
$result['cmms_synced'] = false;
$result['cmms_parts_count'] = 0;
$line->refresh();
if ($syncCmms && CmmsAvailability::isReady()) {
try {
$sync = $this->integrationBridge->syncLineBomToMaintenance($line, $mbom->fresh());
$line = $sync['line'];
$result['cmms_synced'] = true;
$result['cmms_parts_count'] = $sync['parts_count'] ?? 0;
$result['messages'][] = ['type' => 'success', 'text' => __('industrialengineering::lang.cmms_synced_parts', [
'code' => $line->effectiveCode(),
'count' => $result['cmms_parts_count'],
])];
} catch (\Throwable $e) {
Log::warning('IE resolve CMMS: '.$e->getMessage());
$result['messages'][] = ['type' => 'warning', 'text' => __('industrialengineering::lang.cmms_sync_failed', ['error' => $e->getMessage()])];
}
}
$mbom->unsetRelation('lines');
$result['gate'] = $this->connectionGate->assessBom($mbom->fresh(), 1);
$result['fully_resolved'] = ($result['gate']['can_run_mrp'] ?? false)
&& (! CmmsAvailability::isReady() || (bool) $line->fresh()->maintenance_equipment_id);
$result['summary'] = $this->buildSummary($result);
return $result;
}
public function resolveBom(Bom $bom, bool $createMissing = true): array
{
$gate = $this->connectionGate->assessBom($bom, 1);
$itemIds = collect($gate['analysis']['leaf_lines'] ?? [])
->filter(fn ($row) => empty($row['variation_id']) && ! empty($row['item_master_id']))
->pluck('item_master_id')
->unique()
->values();
$stats = ['matched' => 0, 'created' => 0, 'failed' => 0];
$messages = [];
$failures = [];
foreach ($itemIds as $itemId) {
$item = ItemMaster::forBusiness($bom->business_id)->find($itemId);
if (! $item) {
continue;
}
$outcome = $this->resolveItem($item, $createMissing);
if ($outcome['action'] === 'failed') {
$stats['failed']++;
} elseif ($outcome['action'] === 'created') {
$stats['created']++;
} else {
$stats['matched']++;
}
if ($outcome['action'] === 'failed') {
$failures[] = $outcome['message'];
$messages[] = ['type' => 'warning', 'text' => $outcome['message']];
} elseif ($outcome['action'] === 'created') {
$messages[] = ['type' => 'success', 'text' => __('industrialengineering::lang.erp_product_created', [
'code' => $item->item_code,
'name' => $item->name,
])];
} else {
$messages[] = ['type' => 'info', 'text' => $outcome['message']];
}
}
return [
'stats' => $stats,
'messages' => $messages,
'failures' => $failures,
'gate_before' => $gate,
'gate' => $this->connectionGate->assessBom($bom->fresh(), 1),
];
}
protected function resolveActorId(int $businessId): int
{
return ActorResolver::id($businessId) ?? 1;
}
public function resolveItem(ItemMaster $item, bool $createMissing = true): array
{
if ($item->variation_id) {
return ['action' => 'matched', 'message' => __('industrialengineering::lang.auto_erp_already_linked')];
}
$match = $this->erpItemLink->autoMatchProduct($item->fresh());
if ($match['linked']) {
return ['action' => 'matched', 'message' => $match['message']];
}
if (! $createMissing) {
return ['action' => 'failed', 'message' => $match['message']];
}
return $this->provisionErpProduct($item);
}
public function provisionErpProduct(ItemMaster $item): array
{
try {
return DB::transaction(function () use ($item) {
$unitId = Unit::where('business_id', $item->business_id)
->whereNull('base_unit_id')
->value('id')
?? Unit::where('business_id', $item->business_id)->value('id');
if (! $unitId) {
throw new \RuntimeException(__('industrialengineering::lang.erp_no_unit'));
}
$sku = trim((string) $item->item_code) ?: ('IE-'.$item->id);
$existingVariation = Variation::query()
->whereHas('product', fn ($q) => $q->where('business_id', $item->business_id))
->where('sub_sku', $sku)
->first();
if ($existingVariation) {
$this->erpItemLink->linkItem($item, ['variation_id' => $existingVariation->id]);
return [
'action' => 'matched',
'message' => __('industrialengineering::lang.auto_erp_matched', [
'name' => $this->erpItemLink->variationLabel($existingVariation),
]),
'variation_id' => $existingVariation->id,
];
}
$cost = max(0, (float) ($item->standard_cost ?? 0));
$product = Product::create([
'business_id' => $item->business_id,
'name' => $item->name,
'type' => 'single',
'unit_id' => $unitId,
'sku' => $sku,
'enable_stock' => 1,
'not_for_selling' => 0,
'created_by' => $this->resolveActorId($item->business_id),
'tax_type' => 'exclusive',
'barcode_type' => 'C128',
]);
$locationIds = BusinessLocation::where('business_id', $item->business_id)->pluck('id');
if ($locationIds->isNotEmpty()) {
$product->product_locations()->sync($locationIds);
}
$this->productUtil->createSingleProductVariation(
$product->id,
$sku,
$cost,
$cost,
0,
$cost,
$cost
);
$variation = $product->fresh()->variations()->first();
if (! $variation) {
throw new \RuntimeException(__('industrialengineering::lang.erp_provision_failed', ['code' => $item->item_code]));
}
$this->erpItemLink->linkItem($item, ['variation_id' => $variation->id]);
return [
'action' => 'created',
'message' => __('industrialengineering::lang.erp_product_created', [
'code' => $item->item_code,
'name' => $item->name,
]),
'product_id' => $product->id,
'variation_id' => $variation->id,
];
});
} catch (\Throwable $e) {
Log::error('IE ERP provision '.$item->item_code.': '.$e->getMessage());
return [
'action' => 'failed',
'message' => __('industrialengineering::lang.erp_provision_failed', ['code' => $item->item_code]).' — '.$e->getMessage(),
];
}
}
protected function buildSummary(array $result): string
{
$s = $result['stats'];
$gate = $result['gate'] ?? [];
$parts = [
__('industrialengineering::lang.resolve_summary_matched', ['n' => $s['matched'] ?? 0]),
__('industrialengineering::lang.resolve_summary_created', ['n' => $s['created'] ?? 0]),
];
if (($s['failed'] ?? 0) > 0) {
$parts[] = __('industrialengineering::lang.resolve_summary_failed', ['n' => $s['failed']]);
}
if (! empty($gate['can_run_mrp'])) {
$parts[] = __('industrialengineering::lang.resolve_mrp_ready');
} elseif (($s['failed'] ?? 0) === 0 && ($s['matched'] ?? 0) === 0 && ($s['created'] ?? 0) === 0) {
$parts[] = __('industrialengineering::lang.resolve_nothing_todo');
}
return implode(' — ', $parts);
}
}