refactor: products/materials 테이블 및 관련 코드 삭제

- products, materials, product_components 테이블 삭제 마이그레이션
- FK 제약조건 정리 (orders, order_items, material_receipts, lots)
- 관련 Models 삭제: Product, Material, ProductComponent 등
- 관련 Controllers 삭제: ProductController, MaterialController, ProductBomItemController
- 관련 Services 삭제: ProductService, MaterialService, ProductBomService
- 관련 Requests, Swagger 파일 삭제
- 라우트 정리: /products, /materials 엔드포인트 제거

모든 품목 관리는 /items 엔드포인트로 통합됨
item_id_mappings 테이블에 ID 매핑 보존

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-14 00:20:09 +09:00
parent a486595d07
commit 039fd623df
23 changed files with 158 additions and 4111 deletions

View File

@@ -1,52 +0,0 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Material\MaterialStoreRequest;
use App\Http\Requests\Material\MaterialUpdateRequest;
use App\Services\MaterialService;
use Illuminate\Http\Request;
class MaterialController extends Controller
{
public function __construct(private MaterialService $service) {}
public function index(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->getMaterials($request->all());
}, __('message.material.fetched'));
}
public function store(MaterialStoreRequest $request)
{
return ApiResponse::handle(function () use ($request) {
// 동적 필드 지원을 위해 전체 입력값 전달 (Service에서 검증)
return $this->service->setMaterial($request->all());
}, __('message.material.created'));
}
public function show(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->getMaterial($id);
}, __('message.material.fetched'));
}
public function update(MaterialUpdateRequest $request, int $id)
{
return ApiResponse::handle(function () use ($request, $id) {
// 동적 필드 지원을 위해 전체 입력값 전달 (Service에서 검증)
return $this->service->updateMaterial($id, $request->all());
}, __('message.material.updated'));
}
public function destroy(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->destroyMaterial($id);
}, __('message.material.deleted'));
}
}

View File

@@ -1,114 +0,0 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\ProductBomService;
use Illuminate\Http\Request;
class ProductBomItemController extends Controller
{
public function __construct(private ProductBomService $service) {}
// GET /products/{id}/bom/items
public function index(int $id, Request $request)
{
return ApiResponse::handle(function () use ($id, $request) {
return $this->service->index($id, $request->all());
}, 'BOM 항목 목록');
}
// POST /products/{id}/bom/items/bulk
public function bulkUpsert(int $id, Request $request)
{
return ApiResponse::handle(function () use ($id, $request) {
return $this->service->bulkUpsert($id, $request->input('items', []));
}, 'BOM 일괄 업서트');
}
// PATCH /products/{id}/bom/items/{item}
public function update(int $id, int $item, Request $request)
{
return ApiResponse::handle(function () use ($id, $item, $request) {
return $this->service->update($id, $item, $request->all());
}, 'BOM 항목 수정');
}
// DELETE /products/{id}/bom/items/{item}
public function destroy(int $id, int $item)
{
return ApiResponse::handle(function () use ($id, $item) {
$this->service->destroy($id, $item);
return 'success';
}, 'BOM 항목 삭제');
}
// POST /products/{id}/bom/items/reorder
public function reorder(int $id, Request $request)
{
return ApiResponse::handle(function () use ($id, $request) {
$this->service->reorder($id, $request->input('items', []));
return 'success';
}, 'BOM 정렬 변경');
}
// GET /products/{id}/bom/summary
public function summary(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->summary($id);
}, 'BOM 요약');
}
// GET /products/{id}/bom/validate
public function validateBom(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->validateBom($id);
}, 'BOM 유효성 검사');
}
/**
* POST /api/v1/products/{id}/bom
* BOM 구성 저장 (기존 전체 삭제 후 재등록)
*/
public function replace(Request $request, int $id)
{
return ApiResponse::handle(function () use ($request, $id) {
// 서비스에서 트랜잭션 처리 + 예외는 글로벌 핸들러로
return $this->service->replaceBom($id, $request->all());
}, __('message.bom.creat'));
}
/** 특정 제품 BOM에서 사용 중인 카테고리 목록 */
public function listCategories(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->listCategoriesForProduct($id);
}, __('message.bom.fetch'));
}
/** 테넌트 전역 카테고리 추천(히스토리) */
public function suggestCategories(Request $request)
{
return ApiResponse::handle(function () use ($request) {
$q = $request->query('q');
$limit = (int) ($request->query('limit', 20));
return $this->service->listCategoriesForTenant($q, $limit);
}, __('message.bom.fetch'));
}
/** Bom Tree */
public function tree(Request $request, int $id)
{
return ApiResponse::handle(
function () use ($request, $id) {
return $this->service->tree($request, $id);
}, __('message.bom.fetch')
);
}
}

View File

@@ -1,82 +0,0 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Product\ProductStoreRequest;
use App\Http\Requests\Product\ProductUpdateRequest;
use App\Services\ProductService;
use Illuminate\Http\Request;
class ProductController extends Controller
{
public function __construct(private ProductService $service) {}
public function getCategory(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->getCategory($request);
}, __('message.product.category_fetched'));
}
// GET /products
public function index(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->index($request->all());
}, __('message.product.fetched'));
}
// POST /products
public function store(ProductStoreRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->store($request->validated());
}, __('message.product.created'));
}
// GET /products/{id}
public function show(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->show($id);
}, __('message.product.fetched'));
}
// PATCH /products/{id}
public function update(int $id, ProductUpdateRequest $request)
{
return ApiResponse::handle(function () use ($id, $request) {
return $this->service->update($id, $request->validated());
}, __('message.product.updated'));
}
// DELETE /products/{id}
public function destroy(int $id)
{
return ApiResponse::handle(function () use ($id) {
$this->service->destroy($id);
return 'success';
}, __('message.product.deleted'));
}
// GET /products/search
public function search(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->search($request->all());
}, __('message.product.searched'));
}
// Note: toggle 메서드는 is_active 필드 제거로 인해 비활성화됨
// 필요시 attributes JSON이나 별도 필드로 구현
// POST /products/{id}/toggle
// public function toggle(int $id)
// {
// return ApiResponse::handle(function () use ($id) {
// return $this->service->toggle($id);
// }, __('message.product.toggled'));
// }
}

View File

@@ -1,32 +0,0 @@
<?php
namespace App\Http\Requests\Material;
use Illuminate\Foundation\Http\FormRequest;
class MaterialStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'category_id' => 'nullable|integer',
'name' => 'required|string|max:100',
'unit' => 'required|string|max:20',
'is_inspection' => 'nullable|in:Y,N',
'search_tag' => 'nullable|string|max:255',
'remarks' => 'nullable|string|max:500',
'attributes' => 'nullable|array',
'attributes.*.label' => 'required|string|max:50',
'attributes.*.value' => 'required|string|max:100',
'attributes.*.unit' => 'nullable|string|max:20',
'options' => 'nullable|array',
'material_code' => 'nullable|string|max:30',
'specification' => 'nullable|string|max:255',
];
}
}

View File

@@ -1,32 +0,0 @@
<?php
namespace App\Http\Requests\Material;
use Illuminate\Foundation\Http\FormRequest;
class MaterialUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'category_id' => 'nullable|integer',
'name' => 'sometimes|string|max:100',
'unit' => 'sometimes|string|max:20',
'is_inspection' => 'nullable|in:Y,N',
'search_tag' => 'nullable|string|max:255',
'remarks' => 'nullable|string|max:500',
'attributes' => 'nullable|array',
'attributes.*.label' => 'required|string|max:50',
'attributes.*.value' => 'required|string|max:100',
'attributes.*.unit' => 'nullable|string|max:20',
'options' => 'nullable|array',
'material_code' => 'nullable|string|max:30',
'specification' => 'nullable|string|max:255',
];
}
}

View File

@@ -1,42 +0,0 @@
<?php
namespace App\Http\Requests\Product;
use Illuminate\Foundation\Http\FormRequest;
class ProductStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
// 기본 필드
'code' => 'required|string|max:30',
'name' => 'required|string|max:100',
'unit' => 'nullable|string|max:10',
'category_id' => 'required|integer',
'product_type' => 'required|string|max:30',
'description' => 'nullable|string|max:255',
// 상태 플래그
'is_sellable' => 'nullable|boolean',
'is_purchasable' => 'nullable|boolean',
'is_producible' => 'nullable|boolean',
// 하이브리드 구조: 고정 필드
'safety_stock' => 'nullable|integer|min:0',
'lead_time' => 'nullable|integer|min:0',
'is_variable_size' => 'nullable|boolean',
'product_category' => 'nullable|string|max:20',
'part_type' => 'nullable|string|max:20',
// 하이브리드 구조: 동적 필드
'attributes' => 'nullable|array',
'attributes_archive' => 'nullable|array',
];
}
}

View File

@@ -1,42 +0,0 @@
<?php
namespace App\Http\Requests\Product;
use Illuminate\Foundation\Http\FormRequest;
class ProductUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
// 기본 필드
'code' => 'sometimes|string|max:30',
'name' => 'sometimes|string|max:100',
'unit' => 'nullable|string|max:10',
'category_id' => 'sometimes|integer',
'product_type' => 'sometimes|string|max:30',
'description' => 'nullable|string|max:255',
// 상태 플래그
'is_sellable' => 'nullable|boolean',
'is_purchasable' => 'nullable|boolean',
'is_producible' => 'nullable|boolean',
// 하이브리드 구조: 고정 필드
'safety_stock' => 'nullable|integer|min:0',
'lead_time' => 'nullable|integer|min:0',
'is_variable_size' => 'nullable|boolean',
'product_category' => 'nullable|string|max:20',
'part_type' => 'nullable|string|max:20',
// 하이브리드 구조: 동적 필드
'attributes' => 'nullable|array',
'attributes_archive' => 'nullable|array',
];
}
}

View File

@@ -1,79 +0,0 @@
<?php
namespace App\Models\Materials;
use App\Models\Commons\Category;
use App\Models\Commons\File;
use App\Models\Commons\Tag;
use App\Models\Qualitys\Lot;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* @mixin IdeHelperMaterial
*/
class Material extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
protected $fillable = [
'tenant_id',
'category_id',
'name',
'item_name',
'specification',
'material_code',
'material_type',
'unit',
'is_inspection',
'search_tag',
'remarks',
'attributes',
'options',
'created_by',
'updated_by',
'is_active',
];
protected $casts = [
'attributes' => 'array',
'options' => 'array',
'is_active' => 'boolean',
];
protected $hidden = [
'deleted_at',
];
// 카테고리
public function category()
{
return $this->belongsTo(Category::class);
}
// 자재 입고 내역
public function receipts()
{
return $this->hasMany(MaterialReceipt::class, 'material_id');
}
// 로트 관리
public function lots()
{
return $this->hasMany(Lot::class, 'material_id');
}
// 파일 목록 (N:M, 폴리모픽)
public function files()
{
return $this->morphMany(File::class, 'fileable');
}
// 태그 목록 (N:M, 폴리모픽)
public function tags()
{
return $this->morphToMany(Tag::class, 'taggable');
}
}

View File

@@ -1,47 +0,0 @@
<?php
namespace App\Models\Products;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* @mixin IdeHelperCommonCode
*/
class CommonCode extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
protected $table = 'common_codes';
protected $fillable = [
'tenant_id',
'code_group',
'code',
'name',
'parent_id',
'attributes',
'description',
'is_active',
'sort_order',
];
protected $casts = [
'attributes' => 'array',
'is_active' => 'boolean',
];
// 관계: 상위 코드
public function parent()
{
return $this->belongsTo(self::class, 'parent_id');
}
// 관계: 하위 코드들
public function children()
{
return $this->hasMany(self::class, 'parent_id');
}
}

View File

@@ -1,33 +0,0 @@
<?php
namespace App\Models\Products;
use App\Models\Commons\Tag;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* @mixin IdeHelperPart
*/
class Part extends Model
{
use SoftDeletes;
protected $fillable = ['tenant_id', 'code', 'name', 'category_id', 'part_type_id', 'unit', 'attributes', 'description', 'is_active'];
public function category()
{
return $this->belongsTo(CommonCode::class, 'category_id');
}
public function partType()
{
return $this->belongsTo(CommonCode::class, 'part_type_id');
}
// 태그 목록 (N:M, 폴리모픽)
public function tags()
{
return $this->morphToMany(Tag::class, 'taggable');
}
}

View File

