fix : 모델, BOM 구성 수정

- 설계용 모델, BOM 기능 추가
This commit is contained in:
2025-09-05 17:59:34 +09:00
parent 41d0afa245
commit d9563c96cb
19 changed files with 1972 additions and 290 deletions

View File

@@ -0,0 +1,221 @@
<?php
namespace App\Services\Design;
use App\Models\Design\BomTemplate;
use App\Models\Design\BomTemplateItem;
use App\Models\Design\ModelVersion;
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();
if (!$tpl) {
$tpl = BomTemplate::create([
'tenant_id' => $tenantId,
'model_version_id' => $mv->id,
'name' => $name,
'is_primary' => $isPrimary,
'notes' => $notes,
]);
} else {
$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]);
}
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')]);
}
}
$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]);
}
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'));
}
$tpl->delete();
}
/** 템플릿 상세 (옵션: 항목 포함) */
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) {
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);
}
});
}
/** 항목 조회 */
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();
}
}

View File

@@ -0,0 +1,112 @@
<?php
namespace App\Services\Design;
use App\Models\Design\DesignModel;
use App\Services\Service;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ModelService extends Service
{
/** 목록 */
public function list(string $q = '', int $page = 1, int $size = 20): LengthAwarePaginator
{
$tenantId = $this->tenantId();
$query = DesignModel::query()->where('tenant_id', $tenantId);
if ($q !== '') {
$query->where(function ($w) use ($q) {
$w->where('code', 'like', "%{$q}%")
->orWhere('name', 'like', "%{$q}%")
->orWhere('description', 'like', "%{$q}%");
});
}
return $query->orderByDesc('id')->paginate($size, ['*'], 'page', $page);
}
/** 생성 */
public function create(array $data): DesignModel
{
$tenantId = $this->tenantId();
$exists = DesignModel::query()
->where('tenant_id', $tenantId)
->where('code', $data['code'])
->exists();
if ($exists) {
throw ValidationException::withMessages(['code' => __('error.duplicate')]);
}
return DB::transaction(function () use ($tenantId, $data) {
$payload = array_merge($data, ['tenant_id' => $tenantId]);
return DesignModel::create($payload);
});
}
/** 단건 */
public function find(int $id): DesignModel
{
$tenantId = $this->tenantId();
$model = DesignModel::query()
->where('tenant_id', $tenantId)
->with(['versions' => fn($q) => $q->orderBy('version_no')])
->find($id);
if (!$model) {
throw new NotFoundHttpException(__('error.not_found'));
}
return $model;
}
/** 수정 */
public function update(int $id, array $data): DesignModel
{
$tenantId = $this->tenantId();
$model = DesignModel::query()
->where('tenant_id', $tenantId)
->find($id);
if (!$model) {
throw new NotFoundHttpException(__('error.not_found'));
}
if (isset($data['code']) && $data['code'] !== $model->code) {
$dup = DesignModel::query()
->where('tenant_id', $tenantId)
->where('code', $data['code'])
->exists();
if ($dup) {
throw ValidationException::withMessages(['code' => __('error.duplicate')]);
}
}
$model->fill($data);
$model->save();
return $model;
}
/** 삭제(soft) */
public function delete(int $id): void
{
$tenantId = $this->tenantId();
$model = DesignModel::query()
->where('tenant_id', $tenantId)
->find($id);
if (!$model) {
throw new NotFoundHttpException(__('error.not_found'));
}
$model->delete();
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace App\Services\Design;
use App\Models\Design\DesignModel;
use App\Models\Design\ModelVersion;
use App\Services\Service;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ModelVersionService extends Service
{
/** 특정 모델의 버전 목록 */
public function listByModel(int $modelId)
{
$tenantId = $this->tenantId();
$model = DesignModel::query()
->where('tenant_id', $tenantId)
->find($modelId);
if (!$model) {
throw new NotFoundHttpException(__('error.not_found'));
}
return ModelVersion::query()
->where('tenant_id', $tenantId)
->where('model_id', $modelId)
->orderBy('version_no')
->get();
}
/** DRAFT 생성 (version_no 자동/수동) */
public function createDraft(int $modelId, array $extra = []): ModelVersion
{
$tenantId = $this->tenantId();
$model = DesignModel::query()
->where('tenant_id', $tenantId)
->find($modelId);
if (!$model) {
throw new NotFoundHttpException(__('error.not_found'));
}
return DB::transaction(function () use ($tenantId, $model, $extra) {
$versionNo = $extra['version_no'] ?? null;
if ($versionNo === null) {
$max = ModelVersion::query()
->where('model_id', $model->id)
->max('version_no');
$versionNo = (int)($max ?? 0) + 1;
} else {
$exists = ModelVersion::query()
->where('model_id', $model->id)
->where('version_no', $versionNo)
->exists();
if ($exists) {
throw ValidationException::withMessages(['version_no' => __('error.duplicate')]);
}
}
return ModelVersion::create([
'tenant_id' => $tenantId,
'model_id' => $model->id,
'version_no' => $versionNo,
'status' => 'DRAFT',
'is_active' => true,
'notes' => $extra['notes'] ?? null,
'effective_from' => $extra['effective_from'] ?? null,
'effective_to' => $extra['effective_to'] ?? null,
]);
});
}
/** RELEASED 전환 */
public function release(int $versionId): ModelVersion
{
$tenantId = $this->tenantId();
$mv = ModelVersion::query()
->where('tenant_id', $tenantId)
->find($versionId);
if (!$mv) {
throw new NotFoundHttpException(__('error.not_found'));
}
if ($mv->status === 'RELEASED') {
return $mv; // 멱등
}
// TODO: 대표 템플릿 존재 등 사전 검증 훅 가능
$mv->status = 'RELEASED';
$mv->effective_from = $mv->effective_from ?? now();
$mv->save();
return $mv;
}
}