335 lines
12 KiB
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'));
|
|
}
|
|
}
|
|
}
|