@@ -1,258 +0,0 @@
<?php
namespace App\Models\Products;
use App\Models\Materials\Material;
use App\Models\Orders\ClientGroup;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* 단가 마스터 모델
*
* @property int $id
* @property int $tenant_id
* @property string $item_type_code
* @property int $item_id
* @property int|null $client_group_id
* @property float|null $purchase_price
* @property float|null $processing_cost
* @property float|null $loss_rate
* @property float|null $margin_rate
* @property float|null $sales_price
* @property string $rounding_rule
* @property int $rounding_unit
* @property string|null $supplier
* @property \Carbon\Carbon $effective_from
* @property \Carbon\Carbon|null $effective_to
* @property string|null $note
* @property string $status
* @property bool $is_final
* @property \Carbon\Carbon|null $finalized_at
* @property int|null $finalized_by
*/
class Price extends Model
{
use BelongsToTenant, SoftDeletes;
protected $fillable = [
'tenant_id',
'item_type_code',
'item_id',
'client_group_id',
'purchase_price',
'processing_cost',
'loss_rate',
'margin_rate',
'sales_price',
'rounding_rule',
'rounding_unit',
'supplier',
'effective_from',
'effective_to',
'note',
'status',
'is_final',
'finalized_at',
'finalized_by',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'purchase_price' => 'decimal:4',
'processing_cost' => 'decimal:4',
'loss_rate' => 'decimal:2',
'margin_rate' => 'decimal:2',
'sales_price' => 'decimal:4',
'rounding_unit' => 'integer',
'effective_from' => 'date',
'effective_to' => 'date',
'is_final' => 'boolean',
'finalized_at' => 'datetime',
];
/**
* 고객 그룹 관계
*/
public function clientGroup(): BelongsTo
{
return $this->belongsTo(ClientGroup::class, 'client_group_id');
}
/**
* 리비전 이력 관계
*/
public function revisions(): HasMany
{
return $this->hasMany(PriceRevision::class, 'price_id')->orderBy('revision_number', 'desc');
}
/**
* 품목 관계 (Polymorphic - item_type_code에 따라)
*/
public function item()
{
if ($this->item_type_code === 'PRODUCT') {
return $this->belongsTo(Product::class, 'item_id');
} elseif ($this->item_type_code === 'MATERIAL') {
return $this->belongsTo(Material::class, 'item_id');
}
return null;
}
/**
* 제품 관계 (item_type_code = PRODUCT인 경우)
*/
public function product(): BelongsTo
{
return $this->belongsTo(Product::class, 'item_id');
}
/**
* 자재 관계 (item_type_code = MATERIAL인 경우)
*/
public function material(): BelongsTo
{
return $this->belongsTo(Material::class, 'item_id');
}
// ========== 스코프 ==========
/**
* 특정 품목 필터
*/
public function scopeForItem($query, string $itemType, int $itemId)
{
return $query->where('item_type_code', $itemType)
->where('item_id', $itemId);
}
/**
* 고객 그룹 필터
*/
public function scopeForClientGroup($query, ?int $clientGroupId)
{
return $query->where('client_group_id', $clientGroupId);
}
/**
* 특정 일자에 유효한 단가
*/
public function scopeValidAt($query, $date)
{
return $query->where('effective_from', '<=', $date)
->where(function ($q) use ($date) {
$q->whereNull('effective_to')
->orWhere('effective_to', '>=', $date);
});
}
/**
* 상태 필터
*/
public function scopeStatus($query, string $status)
{
return $query->where('status', $status);
}
/**
* 활성 단가만
*/
public function scopeActive($query)
{
return $query->where('status', 'active');
}
/**
* 확정된 단가만
*/
public function scopeFinalized($query)
{
return $query->where('is_final', true);
}
// ========== 계산 메서드 ==========
/**
* 총원가 계산
* 총원가 = (매입단가 + 가공비) × (1 + LOSS율/100)
*/
public function calculateTotalCost(): float
{
$baseCost = ($this->purchase_price ?? 0) + ($this->processing_cost ?? 0);
$lossMultiplier = 1 + (($this->loss_rate ?? 0) / 100);
return $baseCost * $lossMultiplier;
}
/**
* 판매단가 계산 (마진율 기반)
* 판매단가 = 반올림(총원가 × (1 + 마진율/100), 반올림단위, 반올림규칙)
*/
public function calculateSalesPrice(): float
{
$totalCost = $this->calculateTotalCost();
$marginMultiplier = 1 + (($this->margin_rate ?? 0) / 100);
$rawPrice = $totalCost * $marginMultiplier;
return $this->applyRounding($rawPrice);
}
/**
* 반올림 적용
*/
private function applyRounding(float $value): float
{
$unit = $this->rounding_unit ?: 1;
return match ($this->rounding_rule) {
'ceil' => ceil($value / $unit) * $unit,
'floor' => floor($value / $unit) * $unit,
default => round($value / $unit) * $unit, // 'round'
};
}
/**
* 확정 가능 여부
*/
public function canFinalize(): bool
{
return ! $this->is_final && in_array($this->status, ['draft', 'active']);
}
/**
* 수정 가능 여부
*/
public function canEdit(): bool
{
return ! $this->is_final;
}
/**
* 스냅샷 생성 (리비전용)
*/
public function toSnapshot(): array
{
return [
'purchase_price' => $this->purchase_price,
'processing_cost' => $this->processing_cost,
'loss_rate' => $this->loss_rate,
'margin_rate' => $this->margin_rate,
'sales_price' => $this->sales_price,
'rounding_rule' => $this->rounding_rule,
'rounding_unit' => $this->rounding_unit,
'supplier' => $this->supplier,
'effective_from' => $this->effective_from?->format('Y-m-d'),
'effective_to' => $this->effective_to?->format('Y-m-d'),
'status' => $this->status,
'is_final' => $this->is_final,
'note' => $this->note,
];
}
}

View File

@@ -1,92 +0,0 @@
<?php
namespace App\Models\Products;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 단가 변경 이력 모델
*
* @property int $id
* @property int $tenant_id
* @property int $price_id
* @property int $revision_number
* @property \Carbon\Carbon $changed_at
* @property int $changed_by
* @property string|null $change_reason
* @property array|null $before_snapshot
* @property array $after_snapshot
*/
class PriceRevision extends Model
{
use BelongsToTenant;
public $timestamps = false;
protected $fillable = [
'tenant_id',
'price_id',
'revision_number',
'changed_at',
'changed_by',
'change_reason',
'before_snapshot',
'after_snapshot',
];
protected $casts = [
'revision_number' => 'integer',
'changed_at' => 'datetime',
'before_snapshot' => 'array',
'after_snapshot' => 'array',
];
/**
* 단가 관계
*/
public function price(): BelongsTo
{
return $this->belongsTo(Price::class, 'price_id');
}
/**
* 변경자 관계
*/
public function changedByUser(): BelongsTo
{
return $this->belongsTo(\App\Models\Members\User::class, 'changed_by');
}
/**
* 변경된 필드 목록 추출
*/
public function getChangedFields(): array
{
if (! $this->before_snapshot) {
return array_keys($this->after_snapshot ?? []);
}
$changed = [];
foreach ($this->after_snapshot as $key => $newValue) {
$oldValue = $this->before_snapshot[$key] ?? null;
if ($oldValue !== $newValue) {
$changed[] = $key;
}
}
return $changed;
}
/**
* 특정 필드의 이전/이후 값
*/
public function getFieldChange(string $field): array
{
return [
'before' => $this->before_snapshot[$field] ?? null,
'after' => $this->after_snapshot[$field] ?? null,
];
}
}

View File

@@ -1,118 +0,0 @@
<?php
namespace App\Models\Products;
use App\Models\Commons\Category;
use App\Models\Commons\File;
use App\Models\Commons\Tag;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Product extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
protected $fillable = [
'tenant_id', 'code', 'name', 'unit', 'category_id',
'product_type', // 라벨/분류용
'attributes', 'attributes_archive', 'options', 'bom', 'description',
'is_sellable', 'is_purchasable', 'is_producible',
// 하이브리드 구조: 최소 고정 필드
'safety_stock', 'lead_time', 'is_variable_size',
'product_category', 'part_type',
// 파일 필드
'bending_diagram', 'bending_details',
'specification_file', 'specification_file_name',
'certification_file', 'certification_file_name',
'certification_number', 'certification_start_date', 'certification_end_date',
'created_by', 'updated_by', 'is_active',
];
protected $casts = [
'attributes' => 'array',
'attributes_archive' => 'array',
'options' => 'array',
'bom' => 'array',
'bending_details' => 'array',
'certification_start_date' => 'date',
'certification_end_date' => 'date',
'is_sellable' => 'boolean',
'is_purchasable' => 'boolean',
'is_producible' => 'boolean',
'is_variable_size' => 'boolean',
'is_active' => 'boolean',
];
protected $hidden = [
'deleted_at',
];
// 분류
public function category()
{
return $this->belongsTo(Category::class, 'category_id');
}
// BOM (자기참조) — 라인 모델 경유
public function componentLines()
{
return $this->hasMany(ProductComponent::class, 'parent_product_id')->orderBy('sort_order');
}
// 라인들
public function parentLines()
{
return $this->hasMany(ProductComponent::class, 'child_product_id');
} // 나를 쓰는 상위 라인들
// 편의: 직접 children/parents 제품에 접근
public function children()
{
return $this->belongsToMany(
self::class, 'product_components', 'parent_product_id', 'child_product_id'
)->withPivot(['quantity', 'sort_order', 'is_default'])
->withTimestamps();
}
public function parents()
{
return $this->belongsToMany(
self::class, 'product_components', 'child_product_id', 'parent_product_id'
)->withPivot(['quantity', 'sort_order', 'is_default'])
->withTimestamps();
}
// 파일 / 태그 (폴리모픽)
public function files()
{
return $this->morphMany(File::class, 'fileable');
}
public function tags()
{
return $this->morphToMany(Tag::class, 'taggable');
}
// 스코프
public function scopeType($q, string $type)
{
return $q->where('product_type', $type);
}
public function scopeSellable($q)
{
return $q->where('is_sellable', 1);
}
public function scopePurchasable($q)
{
return $q->where('is_purchasable', 1);
}
public function scopeProducible($q)
{
return $q->where('is_producible', 1);
}
}

View File

@@ -1,115 +0,0 @@
<?php
namespace App\Models\Products;
use App\Models\Materials\Material;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class ProductComponent extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
protected $table = 'product_components';
protected $fillable = [
'tenant_id',
'parent_product_id',
'category_id',
'category_name',
'ref_type',
'ref_id',
'quantity',
'sort_order',
// 하이브리드 구조: 고정 필드 (범용 BOM 계산)
'quantity_formula',
'condition',
// 동적 필드 (테넌트별 커스텀)
'attributes',
'created_by',
'updated_by',
];
protected $casts = [
'quantity' => 'decimal:6',
'attributes' => 'array',
'created_at' => 'datetime',
'updated_at' => 'datetime',
'deleted_at' => 'datetime',
];
protected $hidden = [
'deleted_at',
];
/**
* 상위 제품 (모델/제품)
*/
public function parentProduct()
{
return $this->belongsTo(Product::class, 'parent_product_id');
}
/**
* 참조된 제품 또는 자재를 동적으로 가져오기
* ref_type에 따라 Product 또는 Material을 반환
*/
public function referencedItem()
{
if ($this->ref_type === 'PRODUCT') {
return $this->belongsTo(Product::class, 'ref_id');
} elseif ($this->ref_type === 'MATERIAL') {
return $this->belongsTo(Material::class, 'ref_id');
}
return null;
}
/**
* 하위 제품 (ref_type = PRODUCT일 때만)
*/
public function product()
{
return $this->belongsTo(Product::class, 'ref_id')
->where('ref_type', 'PRODUCT');
}
/**
* 하위 자재 (ref_type = MATERIAL일 때만)
*/
public function material()
{
return $this->belongsTo(Material::class, 'ref_id')
->where('ref_type', 'MATERIAL');
}
// ---------------------------------------------------
// 🔎 Query Scopes
// ---------------------------------------------------
/**
* 제품 BOM 아이템만
*/
public function scopeProducts($query)
{
return $query->where('ref_type', 'PRODUCT');
}
/**
* 자재 BOM 아이템만
*/
public function scopeMaterials($query)
{
return $query->where('ref_type', 'MATERIAL');
}
/**
* 특정 상위 제품의 BOM
*/
public function scopeForParent($query, int $parentProductId)
{
return $query->where('parent_product_id', $parentProductId);
}
}

View File

