559 lines
19 KiB
PHP
559 lines
19 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Design;
|
|
|
|
use App\Models\Design\BomTemplate;
|
|
use App\Models\Design\BomTemplateItem;
|
|
use App\Models\Design\ModelVersion;
|
|
use App\Models\Materials\Material;
|
|
use App\Models\Products\Product;
|
|
use App\Services\Service;
|
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
|
|
class BomTemplateService extends Service
|
|
{
|
|
/** 페이징 목록(옵션: 모델버전 필터) */
|
|
public function paginate(?int $modelVersionId, int $page = 1, int $size = 20): LengthAwarePaginator
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$q = BomTemplate::query()->where('tenant_id', $tenantId);
|
|
|
|
if ($modelVersionId) {
|
|
$q->where('model_version_id', $modelVersionId);
|
|
}
|
|
|
|
return $q->orderByDesc('is_primary')
|
|
->orderBy('name')
|
|
->paginate($size, ['*'], 'page', $page);
|
|
}
|
|
|
|
/** 템플릿 upsert(name 기준) */
|
|
public function upsertTemplate(int $modelVersionId, string $name = 'Main', bool $isPrimary = true, ?string $notes = null): BomTemplate
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$mv = ModelVersion::query()
|
|
->where('tenant_id', $tenantId)
|
|
->find($modelVersionId);
|
|
|
|
if (! $mv) {
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
}
|
|
|
|
return DB::transaction(function () use ($tenantId, $mv, $name, $isPrimary, $notes) {
|
|
$tpl = BomTemplate::query()
|
|
->where('model_version_id', $mv->id)
|
|
->where('name', $name)
|
|
->first();
|
|
|
|
$action = 'created';
|
|
$before = null;
|
|
|
|
if (! $tpl) {
|
|
$tpl = BomTemplate::create([
|
|
'tenant_id' => $tenantId,
|
|
'model_version_id' => $mv->id,
|
|
'name' => $name,
|
|
'is_primary' => $isPrimary,
|
|
'notes' => $notes,
|
|
]);
|
|
} else {
|
|
$action = 'updated';
|
|
$before = $tpl->toArray();
|
|
$tpl->fill(['is_primary' => $isPrimary, 'notes' => $notes])->save();
|
|
}
|
|
|
|
if ($isPrimary) {
|
|
BomTemplate::query()
|
|
->where('model_version_id', $mv->id)
|
|
->where('id', '<>', $tpl->id)
|
|
->update(['is_primary' => false]);
|
|
}
|
|
|
|
// 감사 로그
|
|
app(\App\Services\Audit\AuditLogger::class)->log(
|
|
tenantId: $tenantId,
|
|
targetType: 'bom_template',
|
|
targetId: $tpl->id,
|
|
action: $action,
|
|
before: $before,
|
|
after: $tpl->toArray()
|
|
);
|
|
|
|
return $tpl;
|
|
});
|
|
}
|
|
|
|
/** 템플릿 메타 수정 */
|
|
public function updateTemplate(int $templateId, array $data): BomTemplate
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$tpl = BomTemplate::query()
|
|
->where('tenant_id', $tenantId)
|
|
->find($templateId);
|
|
|
|
if (! $tpl) {
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
}
|
|
|
|
$name = $data['name'] ?? $tpl->name;
|
|
if ($name !== $tpl->name) {
|
|
$dup = BomTemplate::query()
|
|
->where('model_version_id', $tpl->model_version_id)
|
|
->where('name', $name)
|
|
->where('id', '<>', $tpl->id)
|
|
->exists();
|
|
if ($dup) {
|
|
throw ValidationException::withMessages(['name' => __('error.duplicate')]);
|
|
}
|
|
}
|
|
|
|
$before = $tpl->toArray();
|
|
$tpl->fill($data)->save();
|
|
|
|
if (array_key_exists('is_primary', $data) && $data['is_primary']) {
|
|
// 다른 템플릿 대표 해제
|
|
BomTemplate::query()
|
|
->where('model_version_id', $tpl->model_version_id)
|
|
->where('id', '<>', $tpl->id)
|
|
->update(['is_primary' => false]);
|
|
}
|
|
|
|
// 감사 로그
|
|
app(\App\Services\Audit\AuditLogger::class)->log(
|
|
tenantId: $tenantId,
|
|
targetType: 'bom_template',
|
|
targetId: $tpl->id,
|
|
action: 'updated',
|
|
before: $before,
|
|
after: $tpl->toArray()
|
|
);
|
|
|
|
return $tpl;
|
|
}
|
|
|
|
/** 템플릿 삭제(soft) */
|
|
public function deleteTemplate(int $templateId): void
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$tpl = BomTemplate::query()
|
|
->where('tenant_id', $tenantId)
|
|
->find($templateId);
|
|
|
|
if (! $tpl) {
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
}
|
|
|
|
$before = $tpl->toArray();
|
|
$tpl->delete();
|
|
|
|
// 감사 로그
|
|
app(\App\Services\Audit\AuditLogger::class)->log(
|
|
tenantId: $tenantId,
|
|
targetType: 'bom_template',
|
|
targetId: $tpl->id,
|
|
action: 'deleted',
|
|
before: $before,
|
|
after: null
|
|
);
|
|
}
|
|
|
|
/** 템플릿 상세 (옵션: 항목 포함) */
|
|
public function show(int $templateId, bool $withItems = false): BomTemplate
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$q = BomTemplate::query()->where('tenant_id', $tenantId);
|
|
if ($withItems) {
|
|
$q->with(['items' => fn ($w) => $w->orderBy('sort_order')]);
|
|
}
|
|
|
|
$tpl = $q->find($templateId);
|
|
if (! $tpl) {
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
}
|
|
|
|
return $tpl;
|
|
}
|
|
|
|
/** 항목 일괄 치환 */
|
|
public function replaceItems(int $templateId, array $items): void
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$tpl = BomTemplate::query()
|
|
->where('tenant_id', $tenantId)
|
|
->find($templateId);
|
|
|
|
if (! $tpl) {
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
}
|
|
|
|
// 1차 검증
|
|
foreach ($items as $i => $row) {
|
|
$refType = strtoupper((string) Arr::get($row, 'ref_type'));
|
|
$qty = (float) Arr::get($row, 'qty', 0);
|
|
if (! in_array($refType, ['MATERIAL', 'PRODUCT'], true)) {
|
|
throw ValidationException::withMessages(["items.$i.ref_type" => __('error.validation_failed')]);
|
|
}
|
|
if ($qty <= 0) {
|
|
throw ValidationException::withMessages(["items.$i.qty" => __('error.validation_failed')]);
|
|
}
|
|
}
|
|
|
|
DB::transaction(function () use ($tenantId, $tpl, $items) {
|
|
// 이전 항목 스냅샷
|
|
$beforeItems = BomTemplateItem::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('bom_template_id', $tpl->id)
|
|
->orderBy('sort_order')
|
|
->get()
|
|
->map(fn ($i) => [
|
|
'ref_type' => strtoupper($i->ref_type),
|
|
'ref_id' => (int) $i->ref_id,
|
|
'qty' => (float) $i->qty,
|
|
'waste_rate' => (float) $i->waste_rate,
|
|
'uom_id' => $i->uom_id,
|
|
'notes' => $i->notes,
|
|
'sort_order' => (int) $i->sort_order,
|
|
])->all();
|
|
|
|
BomTemplateItem::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('bom_template_id', $tpl->id)
|
|
->delete();
|
|
|
|
$now = now();
|
|
$payloads = [];
|
|
foreach ($items as $row) {
|
|
$payloads[] = [
|
|
'tenant_id' => $tenantId,
|
|
'bom_template_id' => $tpl->id,
|
|
'ref_type' => strtoupper($row['ref_type']),
|
|
'ref_id' => (int) $row['ref_id'],
|
|
'qty' => (string) ($row['qty'] ?? 1),
|
|
'waste_rate' => (string) ($row['waste_rate'] ?? 0),
|
|
'uom_id' => $row['uom_id'] ?? null,
|
|
'notes' => $row['notes'] ?? null,
|
|
'sort_order' => (int) ($row['sort_order'] ?? 0),
|
|
'created_at' => $now,
|
|
'updated_at' => $now,
|
|
];
|
|
}
|
|
|
|
if (! empty($payloads)) {
|
|
BomTemplateItem::insert($payloads);
|
|
}
|
|
|
|
// 감사 로그
|
|
app(\App\Services\Audit\AuditLogger::class)->log(
|
|
tenantId: $tenantId,
|
|
targetType: 'bom_template',
|
|
targetId: $tpl->id,
|
|
action: 'items_replaced',
|
|
before: ['items' => $beforeItems],
|
|
after: ['items' => array_map(function ($p) {
|
|
return [
|
|
'ref_type' => $p['ref_type'],
|
|
'ref_id' => $p['ref_id'],
|
|
'qty' => (float) $p['qty'],
|
|
'waste_rate' => (float) $p['waste_rate'],
|
|
'uom_id' => $p['uom_id'],
|
|
'notes' => $p['notes'],
|
|
'sort_order' => $p['sort_order'],
|
|
];
|
|
}, $payloads)]
|
|
);
|
|
});
|
|
}
|
|
|
|
/** 항목 조회 */
|
|
public function listItems(int $templateId)
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$tpl = BomTemplate::query()
|
|
->where('tenant_id', $tenantId)
|
|
->find($templateId);
|
|
|
|
if (! $tpl) {
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
}
|
|
|
|
return BomTemplateItem::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('bom_template_id', $tpl->id)
|
|
->orderBy('sort_order')
|
|
->get();
|
|
}
|
|
|
|
/** 템플릿 간 diff */
|
|
public function diffTemplates(int $leftTemplateId, int $rightTemplateId): array
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$left = BomTemplate::query()->where('tenant_id', $tenantId)->find($leftTemplateId);
|
|
$right = BomTemplate::query()->where('tenant_id', $tenantId)->find($rightTemplateId);
|
|
|
|
if (! $left || ! $right) {
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
}
|
|
|
|
$leftItems = BomTemplateItem::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('bom_template_id', $left->id)
|
|
->get()
|
|
->map(fn ($i) => [
|
|
'key' => strtoupper($i->ref_type).':'.(int) $i->ref_id,
|
|
'ref_type' => strtoupper($i->ref_type),
|
|
'ref_id' => (int) $i->ref_id,
|
|
'qty' => (float) $i->qty,
|
|
'waste_rate' => (float) $i->waste_rate,
|
|
'uom_id' => $i->uom_id ? (int) $i->uom_id : null,
|
|
'notes' => $i->notes,
|
|
'sort_order' => (int) $i->sort_order,
|
|
])->keyBy('key');
|
|
|
|
$rightItems = BomTemplateItem::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('bom_template_id', $right->id)
|
|
->get()
|
|
->map(fn ($i) => [
|
|
'key' => strtoupper($i->ref_type).':'.(int) $i->ref_id,
|
|
'ref_type' => strtoupper($i->ref_type),
|
|
'ref_id' => (int) $i->ref_id,
|
|
'qty' => (float) $i->qty,
|
|
'waste_rate' => (float) $i->waste_rate,
|
|
'uom_id' => $i->uom_id ? (int) $i->uom_id : null,
|
|
'notes' => $i->notes,
|
|
'sort_order' => (int) $i->sort_order,
|
|
])->keyBy('key');
|
|
|
|
$added = [];
|
|
$removed = [];
|
|
$changed = [];
|
|
|
|
foreach ($rightItems as $key => $ri) {
|
|
if (! $leftItems->has($key)) {
|
|
$added[] = Arr::except($ri, ['key']);
|
|
} else {
|
|
$li = $leftItems[$key];
|
|
$diffs = [];
|
|
foreach (['qty', 'waste_rate', 'uom_id', 'notes', 'sort_order'] as $fld) {
|
|
if (($li[$fld] ?? null) !== ($ri[$fld] ?? null)) {
|
|
$diffs[$fld] = ['before' => $li[$fld] ?? null, 'after' => $ri[$fld] ?? null];
|
|
}
|
|
}
|
|
if (! empty($diffs)) {
|
|
$changed[] = [
|
|
'ref_type' => $ri['ref_type'],
|
|
'ref_id' => $ri['ref_id'],
|
|
'changes' => $diffs,
|
|
];
|
|
}
|
|
}
|
|
}
|
|
foreach ($leftItems as $key => $li) {
|
|
if (! $rightItems->has($key)) {
|
|
$removed[] = Arr::except($li, ['key']);
|
|
}
|
|
}
|
|
|
|
$result = [
|
|
'left_template_id' => $left->id,
|
|
'right_template_id' => $right->id,
|
|
'summary' => [
|
|
'added' => count($added),
|
|
'removed' => count($removed),
|
|
'changed' => count($changed),
|
|
],
|
|
'added' => $added,
|
|
'removed' => $removed,
|
|
'changed' => $changed,
|
|
];
|
|
|
|
if (config('audit.log_reads', false)) {
|
|
app(\App\Services\Audit\AuditLogger::class)->log(
|
|
tenantId: $tenantId,
|
|
targetType: 'bom_template',
|
|
targetId: $left->id,
|
|
action: 'diff_viewed',
|
|
before: ['left' => $left->id, 'right' => $right->id],
|
|
after: ['summary' => $result['summary']]
|
|
);
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/** 템플릿 복제(깊은 복사) */
|
|
public function cloneTemplate(int $templateId, ?int $targetVersionId = null, ?string $name = null, bool $isPrimary = false, ?string $notes = null): BomTemplate
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$src = BomTemplate::query()->where('tenant_id', $tenantId)->find($templateId);
|
|
if (! $src) {
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
}
|
|
|
|
$targetVersionId = $targetVersionId ?: $src->model_version_id;
|
|
|
|
$mv = ModelVersion::query()
|
|
->where('tenant_id', $tenantId)
|
|
->find($targetVersionId);
|
|
|
|
if (! $mv) {
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
}
|
|
|
|
// 이름 결정(중복 회피)
|
|
$baseName = $name ?: ($src->name.' Copy');
|
|
$newName = $baseName;
|
|
$i = 2;
|
|
while (BomTemplate::query()
|
|
->where('model_version_id', $mv->id)
|
|
->where('name', $newName)
|
|
->exists()) {
|
|
$newName = $baseName.' '.$i;
|
|
$i++;
|
|
}
|
|
|
|
return DB::transaction(function () use ($tenantId, $src, $mv, $newName, $isPrimary, $notes) {
|
|
$dest = BomTemplate::create([
|
|
'tenant_id' => $tenantId,
|
|
'model_version_id' => $mv->id,
|
|
'name' => $newName,
|
|
'is_primary' => $isPrimary,
|
|
'notes' => $notes ?? $src->notes,
|
|
]);
|
|
|
|
$now = now();
|
|
$items = BomTemplateItem::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('bom_template_id', $src->id)
|
|
->get()
|
|
->map(fn ($i) => [
|
|
'tenant_id' => $tenantId,
|
|
'bom_template_id' => $dest->id,
|
|
'ref_type' => strtoupper($i->ref_type),
|
|
'ref_id' => (int) $i->ref_id,
|
|
'qty' => (string) $i->qty,
|
|
'waste_rate' => (string) $i->waste_rate,
|
|
'uom_id' => $i->uom_id,
|
|
'notes' => $i->notes,
|
|
'sort_order' => (int) $i->sort_order,
|
|
'created_at' => $now,
|
|
'updated_at' => $now,
|
|
])->all();
|
|
|
|
if (! empty($items)) {
|
|
BomTemplateItem::insert($items);
|
|
}
|
|
|
|
if ($isPrimary) {
|
|
BomTemplate::query()
|
|
->where('model_version_id', $mv->id)
|
|
->where('id', '<>', $dest->id)
|
|
->update(['is_primary' => false]);
|
|
}
|
|
|
|
// 감사 로그
|
|
app(\App\Services\Audit\AuditLogger::class)->log(
|
|
tenantId: $tenantId,
|
|
targetType: 'bom_template',
|
|
targetId: $dest->id,
|
|
action: 'cloned',
|
|
before: ['source_template_id' => $src->id],
|
|
after: $dest->toArray()
|
|
);
|
|
|
|
return $dest;
|
|
});
|
|
}
|
|
|
|
/** 모델버전 릴리즈 전 유효성 검사 */
|
|
public function validateForRelease(int $modelVersionId): void
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$mv = ModelVersion::query()
|
|
->where('tenant_id', $tenantId)
|
|
->find($modelVersionId);
|
|
|
|
if (! $mv) {
|
|
throw new NotFoundHttpException(__('error.not_found'));
|
|
}
|
|
|
|
// 대표 템플릿 존재 확인
|
|
$primary = BomTemplate::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('model_version_id', $mv->id)
|
|
->where('is_primary', true)
|
|
->first();
|
|
|
|
if (! $primary) {
|
|
throw ValidationException::withMessages(['template' => __('error.validation_failed')]);
|
|
}
|
|
|
|
$items = BomTemplateItem::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('bom_template_id', $primary->id)
|
|
->orderBy('sort_order')
|
|
->get();
|
|
|
|
if ($items->isEmpty()) {
|
|
throw ValidationException::withMessages(['items' => __('error.validation_failed')]);
|
|
}
|
|
|
|
// 중복 키 및 값 검증
|
|
$seen = [];
|
|
foreach ($items as $idx => $it) {
|
|
$key = strtoupper($it->ref_type).':'.(int) $it->ref_id;
|
|
if (isset($seen[$key])) {
|
|
throw ValidationException::withMessages(["items.$idx" => __('error.duplicate')]);
|
|
}
|
|
$seen[$key] = true;
|
|
|
|
// 수량/로스율
|
|
if ((float) $it->qty <= 0) {
|
|
throw ValidationException::withMessages(["items.$idx.qty" => __('error.validation_failed')]);
|
|
}
|
|
if ((float) $it->waste_rate < 0) {
|
|
throw ValidationException::withMessages(["items.$idx.waste_rate" => __('error.validation_failed')]);
|
|
}
|
|
|
|
// 참조 존재/활성/테넌트 일치
|
|
if (strtoupper($it->ref_type) === 'MATERIAL') {
|
|
$exists = Material::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('id', $it->ref_id)
|
|
->whereNull('deleted_at')
|
|
->exists();
|
|
if (! $exists) {
|
|
throw ValidationException::withMessages(["items.$idx.ref_id" => __('error.not_found')]);
|
|
}
|
|
} elseif (strtoupper($it->ref_type) === 'PRODUCT') {
|
|
$exists = Product::query()
|
|
->where('tenant_id', $tenantId)
|
|
->where('id', $it->ref_id)
|
|
->whereNull('deleted_at')
|
|
->exists();
|
|
if (! $exists) {
|
|
throw ValidationException::withMessages(["items.$idx.ref_id" => __('error.not_found')]);
|
|
}
|
|
} else {
|
|
throw ValidationException::withMessages(["items.$idx.ref_type" => __('error.validation_failed')]);
|
|
}
|
|
}
|
|
|
|
// 주: 순환 참조 검사는 운영 BOM 실제 구성 시 그래프 탐색으로 보완 예정
|
|
}
|
|
}
|