246 lines
9.3 KiB
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);
|
|
}
|
|
}
|