@@ -1,519 +0,0 @@
<?php
namespace App\Services;
use App\Constants\SystemFields;
use App\Models\ItemMaster\ItemField;
use App\Models\Materials\Material;
use Illuminate\Support\Facades\Validator;
class MaterialService extends Service
{
/**
* materials 테이블의 고정 컬럼 목록 조회 (SystemFields + ItemField 기반)
*/
private function getKnownFields(): array
{
$tenantId = $this->tenantId();
// 1. SystemFields에서 materials 테이블 고정 컬럼
$systemFields = SystemFields::getReservedKeys(SystemFields::SOURCE_TABLE_MATERIALS);
// 2. ItemField에서 storage_type='column'인 필드의 field_key 조회
$columnFields = ItemField::where('tenant_id', $tenantId)
->where('source_table', 'materials')
->where('storage_type', 'column')
->whereNotNull('field_key')
->pluck('field_key')
->toArray();
// 3. 추가적인 API 전용 필드 (DB 컬럼이 아니지만 API에서 사용하는 필드)
$apiFields = ['item_type', 'type_code', 'bom'];
return array_unique(array_merge($systemFields, $columnFields, $apiFields));
}
/**
* 정의된 필드 외의 동적 필드를 options로 추출
*/
private function extractDynamicOptions(array $params): array
{
$knownFields = $this->getKnownFields();
$dynamicOptions = [];
foreach ($params as $key => $value) {
if (! in_array($key, $knownFields) && $value !== null && $value !== '') {
$dynamicOptions[$key] = $value;
}
}
return $dynamicOptions;
}
/**
* 기존 options 배열과 동적 필드를 병합
* - 기존 options가 [{label, value}] 배열이면 동적 필드를 배열 항목으로 추가
* - 기존 options가 {key: value} 맵이면 동적 필드를 맵에 병합
*/
private function mergeOptionsWithDynamic($existingOptions, array $dynamicOptions): array
{
if (! is_array($existingOptions) || empty($existingOptions)) {
// 기존 options가 없으면 동적 필드만 반환
return $dynamicOptions;
}
// 기존 options가 연관 배열(맵)인지 판별
$isAssoc = array_keys($existingOptions) !== range(0, count($existingOptions) - 1);
if ($isAssoc) {
// 맵 형태면 단순 병합
return array_merge($existingOptions, $dynamicOptions);
}
// 배열 형태 [{label, value}]면 동적 필드를 배열 항목으로 추가
foreach ($dynamicOptions as $key => $value) {
$existingOptions[] = ['label' => $key, 'value' => $value];
}
return $existingOptions;
}
/** 공통 검증 헬퍼 */
protected function v(array $input, array $rules)
{
$v = Validator::make($input, $rules);
if ($v->fails()) {
return ['error' => $v->errors()->first(), 'code' => 422];
}
return $v->validated();
}
/** 목록 */
public function getMaterials(array $params)
{
$tenantId = $this->tenantId();
$p = $this->v($params, [
'q' => 'nullable|string|max:100',
'category' => 'nullable|integer|min:1',
'page' => 'nullable|integer|min:1',
'per_page' => 'nullable|integer|min:1|max:200',
]);
if (isset($p['error'])) {
return $p;
}
$q = Material::query()
->where('tenant_id', $tenantId); // SoftDeletes가 있으면 기본적으로 deleted_at IS NULL
if (! empty($p['category'])) {
$q->where('category_id', (int) $p['category']);
}
if (! empty($p['q'])) {
$kw = '%'.$p['q'].'%';
$q->where(function ($w) use ($kw) {
$w->where('item_name', 'like', $kw)
->orWhere('name', 'like', $kw)
->orWhere('material_code', 'like', $kw)
->orWhere('search_tag', 'like', $kw);
});
}
$q->orderBy('id');
$perPage = $p['per_page'] ?? 20;
$page = $p['page'] ?? null;
return $q->paginate($perPage, ['*'], 'page', $page);
}
/** 단건 조회 */
public function getMaterial(int $id)
{
$tenantId = $this->tenantId();
/** @var Material|null $row */
$row = Material::query()
->where('tenant_id', $tenantId)
->find($id);
if (! $row) {
return ['error' => '자재를 찾을 수 없습니다.', 'code' => 404];
}
// 모델에서 casts가 없을 수 있으니 안전하게 배열화
$row->attributes = is_array($row->attributes) ? $row->attributes : ($row->attributes ? json_decode($row->attributes, true) : null);
$row->options = is_array($row->options) ? $row->options : ($row->options ? json_decode($row->options, true) : null);
return $row;
}
/** 등록 */
public function setMaterial(array $params)
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// 동적 필드를 options에 병합
$dynamicOptions = $this->extractDynamicOptions($params);
if (! empty($dynamicOptions)) {
$existingOptions = $params['options'] ?? [];
$params['options'] = $this->mergeOptionsWithDynamic($existingOptions, $dynamicOptions);
}
$p = $this->v($params, [
'category_id' => 'nullable|integer|min:1',
'name' => 'required|string|max:100',
'unit' => 'required|string|max:10',
'is_inspection' => 'nullable|in:Y,N',
'search_tag' => 'nullable|string',
'remarks' => 'nullable|string',
'attributes' => 'nullable|array', // [{label,value,unit}] 또는 map
'options' => 'nullable|array', // [{label,value,unit}] 또는 map
'material_code' => 'nullable|string|max:50',
'specification' => 'nullable|string|max:100',
]);
if (isset($p['error'])) {
return $p;
}
// material_code 중복 체크 (삭제된 레코드 포함 - DB unique 제약은 deleted_at 무시)
if (! empty($p['material_code'])) {
$duplicate = Material::withTrashed()
->where('tenant_id', $tenantId)
->where('material_code', $p['material_code'])
->first(['id', 'name', 'deleted_at']);
if ($duplicate) {
if ($duplicate->deleted_at) {
return [
'error' => "자재코드 '{$p['material_code']}'가 삭제된 자재에서 사용 중입니다. 해당 자재를 복구하거나 완전 삭제 후 다시 시도하세요.",
'code' => 422,
'deleted_material_id' => $duplicate->id,
];
}
return ['error' => "자재코드 '{$p['material_code']}'가 이미 존재합니다.", 'code' => 422];
}
}
// 기존 normalizeAttributes 사용(그대로), options는 새 normalizeOptions 사용
$attributes = $this->normalizeAttributes($p['attributes'] ?? null);
$options = $this->normalizeOptions($p['options'] ?? null);
$itemName = $this->buildItemName($p['name'], $attributes);
$specText = $p['specification'] ?? $this->buildSpecText($attributes);
$m = new Material;
$m->tenant_id = $tenantId;
$m->category_id = $p['category_id'] ?? null;
$m->name = $p['name'];
$m->item_name = $itemName;
$m->specification = $specText;
$m->material_code = $p['material_code'] ?? null;
$m->unit = $p['unit'];
$m->is_inspection = $p['is_inspection'] ?? 'N';
$m->search_tag = $p['search_tag'] ?? null;
$m->remarks = $p['remarks'] ?? null;
$m->attributes = $attributes ?? null;
$m->options = $options ?? null;
$m->created_by = $userId ?? 0;
$m->updated_by = $userId ?? null;
$m->save();
return $this->getMaterial($m->id);
}
/** 수정 */
public function updateMaterial(int $id, array $params = [])
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
/** @var Material|null $exists */
$exists = Material::query()->where('tenant_id', $tenantId)->find($id);
if (! $exists) {
return ['error' => '자재를 찾을 수 없습니다.', 'code' => 404];
}
// 동적 필드를 options에 병합
$dynamicOptions = $this->extractDynamicOptions($params);
if (! empty($dynamicOptions)) {
$existingOptions = $params['options'] ?? [];
$params['options'] = $this->mergeOptionsWithDynamic($existingOptions, $dynamicOptions);
}
$p = $this->v($params, [
'category_id' => 'nullable|integer|min:1',
'name' => 'nullable|string|max:100',
'unit' => 'nullable|string|max:10',
'is_inspection' => 'nullable|in:Y,N',
'search_tag' => 'nullable|string',
'remarks' => 'nullable|string',
'attributes' => 'nullable|array', // [{label,value,unit}] 또는 map
'options' => 'nullable|array', // [{label,value,unit}] 또는 map
'material_code' => 'nullable|string|max:50',
'specification' => 'nullable|string|max:100',
]);
if (isset($p['error'])) {
return $p;
}
// material_code 중복 체크 (삭제된 레코드 포함 - DB unique 제약은 deleted_at 무시)
$finalMaterialCode = $p['material_code'] ?? $exists->material_code;
if (! empty($finalMaterialCode)) {
$duplicate = Material::withTrashed()
->where('tenant_id', $tenantId)
->where('material_code', $finalMaterialCode)
->where('id', '!=', $id)
->first(['id', 'name', 'deleted_at']);
if ($duplicate) {
if ($duplicate->deleted_at) {
return [
'error' => "자재코드 '{$finalMaterialCode}'가 삭제된 자재에서 사용 중입니다. 해당 자재를 복구하거나 완전 삭제 후 다시 시도하세요.",
'code' => 422,
'deleted_material_id' => $duplicate->id,
];
}
return ['error' => "자재코드 '{$finalMaterialCode}'가 이미 존재합니다.", 'code' => 422];
}
}
$currentAttrs = is_array($exists->attributes) ? $exists->attributes
: ($exists->attributes ? json_decode($exists->attributes, true) : null);
$currentOpts = is_array($exists->options) ? $exists->options
: ($exists->options ? json_decode($exists->options, true) : null);
// 변경 점만 정규화
$attrs = array_key_exists('attributes', $p)
? $this->normalizeAttributes($p['attributes'])
: $currentAttrs;
$opts = array_key_exists('options', $p)
? $this->normalizeOptions($p['options'])
: $currentOpts;
$baseName = array_key_exists('name', $p) ? ($p['name'] ?? $exists->name) : $exists->name;
$exists->category_id = $p['category_id'] ?? $exists->category_id;
$exists->name = $baseName;
$exists->item_name = $this->buildItemName($baseName, $attrs);
$exists->specification = array_key_exists('specification', $p)
? ($p['specification'] ?? null)
: ($exists->specification ?: $this->buildSpecText($attrs));
$exists->material_code = $p['material_code'] ?? $exists->material_code;
$exists->unit = $p['unit'] ?? $exists->unit;
$exists->is_inspection = $p['is_inspection'] ?? $exists->is_inspection;
$exists->search_tag = $p['search_tag'] ?? $exists->search_tag;
$exists->remarks = $p['remarks'] ?? $exists->remarks;
if (array_key_exists('attributes', $p)) {
$exists->attributes = $attrs;
}
if (array_key_exists('options', $p)) {
$exists->options = $opts;
}
$exists->updated_by = $userId ?? $exists->updated_by;
$exists->save();
return $this->getMaterial($exists->id);
}
/** 삭제(소프트) */
public function destroyMaterial(int $id)
{
$tenantId = $this->tenantId();
/** @var Material|null $row */
$row = Material::query()
->where('tenant_id', $tenantId)
->find($id);
if (! $row) {
return ['error' => '자재를 찾을 수 없습니다.', 'code' => 404];
}
// 사용 중인 자재 삭제 방지
$usageCheck = $this->checkMaterialUsage($id, $tenantId);
if ($usageCheck['in_use']) {
return [
'error' => '사용 중인 자재는 삭제할 수 없습니다.',
'code' => 422,
'usage' => $usageCheck['details'],
];
}
$row->delete();
return ['id' => $id, 'deleted_at' => now()->toDateTimeString()];
}
/**
* 자재 사용 여부 체크
* - material_receipts: 입고 내역
* - lots: 로트 관리
* - product_components: BOM 구성품 (ref_type='MATERIAL')
*/
private function checkMaterialUsage(int $materialId, int $tenantId): array
{
$details = [];
// 1. 입고 내역 체크
$receiptCount = \App\Models\Materials\MaterialReceipt::where('tenant_id', $tenantId)
->where('material_id', $materialId)
->count();
if ($receiptCount > 0) {
$details['receipts'] = $receiptCount;
}
// 2. 로트 체크
$lotCount = \App\Models\Qualitys\Lot::where('tenant_id', $tenantId)
->where('material_id', $materialId)
->count();
if ($lotCount > 0) {
$details['lots'] = $lotCount;
}
// 3. BOM 구성품 체크 (ref_type='MATERIAL', ref_id=material_id)
$bomCount = \App\Models\Products\ProductComponent::where('tenant_id', $tenantId)
->where('ref_type', 'MATERIAL')
->where('ref_id', $materialId)
->count();
if ($bomCount > 0) {
$details['bom_components'] = $bomCount;
}
return [
'in_use' => ! empty($details),
'details' => $details,
];
}
/* -------------------------
헬퍼: 규격/품목명 빌더
attributes 예시:
[
{"label":"두께","value":"10","unit":"T"},
{"label":"길이","value":"150","unit":"CM"}
]
→ item_name: "철판 10T 150CM"
→ specification: "두께 10T, 길이 150CM"
------------------------- */
private function normalizeAttributes(?array $attrs): ?array
{
if (! $attrs) {
return null;
}
$out = [];
foreach ($attrs as $a) {
if (! is_array($a)) {
continue;
}
$label = trim((string) ($a['label'] ?? ''));
$value = trim((string) ($a['value'] ?? ''));
$unit = trim((string) ($a['unit'] ?? ''));
if ($label === '' && $value === '' && $unit === '') {
continue;
}
$out[] = ['label' => $label, 'value' => $value, 'unit' => $unit];
}
return $out ?: null;
}
private function buildItemName(string $name, ?array $attrs): string
{
if (! $attrs || count($attrs) === 0) {
return $name;
}
$parts = [];
foreach ($attrs as $a) {
$value = (string) ($a['value'] ?? '');
$unit = (string) ($a['unit'] ?? '');
$chunk = trim($value.$unit);
if ($chunk !== '') {
$parts[] = $chunk;
}
}
return trim($name.' '.implode(' ', $parts));
}
private function buildSpecText(?array $attrs): ?string
{
if (! $attrs || count($attrs) === 0) {
return null;
}
$parts = [];
foreach ($attrs as $a) {
$label = (string) ($a['label'] ?? '');
$value = (string) ($a['value'] ?? '');
$unit = (string) ($a['unit'] ?? '');
$valueWithUnit = trim($value.$unit);
if ($label !== '' && $valueWithUnit !== '') {
$parts[] = "{$label} {$valueWithUnit}";
} elseif ($valueWithUnit !== '') {
$parts[] = $valueWithUnit;
}
}
return $parts ? implode(', ', $parts) : null;
}
/**
* options 입력을 [{label, value, unit}] 형태로 정규화.
* - 맵 형태 {"key": "value"}도 배열로 변환
* - 항상 [{label, value, unit}] 형태로 저장
*/
private function normalizeOptions(?array $in): ?array
{
if (! $in) {
return null;
}
// 연관 맵 형태인지 간단 판별
$isAssoc = array_keys($in) !== range(0, count($in) - 1);
if ($isAssoc) {
// 맵 형태를 [{label, value, unit}] 배열로 변환
$out = [];
foreach ($in as $k => $v) {
$label = trim((string) $k);
$value = is_scalar($v) ? trim((string) $v) : json_encode($v, JSON_UNESCAPED_UNICODE);
if ($label !== '' || $value !== '') {
$out[] = ['label' => $label, 'value' => $value, 'unit' => ''];
}
}
return $out ?: null;
}
// 리스트(triple) 정규화
$out = [];
foreach ($in as $a) {
if (! is_array($a)) {
continue;
}
$label = trim((string) ($a['label'] ?? ''));
$value = trim((string) ($a['value'] ?? ''));
$unit = trim((string) ($a['unit'] ?? ''));
if ($label === '' && $value === '') {
continue;
}
$out[] = ['label' => $label, 'value' => $value, 'unit' => $unit];
}
return $out ?: null;
}
}

View File

