ultimatepos/Modules/IndustrialEngineering/Services/BomTreeService.php

335 lines
12 KiB
PHP

<?php
namespace Modules\IndustrialEngineering\Services;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use Modules\IndustrialEngineering\Models\Bom;
use Modules\IndustrialEngineering\Models\BomLine;
use Modules\IndustrialEngineering\Models\ItemMaster;
class BomTreeService
{
protected array $parentAllowedTypes = ['assembly', 'subassembly', 'device'];
public function __construct(
protected CodeGeneratorService $codeGenerator,
protected ErpItemLinkService $erpItemLink,
protected AutoIntegrationService $autoIntegration
) {
}
public function buildEditorTree(Bom $bom): array
{
$bom->load(['rootLines.itemMaster.variation', 'rootLines.itemMaster.product']);
return $bom->rootLines->map(fn (BomLine $line) => $this->lineToEditorNode($line))->values()->all();
}
public function lineToEditorNode(BomLine $line): array
{
$line->loadMissing(['itemMaster.variation', 'itemMaster.product', 'children']);
$item = $line->itemMaster;
return [
'id' => $line->id,
'bom_line_id' => $line->id,
'item_master_id' => $line->item_master_id,
'item_code' => $item?->item_code,
'item_name' => $item?->name,
'item_type' => $item?->item_type,
'position_code' => $line->position_code,
'find_number' => $line->find_number,
'quantity' => (float) $line->quantity,
'line_type' => $line->line_type,
'is_serviceable' => (bool) $line->is_serviceable,
'standard_cost' => (float) ($line->standard_cost ?: $item?->standard_cost ?? 0),
'sort_order' => (int) $line->sort_order,
'variation_id' => $item?->variation_id,
'product_id' => $item?->product_id,
'erp_label' => $this->erpItemLink->erpLabel($item),
'is_assembly' => in_array($item?->item_type, ['assembly', 'subassembly', 'device'], true),
'children' => $line->children->map(fn (BomLine $child) => $this->lineToEditorNode($child))->values()->all(),
];
}
/**
* Add a BOM node under any parent (null = root). Creates or links ItemMaster.
*/
public function addNode(Bom $bom, ?int $parentLineId, array $data): BomLine
{
return DB::transaction(function () use ($bom, $parentLineId, $data) {
$this->assertSameBomParent($bom, $parentLineId);
$item = $this->resolveItemForNode($bom->business_id, $data);
$parentLine = $parentLineId ? BomLine::findOrFail($parentLineId) : null;
if ($parentLine) {
$this->assertCanContainChildren($parentLine);
}
$siblingQuery = $bom->lines()->when($parentLineId, fn ($q) => $q->where('parent_line_id', $parentLineId), fn ($q) => $q->whereNull('parent_line_id'));
return BomLine::create([
'business_id' => $bom->business_id,
'bom_id' => $bom->id,
'parent_line_id' => $parentLineId,
'item_master_id' => $item->id,
'quantity' => (float) ($data['quantity'] ?? 1),
'position_code' => $data['position_code'] ?? null,
'find_number' => $data['find_number'] ?? null,
'line_type' => $data['line_type'] ?? 'component',
'is_serviceable' => $data['is_serviceable'] ?? true,
'standard_cost' => $data['standard_cost'] ?? $item->standard_cost ?? 0,
'sort_order' => (int) $siblingQuery->max('sort_order') + 10,
'notes' => $data['notes'] ?? null,
]);
});
}
public function updateNode(BomLine $line, array $data): BomLine
{
return DB::transaction(function () use ($line, $data) {
if (isset($data['quantity'])) {
$line->quantity = (float) $data['quantity'];
}
foreach (['position_code', 'find_number', 'line_type', 'notes'] as $field) {
if (array_key_exists($field, $data)) {
$line->{$field} = $data[$field];
}
}
if (isset($data['is_serviceable'])) {
$line->is_serviceable = (bool) $data['is_serviceable'];
}
if (isset($data['standard_cost'])) {
$line->standard_cost = (float) $data['standard_cost'];
}
$line->save();
if (! empty($data['item_master_id']) && (int) $data['item_master_id'] !== (int) $line->item_master_id) {
$item = ItemMaster::forBusiness($line->business_id)->findOrFail($data['item_master_id']);
$line->update(['item_master_id' => $item->id]);
}
if (! empty($data['variation_id']) || ! empty($data['product_id'])) {
$item = $line->itemMaster;
$this->erpItemLink->linkItem($item, $data);
}
return $line->fresh(['itemMaster']);
});
}
public function moveNode(BomLine $line, ?int $newParentId, int $sortOrder = 0): BomLine
{
return DB::transaction(function () use ($line, $newParentId, $sortOrder) {
if ($newParentId === $line->id) {
throw new InvalidArgumentException(__('industrialengineering::lang.cannot_move_to_self'));
}
if ($newParentId && $this->isDescendant($line->id, $newParentId)) {
throw new InvalidArgumentException(__('industrialengineering::lang.cannot_move_to_descendant'));
}
$this->assertSameBomParent($line->bom, $newParentId);
if ($newParentId) {
$newParent = BomLine::findOrFail($newParentId);
$this->assertCanContainChildren($newParent);
}
$line->update([
'parent_line_id' => $newParentId,
'sort_order' => $sortOrder,
]);
return $line->fresh();
});
}
/**
* Apply nestable plugin JSON output to BOM structure.
*
* @param array<int, array{id:int, children?:array}> $nodes
*/
public function applyNestable(Bom $bom, array $nodes, ?int $parentId = null): void
{
DB::transaction(function () use ($bom, $nodes, $parentId) {
foreach ($nodes as $index => $node) {
$lineId = (int) ($node['id'] ?? 0);
$line = BomLine::where('bom_id', $bom->id)->findOrFail($lineId);
if ($parentId && $this->isDescendant($lineId, $parentId)) {
throw new InvalidArgumentException(__('industrialengineering::lang.cannot_move_to_descendant'));
}
if ($parentId) {
$parentLine = BomLine::where('bom_id', $bom->id)->findOrFail($parentId);
$this->assertCanContainChildren($parentLine);
}
$line->update([
'parent_line_id' => $parentId,
'sort_order' => ($index + 1) * 10,
]);
if (! empty($node['children'])) {
$this->applyNestable($bom, $node['children'], $lineId);
}
}
});
}
public function deleteNode(BomLine $line): void
{
DB::transaction(function () use ($line) {
$this->deleteRecursive($line);
});
}
/**
* Duplicate a subtree (same item references) under a new parent.
*
* @return array{root_line_id:int, created_count:int}
*/
public function duplicateSubtree(Bom $bom, BomLine $sourceLine, ?int $targetParentLineId): array
{
if ((int) $sourceLine->bom_id !== (int) $bom->id) {
throw new InvalidArgumentException(__('industrialengineering::lang.parent_bom_mismatch'));
}
$this->assertSameBomParent($bom, $targetParentLineId);
if ($targetParentLineId) {
$targetParent = BomLine::findOrFail($targetParentLineId);
$this->assertCanContainChildren($targetParent);
if ($this->isDescendant($sourceLine->id, $targetParentLineId)) {
throw new InvalidArgumentException(__('industrialengineering::lang.cannot_paste_into_descendant'));
}
}
return DB::transaction(function () use ($bom, $sourceLine, $targetParentLineId) {
$sourceLine->load(['children.itemMaster', 'itemMaster']);
$createdCount = 0;
$root = $this->duplicateLineRecursive($bom, $sourceLine, $targetParentLineId, $createdCount);
return [
'root_line_id' => $root->id,
'created_count' => $createdCount,
];
});
}
protected function duplicateLineRecursive(Bom $bom, BomLine $source, ?int $parentLineId, int &$createdCount): BomLine
{
$siblingQuery = $bom->lines()->when(
$parentLineId,
fn ($q) => $q->where('parent_line_id', $parentLineId),
fn ($q) => $q->whereNull('parent_line_id')
);
$newLine = BomLine::create([
'business_id' => $bom->business_id,
'bom_id' => $bom->id,
'parent_line_id' => $parentLineId,
'item_master_id' => $source->item_master_id,
'quantity' => (float) $source->quantity,
'position_code' => $source->position_code,
'find_number' => $source->find_number,
'line_type' => $source->line_type,
'is_serviceable' => (bool) $source->is_serviceable,
'standard_cost' => (float) $source->standard_cost,
'sort_order' => (int) $siblingQuery->max('sort_order') + 10,
'notes' => $source->notes,
]);
$createdCount++;
foreach ($source->children as $child) {
$child->load(['children.itemMaster', 'itemMaster']);
$this->duplicateLineRecursive($bom, $child, $newLine->id, $createdCount);
}
return $newLine;
}
protected function deleteRecursive(BomLine $line): void
{
foreach ($line->children as $child) {
$this->deleteRecursive($child);
}
$line->delete();
}
public function isDescendant(int $ancestorId, int $nodeId): bool
{
$current = BomLine::find($nodeId);
while ($current && $current->parent_line_id) {
if ((int) $current->parent_line_id === $ancestorId) {
return true;
}
$current = $current->parentLine;
}
return false;
}
public function flattenForCost(Bom $bom): Collection
{
return app(BomService::class)->explode($bom);
}
protected function assertSameBomParent(Bom $bom, ?int $parentLineId): void
{
if (! $parentLineId) {
return;
}
$parent = BomLine::findOrFail($parentLineId);
if ((int) $parent->bom_id !== (int) $bom->id) {
throw new InvalidArgumentException(__('industrialengineering::lang.parent_bom_mismatch'));
}
}
protected function resolveItemForNode(int $businessId, array $data): ItemMaster
{
if (! empty($data['item_master_id'])) {
return ItemMaster::forBusiness($businessId)->findOrFail($data['item_master_id']);
}
$itemCodes = $this->codeGenerator->resolveCode(
'item',
$businessId,
$data['item_code'] ?? null,
new ItemMaster(),
'item_code'
);
$itemData = array_merge($itemCodes, [
'business_id' => $businessId,
'name' => $data['name'] ?? $itemCodes['item_code'],
'item_type' => $data['item_type'] ?? 'part',
'description' => $data['description'] ?? null,
'standard_cost' => $data['standard_cost'] ?? 0,
'is_serviceable' => $data['is_serviceable'] ?? true,
]);
$item = ItemMaster::create($itemData);
if (! empty($data['variation_id']) || ! empty($data['product_id'])) {
$this->erpItemLink->linkItem($item, $data);
} else {
$this->autoIntegration->afterItemCreated($item, $data);
}
return $item->fresh();
}
protected function assertCanContainChildren(BomLine $parentLine): void
{
$parentType = $parentLine->itemMaster?->item_type;
if (! in_array($parentType, $this->parentAllowedTypes, true)) {
throw new InvalidArgumentException(__('industrialengineering::lang.parent_type_cannot_have_children'));
}
}
}