fix : 카테고리, 제품등록, BOM등록 API (일부 개발 - BOM 추가 작업 필요)
This commit is contained in:
71
app/Http/Controllers/Api/V1/CategoryFieldController.php
Normal file
71
app/Http/Controllers/Api/V1/CategoryFieldController.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Services\CategoryFieldService;
|
||||
use App\Helpers\ApiResponse;
|
||||
|
||||
class CategoryFieldController extends Controller
|
||||
{
|
||||
public function __construct(private CategoryFieldService $service) {}
|
||||
|
||||
// GET /categories/{id}/fields
|
||||
public function index(int $id, Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $request) {
|
||||
return $this->service->index($id, $request->all());
|
||||
}, '카테고리 필드 목록');
|
||||
}
|
||||
|
||||
// POST /categories/{id}/fields
|
||||
public function store(int $id, Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $request) {
|
||||
return $this->service->store($id, $request->all());
|
||||
}, '카테고리 필드 생성');
|
||||
}
|
||||
|
||||
// GET /categories/fields/{field}
|
||||
public function show(int $field)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($field) {
|
||||
return $this->service->show($field);
|
||||
}, '카테고리 필드 조회');
|
||||
}
|
||||
|
||||
// PATCH /categories/fields/{field}
|
||||
public function update(int $field, Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($field, $request) {
|
||||
return $this->service->update($field, $request->all());
|
||||
}, '카테고리 필드 수정');
|
||||
}
|
||||
|
||||
// DELETE /categories/fields/{field}
|
||||
public function destroy(int $field)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($field) {
|
||||
$this->service->destroy($field);
|
||||
return 'success';
|
||||
}, '카테고리 필드 삭제');
|
||||
}
|
||||
|
||||
// POST /categories/{id}/fields/reorder
|
||||
public function reorder(int $id, Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $request) {
|
||||
$this->service->reorder($id, $request->input());
|
||||
return 'success';
|
||||
}, '카테고리 필드 정렬 저장');
|
||||
}
|
||||
|
||||
// PUT /categories/{id}/fields/bulk-upsert
|
||||
public function bulkUpsert(int $id, Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $request) {
|
||||
return $this->service->bulkUpsert($id, $request->input('items', []));
|
||||
}, '카테고리 필드 일괄 업서트');
|
||||
}
|
||||
}
|
||||
29
app/Http/Controllers/Api/V1/CategoryLogController.php
Normal file
29
app/Http/Controllers/Api/V1/CategoryLogController.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Services\CategoryLogService;
|
||||
use App\Helpers\ApiResponse;
|
||||
|
||||
class CategoryLogController extends Controller
|
||||
{
|
||||
public function __construct(private CategoryLogService $service) {}
|
||||
|
||||
// GET /categories/{id}/logs
|
||||
public function index(int $id, Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $request) {
|
||||
return $this->service->index($id, $request->all());
|
||||
}, '카테고리 변경이력 목록');
|
||||
}
|
||||
|
||||
// GET /categories/logs/{log}
|
||||
public function show(int $log)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($log) {
|
||||
return $this->service->show($log);
|
||||
}, '카테고리 변경이력 조회');
|
||||
}
|
||||
}
|
||||
79
app/Http/Controllers/Api/V1/CategoryTemplateController.php
Normal file
79
app/Http/Controllers/Api/V1/CategoryTemplateController.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Services\CategoryTemplateService;
|
||||
use App\Helpers\ApiResponse;
|
||||
|
||||
class CategoryTemplateController extends Controller
|
||||
{
|
||||
public function __construct(private CategoryTemplateService $service) {}
|
||||
|
||||
// GET /categories/{id}/templates
|
||||
public function index(int $id, Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $request) {
|
||||
return $this->service->index($id, $request->all());
|
||||
}, '카테고리 템플릿 목록');
|
||||
}
|
||||
|
||||
// POST /categories/{id}/templates
|
||||
public function store(int $id, Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $request) {
|
||||
return $this->service->store($id, $request->all());
|
||||
}, '카테고리 템플릿 생성');
|
||||
}
|
||||
|
||||
// GET /categories/templates/{tpl}
|
||||
public function show(int $tpl)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($tpl) {
|
||||
return $this->service->show($tpl);
|
||||
}, '카테고리 템플릿 조회');
|
||||
}
|
||||
|
||||
// PATCH /categories/templates/{tpl}
|
||||
public function update(int $tpl, Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($tpl, $request) {
|
||||
return $this->service->update($tpl, $request->all());
|
||||
}, '카테고리 템플릿 수정');
|
||||
}
|
||||
|
||||
// DELETE /categories/templates/{tpl}
|
||||
public function destroy(int $tpl)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($tpl) {
|
||||
$this->service->destroy($tpl);
|
||||
return 'success';
|
||||
}, '카테고리 템플릿 삭제');
|
||||
}
|
||||
|
||||
// POST /categories/{id}/templates/{tpl}/apply
|
||||
public function apply(int $id, int $tpl)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $tpl) {
|
||||
$this->service->apply($id, $tpl);
|
||||
return 'success';
|
||||
}, '카테고리 템플릿 적용');
|
||||
}
|
||||
|
||||
// GET /categories/{id}/templates/{tpl}/preview
|
||||
public function preview(int $id, int $tpl)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $tpl) {
|
||||
return $this->service->preview($id, $tpl);
|
||||
}, '카테고리 템플릿 미리보기');
|
||||
}
|
||||
|
||||
// GET /categories/{id}/templates/diff?a=&b=
|
||||
public function diff(int $id, Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $request) {
|
||||
return $this->service->diff($id, (int)$request->query('a'), (int)$request->query('b'));
|
||||
}, '카테고리 템플릿 비교');
|
||||
}
|
||||
}
|
||||
71
app/Http/Controllers/Api/V1/ProductBomItemController.php
Normal file
71
app/Http/Controllers/Api/V1/ProductBomItemController.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?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 유효성 검사');
|
||||
}
|
||||
}
|
||||
@@ -10,56 +10,70 @@
|
||||
|
||||
class ProductController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
//
|
||||
}
|
||||
public function __construct(private ProductService $service) {}
|
||||
|
||||
public function getCategory(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return ProductService::getCategory($request);
|
||||
return $this->service->getCategory($request);
|
||||
}, '제품 카테고리 조회');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Show the form for creating a new resource.
|
||||
*/
|
||||
public function create()
|
||||
// GET /products
|
||||
public function index(Request $request)
|
||||
{
|
||||
//
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->index($request->all());
|
||||
}, '제품 목록');
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*/
|
||||
// POST /products
|
||||
public function store(Request $request)
|
||||
{
|
||||
//
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->store($request->all());
|
||||
}, '제품 생성');
|
||||
}
|
||||
|
||||
|
||||
public function show(Request $request, $userNo)
|
||||
// GET /products/{id}
|
||||
public function show(int $id)
|
||||
{
|
||||
//
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->show($id);
|
||||
}, '제품 단건');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified resource.
|
||||
*/
|
||||
public function edit(string $id)
|
||||
// PATCH /products/{id}
|
||||
public function update(int $id, Request $request)
|
||||
{
|
||||
//
|
||||
return ApiResponse::handle(function () use ($id, $request) {
|
||||
return $this->service->update($id, $request->all());
|
||||
}, '제품 수정');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*/
|
||||
public function update(Request $request, string $id)
|
||||
// DELETE /products/{id}
|
||||
public function destroy(int $id)
|
||||
{
|
||||
//
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
$this->service->destroy($id);
|
||||
return 'success';
|
||||
}, '제품 삭제');
|
||||
}
|
||||
|
||||
// GET /products/search
|
||||
public function search(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->search($request->all());
|
||||
}, '제품 검색');
|
||||
}
|
||||
|
||||
// POST /products/{id}/toggle
|
||||
public function toggle(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->toggle($id);
|
||||
}, '제품 활성 토글');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,29 +2,92 @@
|
||||
|
||||
namespace App\Models\Products;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use App\Traits\ModelTrait;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use App\Traits\ModelTrait;
|
||||
use App\Traits\BelongsToTenant;
|
||||
|
||||
class ProductComponent extends Model
|
||||
{
|
||||
use SoftDeletes, BelongsToTenant, ModelTrait;
|
||||
use SoftDeletes, ModelTrait, BelongsToTenant;
|
||||
|
||||
protected $table = 'product_components';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id','parent_product_id','child_product_id',
|
||||
'quantity','sort_order','is_default',
|
||||
'created_by','updated_by'
|
||||
'tenant_id',
|
||||
'parent_product_id',
|
||||
'ref_type',
|
||||
'child_product_id',
|
||||
'material_id',
|
||||
'quantity',
|
||||
'sort_order',
|
||||
'is_default',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'quantity' => 'decimal:4',
|
||||
'sort_order' => 'integer',
|
||||
'is_default' => 'boolean',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
'deleted_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function parent() { return $this->belongsTo(Product::class, 'parent_product_id'); }
|
||||
public function child() { return $this->belongsTo(Product::class, 'child_product_id'); }
|
||||
protected $hidden = [
|
||||
'deleted_at',
|
||||
];
|
||||
|
||||
|
||||
/**
|
||||
* 상위 제품 (모델/제품)
|
||||
*/
|
||||
public function parentProduct()
|
||||
{
|
||||
return $this->belongsTo(Product::class, 'parent_product_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 하위 제품 (ref_type = PRODUCT일 때만 의미 있음)
|
||||
*/
|
||||
public function childProduct()
|
||||
{
|
||||
return $this->belongsTo(Product::class, 'child_product_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 하위 자재 (ref_type = MATERIAL일 때만 의미 있음)
|
||||
*/
|
||||
public function material()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Materials\Material::class, 'material_id');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------
|
||||
// 🔎 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);
|
||||
}
|
||||
}
|
||||
|
||||
246
app/Services/CategoryFieldService.php
Normal file
246
app/Services/CategoryFieldService.php
Normal file
@@ -0,0 +1,246 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use App\Models\Commons\CategoryField; // 가정: Eloquent 모델 경로
|
||||
use App\Models\Commons\Category;
|
||||
|
||||
class CategoryFieldService extends Service
|
||||
{
|
||||
public function index(int $categoryId, array $params)
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$size = (int)($params['size'] ?? 20);
|
||||
$sort = $params['sort'] ?? 'sort_order';
|
||||
$order = strtolower($params['order'] ?? 'asc') === 'desc' ? 'desc' : 'asc';
|
||||
|
||||
return CategoryField::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('category_id', $categoryId)
|
||||
->orderBy($sort, $order)
|
||||
->paginate($size);
|
||||
}
|
||||
|
||||
public function store(int $categoryId, array $data)
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
$this->assertCategoryExists($tenantId, $categoryId);
|
||||
|
||||
$v = Validator::make($data, [
|
||||
'field_key' => 'required|string|max:30|alpha_dash',
|
||||
'field_name' => 'required|string|max:100',
|
||||
'field_type' => 'required|string|max:20',
|
||||
'is_required' => 'nullable|in:Y,N',
|
||||
'sort_order' => 'nullable|integer|min:0',
|
||||
'default_value' => 'nullable|string|max:100',
|
||||
'options' => 'nullable|json',
|
||||
'description' => 'nullable|string|max:255',
|
||||
]);
|
||||
$payload = $v->validate();
|
||||
|
||||
// 카테고리 내 field_key 유니크 검증
|
||||
$exists = CategoryField::query()
|
||||
->where(compact('tenant_id'))
|
||||
->where('category_id', $categoryId)
|
||||
->where('field_key', $payload['field_key'])
|
||||
->exists();
|
||||
if ($exists) {
|
||||
throw new BadRequestHttpException(__('error.duplicate_key')); // ko/error.php에 매핑
|
||||
}
|
||||
|
||||
$payload['tenant_id'] = $tenantId;
|
||||
$payload['category_id'] = $categoryId;
|
||||
$payload['is_required'] = $payload['is_required'] ?? 'N';
|
||||
$payload['sort_order'] = $payload['sort_order'] ?? 0;
|
||||
$payload['created_by'] = $userId;
|
||||
|
||||
return CategoryField::create($payload);
|
||||
}
|
||||
|
||||
public function show(int $fieldId)
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$field = CategoryField::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->find($fieldId);
|
||||
|
||||
if (!$field) {
|
||||
throw new BadRequestHttpException(__('error.not_found'));
|
||||
}
|
||||
return $field;
|
||||
}
|
||||
|
||||
public function update(int $fieldId, array $data)
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
$field = CategoryField::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->find($fieldId);
|
||||
|
||||
if (!$field) {
|
||||
throw new BadRequestHttpException(__('error.not_found'));
|
||||
}
|
||||
|
||||
$v = Validator::make($data, [
|
||||
'field_key' => 'sometimes|string|max:30|alpha_dash',
|
||||
'field_name' => 'sometimes|string|max:100',
|
||||
'field_type' => 'sometimes|string|max:20',
|
||||
'is_required' => 'sometimes|in:Y,N',
|
||||
'sort_order' => 'sometimes|integer|min:0',
|
||||
'default_value' => 'nullable|string|max:100',
|
||||
'options' => 'nullable|json',
|
||||
'description' => 'nullable|string|max:255',
|
||||
]);
|
||||
$payload = $v->validate();
|
||||
|
||||
if (isset($payload['field_key']) && $payload['field_key'] !== $field->field_key) {
|
||||
$dup = CategoryField::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('category_id', $field->category_id)
|
||||
->where('field_key', $payload['field_key'])
|
||||
->exists();
|
||||
if ($dup) {
|
||||
throw new BadRequestHttpException(__('error.duplicate_key'));
|
||||
}
|
||||
}
|
||||
|
||||
$payload['updated_by'] = $userId;
|
||||
$field->update($payload);
|
||||
return $field->refresh();
|
||||
}
|
||||
|
||||
public function destroy(int $fieldId): void
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$field = CategoryField::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->find($fieldId);
|
||||
|
||||
if (!$field) {
|
||||
throw new BadRequestHttpException(__('error.not_found'));
|
||||
}
|
||||
$field->delete();
|
||||
}
|
||||
|
||||
public function reorder(int $categoryId, array $items): void
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$this->assertCategoryExists($tenantId, $categoryId);
|
||||
|
||||
$rows = $items['items'] ?? $items; // 둘 다 허용
|
||||
if (!is_array($rows)) {
|
||||
throw new BadRequestHttpException(__('error.invalid_payload'));
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($tenantId, $categoryId, $rows) {
|
||||
foreach ($rows as $row) {
|
||||
if (!isset($row['id'], $row['sort_order'])) continue;
|
||||
CategoryField::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('category_id', $categoryId)
|
||||
->where('id', $row['id'])
|
||||
->update(['sort_order' => (int)$row['sort_order']]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function bulkUpsert(int $categoryId, array $items): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
$this->assertCategoryExists($tenantId, $categoryId);
|
||||
|
||||
if (!is_array($items) || empty($items)) {
|
||||
throw new BadRequestHttpException(__('error.empty_items'));
|
||||
}
|
||||
|
||||
$result = ['created' => 0, 'updated' => 0];
|
||||
|
||||
DB::transaction(function () use ($tenantId, $userId, $categoryId, $items, &$result) {
|
||||
foreach ($items as $it) {
|
||||
$v = Validator::make($it, [
|
||||
'id' => 'nullable|integer',
|
||||
'field_key' => 'sometimes|required_without:id|string|max:30|alpha_dash',
|
||||
'field_name' => 'required|string|max:100',
|
||||
'field_type' => 'required|string|max:20',
|
||||
'is_required' => 'nullable|in:Y,N',
|
||||
'sort_order' => 'nullable|integer|min:0',
|
||||
'default_value' => 'nullable|string|max:100',
|
||||
'options' => 'nullable|json',
|
||||
'description' => 'nullable|string|max:255',
|
||||
]);
|
||||
$payload = $v->validate();
|
||||
|
||||
if (!empty($payload['id'])) {
|
||||
$model = CategoryField::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('category_id', $categoryId)
|
||||
->find($payload['id']);
|
||||
if (!$model) {
|
||||
throw new BadRequestHttpException(__('error.not_found'));
|
||||
}
|
||||
|
||||
// field_key 변경 유니크 검사
|
||||
if (isset($payload['field_key']) && $payload['field_key'] !== $model->field_key) {
|
||||
$dup = CategoryField::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('category_id', $categoryId)
|
||||
->where('field_key', $payload['field_key'])
|
||||
->exists();
|
||||
if ($dup) {
|
||||
throw new BadRequestHttpException(__('error.duplicate_key'));
|
||||
}
|
||||
}
|
||||
|
||||
$payload['updated_by'] = $userId;
|
||||
$model->update($payload);
|
||||
$result['updated']++;
|
||||
} else {
|
||||
// 신규 생성
|
||||
if (empty($payload['field_key'])) {
|
||||
throw new BadRequestHttpException(__('error.required', ['attr' => 'field_key']));
|
||||
}
|
||||
$dup = CategoryField::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('category_id', $categoryId)
|
||||
->where('field_key', $payload['field_key'])
|
||||
->exists();
|
||||
if ($dup) {
|
||||
throw new BadRequestHttpException(__('error.duplicate_key'));
|
||||
}
|
||||
|
||||
$payload['tenant_id'] = $tenantId;
|
||||
$payload['category_id'] = $categoryId;
|
||||
$payload['is_required'] = $payload['is_required'] ?? 'N';
|
||||
$payload['sort_order'] = $payload['sort_order'] ?? 0;
|
||||
$payload['created_by'] = $userId;
|
||||
|
||||
CategoryField::create($payload);
|
||||
$result['created']++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function assertCategoryExists(int $tenantId, int $categoryId): void
|
||||
{
|
||||
$exists = Category::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('id', $categoryId)
|
||||
->exists();
|
||||
if (!$exists) {
|
||||
throw new BadRequestHttpException(__('error.category_not_found'));
|
||||
}
|
||||
}
|
||||
}
|
||||
42
app/Services/CategoryLogService.php
Normal file
42
app/Services/CategoryLogService.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use App\Models\Commons\CategoryLog;
|
||||
|
||||
class CategoryLogService extends Service
|
||||
{
|
||||
public function index(int $categoryId, array $params)
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$size = (int)($params['size'] ?? 20);
|
||||
$action = $params['action'] ?? null; // insert|update|delete
|
||||
$from = $params['from'] ?? null; // Y-m-d
|
||||
$to = $params['to'] ?? null;
|
||||
|
||||
$q = CategoryLog::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('category_id', $categoryId)
|
||||
->orderByDesc('changed_at');
|
||||
|
||||
if ($action) $q->where('action', $action);
|
||||
if ($from) $q->whereDate('changed_at', '>=', Carbon::parse($from)->toDateString());
|
||||
if ($to) $q->whereDate('changed_at', '<=', Carbon::parse($to)->toDateString());
|
||||
|
||||
return $q->paginate($size);
|
||||
}
|
||||
|
||||
public function show(int $logId)
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$log = CategoryLog::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->find($logId);
|
||||
|
||||
if (!$log) throw new BadRequestHttpException(__('error.not_found'));
|
||||
return $log;
|
||||
}
|
||||
}
|
||||
194
app/Services/CategoryTemplateService.php
Normal file
194
app/Services/CategoryTemplateService.php
Normal file
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use App\Models\Commons\CategoryTemplate;
|
||||
use App\Models\Commons\Category;
|
||||
use App\Models\Commons\CategoryField;
|
||||
|
||||
class CategoryTemplateService extends Service
|
||||
{
|
||||
public function index(int $categoryId, array $params)
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$size = (int)($params['size'] ?? 20);
|
||||
|
||||
return CategoryTemplate::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('category_id', $categoryId)
|
||||
->orderByDesc('version_no')
|
||||
->paginate($size);
|
||||
}
|
||||
|
||||
public function store(int $categoryId, array $data)
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
$this->assertCategoryExists($tenantId, $categoryId);
|
||||
|
||||
$v = Validator::make($data, [
|
||||
'version_no' => 'required|integer|min:1',
|
||||
'template_json' => 'required|json',
|
||||
'applied_at' => 'required|date',
|
||||
'remarks' => 'nullable|string|max:255',
|
||||
]);
|
||||
$payload = $v->validate();
|
||||
|
||||
$dup = CategoryTemplate::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('category_id', $categoryId)
|
||||
->where('version_no', $payload['version_no'])
|
||||
->exists();
|
||||
if ($dup) {
|
||||
throw new BadRequestHttpException(__('error.duplicate_key')); // version_no 중복
|
||||
}
|
||||
|
||||
$payload['tenant_id'] = $tenantId;
|
||||
$payload['category_id'] = $categoryId;
|
||||
$payload['created_by'] = $userId;
|
||||
|
||||
return CategoryTemplate::create($payload);
|
||||
}
|
||||
|
||||
public function show(int $tplId)
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$tpl = CategoryTemplate::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->find($tplId);
|
||||
|
||||
if (!$tpl) throw new BadRequestHttpException(__('error.not_found'));
|
||||
return $tpl;
|
||||
}
|
||||
|
||||
public function update(int $tplId, array $data)
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
$tpl = CategoryTemplate::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->find($tplId);
|
||||
|
||||
if (!$tpl) throw new BadRequestHttpException(__('error.not_found'));
|
||||
|
||||
$v = Validator::make($data, [
|
||||
'template_json' => 'nullable|json',
|
||||
'applied_at' => 'nullable|date',
|
||||
'remarks' => 'nullable|string|max:255',
|
||||
]);
|
||||
$payload = $v->validate();
|
||||
$payload['updated_by'] = $userId;
|
||||
|
||||
$tpl->update($payload);
|
||||
return $tpl->refresh();
|
||||
}
|
||||
|
||||
public function destroy(int $tplId): void
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$tpl = CategoryTemplate::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->find($tplId);
|
||||
|
||||
if (!$tpl) throw new BadRequestHttpException(__('error.not_found'));
|
||||
$tpl->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 적용 정책:
|
||||
* - categories.active_template_version (또는 별도 맵 테이블)에 version_no 반영
|
||||
* - (옵션) template_json 기반으로 category_fields를 실제로 갱신하려면 여기서 동기화
|
||||
*/
|
||||
public function apply(int $categoryId, int $tplId): void
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
$tpl = CategoryTemplate::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('category_id', $categoryId)
|
||||
->find($tplId);
|
||||
|
||||
if (!$tpl) throw new BadRequestHttpException(__('error.not_found'));
|
||||
|
||||
DB::transaction(function () use ($tenantId, $userId, $categoryId, $tpl) {
|
||||
// 1) categories 테이블에 활성 버전 반영(컬럼이 있다면)
|
||||
// Category::where('tenant_id', $tenantId)->where('id', $categoryId)->update([
|
||||
// 'active_template_version' => $tpl->version_no,
|
||||
// 'updated_by' => $userId,
|
||||
// ]);
|
||||
|
||||
// 2) (선택) template_json → category_fields 동기화
|
||||
// - 추가/수정/삭제 전략은 운영정책에 맞게 구현
|
||||
// - 여기서는 예시로 "fields" 배열만 처리
|
||||
// $snapshot = json_decode($tpl->template_json, true);
|
||||
// foreach (($snapshot['fields'] ?? []) as $i => $f) {
|
||||
// // key, name, type, required, default, options 매핑 ...
|
||||
// }
|
||||
});
|
||||
}
|
||||
|
||||
public function preview(int $categoryId, int $tplId): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$tpl = CategoryTemplate::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('category_id', $categoryId)
|
||||
->find($tplId);
|
||||
|
||||
if (!$tpl) throw new BadRequestHttpException(__('error.not_found'));
|
||||
|
||||
$json = json_decode($tpl->template_json, true);
|
||||
if (!is_array($json)) {
|
||||
throw new BadRequestHttpException(__('error.invalid_payload'));
|
||||
}
|
||||
// 프론트 렌더링 편의 구조로 가공 가능
|
||||
return $json;
|
||||
}
|
||||
|
||||
public function diff(int $categoryId, int $a, int $b): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$aTpl = CategoryTemplate::query()
|
||||
->where('tenant_id', $tenantId)->where('category_id', $categoryId)
|
||||
->where('version_no', $a)->first();
|
||||
|
||||
$bTpl = CategoryTemplate::query()
|
||||
->where('tenant_id', $tenantId)->where('category_id', $categoryId)
|
||||
->where('version_no', $b)->first();
|
||||
|
||||
if (!$aTpl || !$bTpl) throw new BadRequestHttpException(__('error.not_found'));
|
||||
|
||||
$aj = json_decode($aTpl->template_json, true) ?: [];
|
||||
$bj = json_decode($bTpl->template_json, true) ?: [];
|
||||
|
||||
// 아주 단순한 diff 예시 (fields 키만 비교)
|
||||
$aKeys = collect($aj['fields'] ?? [])->pluck('key')->all();
|
||||
$bKeys = collect($bj['fields'] ?? [])->pluck('key')->all();
|
||||
|
||||
return [
|
||||
'added' => array_values(array_diff($bKeys, $aKeys)),
|
||||
'removed' => array_values(array_diff($aKeys, $bKeys)),
|
||||
// 변경(diff in detail)은 정책에 맞게 확장
|
||||
];
|
||||
}
|
||||
|
||||
private function assertCategoryExists(int $tenantId, int $categoryId): void
|
||||
{
|
||||
$exists = Category::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('id', $categoryId)
|
||||
->exists();
|
||||
if (!$exists) {
|
||||
throw new BadRequestHttpException(__('error.category_not_found'));
|
||||
}
|
||||
}
|
||||
}
|
||||
335
app/Services/ProductBomService.php
Normal file
335
app/Services/ProductBomService.php
Normal file
@@ -0,0 +1,335 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Materials\Material;
|
||||
use App\Models\Products\Product;
|
||||
use App\Models\Products\ProductComponent;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
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();
|
||||
|
||||
// 리졸브(제품/자재)
|
||||
$productIds = $items->where('ref_type', 'PRODUCT')->pluck('child_product_id')->filter()->unique()->values();
|
||||
$materialIds = $items->where('ref_type', 'MATERIAL')->pluck('material_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,
|
||||
'quantity' => $row->quantity,
|
||||
'sort_order' => (int)$row->sort_order,
|
||||
'is_default' => (int)$row->is_default,
|
||||
];
|
||||
|
||||
if ($row->ref_type === 'PRODUCT') {
|
||||
$p = $products->get($row->child_product_id);
|
||||
return $base + [
|
||||
'ref_id' => (int)$row->child_product_id,
|
||||
'code' => $p?->code,
|
||||
'name' => $p?->name,
|
||||
'product_type' => $p?->product_type,
|
||||
'category_id' => $p?->category_id,
|
||||
];
|
||||
} else { // MATERIAL
|
||||
$m = $materials->get($row->material_id);
|
||||
return $base + [
|
||||
'ref_id' => (int)$row->material_id,
|
||||
'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;
|
||||
|
||||
DB::transaction(function () use ($tenantId, $userId, $parentProductId, $items, &$created, &$updated) {
|
||||
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'));
|
||||
|
||||
// ref 변경 허용 시: 충돌 검사
|
||||
[$childProductId, $materialId] = $this->splitRef($payload);
|
||||
|
||||
$pc->update([
|
||||
'ref_type' => $payload['ref_type'],
|
||||
'child_product_id' => $childProductId,
|
||||
'material_id' => $materialId,
|
||||
'quantity' => $payload['quantity'],
|
||||
'sort_order' => $payload['sort_order'] ?? $pc->sort_order,
|
||||
'is_default' => $payload['is_default'] ?? $pc->is_default,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
$updated++;
|
||||
} else {
|
||||
// 신규
|
||||
[$childProductId, $materialId] = $this->splitRef($payload);
|
||||
|
||||
ProductComponent::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'parent_product_id' => $parentProductId,
|
||||
'ref_type' => $payload['ref_type'],
|
||||
'child_product_id' => $childProductId,
|
||||
'material_id' => $materialId,
|
||||
'quantity' => $payload['quantity'],
|
||||
'sort_order' => $payload['sort_order'] ?? 0,
|
||||
'is_default' => $payload['is_default'] ?? 0,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
$created++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return compact('created', 'updated');
|
||||
}
|
||||
|
||||
// 단건 수정
|
||||
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']
|
||||
: ($pc->ref_type === 'PRODUCT' ? (int)$pc->child_product_id : (int)$pc->material_id);
|
||||
|
||||
$this->assertReference($tenantId, $parentProductId, $refType, $refId);
|
||||
[$childProductId, $materialId] = $this->splitRef(['ref_type' => $refType, 'ref_id' => $refId]);
|
||||
|
||||
$pc->ref_type = $refType;
|
||||
$pc->child_product_id = $childProductId;
|
||||
$pc->material_id = $materialId;
|
||||
}
|
||||
|
||||
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_type === 'PRODUCT' ? $row->child_product_id : $row->material_id);
|
||||
if (isset($seen[$key])) {
|
||||
$errors[] = ['id' => $row->id, 'error' => 'DUPLICATE_ITEM'];
|
||||
} else {
|
||||
$seen[$key] = true;
|
||||
}
|
||||
// 자기참조
|
||||
if ($row->ref_type === 'PRODUCT' && (int)$row->child_product_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 splitRef(array $payload): array
|
||||
{
|
||||
// returns [child_product_id, material_id]
|
||||
if ($payload['ref_type'] === 'PRODUCT') {
|
||||
return [(int)$payload['ref_id'], null];
|
||||
}
|
||||
return [null, (int)$payload['ref_id']];
|
||||
}
|
||||
|
||||
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'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,12 @@
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Products\CommonCode;
|
||||
use App\Models\Products\Product;
|
||||
use App\Models\Commons\Category;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
class ProductService
|
||||
class ProductService extends Service
|
||||
{
|
||||
|
||||
/**
|
||||
@@ -24,16 +28,27 @@ public static function getCategory($request)
|
||||
/**
|
||||
* 내부 재귀 함수 (하위 카테고리 트리 구조로 구성)
|
||||
*/
|
||||
protected static function fetchCategoryTree($parentId = null, $group = 'category')
|
||||
protected function fetchCategoryTree(?int $parentId = null)
|
||||
{
|
||||
$categories = CommonCode::where('code_group', 'category')
|
||||
->where('parent_id', $parentId)
|
||||
->orderBy('sort_order')->debug();
|
||||
$categories = $categories->get();
|
||||
$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) {
|
||||
$category->children = self::fetchCategoryTree($category->id);
|
||||
$children = $this->fetchCategoryTree($category->id);
|
||||
$category->setRelation('children', $children);
|
||||
}
|
||||
|
||||
return $categories;
|
||||
}
|
||||
|
||||
@@ -46,5 +61,157 @@ public static function getCategoryFlat($group = 'category')
|
||||
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()->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);
|
||||
if ($active !== null && $active !== '') $query->where('is_active', (int)$active);
|
||||
|
||||
return $query->orderByDesc('id')->paginate($size);
|
||||
}
|
||||
|
||||
// 생성
|
||||
public function store(array $data)
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
$v = Validator::make($data, [
|
||||
'code' => 'required|string|max:30',
|
||||
'name' => 'required|string|max:100',
|
||||
'category_id' => 'required|integer',
|
||||
'product_type' => 'required|string|max:30',
|
||||
'attributes' => 'nullable|array',
|
||||
'description' => 'nullable|string|max:255',
|
||||
'is_sellable' => 'nullable|in:0,1',
|
||||
'is_purchasable' => 'nullable|in:0,1',
|
||||
'is_producible' => 'nullable|in:0,1',
|
||||
'is_active' => 'nullable|in:0,1',
|
||||
]);
|
||||
$payload = $v->validate();
|
||||
|
||||
// tenant별 code 유니크 수동 체크(운영 전 DB 유니크 구성도 권장)
|
||||
$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'] ?? 1;
|
||||
$payload['is_purchasable'] = $payload['is_purchasable'] ?? 0;
|
||||
$payload['is_producible'] = $payload['is_producible'] ?? 1;
|
||||
$payload['is_active'] = $payload['is_active'] ?? 1;
|
||||
|
||||
// attributes array → json 저장 (Eloquent casts가 array면 그대로 가능)
|
||||
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'));
|
||||
|
||||
$v = Validator::make($data, [
|
||||
'code' => 'sometimes|string|max:30',
|
||||
'name' => 'sometimes|string|max:100',
|
||||
'category_id' => 'sometimes|integer',
|
||||
'product_type' => 'sometimes|string|max:30',
|
||||
'attributes' => 'nullable|array',
|
||||
'description' => 'nullable|string|max:255',
|
||||
'is_sellable' => 'nullable|in:0,1',
|
||||
'is_purchasable' => 'nullable|in:0,1',
|
||||
'is_producible' => 'nullable|in:0,1',
|
||||
'is_active' => 'nullable|in:0,1',
|
||||
]);
|
||||
$payload = $v->validate();
|
||||
|
||||
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','is_active']);
|
||||
}
|
||||
|
||||
// 활성 토글
|
||||
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];
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
564
app/Swagger/v1/CategoryExtras.php
Normal file
564
app/Swagger/v1/CategoryExtras.php
Normal file
@@ -0,0 +1,564 @@
|
||||
<?php
|
||||
|
||||
namespace App\Swagger\v1;
|
||||
|
||||
/**
|
||||
* @OA\Tag(name="Category-Fields", description="카테고리별 동적 필드 관리")
|
||||
* @OA\Tag(name="Category-Templates", description="카테고리 템플릿 버전 관리")
|
||||
* @OA\Tag(name="Category-Logs", description="카테고리 변경 이력 조회")
|
||||
*/
|
||||
|
||||
/**
|
||||
* =========================
|
||||
* ===== Schemas ========
|
||||
* =========================
|
||||
*/
|
||||
|
||||
/**
|
||||
* @OA\Schema(
|
||||
* schema="CategoryField",
|
||||
* type="object",
|
||||
* required={"id","tenant_id","category_id","field_key","field_name","field_type"},
|
||||
* @OA\Property(property="id", type="integer", example=11),
|
||||
* @OA\Property(property="tenant_id", type="integer", example=1),
|
||||
* @OA\Property(property="category_id", type="integer", example=7),
|
||||
* @OA\Property(property="field_key", type="string", example="width"),
|
||||
* @OA\Property(property="field_name", type="string", example="폭(mm)"),
|
||||
* @OA\Property(property="field_type", type="string", example="number"),
|
||||
* @OA\Property(property="is_required", type="string", enum={"Y","N"}, example="N"),
|
||||
* @OA\Property(property="sort_order", type="integer", example=1),
|
||||
* @OA\Property(property="default_value", type="string", nullable=true, example=null),
|
||||
* @OA\Property(
|
||||
* property="options",
|
||||
* type="object",
|
||||
* nullable=true,
|
||||
* description="선택지(콤보박스 등)",
|
||||
* example={"units": {"mm","cm","m"}}
|
||||
* ),
|
||||
* @OA\Property(property="description", type="string", nullable=true, example=null),
|
||||
* @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 10:20:00")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="CategoryFieldPagination",
|
||||
* type="object",
|
||||
* @OA\Property(property="current_page", type="integer", example=1),
|
||||
* @OA\Property(
|
||||
* property="data",
|
||||
* type="array",
|
||||
* @OA\Items(ref="#/components/schemas/CategoryField")
|
||||
* ),
|
||||
* @OA\Property(property="per_page", type="integer", example=20),
|
||||
* @OA\Property(property="total", type="integer", example=3)
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="CategoryFieldCreateRequest",
|
||||
* type="object",
|
||||
* required={"field_key","field_name","field_type"},
|
||||
* @OA\Property(property="field_key", type="string", example="height"),
|
||||
* @OA\Property(property="field_name", type="string", example="높이(mm)"),
|
||||
* @OA\Property(property="field_type", type="string", example="number"),
|
||||
* @OA\Property(property="is_required", type="string", enum={"Y","N"}, example="N"),
|
||||
* @OA\Property(property="sort_order", type="integer", example=2),
|
||||
* @OA\Property(property="default_value", type="string", nullable=true, example=null),
|
||||
* @OA\Property(
|
||||
* property="options",
|
||||
* type="object",
|
||||
* nullable=true,
|
||||
* example={"min": 0, "max": 9999, "step": 1}
|
||||
* ),
|
||||
* @OA\Property(property="description", type="string", nullable=true, example="선택 입력")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="CategoryFieldUpdateRequest",
|
||||
* type="object",
|
||||
* @OA\Property(property="field_key", type="string", example="height"),
|
||||
* @OA\Property(property="field_name", type="string", example="높이(mm)"),
|
||||
* @OA\Property(property="field_type", type="string", example="number"),
|
||||
* @OA\Property(property="is_required", type="string", enum={"Y","N"}, example="Y"),
|
||||
* @OA\Property(property="sort_order", type="integer", example=1),
|
||||
* @OA\Property(property="default_value", type="string", nullable=true, example=null),
|
||||
* @OA\Property(
|
||||
* property="options",
|
||||
* type="object",
|
||||
* nullable=true,
|
||||
* example={"min": 0, "max": 9999, "step": 1}
|
||||
* ),
|
||||
* @OA\Property(property="description", type="string", nullable=true, example=null)
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="CategoryFieldReorderRequest",
|
||||
* 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)
|
||||
* )
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="CategoryFieldBulkUpsertRequest",
|
||||
* 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="field_key", type="string", example="thickness"),
|
||||
* @OA\Property(property="field_name", type="string", example="두께(mm)"),
|
||||
* @OA\Property(property="field_type", type="string", example="number"),
|
||||
* @OA\Property(property="is_required", type="string", enum={"Y","N"}, example="N"),
|
||||
* @OA\Property(property="sort_order", type="integer", example=3),
|
||||
* @OA\Property(property="default_value", type="string", nullable=true, example=null),
|
||||
* @OA\Property(property="options", type="object", nullable=true, example={"unit":"mm"}),
|
||||
* @OA\Property(property="description", type="string", nullable=true, example=null)
|
||||
* )
|
||||
* )
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="CategoryTemplate",
|
||||
* type="object",
|
||||
* required={"id","tenant_id","category_id","version_no","template_json","applied_at"},
|
||||
* @OA\Property(property="id", type="integer", example=1001),
|
||||
* @OA\Property(property="tenant_id", type="integer", example=1),
|
||||
* @OA\Property(property="category_id", type="integer", example=7),
|
||||
* @OA\Property(property="version_no", type="integer", example=3),
|
||||
* @OA\Property(
|
||||
* property="template_json",
|
||||
* type="object",
|
||||
* description="템플릿 스냅샷",
|
||||
* example={"fields": {{"key":"width","type":"number","required":true}}}
|
||||
* ),
|
||||
* @OA\Property(property="applied_at", type="string", format="date-time", example="2025-08-25 11:00:00"),
|
||||
* @OA\Property(property="remarks", type="string", nullable=true, example="높이 필드 추가"),
|
||||
* @OA\Property(property="created_at", type="string", format="date-time", example="2025-08-25 10:50:00"),
|
||||
* @OA\Property(property="updated_at", type="string", format="date-time", example="2025-08-25 11:05:00")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="CategoryTemplateCreateRequest",
|
||||
* type="object",
|
||||
* required={"version_no","template_json","applied_at"},
|
||||
* @OA\Property(property="version_no", type="integer", example=4),
|
||||
* @OA\Property(
|
||||
* property="template_json",
|
||||
* type="object",
|
||||
* example={
|
||||
* "fields": {
|
||||
* {"key":"width","type":"number","required":true},
|
||||
* {"key":"height","type":"number","required":false}
|
||||
* }
|
||||
* }
|
||||
* ),
|
||||
* @OA\Property(property="applied_at", type="string", format="date-time", example="2025-08-25 13:00:00"),
|
||||
* @OA\Property(property="remarks", type="string", nullable=true, example="높이 추가 버전")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="CategoryTemplateUpdateRequest",
|
||||
* type="object",
|
||||
* @OA\Property(
|
||||
* property="template_json",
|
||||
* type="object",
|
||||
* example={"fields": {{"key":"width","type":"number"}}}
|
||||
* ),
|
||||
* @OA\Property(property="applied_at", type="string", format="date-time", example="2025-08-26 09:00:00"),
|
||||
* @OA\Property(property="remarks", type="string", nullable=true, example="비고 수정")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="CategoryLog",
|
||||
* type="object",
|
||||
* required={"id","tenant_id","category_id","action","changed_at"},
|
||||
* @OA\Property(property="id", type="integer", example=501),
|
||||
* @OA\Property(property="tenant_id", type="integer", example=1),
|
||||
* @OA\Property(property="category_id", type="integer", example=7),
|
||||
* @OA\Property(property="action", type="string", example="update"),
|
||||
* @OA\Property(property="changed_by", type="integer", nullable=true, example=1),
|
||||
* @OA\Property(property="changed_at", type="string", format="date-time", example="2025-08-25 14:00:00"),
|
||||
* @OA\Property(property="before_json", type="object", nullable=true, example={"name":"old"}),
|
||||
* @OA\Property(property="after_json", type="object", nullable=true, example={"name":"new"}),
|
||||
* @OA\Property(property="remarks", type="string", nullable=true, example=null)
|
||||
* )
|
||||
*/
|
||||
class CategoryExtras
|
||||
{
|
||||
/**
|
||||
* ===============================
|
||||
* ==== Category Field APIs ======
|
||||
* ===============================
|
||||
*/
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/categories/{id}/fields",
|
||||
* tags={"Category-Fields"},
|
||||
* summary="카테고리별 필드 목록",
|
||||
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer"), description="카테고리 ID"),
|
||||
* @OA\Parameter(ref="#/components/parameters/Page"),
|
||||
* @OA\Parameter(ref="#/components/parameters/Size"),
|
||||
* @OA\Parameter(name="sort", in="query", @OA\Schema(type="string", example="sort_order")),
|
||||
* @OA\Parameter(name="order", in="query", @OA\Schema(type="string", enum={"asc","desc"}, example="asc")),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="목록 조회 성공",
|
||||
* @OA\JsonContent(allOf={
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/CategoryFieldPagination"))
|
||||
* })
|
||||
* ),
|
||||
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function fieldsIndex() {}
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/categories/{id}/fields",
|
||||
* tags={"Category-Fields"},
|
||||
* 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/CategoryFieldCreateRequest")),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="생성 성공",
|
||||
* @OA\JsonContent(allOf={
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/CategoryField"))
|
||||
* })
|
||||
* ),
|
||||
* @OA\Response(response=400, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function fieldsStore() {}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/categories/fields/{field}",
|
||||
* tags={"Category-Fields"},
|
||||
* summary="카테고리 필드 단건 조회",
|
||||
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
|
||||
* @OA\Parameter(name="field", 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/CategoryField"))
|
||||
* })
|
||||
* ),
|
||||
* @OA\Response(response=404, description="없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function fieldsShow() {}
|
||||
|
||||
/**
|
||||
* @OA\Patch(
|
||||
* path="/api/v1/categories/fields/{field}",
|
||||
* tags={"Category-Fields"},
|
||||
* summary="카테고리 필드 수정",
|
||||
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
|
||||
* @OA\Parameter(name="field", in="path", required=true, @OA\Schema(type="integer")),
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/CategoryFieldUpdateRequest")),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="수정 성공",
|
||||
* @OA\JsonContent(allOf={
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/CategoryField"))
|
||||
* })
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function fieldsUpdate() {}
|
||||
|
||||
/**
|
||||
* @OA\Delete(
|
||||
* path="/api/v1/categories/fields/{field}",
|
||||
* tags={"Category-Fields"},
|
||||
* summary="카테고리 필드 삭제",
|
||||
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
|
||||
* @OA\Parameter(name="field", in="path", required=true, @OA\Schema(type="integer")),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="삭제 성공",
|
||||
* @OA\JsonContent(ref="#/components/schemas/ApiResponse")
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function fieldsDestroy() {}
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/categories/{id}/fields/reorder",
|
||||
* tags={"Category-Fields"},
|
||||
* 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/CategoryFieldReorderRequest")),
|
||||
* @OA\Response(response=200, description="저장 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse"))
|
||||
* )
|
||||
*/
|
||||
public function fieldsReorder() {}
|
||||
|
||||
/**
|
||||
* @OA\Put(
|
||||
* path="/api/v1/categories/{id}/fields/bulk-upsert",
|
||||
* tags={"Category-Fields"},
|
||||
* 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/CategoryFieldBulkUpsertRequest")),
|
||||
* @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 fieldsBulkUpsert() {}
|
||||
|
||||
/**
|
||||
* ==================================
|
||||
* ==== Category Template APIs =====
|
||||
* ==================================
|
||||
*/
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/categories/{id}/templates",
|
||||
* tags={"Category-Templates"},
|
||||
* summary="카테고리 템플릿 버전 목록",
|
||||
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
|
||||
* @OA\Parameter(ref="#/components/parameters/Page"),
|
||||
* @OA\Parameter(ref="#/components/parameters/Size"),
|
||||
* @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="data",
|
||||
* type="array",
|
||||
* @OA\Items(ref="#/components/schemas/CategoryTemplate")
|
||||
* ),
|
||||
* @OA\Property(property="total", type="integer", example=3)
|
||||
* )
|
||||
* )
|
||||
* })
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function templatesIndex() {}
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/categories/{id}/templates",
|
||||
* tags={"Category-Templates"},
|
||||
* 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/CategoryTemplateCreateRequest")),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="생성 성공",
|
||||
* @OA\JsonContent(allOf={
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/CategoryTemplate"))
|
||||
* })
|
||||
* ),
|
||||
* @OA\Response(response=409, description="버전 중복", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
public function templatesStore() {}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/categories/templates/{tpl}",
|
||||
* tags={"Category-Templates"},
|
||||
* summary="카테고리 템플릿 단건 조회",
|
||||
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
|
||||
* @OA\Parameter(name="tpl", 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/CategoryTemplate"))
|
||||
* })
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function templatesShow() {}
|
||||
|
||||
/**
|
||||
* @OA\Patch(
|
||||
* path="/api/v1/categories/templates/{tpl}",
|
||||
* tags={"Category-Templates"},
|
||||
* summary="카테고리 템플릿 수정",
|
||||
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
|
||||
* @OA\Parameter(name="tpl", in="path", required=true, @OA\Schema(type="integer")),
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/CategoryTemplateUpdateRequest")),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="수정 성공",
|
||||
* @OA\JsonContent(allOf={
|
||||
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/CategoryTemplate"))
|
||||
* })
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function templatesUpdate() {}
|
||||
|
||||
/**
|
||||
* @OA\Delete(
|
||||
* path="/api/v1/categories/templates/{tpl}",
|
||||
* tags={"Category-Templates"},
|
||||
* summary="카테고리 템플릿 삭제",
|
||||
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
|
||||
* @OA\Parameter(name="tpl", in="path", required=true, @OA\Schema(type="integer")),
|
||||
* @OA\Response(response=200, description="삭제 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse"))
|
||||
* )
|
||||
*/
|
||||
public function templatesDestroy() {}
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/categories/{id}/templates/{tpl}/apply",
|
||||
* tags={"Category-Templates"},
|
||||
* summary="카테고리 템플릿 적용(활성화)",
|
||||
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
|
||||
* @OA\Parameter(name="tpl", in="path", required=true, @OA\Schema(type="integer")),
|
||||
* @OA\Response(response=200, description="적용 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse"))
|
||||
* )
|
||||
*/
|
||||
public function templatesApply() {}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/categories/{id}/templates/{tpl}/preview",
|
||||
* tags={"Category-Templates"},
|
||||
* summary="카테고리 템플릿 미리보기",
|
||||
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
|
||||
* @OA\Parameter(name="tpl", in="path", required=true, @OA\Schema(type="integer")),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="미리보기 성공",
|
||||
* @OA\JsonContent(allOf={@OA\Schema(ref="#/components/schemas/ApiResponse")})
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function templatesPreview() {}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/categories/{id}/templates/diff",
|
||||
* tags={"Category-Templates"},
|
||||
* summary="두 버전 비교(diff)",
|
||||
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
|
||||
* @OA\Parameter(name="a", in="query", required=true, @OA\Schema(type="integer"), description="비교 기준 버전"),
|
||||
* @OA\Parameter(name="b", in="query", required=true, @OA\Schema(type="integer"), description="비교 대상 버전"),
|
||||
* @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="added", type="array", @OA\Items(type="string")),
|
||||
* @OA\Property(property="removed", type="array", @OA\Items(type="string"))
|
||||
* )
|
||||
* )
|
||||
* })
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function templatesDiff() {}
|
||||
|
||||
/**
|
||||
* ==============================
|
||||
* ===== Category Log APIs =====
|
||||
* ==============================
|
||||
*/
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/categories/{id}/logs",
|
||||
* tags={"Category-Logs"},
|
||||
* summary="카테고리 변경 이력 목록",
|
||||
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
|
||||
* @OA\Parameter(name="action", in="query", @OA\Schema(type="string", enum={"insert","update","delete"})),
|
||||
* @OA\Parameter(name="from", in="query", @OA\Schema(type="string", format="date")),
|
||||
* @OA\Parameter(name="to", in="query", @OA\Schema(type="string", format="date")),
|
||||
* @OA\Parameter(ref="#/components/parameters/Page"),
|
||||
* @OA\Parameter(ref="#/components/parameters/Size"),
|
||||
* @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="data",
|
||||
* type="array",
|
||||
* @OA\Items(ref="#/components/schemas/CategoryLog")
|
||||
* ),
|
||||
* @OA\Property(property="total", type="integer", example=12)
|
||||
* )
|
||||
* )
|
||||
* })
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function logsIndex() {}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/categories/logs/{log}",
|
||||
* tags={"Category-Logs"},
|
||||
* summary="카테고리 변경 이력 단건 조회",
|
||||
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
|
||||
* @OA\Parameter(name="log", 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/CategoryLog"))
|
||||
* })
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function logsShow() {}
|
||||
}
|
||||
@@ -3,37 +3,19 @@
|
||||
namespace App\Swagger\v1;
|
||||
|
||||
/**
|
||||
* 제품 카테고리 스키마
|
||||
* @OA\Schema(
|
||||
* schema="ProductCategory",
|
||||
* type="object",
|
||||
* description="제품 카테고리",
|
||||
* 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,
|
||||
* description="카테고리 속성(자유형식 JSON)",
|
||||
* 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")
|
||||
* )
|
||||
*
|
||||
* 카테고리 목록 조회
|
||||
* @OA\Tag(name="Product", description="제품 카테고리/검색(간단)")
|
||||
* @OA\Tag(name="Products", description="제품/부품/서브어셈블리 CRUD")
|
||||
* @OA\Tag(name="Products-BOM", description="제품 BOM (제품/자재 혼합) 관리")
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* 카테고리 목록 조회 (기존)
|
||||
* @OA\Get(
|
||||
* path="/api/v1/product/category",
|
||||
* summary="제품 카테고리 목록 조회",
|
||||
* description="제품 카테고리(최상위: parent_id = null) 리스트를 반환합니다.",
|
||||
* tags={"Product"},
|
||||
* tags={"Products"},
|
||||
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
@@ -51,12 +33,326 @@
|
||||
* }
|
||||
* )
|
||||
* ),
|
||||
* @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=405, description="허용되지 않는 메서드", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
|
||||
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
|
||||
* )
|
||||
*/
|
||||
class ProductApi {}
|
||||
class ProductApi
|
||||
{
|
||||
/**
|
||||
* 제품 목록/검색
|
||||
* @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() {}
|
||||
}
|
||||
|
||||
161
app/Swagger/v1/ProductExtraSchemas.php
Normal file
161
app/Swagger/v1/ProductExtraSchemas.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?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() {}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?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
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// 1) child_product_id 를 NULL 허용으로 변경 (MySQL 원시 SQL 사용)
|
||||
// (doctrine/dbal 없이 컬럼 수정)
|
||||
DB::statement("
|
||||
ALTER TABLE product_components
|
||||
MODIFY child_product_id BIGINT UNSIGNED NULL COMMENT '하위 제품/부품 ID';
|
||||
");
|
||||
|
||||
Schema::table('product_components', function (Blueprint $table) {
|
||||
// 2) ref_type, material_id 추가
|
||||
$table->enum('ref_type', ['PRODUCT', 'MATERIAL'])
|
||||
->default('PRODUCT')
|
||||
->after('parent_product_id')
|
||||
->comment('참조 대상 타입(PRODUCT=제품, MATERIAL=자재)');
|
||||
|
||||
$table->unsignedBigInteger('material_id')
|
||||
->nullable()
|
||||
->after('child_product_id')
|
||||
->comment('자재 ID');
|
||||
|
||||
$table->foreign('material_id')
|
||||
->references('id')
|
||||
->on('materials')
|
||||
->nullOnDelete();
|
||||
});
|
||||
|
||||
// 3) 기존 유니크 키 재정의:
|
||||
// (tenant_id, parent_product_id, ref_type, child_product_id, material_id, sort_order)
|
||||
// - 제품/자재 타입과 각각의 ID를 모두 포함하도록 변경
|
||||
DB::statement("ALTER TABLE product_components DROP INDEX uq_component_row");
|
||||
DB::statement("
|
||||
ALTER TABLE product_components
|
||||
ADD UNIQUE INDEX uq_component_row
|
||||
(tenant_id, parent_product_id, ref_type, child_product_id, material_id, sort_order)
|
||||
");
|
||||
|
||||
// 4) (선택) CHECK 제약: MySQL 8.0.16+ 에서만 유효
|
||||
// ref_type=PRODUCT -> child_product_id NOT NULL AND material_id NULL
|
||||
// ref_type=MATERIAL -> material_id NOT NULL AND child_product_id NULL
|
||||
try {
|
||||
$version = DB::selectOne('SELECT VERSION() AS v')->v ?? '';
|
||||
// 매우 단순한 버전체크 (8.0 이상일 때 시도)
|
||||
if (preg_match('/^8\./', $version)) {
|
||||
DB::statement("
|
||||
ALTER TABLE product_components
|
||||
ADD CONSTRAINT chk_ref_type_consistency
|
||||
CHECK (
|
||||
(ref_type = 'PRODUCT' AND child_product_id IS NOT NULL AND material_id IS NULL) OR
|
||||
(ref_type = 'MATERIAL' AND material_id IS NOT NULL AND child_product_id IS NULL)
|
||||
)
|
||||
");
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// CHECK 미지원(DB버전/엔진)일 경우 무시하고 넘어갑니다.
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// CHECK 제약 삭제 (가능한 경우만)
|
||||
try {
|
||||
DB::statement("
|
||||
ALTER TABLE product_components
|
||||
DROP CHECK chk_ref_type_consistency
|
||||
");
|
||||
} catch (\Throwable $e) {
|
||||
// 무시
|
||||
}
|
||||
|
||||
// 유니크 키를 원래대로 복구
|
||||
try {
|
||||
DB::statement("ALTER TABLE product_components DROP INDEX uq_component_row");
|
||||
} catch (\Throwable $e) {
|
||||
// 무시
|
||||
}
|
||||
DB::statement("
|
||||
ALTER TABLE product_components
|
||||
ADD UNIQUE INDEX uq_component_row
|
||||
(tenant_id, parent_product_id, child_product_id, sort_order)
|
||||
");
|
||||
|
||||
Schema::table('product_components', function (Blueprint $table) {
|
||||
// FK 우선 제거
|
||||
$table->dropForeign(['material_id']);
|
||||
// 컬럼 삭제
|
||||
$table->dropColumn(['ref_type', 'material_id']);
|
||||
});
|
||||
|
||||
// child_product_id 를 NOT NULL 로 되돌림
|
||||
DB::statement("
|
||||
ALTER TABLE product_components
|
||||
MODIFY child_product_id BIGINT UNSIGNED NOT NULL COMMENT '하위 제품/부품 ID';
|
||||
");
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\V1\ClassificationController;
|
||||
use App\Http\Controllers\Api\V1\CategoryLogController;
|
||||
use App\Http\Controllers\Api\V1\ProductBomItemController;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Http\Controllers\Api\V1\CommonController;
|
||||
@@ -24,6 +25,9 @@
|
||||
use App\Http\Controllers\Api\V1\TenantOptionValueController;
|
||||
use App\Http\Controllers\Api\V1\TenantUserProfileController;
|
||||
use App\Http\Controllers\Api\V1\CategoryController;
|
||||
use App\Http\Controllers\Api\V1\CategoryFieldController;
|
||||
use App\Http\Controllers\Api\V1\CategoryTemplateController;
|
||||
use App\Http\Controllers\Api\V1\ClassificationController;
|
||||
|
||||
// error test
|
||||
Route::get('/test-error', function () {
|
||||
@@ -239,7 +243,51 @@
|
||||
Route::delete('/{id}', [CategoryController::class, 'destroy'])->name('v1.categories.destroy'); // 삭제(soft)
|
||||
});
|
||||
|
||||
// Product API
|
||||
// Category Field API
|
||||
Route::prefix('categories')->group(function () {
|
||||
// 목록/생성 (카테고리 기준)
|
||||
Route::get ('/{id}/fields', [CategoryFieldController::class, 'index'])->name('v1.categories.fields.index'); // ?page&size&sort&order
|
||||
Route::post ('/{id}/fields', [CategoryFieldController::class, 'store'])->name('v1.categories.fields.store');
|
||||
|
||||
// 단건
|
||||
Route::get ('/fields/{field}', [CategoryFieldController::class, 'show'])->name('v1.categories.fields.show');
|
||||
Route::patch ('/fields/{field}', [CategoryFieldController::class, 'update'])->name('v1.categories.fields.update');
|
||||
Route::delete('/fields/{field}', [CategoryFieldController::class, 'destroy'])->name('v1.categories.fields.destroy');
|
||||
|
||||
// 일괄 정렬/업서트
|
||||
Route::post ('/{id}/fields/reorder', [CategoryFieldController::class, 'reorder'])->name('v1.categories.fields.reorder'); // [{id,sort_order}]
|
||||
Route::put ('/{id}/fields/bulk-upsert', [CategoryFieldController::class, 'bulkUpsert'])->name('v1.categories.fields.bulk'); // [{id?,field_key,...}]
|
||||
});
|
||||
|
||||
|
||||
// Category Template API
|
||||
Route::prefix('categories')->group(function () {
|
||||
// 버전 목록/생성 (카테고리 기준)
|
||||
Route::get ('/{id}/templates', [CategoryTemplateController::class, 'index'])->name('v1.categories.templates.index'); // ?page&size
|
||||
Route::post ('/{id}/templates', [CategoryTemplateController::class, 'store'])->name('v1.categories.templates.store'); // 새 버전 등록
|
||||
|
||||
// 단건
|
||||
Route::get ('/templates/{tpl}', [CategoryTemplateController::class, 'show'])->name('v1.categories.templates.show');
|
||||
Route::patch ('/templates/{tpl}', [CategoryTemplateController::class, 'update'])->name('v1.categories.templates.update'); // remarks 등 메타 수정
|
||||
Route::delete('/templates/{tpl}', [CategoryTemplateController::class, 'destroy'])->name('v1.categories.templates.destroy');
|
||||
|
||||
// 운영 편의
|
||||
Route::post ('/{id}/templates/{tpl}/apply', [CategoryTemplateController::class, 'apply'])->name('v1.categories.templates.apply'); // 해당 버전 활성화
|
||||
Route::get ('/{id}/templates/{tpl}/preview', [CategoryTemplateController::class, 'preview'])->name('v1.categories.templates.preview');// 렌더용 스냅샷
|
||||
// (선택) 버전 간 diff
|
||||
Route::get ('/{id}/templates/diff', [CategoryTemplateController::class, 'diff'])->name('v1.categories.templates.diff'); // ?a=ver&b=ver
|
||||
});
|
||||
|
||||
|
||||
// Category Log API
|
||||
Route::prefix('categories')->group(function () {
|
||||
Route::get ('/{id}/logs', [CategoryLogController::class, 'index'])->name('v1.categories.logs.index'); // ?action=&from=&to=&page&size
|
||||
Route::get ('/logs/{log}', [CategoryLogController::class, 'show'])->name('v1.categories.logs.show');
|
||||
// (선택) 특정 변경 시점으로 카테고리 복구(템플릿/필드와 별개)
|
||||
// Route::post('{id}/logs/{log}/restore', [CategoryLogController::class, 'restore'])->name('v1.categories.logs.restore');
|
||||
});
|
||||
|
||||
// Classifications API
|
||||
Route::prefix('classifications')->group(function () {
|
||||
Route::get ('', [ClassificationController::class, 'index'])->name('v1.classifications.index'); // 목록
|
||||
Route::post ('', [ClassificationController::class, 'store'])->name('v1.classifications.store'); // 생성
|
||||
@@ -248,5 +296,31 @@
|
||||
Route::delete('/{id}', [ClassificationController::class, 'destroy'])->whereNumber('id')->name('v1.classifications.destroy'); // 삭제
|
||||
});
|
||||
|
||||
// Products (모델/부품/서브어셈블리)
|
||||
Route::prefix('products')->group(function () {
|
||||
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)
|
||||
|
||||
// (선택) 드롭다운/모달용 간편 검색 & 활성 토글
|
||||
Route::get ('/search', [ProductController::class, 'search'])->name('v1.products.search');
|
||||
Route::post ('/{id}/toggle', [ProductController::class, 'toggle'])->name('v1.products.toggle');
|
||||
});
|
||||
|
||||
// BOM (product_components: ref_type=PRODUCT|MATERIAL)
|
||||
Route::prefix('products/{id}/bom')->group(function () {
|
||||
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');
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user