@@ -1,537 +0,0 @@
<?php
namespace App\Services;
use App\Models\Materials\Material;
use App\Models\Products\Product;
use App\Models\Products\ProductComponent;
use App\Services\Products\ProductComponentResolver;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ProductBomService extends Service
{
/**
* 목록: 제품/자재를 통합 반환
* - 반환 형태 예:
* [
* { "id": 10, "ref_type": "PRODUCT", "ref_id": 3, "code": "P-003", "name": "모듈A", "quantity": "2.0000", "sort_order": 1, "is_default": 1 },
* { "id": 11, "ref_type": "MATERIAL", "ref_id": 5, "code": "M-005", "name": "알루미늄판", "unit":"EA", "quantity": "4.0000", "sort_order": 2 }
* ]
*/
public function index(int $parentProductId, array $params)
{
$tenantId = $this->tenantId();
// 부모 제품 유효성
$this->assertProduct($tenantId, $parentProductId);
$items = ProductComponent::query()
->where('tenant_id', $tenantId)
->where('parent_product_id', $parentProductId)
->orderBy('sort_order')
->get();
// 리졸브(제품/자재) - ref_id 기준
$productIds = $items->where('ref_type', 'PRODUCT')->pluck('ref_id')->filter()->unique()->values();
$materialIds = $items->where('ref_type', 'MATERIAL')->pluck('ref_id')->filter()->unique()->values();
$products = $productIds->isNotEmpty()
? Product::query()->where('tenant_id', $tenantId)->whereIn('id', $productIds)->get(['id', 'code', 'name', 'product_type', 'category_id'])->keyBy('id')
: collect();
$materials = $materialIds->isNotEmpty()
? Material::query()->where('tenant_id', $tenantId)->whereIn('id', $materialIds)->get(['id', 'material_code as code', 'name', 'unit', 'category_id'])->keyBy('id')
: collect();
return $items->map(function ($row) use ($products, $materials) {
$base = [
'id' => (int) $row->id,
'ref_type' => $row->ref_type,
'ref_id' => (int) $row->ref_id,
'quantity' => $row->quantity,
'sort_order' => (int) $row->sort_order,
'is_default' => (int) $row->is_default,
];
if ($row->ref_type === 'PRODUCT') {
$p = $products->get($row->ref_id);
return $base + [
'code' => $p?->code,
'name' => $p?->name,
'product_type' => $p?->product_type,
'category_id' => $p?->category_id,
];
} else { // MATERIAL
$m = $materials->get($row->ref_id);
return $base + [
'code' => $m?->code,
'name' => $m?->name,
'unit' => $m?->unit,
'category_id' => $m?->category_id,
];
}
})->values();
}
/**
* 일괄 업서트
* items[]: { id?, ref_type: PRODUCT|MATERIAL, ref_id: int, quantity: number, sort_order?: int, is_default?: 0|1 }
*/
public function bulkUpsert(int $parentProductId, array $items): array
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$this->assertProduct($tenantId, $parentProductId);
if (! is_array($items) || empty($items)) {
throw new BadRequestHttpException(__('error.empty_items'));
}
$created = 0;
$updated = 0;
$createdIds = [];
$updatedIds = [];
DB::transaction(function () use ($tenantId, $userId, $parentProductId, $items, &$created, &$updated, &$createdIds, &$updatedIds) {
foreach ($items as $it) {
$payload = $this->validateItem($it);
// ref 확인 & 자기참조 방지
$this->assertReference($tenantId, $parentProductId, $payload['ref_type'], (int) $payload['ref_id']);
if (! empty($it['id'])) {
$pc = ProductComponent::query()
->where('tenant_id', $tenantId)
->where('parent_product_id', $parentProductId)
->find((int) $it['id']);
if (! $pc) {
throw new BadRequestHttpException(__('error.not_found'));
}
$pc->update([
'ref_type' => $payload['ref_type'],
'ref_id' => (int) $payload['ref_id'],
'quantity' => $payload['quantity'],
'sort_order' => $payload['sort_order'] ?? $pc->sort_order,
'is_default' => $payload['is_default'] ?? $pc->is_default,
'updated_by' => $userId,
]);
$updated++;
$updatedIds[] = $pc->id;
} else {
// 신규
$pc = ProductComponent::create([
'tenant_id' => $tenantId,
'parent_product_id' => $parentProductId,
'ref_type' => $payload['ref_type'],
'ref_id' => (int) $payload['ref_id'],
'quantity' => $payload['quantity'],
'sort_order' => $payload['sort_order'] ?? 0,
'is_default' => $payload['is_default'] ?? 0,
'created_by' => $userId,
]);
$created++;
$createdIds[] = $pc->id;
}
}
});
return [
'created' => $created,
'updated' => $updated,
'created_ids' => $createdIds,
'updated_ids' => $updatedIds,
];
}
// 단건 수정
public function update(int $parentProductId, int $itemId, array $data)
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$this->assertProduct($tenantId, $parentProductId);
$pc = ProductComponent::query()
->where('tenant_id', $tenantId)
->where('parent_product_id', $parentProductId)
->find($itemId);
if (! $pc) {
throw new BadRequestHttpException(__('error.not_found'));
}
$v = Validator::make($data, [
'ref_type' => 'sometimes|in:PRODUCT,MATERIAL',
'ref_id' => 'sometimes|integer',
'quantity' => 'sometimes|numeric|min:0.0001',
'sort_order' => 'sometimes|integer|min:0',
'is_default' => 'sometimes|in:0,1',
]);
$payload = $v->validate();
if (isset($payload['ref_type']) || isset($payload['ref_id'])) {
$refType = $payload['ref_type'] ?? $pc->ref_type;
$refId = isset($payload['ref_id'])
? (int) $payload['ref_id']
: (int) $pc->ref_id;
$this->assertReference($tenantId, $parentProductId, $refType, $refId);
$pc->ref_type = $refType;
$pc->ref_id = $refId;
}
if (isset($payload['quantity'])) {
$pc->quantity = $payload['quantity'];
}
if (isset($payload['sort_order'])) {
$pc->sort_order = $payload['sort_order'];
}
if (isset($payload['is_default'])) {
$pc->is_default = $payload['is_default'];
}
$pc->updated_by = $userId;
$pc->save();
return $pc->refresh();
}
// 삭제
public function destroy(int $parentProductId, int $itemId): void
{
$tenantId = $this->tenantId();
$this->assertProduct($tenantId, $parentProductId);
$pc = ProductComponent::query()
->where('tenant_id', $tenantId)
->where('parent_product_id', $parentProductId)
->find($itemId);
if (! $pc) {
throw new BadRequestHttpException(__('error.not_found'));
}
$pc->delete();
}
// 정렬 변경
public function reorder(int $parentProductId, array $items): void
{
$tenantId = $this->tenantId();
$this->assertProduct($tenantId, $parentProductId);
if (! is_array($items)) {
throw new BadRequestHttpException(__('error.invalid_payload'));
}
DB::transaction(function () use ($tenantId, $parentProductId, $items) {
foreach ($items as $row) {
if (! isset($row['id'], $row['sort_order'])) {
continue;
}
ProductComponent::query()
->where('tenant_id', $tenantId)
->where('parent_product_id', $parentProductId)
->where('id', (int) $row['id'])
->update(['sort_order' => (int) $row['sort_order']]);
}
});
}
// 요약(간단 합계/건수)
public function summary(int $parentProductId): array
{
$tenantId = $this->tenantId();
$this->assertProduct($tenantId, $parentProductId);
$items = ProductComponent::query()
->where('tenant_id', $tenantId)
->where('parent_product_id', $parentProductId)
->get();
$cnt = $items->count();
$cntP = $items->where('ref_type', 'PRODUCT')->count();
$cntM = $items->where('ref_type', 'MATERIAL')->count();
$qtySum = (string) $items->sum('quantity');
return [
'count' => $cnt,
'count_product' => $cntP,
'count_material' => $cntM,
'quantity_sum' => $qtySum,
];
}
// 유효성 검사(중복/자기참조/음수 등)
public function validateBom(int $parentProductId): array
{
$tenantId = $this->tenantId();
$this->assertProduct($tenantId, $parentProductId);
$items = ProductComponent::query()
->where('tenant_id', $tenantId)
->where('parent_product_id', $parentProductId)
->orderBy('sort_order')
->get();
$errors = [];
$seen = [];
foreach ($items as $row) {
if ($row->quantity <= 0) {
$errors[] = ['id' => $row->id, 'error' => 'INVALID_QUANTITY'];
}
$key = $row->ref_type.':'.$row->ref_id;
if (isset($seen[$key])) {
$errors[] = ['id' => $row->id, 'error' => 'DUPLICATE_ITEM'];
} else {
$seen[$key] = true;
}
// 자기참조
if ($row->ref_type === 'PRODUCT' && (int) $row->ref_id === (int) $parentProductId) {
$errors[] = ['id' => $row->id, 'error' => 'SELF_REFERENCE'];
}
}
return [
'valid' => count($errors) === 0,
'errors' => $errors,
];
}
// ---------- helpers ----------
private function validateItem(array $it): array
{
$v = Validator::make($it, [
'id' => 'nullable|integer',
'ref_type' => 'required|in:PRODUCT,MATERIAL',
'ref_id' => 'required|integer',
'quantity' => 'required|numeric|min:0.0001',
'sort_order' => 'nullable|integer|min:0',
'is_default' => 'nullable|in:0,1',
]);
return $v->validate();
}
private function assertProduct(int $tenantId, int $productId): void
{
$exists = Product::query()->where('tenant_id', $tenantId)->where('id', $productId)->exists();
if (! $exists) {
// ko: 제품 정보를 찾을 수 없습니다.
throw new NotFoundHttpException(__('error.not_found_resource', ['resource' => '제품']));
}
}
private function assertReference(int $tenantId, int $parentProductId, string $refType, int $refId): void
{
if ($refType === 'PRODUCT') {
if ($refId === $parentProductId) {
throw new BadRequestHttpException(__('error.invalid_payload')); // 자기참조 방지
}
$ok = Product::query()->where('tenant_id', $tenantId)->where('id', $refId)->exists();
if (! $ok) {
throw new BadRequestHttpException(__('error.not_found'));
}
} else {
$ok = Material::query()->where('tenant_id', $tenantId)->where('id', $refId)->exists();
if (! $ok) {
throw new BadRequestHttpException(__('error.not_found'));
}
}
}
/**
* 특정 제품의 BOM을 전체 교체(기존 삭제 → 새 데이터 일괄 삽입)
* - $productId: products.id
* - $payload: ['categories' => [ {id?, name?, items: [{ref_type, ref_id, quantity, sort_order?}, ...]}, ... ]]
* 반환: ['deleted_count' => int, 'inserted_count' => int]
*/
public function replaceBom(int $productId, array $payload): array
{
if ($productId <= 0) {
throw new BadRequestHttpException(__('error.bad_request')); // 400
}
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// 0) ====== 빈 카테고리 제거 ======
$rawCats = Arr::get($payload, 'categories', []);
$normalized = [];
foreach ((array) $rawCats as $cat) {
$catId = Arr::get($cat, 'id');
$catName = Arr::get($cat, 'name');
$items = array_values(array_filter((array) Arr::get($cat, 'items', []), function ($it) {
$type = Arr::get($it, 'ref_type');
$id = (int) Arr::get($it, 'ref_id');
$qty = Arr::get($it, 'quantity');
return in_array($type, ['MATERIAL', 'PRODUCT'], true)
&& $id > 0
&& is_numeric($qty);
}));
if (count($items) === 0) {
continue; // 아이템 없으면 skip
}
$normalized[] = [
'id' => $catId,
'name' => $catName,
'items' => $items,
];
}
// 🔕 전부 비었으면: 기존 BOM 전체 삭제 후 성공
if (count($normalized) === 0) {
$deleted = ProductComponent::where('tenant_id', $tenantId)
->where('parent_product_id', $productId)
->delete();
return [
'deleted_count' => $deleted,
'inserted_count' => 0,
'message' => '모든 BOM 항목이 비어 기존 데이터를 삭제했습니다.',
];
}
// 1) ====== 검증 ======
$v = Validator::make(
['categories' => $normalized],
[
'categories' => ['required', 'array', 'min:1'],
'categories.*.id' => ['nullable', 'integer'],
'categories.*.name' => ['nullable', 'string', 'max:100'],
'categories.*.items' => ['required', 'array', 'min:1'],
'categories.*.items.*.ref_type' => ['required', 'in:MATERIAL,PRODUCT'],
'categories.*.items.*.ref_id' => ['required', 'integer', 'min:1'],
'categories.*.items.*.quantity' => ['required', 'numeric', 'min:0'],
'categories.*.items.*.sort_order' => ['nullable', 'integer', 'min:0'],
]
);
if ($v->fails()) {
throw new ValidationException($v, null, __('error.validation_failed'));
}
// 2) ====== 플랫 레코드 생성 (note 제거) ======
$rows = [];
$now = now();
foreach ($normalized as $cat) {
$catId = Arr::get($cat, 'id');
$catName = Arr::get($cat, 'name');
foreach ($cat['items'] as $idx => $item) {
$rows[] = [
'tenant_id' => $tenantId,
'parent_product_id' => $productId,
'category_id' => $catId,
'category_name' => $catName,
'ref_type' => $item['ref_type'],
'ref_id' => (int) $item['ref_id'],
'quantity' => (string) $item['quantity'],
'sort_order' => isset($item['sort_order']) ? (int) $item['sort_order'] : $idx,
'created_by' => $userId,
'updated_by' => $userId,
'created_at' => $now,
'updated_at' => $now,
];
}
}
// 3) ====== 트랜잭션: 기존 삭제 후 신규 삽입 ======
return DB::transaction(function () use ($tenantId, $productId, $rows) {
$deleted = ProductComponent::where('tenant_id', $tenantId)
->where('parent_product_id', $productId)
->delete();
$inserted = 0;
foreach (array_chunk($rows, 500) as $chunk) {
$ok = ProductComponent::insert($chunk);
$inserted += $ok ? count($chunk) : 0;
}
return [
'deleted_count' => $deleted,
'inserted_count' => $inserted,
'message' => 'BOM 저장 성공',
];
});
}
/** 제품별: 현재 BOM에 쓰인 카테고리 */
public function listCategoriesForProduct(int $productId): array
{
if ($productId <= 0) {
throw new BadRequestHttpException(__('error.bad_request'));
}
$tenantId = $this->tenantId();
$rows = ProductComponent::query()
->where('tenant_id', $tenantId)
->where('parent_product_id', $productId)
->whereNotNull('category_name')
->select([
DB::raw('category_id'),
DB::raw('category_name'),
DB::raw('COUNT(*) as count'),
])
->groupBy('category_id', 'category_name')
->orderByDesc('count')
->orderBy('category_name')
->get()
->toArray();
return $rows;
}
/** 테넌트 전역: 자주 쓰인 카테고리 추천(+검색) */
public function listCategoriesForTenant(?string $q, int $limit = 20): array
{
$tenantId = $this->tenantId();
$query = ProductComponent::query()
->where('tenant_id', $tenantId)
->whereNotNull('category_name')
->select([
DB::raw('category_id'),
DB::raw('category_name'),
DB::raw('COUNT(*) as count'),
])
->groupBy('category_id', 'category_name');
if ($q) {
$query->havingRaw('category_name LIKE ?', ["%{$q}%"]);
}
$rows = $query
->orderByDesc('count')
->orderBy('category_name')
->limit($limit > 0 ? $limit : 20)
->get()
->toArray();
return $rows;
}
public function tree($request, int $productId): array
{
$depth = (int) $request->query('depth', config('products.default_tree_depth', 10));
$resolver = app(ProductComponentResolver::class);
// 트리 배열만 반환 (ApiResponse가 바깥에서 래핑)
return $resolver->resolveTree($productId, $depth);
}
}

View File

