Merge remote-tracking branch 'origin/develop' into develop

This commit is contained in:
2026-01-20 21:01:25 +09:00
32 changed files with 5847 additions and 62 deletions

18
.dockerignore Normal file
View File

@@ -0,0 +1,18 @@
node_modules
vendor
.git
.env
.env.backup
.phpunit.result.cache
storage/logs/*
storage/framework/cache/*
storage/framework/sessions/*
storage/framework/views/*
bootstrap/cache/*
.DS_Store
Thumbs.db
.idea
.vscode
*.log
npm-debug.log*
yarn-debug.log*

View File

@@ -1,6 +1,6 @@
# 논리적 데이터베이스 관계 문서
> **자동 생성**: 2026-01-19 20:29:00
> **자동 생성**: 2026-01-20 20:15:39
> **소스**: Eloquent 모델 관계 분석
## 📊 모델별 관계 현황
@@ -26,15 +26,6 @@ ### bad_debt_memos
- **badDebt()**: belongsTo → `bad_debts`
- **creator()**: belongsTo → `users`
### biddings
**모델**: `App\Models\Bidding\Bidding`
- **quote()**: belongsTo → `quotes`
- **client()**: belongsTo → `clients`
- **bidder()**: belongsTo → `users`
- **creator()**: belongsTo → `users`
- **updater()**: belongsTo → `users`
### boards
**모델**: `App\Models\Boards\Board`
@@ -79,6 +70,7 @@ ### categorys
- **parent()**: belongsTo → `categories`
- **children()**: hasMany → `categories`
- **products()**: hasMany → `products`
- **categoryFields()**: hasMany → `category_fields`
### category_fields
@@ -139,37 +131,6 @@ ### contracts
- **creator()**: belongsTo → `users`
- **updater()**: belongsTo → `users`
### handover_reports
**모델**: `App\Models\Construction\HandoverReport`
- **contract()**: belongsTo → `contracts`
- **contractManager()**: belongsTo → `users`
- **constructionPm()**: belongsTo → `users`
- **creator()**: belongsTo → `users`
- **updater()**: belongsTo → `users`
- **managers()**: hasMany → `handover_report_managers`
- **items()**: hasMany → `handover_report_items`
### handover_report_items
**모델**: `App\Models\Construction\HandoverReportItem`
- **handoverReport()**: belongsTo → `handover_reports`
- **creator()**: belongsTo → `users`
- **updater()**: belongsTo → `users`
### handover_report_managers
**모델**: `App\Models\Construction\HandoverReportManager`
- **handoverReport()**: belongsTo → `handover_reports`
- **creator()**: belongsTo → `users`
- **updater()**: belongsTo → `users`
### structure_reviews
**모델**: `App\Models\Construction\StructureReview`
- **creator()**: belongsTo → `users`
- **updater()**: belongsTo → `users`
### bom_templates
**모델**: `App\Models\Design\BomTemplate`
@@ -263,7 +224,6 @@ ### item_receipts
**모델**: `App\Models\Items\ItemReceipt`
- **item()**: belongsTo → `items`
- **creator()**: belongsTo → `users`
### login_tokens
**모델**: `App\Models\LoginToken`
@@ -281,6 +241,31 @@ ### main_request_flows
- **mainRequest()**: belongsTo → `main_requests`
- **flowable()**: morphTo → `(Polymorphic)`
### materials
**모델**: `App\Models\Materials\Material`
- **category()**: belongsTo → `categories`
- **receipts()**: hasMany → `material_receipts`
- **lots()**: hasMany → `lots`
- **files()**: morphMany → `files`
### material_inspections
**모델**: `App\Models\Materials\MaterialInspection`
- **receipt()**: belongsTo → `material_receipts`
- **items()**: hasMany → `material_inspection_items`
### material_inspection_items
**모델**: `App\Models\Materials\MaterialInspectionItem`
- **inspection()**: belongsTo → `material_inspections`
### material_receipts
**모델**: `App\Models\Materials\MaterialReceipt`
- **material()**: belongsTo → `materials`
- **inspections()**: hasMany → `material_inspections`
### users
**모델**: `App\Models\Members\User`
@@ -347,13 +332,10 @@ ### orders
**모델**: `App\Models\Orders\Order`
- **quote()**: belongsTo → `quotes`
- **client()**: belongsTo → `clients`
- **item()**: belongsTo → `items`
- **items()**: hasMany → `order_items`
- **histories()**: hasMany → `order_histories`
- **versions()**: hasMany → `order_versions`
- **workOrders()**: hasMany → `work_orders`
- **shipments()**: hasMany → `shipments`
### order_historys
**모델**: `App\Models\Orders\OrderHistory`
@@ -432,7 +414,6 @@ ### work_orders
**모델**: `App\Models\Production\WorkOrder`
- **salesOrder()**: belongsTo → `orders`
- **process()**: belongsTo → `processes`
- **assignee()**: belongsTo → `users`
- **team()**: belongsTo → `departments`
- **creator()**: belongsTo → `users`
@@ -441,7 +422,6 @@ ### work_orders
- **primaryAssignee()**: hasMany → `work_order_assignees`
- **items()**: hasMany → `work_order_items`
- **issues()**: hasMany → `work_order_issues`
- **shipments()**: hasMany → `shipments`
- **bendingDetail()**: hasOne → `work_order_bending_details`
### work_order_assignees
@@ -477,6 +457,18 @@ ### work_results
- **creator()**: belongsTo → `users`
- **updater()**: belongsTo → `users`
### common_codes
**모델**: `App\Models\Products\CommonCode`
- **parent()**: belongsTo → `common_codes`
- **children()**: hasMany → `common_codes`
### parts
**모델**: `App\Models\Products\Part`
- **category()**: belongsTo → `common_codes`
- **partType()**: belongsTo → `common_codes`
### prices
**모델**: `App\Models\Products\Price`
@@ -488,6 +480,23 @@ ### price_revisions
- **price()**: belongsTo → `prices`
### products
**모델**: `App\Models\Products\Product`
- **category()**: belongsTo → `categories`
- **componentLines()**: hasMany → `product_components`
- **parentLines()**: hasMany → `product_components`
- **children()**: belongsToMany → `products`
- **parents()**: belongsToMany → `products`
- **files()**: morphMany → `files`
### product_components
**모델**: `App\Models\Products\ProductComponent`
- **parentProduct()**: belongsTo → `products`
- **product()**: belongsTo → `products`
- **material()**: belongsTo → `materials`
### push_device_tokens
**모델**: `App\Models\PushDeviceToken`
@@ -500,8 +509,6 @@ ### inspections
**모델**: `App\Models\Qualitys\Inspection`
- **item()**: belongsTo → `items`
- **inspector()**: belongsTo → `users`
- **creator()**: belongsTo → `users`
### lots
**모델**: `App\Models\Qualitys\Lot`
@@ -520,13 +527,11 @@ ### quotes
- **client()**: belongsTo → `clients`
- **item()**: belongsTo → `items`
- **order()**: belongsTo → `orders`
- **siteBriefing()**: belongsTo → `site_briefings`
- **finalizer()**: belongsTo → `users`
- **creator()**: belongsTo → `users`
- **updater()**: belongsTo → `users`
- **items()**: hasMany → `quote_items`
- **revisions()**: hasMany → `quote_revisions`
- **orders()**: hasMany → `orders`
### quote_formulas
**모델**: `App\Models\Quote\QuoteFormula`
@@ -769,8 +774,6 @@ ### setting_field_defs
### shipments
**모델**: `App\Models\Tenants\Shipment`
- **order()**: belongsTo → `orders`
- **workOrder()**: belongsTo → `work_orders`
- **creator()**: belongsTo → `users`
- **updater()**: belongsTo → `users`
- **items()**: hasMany → `shipment_items`
@@ -788,15 +791,6 @@ ### sites
- **updater()**: belongsTo → `users`
- **client()**: belongsTo → `clients`
### site_briefings
**모델**: `App\Models\Tenants\SiteBriefing`
- **partner()**: belongsTo → `clients`
- **site()**: belongsTo → `sites`
- **creator()**: belongsTo → `users`
- **updater()**: belongsTo → `users`
- **quotes()**: hasMany → `quotes`
### stocks
**모델**: `App\Models\Tenants\Stock`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Models\Materials;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* @mixin IdeHelperMaterialInspection
*/
class MaterialInspection extends Model
{
use SoftDeletes;
// 입고 내역
public function receipt()
{
return $this->belongsTo(MaterialReceipt::class, 'receipt_id');
}
// 검사 항목
public function items()
{
return $this->hasMany(MaterialInspectionItem::class, 'inspection_id');
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Models\Materials;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* @mixin IdeHelperMaterialInspectionItem
*/
class MaterialInspectionItem extends Model
{
use SoftDeletes;
// 검사 내역
public function inspection()
{
return $this->belongsTo(MaterialInspection::class, 'inspection_id');
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Models\Materials;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* @mixin IdeHelperMaterialReceipt
*/
class MaterialReceipt extends Model
{
use SoftDeletes;
protected $fillable = [
'material_id', 'receipt_date', 'lot_number', 'received_qty', 'unit',
'supplier_name', 'manufacturer_name', 'purchase_price_excl_vat',
'weight_kg', 'status_code', 'is_inspection', 'inspection_date', 'remarks',
];
// 자재 마스터
public function material()
{
return $this->belongsTo(Material::class, 'material_id');
}
// 수입검사 내역
public function inspections()
{
return $this->hasMany(MaterialInspection::class, 'receipt_id');
}
}

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,88 @@
<?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
{
/**
* 작업지시 하위 테이블에 tenant_id 컬럼 추가
* - work_order_items
* - work_order_bending_details
* - work_order_issues
*
* 기존 데이터는 work_orders 테이블의 tenant_id를 참조하여 업데이트
*/
public function up(): void
{
// 1. work_order_items
Schema::table('work_order_items', function (Blueprint $table) {
$table->unsignedBigInteger('tenant_id')->nullable()->after('id')->comment('테넌트ID');
$table->index('tenant_id', 'idx_work_order_items_tenant');
});
// 기존 데이터 업데이트
DB::statement('
UPDATE work_order_items wi
JOIN work_orders wo ON wi.work_order_id = wo.id
SET wi.tenant_id = wo.tenant_id
');
// nullable 제거
Schema::table('work_order_items', function (Blueprint $table) {
$table->unsignedBigInteger('tenant_id')->nullable(false)->change();
});
// 2. work_order_bending_details
Schema::table('work_order_bending_details', function (Blueprint $table) {
$table->unsignedBigInteger('tenant_id')->nullable()->after('id')->comment('테넌트ID');
$table->index('tenant_id', 'idx_work_order_bending_details_tenant');
});
DB::statement('
UPDATE work_order_bending_details wbd
JOIN work_orders wo ON wbd.work_order_id = wo.id
SET wbd.tenant_id = wo.tenant_id
');
Schema::table('work_order_bending_details', function (Blueprint $table) {
$table->unsignedBigInteger('tenant_id')->nullable(false)->change();
});
// 3. work_order_issues
Schema::table('work_order_issues', function (Blueprint $table) {
$table->unsignedBigInteger('tenant_id')->nullable()->after('id')->comment('테넌트ID');
$table->index('tenant_id', 'idx_work_order_issues_tenant');
});
DB::statement('
UPDATE work_order_issues woi
JOIN work_orders wo ON woi.work_order_id = wo.id
SET woi.tenant_id = wo.tenant_id
');
Schema::table('work_order_issues', function (Blueprint $table) {
$table->unsignedBigInteger('tenant_id')->nullable(false)->change();
});
}
public function down(): void
{
Schema::table('work_order_items', function (Blueprint $table) {
$table->dropIndex('idx_work_order_items_tenant');
$table->dropColumn('tenant_id');
});
Schema::table('work_order_bending_details', function (Blueprint $table) {
$table->dropIndex('idx_work_order_bending_details_tenant');
$table->dropColumn('tenant_id');
});
Schema::table('work_order_issues', function (Blueprint $table) {
$table->dropIndex('idx_work_order_issues_tenant');
$table->dropColumn('tenant_id');
});
}
};

View File

@@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 작업지시 담당자 피벗 테이블 (Work Order Assignees)
* - 다중 담당자 지원
* - 주 담당자 구분 (is_primary)
*/
public function up(): void
{
Schema::create('work_order_assignees', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('tenant_id')->comment('테넌트ID');
$table->unsignedBigInteger('work_order_id')->comment('작업지시ID');
$table->unsignedBigInteger('user_id')->comment('담당자ID');
$table->boolean('is_primary')->default(false)->comment('주담당자 여부');
$table->timestamps();
// Indexes
$table->unique(['work_order_id', 'user_id'], 'uq_work_order_assignees');
$table->index(['tenant_id', 'work_order_id'], 'idx_wo_assignees_tenant_wo');
$table->index(['tenant_id', 'user_id'], 'idx_wo_assignees_tenant_user');
// Foreign keys
$table->foreign('work_order_id')
->references('id')
->on('work_orders')
->onDelete('cascade');
});
}
public function down(): void
{
Schema::dropIfExists('work_order_assignees');
}
};

View File

@@ -0,0 +1,53 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('bank_accounts', function (Blueprint $table) {
// 예금 종류
$table->string('account_type', 30)->default('보통예금')->after('account_holder')->comment('예금종류');
// 금액 정보
$table->decimal('balance', 18, 2)->default(0)->after('account_name')->comment('잔액');
$table->string('currency', 3)->default('KRW')->after('balance')->comment('통화');
// 날짜 정보
$table->date('opened_at')->nullable()->after('currency')->comment('개설일자');
$table->timestamp('last_transaction_at')->nullable()->after('opened_at')->comment('최종거래일시');
// 추가 정보
$table->string('branch_name', 100)->nullable()->after('last_transaction_at')->comment('지점명');
$table->text('memo')->nullable()->after('branch_name')->comment('메모');
// 정렬 순서
$table->unsignedInteger('sort_order')->default(0)->after('is_primary')->comment('정렬순서');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('bank_accounts', function (Blueprint $table) {
$table->dropColumn([
'account_type',
'balance',
'currency',
'opened_at',
'last_transaction_at',
'branch_name',
'memo',
'sort_order',
]);
});
}
};

View File

@@ -0,0 +1,72 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('bank_transactions', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('bank_account_id')->constrained('bank_accounts')->cascadeOnDelete();
// 거래 정보
$table->string('transaction_type', 20)->comment('거래유형: deposit(입금), withdrawal(출금), transfer(이체)');
$table->decimal('amount', 18, 2)->comment('거래금액');
$table->decimal('balance_after', 18, 2)->comment('거래 후 잔액');
// 날짜
$table->date('transaction_date')->comment('거래일자');
$table->time('transaction_time')->nullable()->comment('거래시간');
// 상세 정보
$table->string('description', 255)->nullable()->comment('적요');
$table->string('counterparty', 100)->nullable()->comment('거래상대방');
$table->string('reference_number', 100)->nullable()->comment('참조번호');
// 분류
$table->string('category', 50)->nullable()->comment('거래분류');
$table->foreignId('related_order_id')->nullable()->comment('관련 주문 ID');
$table->foreignId('related_payment_id')->nullable()->comment('관련 결제 ID');
// 상태
$table->boolean('is_reconciled')->default(false)->comment('대사완료 여부');
$table->timestamp('reconciled_at')->nullable()->comment('대사완료 일시');
// 추가 정보
$table->text('memo')->nullable()->comment('메모');
$table->json('options')->nullable()->comment('추가 옵션');
// 감사 필드
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('deleted_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
$table->softDeletes();
// 인덱스
$table->index('tenant_id', 'idx_bank_transactions_tenant');
$table->index('bank_account_id', 'idx_bank_transactions_account');
$table->index('transaction_date', 'idx_bank_transactions_date');
$table->index('transaction_type', 'idx_bank_transactions_type');
$table->index('is_reconciled', 'idx_bank_transactions_reconciled');
$table->index('deleted_at', 'idx_bank_transactions_deleted');
$table->index(['tenant_id', 'transaction_date'], 'idx_bank_transactions_tenant_date');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('bank_transactions');
}
};

View File

@@ -0,0 +1,75 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('fund_schedules', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
// 기본 정보
$table->string('title', 200)->comment('일정명');
$table->text('description')->nullable()->comment('설명');
// 일정 유형: income(입금예정), expense(지급예정)
$table->enum('schedule_type', ['income', 'expense'])->comment('일정유형: income=입금, expense=지급');
// 일정 정보
$table->date('scheduled_date')->comment('예정일');
$table->decimal('amount', 18, 2)->default(0)->comment('금액');
$table->string('currency', 3)->default('KRW')->comment('통화');
// 관련 정보
$table->unsignedBigInteger('related_bank_account_id')->nullable()->comment('관련 계좌 ID');
$table->string('counterparty', 200)->nullable()->comment('거래상대방');
$table->string('category', 50)->nullable()->comment('분류');
// 상태: pending(예정), completed(완료), cancelled(취소)
$table->enum('status', ['pending', 'completed', 'cancelled'])->default('pending')->comment('상태');
// 반복 설정
$table->boolean('is_recurring')->default(false)->comment('정기 여부');
$table->string('recurrence_rule', 100)->nullable()->comment('반복 규칙 (daily, weekly, monthly, yearly)');
$table->date('recurrence_end_date')->nullable()->comment('반복 종료일');
// 완료 정보
$table->date('completed_date')->nullable()->comment('실제 완료일');
$table->decimal('completed_amount', 18, 2)->nullable()->comment('실제 완료 금액');
// 추가 정보
$table->text('memo')->nullable()->comment('메모');
// 감사 필드
$table->unsignedBigInteger('created_by')->nullable()->comment('생성자 ID');
$table->unsignedBigInteger('updated_by')->nullable()->comment('수정자 ID');
$table->unsignedBigInteger('deleted_by')->nullable()->comment('삭제자 ID');
$table->timestamps();
$table->softDeletes();
// 인덱스
$table->index('tenant_id', 'idx_fund_schedules_tenant');
$table->index('scheduled_date', 'idx_fund_schedules_date');
$table->index('schedule_type', 'idx_fund_schedules_type');
$table->index('status', 'idx_fund_schedules_status');
$table->index(['tenant_id', 'scheduled_date'], 'idx_fund_schedules_tenant_date');
$table->index('deleted_at', 'idx_fund_schedules_deleted');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('fund_schedules');
}
};

View File

@@ -0,0 +1,282 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
class BankAccountSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
// 테넌트 ID 1 (코드보잇지엑스) 기준으로 테스트 데이터 생성
$tenantId = 1;
$userId = 1; // 관리자
// 계좌 데이터 (기존 테이블 구조에 맞게 수정)
$accounts = [
[
'tenant_id' => $tenantId,
'bank_code' => '004',
'bank_name' => '국민은행',
'account_number' => '123-456-789012',
'account_holder' => '주식회사 코드보잇지엑스',
'account_name' => '주거래 계좌',
'account_type' => '보통예금',
'balance' => 450000000, // 4.5억
'currency' => 'KRW',
'opened_at' => '2023-01-15',
'last_transaction_at' => '2026-01-15 14:30:00',
'branch_name' => '역삼지점',
'memo' => '주거래 계좌',
'status' => 'active',
'is_primary' => true,
'sort_order' => 1,
'created_by' => $userId,
'created_at' => now(),
'updated_at' => now(),
],
[
'tenant_id' => $tenantId,
'bank_code' => '088',
'bank_name' => '신한은행',
'account_number' => '234-567-890123',
'account_holder' => '주식회사 코드보잇지엑스',
'account_name' => '법인카드 결제',
'account_type' => '법인카드 출금',
'balance' => 90000000, // 9천만원
'currency' => 'KRW',
'opened_at' => '2023-01-15',
'last_transaction_at' => '2026-01-15 11:20:00',
'branch_name' => '강남지점',
'memo' => '법인카드 결제 계좌',
'status' => 'active',
'is_primary' => false,
'sort_order' => 2,
'created_by' => $userId,
'created_at' => now(),
'updated_at' => now(),
],
[
'tenant_id' => $tenantId,
'bank_code' => '020',
'bank_name' => '우리은행',
'account_number' => '345-678-901234',
'account_holder' => '주식회사 코드보잇지엑스',
'account_name' => '정기예금',
'account_type' => '정기예금',
'balance' => 200000000, // 2억
'currency' => 'KRW',
'opened_at' => '2024-06-01',
'last_transaction_at' => '2026-01-01 09:00:00',
'branch_name' => '테헤란로지점',
'memo' => '정기예금 (만기 2027-06-01)',
'status' => 'active',
'is_primary' => false,
'sort_order' => 3,
'created_by' => $userId,
'created_at' => now(),
'updated_at' => now(),
],
[
'tenant_id' => $tenantId,
'bank_code' => '081',
'bank_name' => '하나은행',
'account_number' => '456-789-012345',
'account_holder' => '주식회사 코드보잇지엑스',
'account_name' => '급여계좌',
'account_type' => '보통예금',
'balance' => 75000000, // 7천5백만원
'currency' => 'KRW',
'opened_at' => '2023-03-20',
'last_transaction_at' => '2026-01-14 16:45:00',
'branch_name' => '선릉역지점',
'memo' => '급여 지급 계좌',
'status' => 'active',
'is_primary' => false,
'sort_order' => 4,
'created_by' => $userId,
'created_at' => now(),
'updated_at' => now(),
],
[
'tenant_id' => $tenantId,
'bank_code' => '011',
'bank_name' => '농협은행',
'account_number' => '567-890-123456',
'account_holder' => '주식회사 코드보잇지엑스',
'account_name' => '퇴직금 적금',
'account_type' => '적금',
'balance' => 50000000, // 5천만원
'currency' => 'KRW',
'opened_at' => '2024-01-01',
'last_transaction_at' => '2026-01-10 10:00:00',
'branch_name' => '강남중앙지점',
'memo' => '직원 퇴직금 적립',
'status' => 'active',
'is_primary' => false,
'sort_order' => 5,
'created_by' => $userId,
'created_at' => now(),
'updated_at' => now(),
],
];
// 계좌 데이터 삽입
$accountIds = [];
foreach ($accounts as $account) {
$id = DB::table('bank_accounts')->insertGetId($account);
$accountIds[$account['account_number']] = $id;
}
$this->command->info('계좌 5개 생성 완료');
// 거래내역 데이터 (국민은행 계좌 기준)
$transactions = [
[
'tenant_id' => $tenantId,
'bank_account_id' => $accountIds['123-456-789012'],
'transaction_type' => 'deposit',
'amount' => 50000000,
'balance_after' => 450000000,
'transaction_date' => '2026-01-15',
'transaction_time' => '14:30:00',
'description' => '프로젝트 대금 입금',
'counterparty' => '주식회사 클라이언트A',
'reference_number' => 'INV-2026-0115',
'category' => '매출',
'is_reconciled' => true,
'reconciled_at' => '2026-01-15 15:00:00',
'created_by' => $userId,
'created_at' => now(),
'updated_at' => now(),
],
[
'tenant_id' => $tenantId,
'bank_account_id' => $accountIds['123-456-789012'],
'transaction_type' => 'withdrawal',
'amount' => 5000000,
'balance_after' => 400000000,
'transaction_date' => '2026-01-14',
'transaction_time' => '11:20:00',
'description' => '사무실 임대료',
'counterparty' => '강남빌딩관리(주)',
'reference_number' => 'RENT-2026-01',
'category' => '임대료',
'is_reconciled' => true,
'reconciled_at' => '2026-01-14 14:00:00',
'created_by' => $userId,
'created_at' => now(),
'updated_at' => now(),
],
[
'tenant_id' => $tenantId,
'bank_account_id' => $accountIds['123-456-789012'],
'transaction_type' => 'transfer',
'amount' => 30000000,
'balance_after' => 405000000,
'transaction_date' => '2026-01-13',
'transaction_time' => '09:00:00',
'description' => '급여계좌 이체',
'counterparty' => '하나은행 급여계좌',
'reference_number' => 'TRF-2026-0113',
'category' => '급여',
'is_reconciled' => true,
'reconciled_at' => '2026-01-13 10:00:00',
'created_by' => $userId,
'created_at' => now(),
'updated_at' => now(),
],
[
'tenant_id' => $tenantId,
'bank_account_id' => $accountIds['123-456-789012'],
'transaction_type' => 'deposit',
'amount' => 25000000,
'balance_after' => 435000000,
'transaction_date' => '2026-01-10',
'transaction_time' => '16:45:00',
'description' => '유지보수 계약금',
'counterparty' => '주식회사 클라이언트B',
'reference_number' => 'MAINT-2026-001',
'category' => '매출',
'is_reconciled' => false,
'reconciled_at' => null,
'created_by' => $userId,
'created_at' => now(),
'updated_at' => now(),
],
[
'tenant_id' => $tenantId,
'bank_account_id' => $accountIds['123-456-789012'],
'transaction_type' => 'withdrawal',
'amount' => 2500000,
'balance_after' => 410000000,
'transaction_date' => '2026-01-08',
'transaction_time' => '14:00:00',
'description' => '클라우드 서비스 이용료',
'counterparty' => 'AWS Korea',
'reference_number' => 'AWS-2026-01',
'category' => '운영비',
'is_reconciled' => false,
'reconciled_at' => null,
'created_by' => $userId,
'created_at' => now(),
'updated_at' => now(),
],
];
// 거래내역 삽입
DB::table('bank_transactions')->insert($transactions);
$this->command->info('거래내역 5개 생성 완료');
// 신한은행 거래내역
$shinhanTransactions = [
[
'tenant_id' => $tenantId,
'bank_account_id' => $accountIds['234-567-890123'],
'transaction_type' => 'withdrawal',
'amount' => 1500000,
'balance_after' => 90000000,
'transaction_date' => '2026-01-15',
'transaction_time' => '11:20:00',
'description' => '법인카드 결제',
'counterparty' => '신한카드',
'reference_number' => 'CARD-2026-0115',
'category' => '카드결제',
'is_reconciled' => true,
'reconciled_at' => '2026-01-15 12:00:00',
'created_by' => $userId,
'created_at' => now(),
'updated_at' => now(),
],
[
'tenant_id' => $tenantId,
'bank_account_id' => $accountIds['234-567-890123'],
'transaction_type' => 'deposit',
'amount' => 10000000,
'balance_after' => 91500000,
'transaction_date' => '2026-01-10',
'transaction_time' => '09:30:00',
'description' => '카드결제계좌 입금',
'counterparty' => '국민은행 주거래계좌',
'reference_number' => 'TRF-2026-0110',
'category' => '이체',
'is_reconciled' => true,
'reconciled_at' => '2026-01-10 10:00:00',
'created_by' => $userId,
'created_at' => now(),
'updated_at' => now(),
],
];
DB::table('bank_transactions')->insert($shinhanTransactions);
$this->command->info('신한은행 거래내역 2개 생성 완료');
$this->command->info('BankAccountSeeder 완료: 계좌 5개, 거래내역 7개');
}
}

View File

@@ -0,0 +1,160 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
class FundScheduleSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
// 테넌트 ID 1 (코드보잇지엑스) 기준으로 테스트 데이터 생성
$tenantId = 1;
$userId = 1; // 관리자
// 현재 월 기준으로 데이터 생성
$year = now()->year;
$month = now()->month;
// 자금계획일정 데이터
$schedules = [
// 입금 예정
[
'tenant_id' => $tenantId,
'title' => '(주)스마트팩토리 개발비 1차',
'description' => 'ERP 시스템 개발 프로젝트 1차 중도금',
'schedule_type' => 'income',
'scheduled_date' => sprintf('%04d-%02d-10', $year, $month),
'amount' => 100000000, // 1억원
'currency' => 'KRW',
'counterparty' => '(주)스마트팩토리',
'category' => '매출',
'status' => 'pending',
'is_recurring' => false,
'recurrence_rule' => null,
'memo' => '계약서 기준 착수금 30%, 중도금 40%, 잔금 30%',
'created_by' => $userId,
'created_at' => now(),
'updated_at' => now(),
],
[
'tenant_id' => $tenantId,
'title' => '영업파트너 수수료 지급',
'description' => '1월 영업 수수료 정산',
'schedule_type' => 'expense',
'scheduled_date' => sprintf('%04d-%02d-10', $year, $month),
'amount' => 20000000, // 2천만원
'currency' => 'KRW',
'counterparty' => '영업파트너',
'category' => '매입',
'status' => 'pending',
'is_recurring' => false,
'recurrence_rule' => null,
'memo' => null,
'created_by' => $userId,
'created_at' => now(),
'updated_at' => now(),
],
[
'tenant_id' => $tenantId,
'title' => '(주)디지털제조 개발비 잔금',
'description' => 'MES 시스템 개발 프로젝트 잔금',
'schedule_type' => 'income',
'scheduled_date' => sprintf('%04d-%02d-15', $year, $month),
'amount' => 30000000, // 3천만원
'currency' => 'KRW',
'counterparty' => '(주)디지털제조',
'category' => '매출',
'status' => 'pending',
'is_recurring' => false,
'recurrence_rule' => null,
'memo' => '검수 완료 후 지급',
'created_by' => $userId,
'created_at' => now(),
'updated_at' => now(),
],
[
'tenant_id' => $tenantId,
'title' => '직원 급여 지급',
'description' => sprintf('%d년 %d월 급여', $year, $month),
'schedule_type' => 'expense',
'scheduled_date' => sprintf('%04d-%02d-25', $year, $month),
'amount' => 25000000, // 2천5백만원
'currency' => 'KRW',
'counterparty' => '직원',
'category' => '급여',
'status' => 'pending',
'is_recurring' => true,
'recurrence_rule' => 'monthly',
'memo' => '매월 25일 정기 지급',
'created_by' => $userId,
'created_at' => now(),
'updated_at' => now(),
],
[
'tenant_id' => $tenantId,
'title' => '구독료 CMS 출금',
'description' => '클라우드 서비스 월 구독료',
'schedule_type' => 'expense',
'scheduled_date' => sprintf('%04d-%02d-28', $year, $month),
'amount' => 8500000, // 850만원
'currency' => 'KRW',
'counterparty' => 'AWS/Azure',
'category' => '운영비',
'status' => 'pending',
'is_recurring' => true,
'recurrence_rule' => 'monthly',
'memo' => 'AWS + Azure 클라우드 비용',
'created_by' => $userId,
'created_at' => now(),
'updated_at' => now(),
],
// 다음 달 일정도 추가
[
'tenant_id' => $tenantId,
'title' => '사무실 임대료',
'description' => '강남 오피스 임대료',
'schedule_type' => 'expense',
'scheduled_date' => sprintf('%04d-%02d-05', $month == 12 ? $year + 1 : $year, $month == 12 ? 1 : $month + 1),
'amount' => 5000000, // 500만원
'currency' => 'KRW',
'counterparty' => '강남빌딩관리(주)',
'category' => '임대료',
'status' => 'pending',
'is_recurring' => true,
'recurrence_rule' => 'monthly',
'memo' => '매월 5일 자동이체',
'created_by' => $userId,
'created_at' => now(),
'updated_at' => now(),
],
[
'tenant_id' => $tenantId,
'title' => '유지보수 계약금 입금',
'description' => '연간 유지보수 계약 선금',
'schedule_type' => 'income',
'scheduled_date' => sprintf('%04d-%02d-15', $month == 12 ? $year + 1 : $year, $month == 12 ? 1 : $month + 1),
'amount' => 50000000, // 5천만원
'currency' => 'KRW',
'counterparty' => '(주)테크솔루션',
'category' => '매출',
'status' => 'pending',
'is_recurring' => false,
'recurrence_rule' => null,
'memo' => '2026년 연간 유지보수 계약',
'created_by' => $userId,
'created_at' => now(),
'updated_at' => now(),
],
];
// 데이터 삽입
DB::table('fund_schedules')->insert($schedules);
$this->command->info('FundScheduleSeeder 완료: 자금계획일정 ' . count($schedules) . '개 생성');
}
}