@@ -1,362 +0,0 @@
<?php
namespace App\Services;
use App\Constants\SystemFields;
use App\Models\Commons\Category;
use App\Models\ItemMaster\ItemField;
use App\Models\Products\CommonCode;
use App\Models\Products\Product;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
class ProductService extends Service
{
/**
* products 테이블의 고정 컬럼 목록 조회 (SystemFields + ItemField 기반)
*/
private function getKnownFields(): array
{
$tenantId = $this->tenantId();
// 1. SystemFields에서 products 테이블 고정 컬럼
$systemFields = SystemFields::getReservedKeys(SystemFields::SOURCE_TABLE_PRODUCTS);
// 2. ItemField에서 storage_type='column'인 필드의 field_key 조회
$columnFields = ItemField::where('tenant_id', $tenantId)
->where('source_table', 'products')
->where('storage_type', 'column')
->whereNotNull('field_key')
->pluck('field_key')
->toArray();
// 3. 추가적인 API 전용 필드 (DB 컬럼이 아니지만 API에서 사용하는 필드)
$apiFields = ['item_type', 'type_code', 'bom'];
return array_unique(array_merge($systemFields, $columnFields, $apiFields));
}
/**
* 정의된 필드 외의 동적 필드를 options로 추출
*/
private function extractDynamicOptions(array $params): array
{
$knownFields = $this->getKnownFields();
$dynamicOptions = [];
foreach ($params as $key => $value) {
if (! in_array($key, $knownFields) && $value !== null && $value !== '') {
$dynamicOptions[$key] = $value;
}
}
return $dynamicOptions;
}
/**
* 기존 options 배열과 동적 필드를 병합
*/
private function mergeOptionsWithDynamic($existingOptions, array $dynamicOptions): array
{
if (! is_array($existingOptions) || empty($existingOptions)) {
return $dynamicOptions;
}
$isAssoc = array_keys($existingOptions) !== range(0, count($existingOptions) - 1);
if ($isAssoc) {
return array_merge($existingOptions, $dynamicOptions);
}
foreach ($dynamicOptions as $key => $value) {
$existingOptions[] = ['label' => $key, 'value' => $value];
}
return $existingOptions;
}
/**
* options 입력을 [{label, value, unit}] 형태로 정규화
*/
private function normalizeOptions(?array $in): ?array
{
if (! $in) {
return null;
}
$isAssoc = array_keys($in) !== range(0, count($in) - 1);
if ($isAssoc) {
$out = [];
foreach ($in as $k => $v) {
$label = trim((string) $k);
$value = is_scalar($v) ? trim((string) $v) : json_encode($v, JSON_UNESCAPED_UNICODE);
if ($label !== '' || $value !== '') {
$out[] = ['label' => $label, 'value' => $value, 'unit' => ''];
}
}
return $out ?: null;
}
$out = [];
foreach ($in as $a) {
if (! is_array($a)) {
continue;
}
$label = trim((string) ($a['label'] ?? ''));
$value = trim((string) ($a['value'] ?? ''));
$unit = trim((string) ($a['unit'] ?? ''));
if ($label === '' && $value === '') {
continue;
}
$out[] = ['label' => $label, 'value' => $value, 'unit' => $unit];
}
return $out ?: null;
}
/**
* 카테고리 트리 전체 조회 (parent_id = null 기준)
*/
public function getCategory($request)
{
$parentId = $request->parentId ?? null;
$group = $request->group ?? 'category';
// 재귀적으로 트리 구성
$list = $this->fetchCategoryTree($parentId, $group);
return $list;
}
/**
* 내부 재귀 함수 (하위 카테고리 트리 구조로 구성)
*/
protected function fetchCategoryTree(?int $parentId = null)
{
$tenantId = $this->tenantId(); // Base Service에서 상속받은 메서드
$query = Category::query()
->when($tenantId, fn ($q) => $q->where('tenant_id', $tenantId))
->when(
is_null($parentId),
fn ($q) => $q->whereNull('parent_id'),
fn ($q) => $q->where('parent_id', $parentId)
)
->where('is_active', 1)
->orderBy('sort_order');
$categories = $query->get();
foreach ($categories as $category) {
$children = $this->fetchCategoryTree($category->id);
$category->setRelation('children', $children);
}
return $categories;
}
/**
* (예시) 기존의 flat 리스트 조회
*/
public static function getCategoryFlat($group = 'category')
{
$query = CommonCode::where('code_group', $group)->whereNull('parent_id');
return $query->get();
}
// 목록/검색
public function index(array $params)
{
$tenantId = $this->tenantId();
$size = (int) ($params['size'] ?? 20);
$q = trim((string) ($params['q'] ?? ''));
$categoryId = $params['category_id'] ?? null;
$productType = $params['product_type'] ?? null; // PRODUCT|PART|SUBASSEMBLY...
$active = $params['active'] ?? null; // 1/0
$query = Product::query()
->with('category:id,name') // 필요한 컬럼만 가져오기
->where('tenant_id', $tenantId);
if ($q !== '') {
$query->where(function ($w) use ($q) {
$w->where('name', 'like', "%{$q}%")
->orWhere('code', 'like', "%{$q}%")
->orWhere('description', 'like', "%{$q}%");
});
}
if ($categoryId) {
$query->where('category_id', (int) $categoryId);
}
if ($productType) {
$query->where('product_type', $productType);
}
// Note: is_active 필드는 하이브리드 구조로 전환하면서 제거됨
// 필요시 attributes JSON이나 별도 필드로 관리
// if ($active !== null && $active !== '') {
// $query->where('is_active', (int) $active);
// }
$paginator = $query->orderBy('id')->paginate($size);
// 날짜 형식을 위해 분리
$paginator->setCollection(
$paginator->getCollection()->transform(function ($item) {
$arr = $item->toArray();
$arr['created_at'] = $item->created_at
? $item->created_at->format('Y-m-d')
: null;
return $arr;
})
);
return $paginator;
}
// 생성
public function store(array $data)
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// 동적 필드를 options에 병합
$dynamicOptions = $this->extractDynamicOptions($data);
if (! empty($dynamicOptions)) {
$existingOptions = $data['options'] ?? [];
$data['options'] = $this->mergeOptionsWithDynamic($existingOptions, $dynamicOptions);
}
// options 정규화
if (isset($data['options'])) {
$data['options'] = $this->normalizeOptions($data['options']);
}
$payload = $data;
// tenant별 code 유니크 수동 체크
$dup = Product::query()
->where('tenant_id', $tenantId)
->where('code', $payload['code'])
->exists();
if ($dup) {
throw new BadRequestHttpException(__('error.duplicate_key'));
}
// 기본값 설정
$payload['tenant_id'] = $tenantId;
$payload['created_by'] = $userId;
$payload['is_sellable'] = $payload['is_sellable'] ?? true;
$payload['is_purchasable'] = $payload['is_purchasable'] ?? false;
$payload['is_producible'] = $payload['is_producible'] ?? true;
return Product::create($payload);
}
// 단건
public function show(int $id)
{
$tenantId = $this->tenantId();
$p = Product::query()->where('tenant_id', $tenantId)->find($id);
if (! $p) {
throw new BadRequestHttpException(__('error.not_found'));
}
return $p;
}
// 수정
public function update(int $id, array $data)
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$p = Product::query()->where('tenant_id', $tenantId)->find($id);
if (! $p) {
throw new BadRequestHttpException(__('error.not_found'));
}
// 동적 필드를 options에 병합
$dynamicOptions = $this->extractDynamicOptions($data);
if (! empty($dynamicOptions)) {
$existingOptions = $data['options'] ?? [];
$data['options'] = $this->mergeOptionsWithDynamic($existingOptions, $dynamicOptions);
}
// options 정규화
if (isset($data['options'])) {
$data['options'] = $this->normalizeOptions($data['options']);
}
$payload = $data;
// code 변경 시 중복 체크
if (isset($payload['code']) && $payload['code'] !== $p->code) {
$dup = Product::query()
->where('tenant_id', $tenantId)
->where('code', $payload['code'])
->exists();
if ($dup) {
throw new BadRequestHttpException(__('error.duplicate_key'));
}
}
$payload['updated_by'] = $userId;
$p->update($payload);
return $p->refresh();
}
// 삭제(soft)
public function destroy(int $id): void
{
$tenantId = $this->tenantId();
$p = Product::query()->where('tenant_id', $tenantId)->find($id);
if (! $p) {
throw new BadRequestHttpException(__('error.not_found'));
}
$p->delete();
}
// 간편 검색(모달/드롭다운)
public function search(array $params)
{
$tenantId = $this->tenantId();
$q = trim((string) ($params['q'] ?? ''));
$lim = (int) ($params['limit'] ?? 20);
$qr = Product::query()->where('tenant_id', $tenantId);
if ($q !== '') {
$qr->where(function ($w) use ($q) {
$w->where('name', 'like', "%{$q}%")
->orWhere('code', 'like', "%{$q}%");
});
}
return $qr->orderBy('name')->limit($lim)->get(['id', 'code', 'name', 'product_type', 'category_id']);
}
// Note: toggle 메서드는 is_active 필드 제거로 인해 비활성화됨
// 필요시 attributes JSON이나 별도 필드로 구현
// public function toggle(int $id)
// {
// $tenantId = $this->tenantId();
// $userId = $this->apiUserId();
//
// $p = Product::query()->where('tenant_id', $tenantId)->find($id);
// if (! $p) {
// throw new BadRequestHttpException(__('error.not_found'));
// }
//
// $p->is_active = $p->is_active ? 0 : 1;
// $p->updated_by = $userId;
// $p->save();
//
// return ['id' => $p->id, 'is_active' => (int) $p->is_active];
// }
}

View File

@@ -1,234 +0,0 @@
<?php
namespace App\Services\Products;
use App\Models\Products\Product;
use App\Models\Products\ProductComponent;
use App\Services\Service;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
class ProductComponentResolver extends Service
{
public function __construct(
protected ?int $tenantId = null
) {
// 주입값 없으면 Service::tenantId() 사용 (app('tenant_id')에서 끌어옴)
$this->tenantId = $this->tenantId ?? $this->tenantId();
}
/** 테넌트별로 "제품처럼 자식이 있을 수 있는" ref_type 목록 */
protected function productLikeTypes(): array
{
$all = config('products.product_like_types', []);
$byTenant = Arr::get($all, (string) $this->tenantId, null);
if (is_array($byTenant) && $byTenant) {
return $byTenant;
}
return Arr::get($all, '*', ['PRODUCT']);
}
/** 한 부모 ID에 달린 컴포넌트 라인들 로드 (메모이즈) */
protected function getLinesForParent(int $parentId): array
{
// 간단 메모이즈(요청 범위); 대량 호출 방지
static $memo = [];
if (array_key_exists($parentId, $memo)) {
return $memo[$parentId];
}
$rows = ProductComponent::where('tenant_id', $this->tenantId)
->where('parent_product_id', $parentId)
->orderBy('sort_order')->orderBy('id')
->get([
'id', 'tenant_id', 'parent_product_id',
'category_id', 'category_name',
'ref_type', 'ref_id', 'quantity', 'sort_order',
])
->map(fn ($r) => $r->getAttributes()) // ✅ 핵심 수정
->all();
return $memo[$parentId] = $rows;
}
/** ref_type/ref_id 에 해당하는 노드의 "표시용 정보"를 로드 */
protected function resolveNodeInfo(string $refType, int $refId): array
{
if ($refType === 'PRODUCT') {
$p = Product::query()
->where('tenant_id', $this->tenantId)
->find($refId, ['id', 'code', 'name', 'product_type', 'category_id']);
if (! $p) {
return [
'id' => $refId,
'code' => null,
'name' => null,
'product_type' => null,
'category_id' => null,
];
}
return [
'id' => (int) $p->id,
'code' => $p->code,
'name' => $p->name,
'product_type' => $p->product_type,
'category_id' => $p->category_id,
];
}
// ✅ MATERIAL 분기: materials 테이블 스키마 반영
if ($refType === 'MATERIAL') {
$m = DB::table('materials')
->where('tenant_id', $this->tenantId)
->where('id', $refId)
->whereNull('deleted_at') // 소프트 삭제 고려
->first([
'id',
'material_code', // 코드
'item_name', // 표시명(있으면 우선)
'name', // fallback 표시명
'specification', // 규격
'unit',
'category_id',
]);
if (! $m) {
return [
'id' => (int) $refId,
'code' => null,
'name' => null,
'unit' => null,
'category_id' => null,
];
}
// item_name 우선, 없으면 name 사용
$displayName = $m->item_name ?: $m->name;
return [
'id' => (int) $m->id,
'code' => $m->material_code, // 표준 코드 필드
'name' => $displayName, // 사용자에게 보일 이름
'unit' => $m->unit,
'spec' => $m->specification, // 있으면 프론트에서 활용 가능
'category_id' => $m->category_id,
];
}
// 알 수 없는 타입 폴백
return [
'id' => $refId,
'code' => null,
'name' => null,
];
}
/**
* 단일 제품을 루트로 트리를 생성 (재귀 / 사이클 방지 / 깊이 제한)
*
* @param int $productId 루트 제품 ID
* @param int|null $maxDepth 최대 깊이(루트=0). null 이면 config default
* @return array 트리 구조
*/
public function resolveTree(int $productId, ?int $maxDepth = null): array
{
$maxDepth = $maxDepth ?? (int) config('products.default_tree_depth', 10);
$root = Product::query()
->where('tenant_id', $this->tenantId)
->findOrFail($productId, ['id', 'code', 'name', 'product_type', 'category_id']);
$visited = []; // 사이클 방지용 (product id 기준)
$node = [
'type' => 'PRODUCT',
'id' => $root->id,
'code' => $root->code,
'name' => $root->name,
'product_type' => $root->product_type,
'category_id' => $root->category_id,
'quantity' => 1, // 루트는 수량 1로 간주
'category' => null, // 루트는 임의
'children' => [],
'depth' => 0,
];
$node['children'] = $this->resolveChildren($root->id, 0, $maxDepth, $visited);
return $node;
}
/**
* 하위 노드(들) 재귀 확장
*
* @param array $visited product-id 기준 사이클 방지
*/
protected function resolveChildren(int $parentId, int $depth, int $maxDepth, array &$visited): array
{
// 깊이 제한
if ($depth >= $maxDepth) {
return [];
}
$lines = $this->getLinesForParent($parentId);
if (! $lines) {
return [];
}
$productLike = $this->productLikeTypes();
$children = [];
foreach ($lines as $line) {
$refType = (string) $line['ref_type'];
$refId = (int) $line['ref_id'];
$qty = (float) $line['quantity'];
if (! $refType || $refId <= 0) {
// 로그 남기고 스킵
// logger()->warning('Invalid component line', ['line' => $line]);
continue;
}
$info = $this->resolveNodeInfo($refType, $refId);
$child = [
'type' => $refType,
'id' => $info['id'] ?? $refId,
'code' => $info['code'] ?? null,
'name' => $info['name'] ?? null,
'product_type' => $info['product_type'] ?? null,
'category_id' => $info['category_id'] ?? null,
'quantity' => $qty,
'category' => [
'id' => $line['category_id'],
'name' => $line['category_name'],
],
'sort_order' => (int) $line['sort_order'],
'children' => [],
'depth' => $depth + 1,
];
// 제품처럼 자식이 달릴 수 있는 타입이면 재귀
if (in_array($refType, $productLike, true)) {
// 사이클 방지: 같은 product id 재방문 금지
$pid = (int) $child['id'];
if ($pid > 0) {
if (isset($visited[$pid])) {
$child['cycle'] = true; // 표식만 남기고 children 안탐
} else {
$visited[$pid] = true;
$child['children'] = $this->resolveChildren($pid, $depth + 1, $maxDepth, $visited);
unset($visited[$pid]); // 백트래킹
}
}
}
$children[] = $child;
}
return $children;
}
}

View File

@@ -1,316 +0,0 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(
* name="Material",
* description="자재 관리(목록/등록/조회/수정/삭제)"
* )
*/
/**
* 자재/요청 스키마
*/
/**
* @OA\Schema(
* schema="MaterialAttribute",
* type="object",
* description="규격 정보 한 항목",
*
* @OA\Property(property="label", type="string", example="두께"),
* @OA\Property(property="value", type="string", example="10"),
* @OA\Property(property="unit", type="string", example="T")
* )
*
* @OA\Schema(
* schema="Material",
* type="object",
* description="자재 상세",
* required={"id","tenant_id","name","item_name","unit"},
*
* @OA\Property(property="id", type="integer", example=101),
* @OA\Property(property="tenant_id", type="integer", example=1),
* @OA\Property(property="category_id", type="integer", nullable=true, example=3),
* @OA\Property(property="name", type="string", example="철판"),
* @OA\Property(property="item_name", type="string", example="철판 10T 150CM"),
* @OA\Property(property="specification", type="string", nullable=true, example="두께 10T, 길이 150CM"),
* @OA\Property(property="material_code", type="string", nullable=true, example=null),
* @OA\Property(property="unit", type="string", example="EA"),
* @OA\Property(property="is_inspection", type="string", enum={"Y","N"}, example="N"),
* @OA\Property(property="search_tag", type="string", nullable=true, example="철판, 판재, 금속"),
* @OA\Property(property="remarks", type="string", nullable=true, example="비고 메모"),
* @OA\Property(
* property="attributes",
* type="array",
* nullable=true,
*
* @OA\Items(ref="#/components/schemas/MaterialAttribute")
* ),
*
* @OA\Property(
* property="options",
* type="object",
* nullable=true,
* example={"manufacturer":"ACME","color":"SILVER"}
* ),
* @OA\Property(property="created_by", type="integer", example=12),
* @OA\Property(property="updated_by", type="integer", nullable=true, example=12),
* @OA\Property(property="created_at", type="string", format="date-time", example="2025-08-21 10:00:00"),
* @OA\Property(property="updated_at", type="string", format="date-time", example="2025-08-21 10:00:00")
* )
*
* @OA\Schema(
* schema="MaterialBrief",
* type="object",
* description="자재 요약",
* required={"id","name","item_name","unit"},
*
* @OA\Property(property="id", type="integer", example=101),
* @OA\Property(property="category_id", type="integer", nullable=true, example=3),
* @OA\Property(property="name", type="string", example="철판"),
* @OA\Property(property="item_name", type="string", example="철판 10T 150CM"),
* @OA\Property(property="unit", type="string", example="EA")
* )
*
* @OA\Schema(
* schema="MaterialList",
* type="array",
*
* @OA\Items(ref="#/components/schemas/MaterialBrief")
* )
*
* @OA\Schema(
* schema="MaterialIndexParams",
* type="object",
*
* @OA\Property(property="q", type="string", nullable=true, example="알루미늄"),
* @OA\Property(property="category", type="integer", nullable=true, example=3),
* @OA\Property(property="page", type="integer", nullable=true, example=1),
* @OA\Property(property="per_page", type="integer", nullable=true, example=20)
* )
*
* @OA\Schema(
* schema="MaterialCreateRequest",
* type="object",
* required={"name","unit"},
*
* @OA\Property(property="category_id", type="integer", nullable=true, example=3),
* @OA\Property(property="name", type="string", example="철판"),
* @OA\Property(property="unit", type="string", example="EA"),
* @OA\Property(property="is_inspection", type="string", enum={"Y","N"}, example="N"),
* @OA\Property(property="search_tag", type="string", nullable=true, example="철판, 판재, 금속"),
* @OA\Property(property="remarks", type="string", nullable=true, example="비고 메모"),
* @OA\Property(
* property="attributes",
* type="array",
* nullable=true,
*
* @OA\Items(ref="#/components/schemas/MaterialAttribute")
* ),
*
* @OA\Property(
* property="options",
* type="object",
* nullable=true,
* example={"manufacturer":"ACME","color":"SILVER"}
* ),
* @OA\Property(property="material_code", type="string", nullable=true, example=null),
* @OA\Property(property="specification", type="string", nullable=true, example=null)
* )
*
* @OA\Schema(
* schema="MaterialUpdateRequest",
* type="object",
*
* @OA\Property(property="category_id", type="integer", nullable=true, example=3),
* @OA\Property(property="name", type="string", nullable=true, example="철판(개선)"),
* @OA\Property(property="unit", type="string", nullable=true, example="KG"),
* @OA\Property(property="is_inspection", type="string", enum={"Y","N"}, nullable=true, example="Y"),
* @OA\Property(property="search_tag", type="string", nullable=true, example="철판, 금속"),
* @OA\Property(property="remarks", type="string", nullable=true, example="비고 변경"),
* @OA\Property(
* property="attributes",
* type="array",
* nullable=true,
*
* @OA\Items(ref="#/components/schemas/MaterialAttribute")
* ),
*
* @OA\Property(
* property="options",
* type="object",
* nullable=true,
* example={"manufacturer":"ACME","color":"BLACK"}
* ),
* @OA\Property(property="material_code", type="string", nullable=true, example="MAT-0002"),
* @OA\Property(property="specification", type="string", nullable=true, example="두께 12T, 길이 180CM")
* )
*/
class MaterialApi
{
/**
* @OA\Get(
* path="/api/v1/products/materials",
* summary="자재 목록 조회",
* description="자재 목록을 페이징으로 반환합니다. (q로 코드/이름/태그 검색, category로 분류 필터)",
* tags={"Material"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
*
* @OA\Parameter(ref="#/components/parameters/Page"),
* @OA\Parameter(ref="#/components/parameters/Size"),
* @OA\Parameter(name="q", in="query", required=false, @OA\Schema(type="string"), description="검색어(이름/코드/태그 등)"),
* @OA\Parameter(name="category", in="query", required=false, @OA\Schema(type="integer"), description="카테고리 ID"),
*
* @OA\Response(
* response=200, description="자재 목록 조회 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(
* property="data",
* type="object",
*
* @OA\Property(property="current_page", type="integer", example=1),
* @OA\Property(property="per_page", type="integer", example=20),
* @OA\Property(property="total", type="integer", example=2),
* @OA\Property(property="data", ref="#/components/schemas/MaterialList")
* ))
* }
* )
* )
* )
*/
public function index() {}
/**
* @OA\Post(
* path="/api/v1/products/materials",
* summary="자재 등록",
* description="자재를 등록합니다. item_name/specification은 name+attributes를 기반으로 서버에서 자동 생성됩니다.",
* tags={"Material"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
*
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/MaterialCreateRequest")),
*
* @OA\Response(
* response=200, description="자재 등록 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Material"))
* }
* )
* ),
*
* @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function store() {}
/**
* @OA\Get(
* path="/api/v1/products/materials/{id}",
* summary="자재 단건 조회",
* tags={"Material"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", example=101)),
*
* @OA\Response(
* response=200, description="자재 단건 조회 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Material"))
* }
* )
* ),
*
* @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function show() {}
/**
* @OA\Put(
* path="/api/v1/products/materials/{id}",
* summary="자재 수정(전체)",
* description="자재를 수정합니다. name/attributes 변경 시 item_name/specification이 서버에서 재계산됩니다.",
* tags={"Material"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", example=101)),
*
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/MaterialUpdateRequest")),
*
* @OA\Response(
* response=200, description="자재 수정 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Material"))
* }
* )
* ),
*
* @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function updatePut() {}
/**
* @OA\Patch(
* path="/api/v1/products/materials/{id}",
* summary="자재 부분 수정",
* description="자재의 일부 필드를 수정합니다. name/attributes 변경 시 item_name/specification이 서버에서 재계산됩니다.",
* tags={"Material"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", example=101)),
*
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/MaterialUpdateRequest")),
*
* @OA\Response(
* response=200, description="자재 수정 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Material"))
* }
* )
* ),
*
* @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function updatePatch() {}
/**
* @OA\Delete(
* path="/api/v1/products/materials/{id}",
* summary="자재 삭제",
* description="자재를 소프트 삭제합니다.",
* tags={"Material"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", example=101)),
*
* @OA\Response(response=200, description="자재 삭제 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")),
* @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function destroy() {}
}

View File

@@ -1,780 +0,0 @@
<?php
namespace App\Swagger\v1;
use OpenApi\Annotations as OA;
/**
* @OA\Tag(name="Product", description="제품 카테고리/검색(간단)")
* @OA\Tag(name="Products", description="제품/부품/서브어셈블리 CRUD")
* @OA\Tag(name="Products-BOM", description="제품 BOM (제품/자재 혼합) 관리")
*
* ========= 공용 스키마(이 파일에서 사용하는 것만 정의) =========
*
* 트리 노드 스키마 (재귀)
*
* @OA\Schema(
* schema="BomTreeNode",
* type="object",
* description="BOM 트리 한 노드(제품/자재 공통)",
*
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="type", type="string", example="PRODUCT", description="PRODUCT / MATERIAL 등"),
* @OA\Property(property="code", type="string", example="PRD-001"),
* @OA\Property(property="name", type="string", example="스크린 모듈 KS001"),
* @OA\Property(property="unit", type="string", nullable=true, example="SET"),
* @OA\Property(property="quantity", type="number", format="float", example=1),
* @OA\Property(property="sort_order", type="integer", example=0),
* @OA\Property(
* property="category",
* type="object",
* nullable=true,
* @OA\Property(property="id", type="integer", nullable=true, example=2),
* @OA\Property(property="name", type="string", nullable=true, example="본체")
* ),
* @OA\Property(
* property="children",
* type="array",
* description="자식 노드 목록(없으면 빈 배열)",
*
* @OA\Items(ref="#/components/schemas/BomTreeNode")
* )
* )
*
* BOM 카테고리 사용/추천 항목
*
* @OA\Schema(
* schema="BomCategoryStat",
* type="object",
*
* @OA\Property(property="category_id", type="integer", nullable=true, example=1),
* @OA\Property(property="category_name", type="string", example="기본"),
* @OA\Property(property="count", type="integer", example=5, description="해당 카테고리를 사용하는 BOM 항목 수(빈도)")
* )
*
* BOM 전체 교체 저장용 스키마
*
* @OA\Schema(
* schema="BomReplaceItem",
* type="object",
* required={"ref_type","ref_id","quantity"},
*
* @OA\Property(property="ref_type", type="string", enum={"MATERIAL","PRODUCT"}, example="MATERIAL", description="참조 타입"),
* @OA\Property(property="ref_id", type="integer", example=201, description="참조 ID (materials.id 또는 products.id)"),
* @OA\Property(property="quantity", type="number", format="float", example=2, description="수량(0 이상, 소수 허용)"),
* @OA\Property(property="sort_order", type="integer", nullable=true, example=0, description="정렬 순서(선택)")
* )
*
* @OA\Schema(
* schema="BomReplaceCategory",
* type="object",
* required={"items"},
*
* @OA\Property(property="id", type="integer", nullable=true, example=1, description="프론트 임시 카테고리 ID(선택)"),
* @OA\Property(property="name", type="string", nullable=true, example="기본", description="프론트 카테고리명(선택)"),
* @OA\Property(
* property="items",
* type="array",
*
* @OA\Items(ref="#/components/schemas/BomReplaceItem")
* )
* )
*
* @OA\Schema(
* schema="BomReplaceRequest",
* type="object",
* required={"categories"},
*
* @OA\Property(
* property="categories",
* type="array",
*
* @OA\Items(ref="#/components/schemas/BomReplaceCategory")
* ),
* example={
* "categories": {
* {
* "id": 1,
* "name": "기본",
* "items": {
* { "ref_type": "MATERIAL", "ref_id": 201, "quantity": 2, "sort_order": 0 },
* { "ref_type": "PRODUCT", "ref_id": 301, "quantity": 1 }
* }
* },
* {
* "id": 2,
* "name": "옵션",
* "items": {
* { "ref_type": "MATERIAL", "ref_id": 202, "quantity": 5 }
* }
* }
* }
* }
* )
*
* @OA\Schema(
* schema="BomReplaceResult",
* type="object",
*
* @OA\Property(property="deleted_count", type="integer", example=5, description="삭제된 기존 항목 수"),
* @OA\Property(property="inserted_count", type="integer", example=7, description="신규로 삽입된 항목 수")
* )
*/
class ProductApi
{
/**
* 카테고리 목록 조회 (기존)
*
* @OA\Get(
* path="/api/v1/product/category",
* summary="제품 카테고리 목록 조회",
* description="제품 카테고리(최상위: parent_id = null) 리스트를 반환합니다.",
* tags={"Products"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
*
* @OA\Response(
* response=200,
* description="카테고리 목록 조회 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(
* property="data",
* type="array",
*
* @OA\Items(ref="#/components/schemas/ProductCategory")
* )
* )
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function productCategoryIndex() {}
/**
* 제품 목록/검색
*
* @OA\Get(
* path="/api/v1/products",
* tags={"Products"},
* summary="제품 목록/검색",
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
*
* @OA\Parameter(ref="#/components/parameters/Page"),
* @OA\Parameter(ref="#/components/parameters/Size"),
* @OA\Parameter(name="q", in="query", @OA\Schema(type="string"), description="코드/이름/설명 검색"),
* @OA\Parameter(name="category_id", in="query", @OA\Schema(type="integer")),
* @OA\Parameter(name="product_type", in="query", @OA\Schema(type="string", example="PRODUCT")),
* @OA\Parameter(name="active", in="query", @OA\Schema(type="integer", enum={0,1})),
*
* @OA\Response(
* response=200,
* description="조회 성공",
*
* @OA\JsonContent(allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/ProductPagination"))
* })
* )
* )
*/
public function productsIndex() {}
/**
* 제품 생성
*
* @OA\Post(
* path="/api/v1/products",
* tags={"Products"},
* summary="제품 생성",
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
*
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/ProductCreateRequest")),
*
* @OA\Response(
* response=200,
* description="생성 성공",
*
* @OA\JsonContent(allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Product"))
* })
* )
* )
*/
public function productsStore() {}
/**
* 제품 단건
*
* @OA\Get(
* path="/api/v1/products/{id}",
* tags={"Products"},
* summary="제품 단건 조회",
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\Response(
* response=200,
* description="조회 성공",
*
* @OA\JsonContent(allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Product"))
* })
* ),
*
* @OA\Response(response=404, description="없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function productsShow() {}
/**
* 제품 수정
*
* @OA\Patch(
* path="/api/v1/products/{id}",
* tags={"Products"},
* summary="제품 수정",
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/ProductUpdateRequest")),
*
* @OA\Response(
* response=200,
* description="수정 성공",
*
* @OA\JsonContent(allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Product"))
* })
* )
* )
*/
public function productsUpdate() {}
/**
* 제품 삭제(soft)
*
* @OA\Delete(
* path="/api/v1/products/{id}",
* tags={"Products"},
* summary="제품 삭제(soft)",
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\Response(response=200, description="삭제 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse"))
* )
*/
public function productsDestroy() {}
/**
* 제품 간편 검색(모달/드롭다운)
*
* @OA\Get(
* path="/api/v1/products/search",
* tags={"Products"},
* summary="제품 간편 검색",
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
*
* @OA\Parameter(name="q", in="query", @OA\Schema(type="string")),
* @OA\Parameter(name="limit", in="query", @OA\Schema(type="integer"), description="기본 20"),
*
* @OA\Response(
* response=200,
* description="검색 성공",
*
* @OA\JsonContent(allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(
* property="data",
* type="array",
*
* @OA\Items(type="object",
*
* @OA\Property(property="id", type="integer", example=101),
* @OA\Property(property="code", type="string", example="PRD-001"),
* @OA\Property(property="name", type="string", example="스크린 모듈 KS001"),
* @OA\Property(property="product_type", type="string", example="PRODUCT"),
* @OA\Property(property="category_id", type="integer", example=7),
* @OA\Property(property="is_active", type="integer", example=1)
* )
* )
* )
* })
* )
* )
*/
public function productsSearch() {}
/**
* 제품 활성/비활성 토글
*
* @OA\Post(
* path="/api/v1/products/{id}/toggle",
* tags={"Products"},
* summary="제품 활성/비활성 토글",
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\Response(
* response=200,
* description="토글 성공",
*
* @OA\JsonContent(allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(property="data", type="object",
* @OA\Property(property="id", type="integer", example=101),
* @OA\Property(property="is_active", type="integer", example=0)
* )
* )
* })
* )
* )
* )
*/
public function productsToggle() {}
/**
* BOM 항목 목록
*
* @OA\Get(
* path="/api/v1/products/{id}/bom/items",
* tags={"Products-BOM"},
* summary="BOM 항목 목록(제품+자재 병합)",
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\Response(
* response=200,
* description="조회 성공",
*
* @OA\JsonContent(allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/BomItem"))
* )
* })
* )
* )
*/
public function bomItemsIndex() {}
/**
* BOM 대량 업서트
*
* @OA\Post(
* path="/api/v1/products/{id}/bom/items/bulk",
* tags={"Products-BOM"},
* summary="BOM 항목 대량 업서트",
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/BomItemBulkUpsertRequest")),
*
* @OA\Response(
* response=200,
* description="업서트 성공",
*
* @OA\JsonContent(allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(property="data", type="object",
* @OA\Property(property="created", type="integer", example=2),
* @OA\Property(property="updated", type="integer", example=3)
* )
* )
* })
* )
* )
*/
public function bomItemsBulk() {}
/**
* BOM 단건 수정
*
* @OA\Patch(
* path="/api/v1/products/{id}/bom/items/{item}",
* tags={"Products-BOM"},
* summary="BOM 항목 단건 수정",
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
* @OA\Parameter(name="item", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/BomItemUpdateRequest")),
*
* @OA\Response(
* response=200,
* description="수정 성공",
*
* @OA\JsonContent(allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/BomItem"))
* })
* )
* )
*/
public function bomItemsUpdate() {}
/**
* BOM 단건 삭제
*
* @OA\Delete(
* path="/api/v1/products/{id}/bom/items/{item}",
* tags={"Products-BOM"},
* summary="BOM 항목 단건 삭제",
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
* @OA\Parameter(name="item", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\Response(response=200, description="삭제 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse"))
* )
*/
public function bomItemsDestroy() {}
/**
* BOM 정렬 변경
*
* @OA\Post(
* path="/api/v1/products/{id}/bom/items/reorder",
* tags={"Products-BOM"},
* summary="BOM 정렬 변경",
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/BomReorderRequest")),
*
* @OA\Response(response=200, description="저장 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse"))
* )
*/
public function bomItemsReorder() {}
/**
* BOM 요약(건수/합계 등)
*
* @OA\Get(
* path="/api/v1/products/{id}/bom/summary",
* tags={"Products-BOM"},
* summary="BOM 요약",
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\Response(
* response=200,
* description="요약 성공",
*
* @OA\JsonContent(allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(property="data", type="object",
* @OA\Property(property="count", type="integer", example=5),
* @OA\Property(property="count_product", type="integer", example=2),
* @OA\Property(property="count_material", type="integer", example=3),
* @OA\Property(property="quantity_sum", type="string", example="7.0000")
* )
* )
* })
* )
* )
*/
public function bomSummary() {}
/**
* BOM 유효성 검사
*
* @OA\Get(
* path="/api/v1/products/{id}/bom/validate",
* tags={"Products-BOM"},
* summary="BOM 유효성 검사",
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\Response(
* response=200,
* description="검증 결과",
*
* @OA\JsonContent(allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(property="data", type="object",
* @OA\Property(property="valid", type="boolean", example=false),
* @OA\Property(property="errors", type="array",
*
* @OA\Items(type="object",
*
* @OA\Property(property="id", type="integer", example=11),
* @OA\Property(property="error", type="string", example="DUPLICATE_ITEM")
* )
* )
* )
* )
* })
* )
* )
*/
public function bomValidate() {}
/**
* BOM 전체 교체 저장
* 프론트에서 보낸 현재 BOM 상태로 기존 구성을 모두 삭제 후 재등록합니다.
*
* @OA\Post(
* path="/api/v1/products/{id}/bom",
* tags={"Products-BOM"},
* summary="BOM 구성 저장(구성 전체 교체)",
* description="기존 BOM을 모두 삭제하고, 전달된 categories/items 기준으로 다시 저장합니다.",
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
*
* @OA\Parameter(
* name="id",
* in="path",
* required=true,
* description="상위(모델) 제품 ID",
*
* @OA\Schema(type="integer", example=123)
* ),
*
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/BomReplaceRequest")),
*
* @OA\Response(
* response=200,
* description="저장 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(property="message", type="string", example="BOM 항목이 저장되었습니다."),
* @OA\Property(property="data", ref="#/components/schemas/BomReplaceResult")
* )
* }
* )
* ),
*
* @OA\Response(response=400, description="잘못된 요청", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="존재하지 않는 URI 또는 데이터", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function bomReplace() {}
/**
* 제품별 사용 중인 BOM 카테고리 목록
*
* @OA\Get(
* path="/api/v1/products/{id}/bom/categories",
* tags={"Products-BOM"},
* summary="해당 제품에서 사용 중인 BOM 카테고리 목록",
* description="product_components 테이블에서 해당 제품(parent_product_id)의 카테고리(id/name)를 집계하여 반환합니다.",
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
*
* @OA\Parameter(
* name="id",
* in="path",
* required=true,
* description="상위(모델) 제품 ID",
*
* @OA\Schema(type="integer", example=123)
* ),
*
* @OA\Response(
* response=200,
* description="조회 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(
* property="data",
* type="array",
*
* @OA\Items(ref="#/components/schemas/BomCategoryStat")
* )
* )
* },
* example={
* "success": true,
* "message": "조회 성공",
* "data": {
* {"category_id":1, "category_name":"기본", "count":5},
* {"category_id":2, "category_name":"옵션", "count":3}
* }
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function bomCategoriesForProduct() {}
/**
* 테넌트 전역 카테고리 추천(히스토리 기반)
*
* @OA\Get(
* path="/api/v1/products/bom/categories",
* tags={"Products-BOM"},
* summary="자주 사용된 BOM 카테고리 추천",
* description="테넌트 전체 product_components 데이터를 집계해 카테고리 사용 빈도가 높은 순으로 반환합니다. q로 부분 검색 가능합니다.",
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
*
* @OA\Parameter(
* name="q",
* in="query",
* required=false,
* description="카테고리명 부분 검색",
*
* @OA\Schema(type="string", example="기")
* ),
*
* @OA\Parameter(
* name="limit",
* in="query",
* required=false,
* description="최대 항목 수(기본 20)",
*
* @OA\Schema(type="integer", example=20)
* ),
*
* @OA\Response(
* response=200,
* description="조회 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(
* property="data",
* type="array",
*
* @OA\Items(ref="#/components/schemas/BomCategoryStat")
* )
* )
* },
* example={
* "success": true,
* "message": "조회 성공",
* "data": {
* {"category_id":1, "category_name":"기본", "count":127},
* {"category_id":2, "category_name":"옵션", "count":88},
* {"category_id":null, "category_name":"패키지", "count":12}
* }
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function bomCategoriesSuggest() {}
/**
* 제품 BOM 트리 조회
*
* @OA\Get(
* path="/api/v1/products/{id}/bom/tree",
* tags={"Products-BOM"},
* summary="제품 BOM 트리 조회(재귀)",
* description="특정 제품의 하위 구성(제품/자재)을 재귀적으로 트리 형태로 반환합니다. depth로 최대 깊이를 제한합니다(기본 10).",
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", minimum=1), description="제품 ID"),
* @OA\Parameter(
* name="depth",
* in="query",
* required=false,
* description="재귀 깊이(루트=0). 기본 10",
*
* @OA\Schema(type="integer", minimum=0, maximum=50, default=10, example=5)
* ),
*
* @OA\Response(
* response=200,
* description="조회 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(
* property="data",
* type="object",
* @OA\Property(
* property="product",
* type="object",
* @OA\Property(property="id", type="integer"),
* @OA\Property(property="code", type="string"),
* @OA\Property(property="name", type="string"),
* @OA\Property(property="unit", type="string", nullable=true),
* @OA\Property(property="category_id", type="integer", nullable=true),
* @OA\Property(property="product_type", type="string")
* ),
* @OA\Property(property="tree", ref="#/components/schemas/BomTreeNode")
* )
* )
* }
* )
* ),
*
* @OA\Response(response=400, description="잘못된 요청", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="대상 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function bomTree() {}
}

View File

@@ -1,178 +0,0 @@
<?php
namespace App\Swagger\v1;
/**
* 스키마 컨테이너(어노테이션 스캔 용)
*/
class ProductExtraSchemas
{
/**
* 제품 카테고리 스키마
*
* @OA\Schema(
* schema="ProductCategory",
* type="object",
* required={"id","code_group","code","name","is_active","sort_order"},
*
* @OA\Property(property="id", type="integer", example=4),
* @OA\Property(property="code_group", type="string", example="category"),
* @OA\Property(property="code", type="string", example="BP"),
* @OA\Property(property="name", type="string", example="절곡판"),
* @OA\Property(property="parent_id", type="integer", nullable=true, example=null),
* @OA\Property(
* property="attributes", type="object", nullable=true,
* example={"color":"blue","flags":{"screen":true}}
* ),
* @OA\Property(property="description", type="string", nullable=true, example="절곡판"),
* @OA\Property(property="is_active", type="integer", example=1),
* @OA\Property(property="sort_order", type="integer", example=10),
* @OA\Property(property="created_at", type="string", format="date-time", example="2025-07-23T09:00:00+09:00"),
* @OA\Property(property="updated_at", type="string", format="date-time", example="2025-07-23T09:00:00+09:00")
* )
*/
public function _category() {}
/**
* 제품 관련 스키마들
*
* @OA\Schema(
* schema="Product",
* type="object",
* required={"id","tenant_id","code","name","category_id","product_type","is_active"},
*
* @OA\Property(property="id", type="integer", example=101),
* @OA\Property(property="tenant_id", type="integer", example=1),
* @OA\Property(property="code", type="string", example="PRD-001"),
* @OA\Property(property="name", type="string", example="스크린 모듈 KS001"),
* @OA\Property(property="category_id", type="integer", example=7),
* @OA\Property(property="product_type", type="string", example="PRODUCT"),
* @OA\Property(property="attributes", type="object", nullable=true, example={"color":"black","size":"L"}),
* @OA\Property(property="description", type="string", nullable=true, example="고객사 스펙"),
* @OA\Property(property="is_sellable", type="integer", example=1),
* @OA\Property(property="is_purchasable", type="integer", example=0),
* @OA\Property(property="is_producible", type="integer", example=1),
* @OA\Property(property="is_active", type="integer", example=1),
* @OA\Property(property="created_at", type="string", format="date-time", example="2025-08-25 10:00:00"),
* @OA\Property(property="updated_at", type="string", format="date-time", example="2025-08-25 11:00:00")
* )
*
* @OA\Schema(
* schema="ProductPagination",
* type="object",
* description="라라벨 LengthAwarePaginator 구조",
*
* @OA\Property(property="current_page", type="integer", example=1),
* @OA\Property(
* property="data",
* type="array",
*
* @OA\Items(ref="#/components/schemas/Product")
* ),
*
* @OA\Property(property="per_page", type="integer", example=20),
* @OA\Property(property="total", type="integer", example=123)
* )
*
* @OA\Schema(
* schema="ProductCreateRequest",
* type="object",
* required={"code","name","category_id","product_type"},
*
* @OA\Property(property="code", type="string", example="PRD-001"),
* @OA\Property(property="name", type="string", example="스크린 모듈 KS001"),
* @OA\Property(property="category_id", type="integer", example=7),
* @OA\Property(property="product_type", type="string", example="PRODUCT"),
* @OA\Property(property="attributes", type="object", nullable=true, example={"color":"black"}),
* @OA\Property(property="description", type="string", nullable=true, example=null),
* @OA\Property(property="is_sellable", type="integer", example=1),
* @OA\Property(property="is_purchasable", type="integer", example=0),
* @OA\Property(property="is_producible", type="integer", example=1),
* @OA\Property(property="is_active", type="integer", example=1)
* )
*
* @OA\Schema(
* schema="ProductUpdateRequest",
* type="object",
*
* @OA\Property(property="code", type="string", example="PRD-001"),
* @OA\Property(property="name", type="string", example="스크린 모듈 KS001"),
* @OA\Property(property="category_id", type="integer", example=7),
* @OA\Property(property="product_type", type="string", example="PART"),
* @OA\Property(property="attributes", type="object", nullable=true, example={"size":"XL"}),
* @OA\Property(property="description", type="string", nullable=true, example="사양 변경"),
* @OA\Property(property="is_sellable", type="integer", example=1),
* @OA\Property(property="is_purchasable", type="integer", example=0),
* @OA\Property(property="is_producible", type="integer", example=1),
* @OA\Property(property="is_active", type="integer", example=1)
* )
*/
public function _product() {}
/**
* BOM 스키마들
*
* @OA\Schema(
* schema="BomItem",
* type="object",
* required={"id","ref_type","ref_id","quantity","sort_order"},
*
* @OA\Property(property="id", type="integer", example=11),
* @OA\Property(property="ref_type", type="string", enum={"PRODUCT","MATERIAL"}, example="PRODUCT"),
* @OA\Property(property="ref_id", type="integer", example=3),
* @OA\Property(property="code", type="string", nullable=true, example="PRD-003"),
* @OA\Property(property="name", type="string", nullable=true, example="모듈A"),
* @OA\Property(property="unit", type="string", nullable=true, example="EA"),
* @OA\Property(property="quantity", type="number", format="float", example=2.0000),
* @OA\Property(property="sort_order", type="integer", example=1),
* @OA\Property(property="is_default", type="integer", example=1)
* )
*
* @OA\Schema(
* schema="BomItemBulkUpsertRequest",
* type="object",
* required={"items"},
*
* @OA\Property(
* property="items",
* type="array",
*
* @OA\Items(
* type="object",
*
* @OA\Property(property="id", type="integer", nullable=true, example=null),
* @OA\Property(property="ref_type", type="string", enum={"PRODUCT","MATERIAL"}, example="MATERIAL"),
* @OA\Property(property="ref_id", type="integer", example=5),
* @OA\Property(property="quantity", type="number", format="float", example=4.0000),
* @OA\Property(property="sort_order", type="integer", example=2),
* @OA\Property(property="is_default", type="integer", example=0)
* )
* )
* )
*
* @OA\Schema(
* schema="BomItemUpdateRequest",
* type="object",
*
* @OA\Property(property="ref_type", type="string", enum={"PRODUCT","MATERIAL"}, example="PRODUCT"),
* @OA\Property(property="ref_id", type="integer", example=9),
* @OA\Property(property="quantity", type="number", format="float", example=1.5000),
* @OA\Property(property="sort_order", type="integer", example=3),
* @OA\Property(property="is_default", type="integer", example=1)
* )
*
* @OA\Schema(
* schema="BomReorderRequest",
* type="array",
*
* @OA\Items(
* type="object",
* required={"id","sort_order"},
*
* @OA\Property(property="id", type="integer", example=11),
* @OA\Property(property="sort_order", type="integer", example=1)
* )
* )
*/
public function _bom() {}
}

View File

@@ -0,0 +1,150 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* products, materials 테이블 삭제
*
* 전제조건:
* - items 테이블로 데이터 마이그레이션 완료
* - item_id_mappings 테이블에 ID 매핑 보존
* - BOM의 child_item_id가 새 items.id로 업데이트됨
*/
public function up(): void
{
// 1. FK 제약조건 삭제 (products 참조)
Schema::table('orders', function (Blueprint $table) {
// FK 존재 여부 확인 후 삭제
$this->dropForeignKeyIfExists($table, 'orders', 'fk_orders_product');
});
Schema::table('order_items', function (Blueprint $table) {
$this->dropForeignKeyIfExists($table, 'order_items', 'fk_order_items_product');
});
// 2. FK 제약조건 삭제 (materials 참조)
Schema::table('material_receipts', function (Blueprint $table) {
$this->dropForeignKeyIfExists($table, 'material_receipts', 'fk_receipts_material_id');
});
Schema::table('lots', function (Blueprint $table) {
$this->dropForeignKeyIfExists($table, 'lots', 'fk_lots_material_id');
});
// 3. 관련 테이블 삭제 (종속 테이블 먼저)
Schema::dropIfExists('product_components'); // products의 BOM 테이블
// 4. products 테이블 삭제
Schema::dropIfExists('products');
// 5. materials 테이블 삭제
Schema::dropIfExists('materials');
// 6. 삭제 완료 로그
DB::statement("SELECT 'Dropped: products, materials, product_components tables' AS result");
}
/**
* 롤백: 테이블 재생성 (데이터 복원 불가)
*
* 주의: 데이터는 item_id_mappings를 통해 items 테이블에서 복원해야 함
*/
public function down(): void
{
// products 테이블 재생성
if (! Schema::hasTable('products')) {
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id');
$table->string('code', 100);
$table->string('name', 255);
$table->string('unit', 20)->nullable();
$table->unsignedBigInteger('category_id')->nullable();
$table->enum('type', ['PRODUCT', 'PART', 'SUBASSEMBLY'])->default('PRODUCT');
$table->json('attributes')->nullable();
$table->json('options')->nullable();
$table->text('description')->nullable();
$table->boolean('is_active')->default(true);
$table->unsignedBigInteger('created_by')->nullable();
$table->unsignedBigInteger('updated_by')->nullable();
$table->unsignedBigInteger('deleted_by')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['tenant_id', 'code']);
$table->index(['tenant_id', 'type']);
});
}
// materials 테이블 재생성
if (! Schema::hasTable('materials')) {
Schema::create('materials', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id');
$table->string('code', 100);
$table->string('name', 255);
$table->string('unit', 20)->nullable();
$table->unsignedBigInteger('category_id')->nullable();
$table->enum('type', ['SUB_MATERIAL', 'RAW_MATERIAL', 'CONSUMABLE'])->default('SUB_MATERIAL');
$table->json('attributes')->nullable();
$table->json('options')->nullable();
$table->text('description')->nullable();
$table->boolean('is_active')->default(true);
$table->unsignedBigInteger('created_by')->nullable();
$table->unsignedBigInteger('updated_by')->nullable();
$table->unsignedBigInteger('deleted_by')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['tenant_id', 'code']);
$table->index(['tenant_id', 'type']);
});
}
// product_components 테이블 재생성
if (! Schema::hasTable('product_components')) {
Schema::create('product_components', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id');
$table->unsignedBigInteger('product_id');
$table->string('ref_type', 20); // MATERIAL, PRODUCT
$table->unsignedBigInteger('ref_id');
$table->decimal('quantity', 10, 4)->default(1);
$table->string('unit', 20)->nullable();
$table->integer('order_no')->default(0);
$table->text('note')->nullable();
$table->timestamps();
$table->index(['tenant_id', 'product_id']);
});
}
// FK 재생성 (주의: 데이터가 없으면 무결성 오류 가능)
// 실제 운영에서는 데이터 복원 후 FK를 별도로 추가해야 함
DB::statement("SELECT 'Tables recreated. FK constraints NOT restored. Restore data from items table using item_id_mappings.' AS warning");
}
/**
* FK 존재 여부 확인 후 삭제
*/
private function dropForeignKeyIfExists(Blueprint $table, string $tableName, string $fkName): void
{
$fkExists = DB::select("
SELECT COUNT(*) as cnt
FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = ?
AND TABLE_NAME = ?
AND CONSTRAINT_NAME = ?
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
", [config('database.connections.mysql.database'), $tableName, $fkName]);
if ($fkExists[0]->cnt > 0) {
$table->dropForeign($fkName);
}
}
};

View File

@@ -35,14 +35,14 @@
use App\Http\Controllers\Api\V1\ItemsBomController;
use App\Http\Controllers\Api\V1\ItemsController;
use App\Http\Controllers\Api\V1\ItemsFileController;
use App\Http\Controllers\Api\V1\MaterialController;
// use App\Http\Controllers\Api\V1\MaterialController; // REMOVED: materials 테이블 삭제됨
use App\Http\Controllers\Api\V1\MenuController;
use App\Http\Controllers\Api\V1\ModelSetController;
use App\Http\Controllers\Api\V1\PermissionController;
use App\Http\Controllers\Api\V1\PostController;
use App\Http\Controllers\Api\V1\PricingController;
use App\Http\Controllers\Api\V1\ProductBomItemController;
use App\Http\Controllers\Api\V1\ProductController;
// use App\Http\Controllers\Api\V1\ProductBomItemController; // REMOVED: products 테이블 삭제됨
// use App\Http\Controllers\Api\V1\ProductController; // REMOVED: products 테이블 삭제됨
use App\Http\Controllers\Api\V1\QuoteController;
use App\Http\Controllers\Api\V1\RefreshController;
use App\Http\Controllers\Api\V1\RegisterController;
@@ -409,35 +409,10 @@
Route::get('/{id}/revisions', [PricingController::class, 'revisions'])->whereNumber('id')->name('v1.pricing.revisions'); // 변경이력
});
// Products & Materials (제품/자재 통합 관리)
Route::prefix('products')->group(function () {
// REMOVED: Products & Materials 라우트 삭제됨 (products/materials 테이블 삭제)
// 모든 품목 관리는 /items 엔드포인트 사용
// 제품 카테고리 (기존 product/category에서 이동)
Route::get('/categories', [ProductController::class, 'getCategory'])->name('v1.products.categories'); // 제품 카테고리
// 자재 관리 (기존 독립 materials에서 이동) - ProductController 기본 라우팅보다 앞에 위치
Route::get('/materials', [MaterialController::class, 'index'])->name('v1.products.materials.index'); // 자재 목록
Route::post('/materials', [MaterialController::class, 'store'])->name('v1.products.materials.store'); // 자재 생성
Route::get('/materials/{id}', [MaterialController::class, 'show'])->name('v1.products.materials.show'); // 자재 단건
Route::patch('/materials/{id}', [MaterialController::class, 'update'])->name('v1.products.materials.update'); // 자재 수정
Route::delete('/materials/{id}', [MaterialController::class, 'destroy'])->name('v1.products.materials.destroy'); // 자재 삭제
// (선택) 드롭다운/모달용 간편 검색 & 활성 토글
Route::get('/search', [ProductController::class, 'search'])->name('v1.products.search');
Route::post('/{id}/toggle', [ProductController::class, 'toggle'])->name('v1.products.toggle');
Route::get('', [ProductController::class, 'index'])->name('v1.products.index'); // 목록/검색(q, category_id, product_type, active, page/size)
Route::post('', [ProductController::class, 'store'])->name('v1.products.store'); // 생성
Route::get('/{id}', [ProductController::class, 'show'])->name('v1.products.show'); // 단건
Route::patch('/{id}', [ProductController::class, 'update'])->name('v1.products.update'); // 수정
Route::delete('/{id}', [ProductController::class, 'destroy'])->name('v1.products.destroy'); // 삭제(soft)
// BOM 카테고리
Route::get('bom/categories', [ProductBomItemController::class, 'suggestCategories'])->name('v1.products.bom.categories.suggest'); // 전역(테넌트) 추천
Route::get('{id}/bom/categories', [ProductBomItemController::class, 'listCategories'])->name('v1.products.bom.categories'); // 해당 제품에서 사용 중
});
// Items (통합 품목 조회 - materials + products UNION)
// Items (통합 품목 관리 - items 테이블)
Route::prefix('items')->group(function () {
Route::get('', [ItemsController::class, 'index'])->name('v1.items.index'); // 통합 목록
Route::post('', [ItemsController::class, 'store'])->name('v1.items.store'); // 품목 생성
@@ -469,22 +444,8 @@
Route::delete('/{fileId}', [ItemsFileController::class, 'delete'])->name('v1.items.files.delete'); // 파일 삭제 (file_id)
});
// BOM (product_components: ref_type=PRODUCT|MATERIAL)
Route::prefix('products/{id}/bom')->group(function () {
Route::post('/', [ProductBomItemController::class, 'replace'])->name('v1.products.bom.replace');
Route::get('/items', [ProductBomItemController::class, 'index'])->name('v1.products.bom.items.index'); // 조회(제품+자재 병합)
Route::post('/items/bulk', [ProductBomItemController::class, 'bulkUpsert'])->name('v1.products.bom.items.bulk'); // 대량 업서트
Route::patch('/items/{item}', [ProductBomItemController::class, 'update'])->name('v1.products.bom.items.update'); // 단건 수정
Route::delete('/items/{item}', [ProductBomItemController::class, 'destroy'])->name('v1.products.bom.items.destroy'); // 단건 삭제
Route::post('/items/reorder', [ProductBomItemController::class, 'reorder'])->name('v1.products.bom.items.reorder'); // 정렬 변경
// (선택) 합계/검증
Route::get('/summary', [ProductBomItemController::class, 'summary'])->name('v1.products.bom.summary');
Route::get('/validate', [ProductBomItemController::class, 'validateBom'])->name('v1.products.bom.validate');
Route::get('/tree', [ProductBomItemController::class, 'tree'])->name('v1.products.bom.tree');
});
// REMOVED: products/{id}/bom 라우트 삭제됨 (product_components 테이블 삭제)
// BOM 관리는 /items/{id}/bom 엔드포인트 사용
// 설계 전용 (Design) - 운영과 분리된 네임스페이스/경로
Route::prefix('design')->group(function () {