feat: 견적 시스템 API

- 5130의 71개 하드코딩 컬럼을 동적 카테고리 필드 시스템으로 전환
- 모터 브라켓 계산 등 핵심 비즈니스 로직 FormulaParser에 통합
- 파라미터 기반 동적 견적 폼 시스템 구축
- 견적 상태 워크플로 (DRAFT → SENT → APPROVED/REJECTED/EXPIRED)
- 모델셋 관리 API: 카테고리+제품+BOM 통합 관리
- 견적 관리 API: 생성/수정/복제/상태변경/미리보기 기능

주요 구현 사항:
- EstimateController/EstimateService: 견적 비즈니스 로직
- ModelSetController/ModelSetService: 모델셋 관리 로직
- Estimate/EstimateItem 모델: 견적 데이터 구조
- 동적 견적 필드 마이그레이션: 스크린/철재 제품 구조
- API 라우트 17개 엔드포인트 추가
- 다국어 메시지 지원 (성공/에러 메시지)

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-24 17:41:26 +09:00
parent eb42d11f5e
commit 2d9217c9b4
14 changed files with 2021 additions and 0 deletions

View File

@@ -0,0 +1,269 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\Estimate\CreateEstimateRequest;
use App\Http\Requests\Estimate\UpdateEstimateRequest;
use App\Services\Estimate\EstimateService;
use App\Helpers\ApiResponse;
use Illuminate\Http\Request;
/**
* @OA\Tag(name="Estimate", description="견적 관리 API")
*/
class EstimateController extends Controller
{
protected EstimateService $estimateService;
public function __construct(EstimateService $estimateService)
{
$this->estimateService = $estimateService;
}
/**
* @OA\Get(
* path="/v1/estimates",
* summary="견적 목록 조회",
* tags={"Estimate"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="status", in="query", description="견적 상태", @OA\Schema(type="string")),
* @OA\Parameter(name="customer_name", in="query", description="고객명", @OA\Schema(type="string")),
* @OA\Parameter(name="model_set_id", in="query", description="모델셋 ID", @OA\Schema(type="integer")),
* @OA\Parameter(name="date_from", in="query", description="시작일", @OA\Schema(type="string", format="date")),
* @OA\Parameter(name="date_to", in="query", description="종료일", @OA\Schema(type="string", format="date")),
* @OA\Parameter(name="search", in="query", description="검색어", @OA\Schema(type="string")),
* @OA\Parameter(name="per_page", in="query", description="페이지당 항목수", @OA\Schema(type="integer", default=20)),
* @OA\Response(response=200, description="성공")
* )
*/
public function index(Request $request)
{
$estimates = $this->estimateService->getEstimates($request->all());
return ApiResponse::success([
'estimates' => $estimates
], __('message.fetched'));
}
/**
* @OA\Get(
* path="/v1/estimates/{id}",
* summary="견적 상세 조회",
* tags={"Estimate"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="견적 ID", @OA\Schema(type="integer")),
* @OA\Response(response=200, description="성공")
* )
*/
public function show($id)
{
$estimate = $this->estimateService->getEstimateDetail($id);
return ApiResponse::success([
'estimate' => $estimate
], __('message.fetched'));
}
/**
* @OA\Post(
* path="/v1/estimates",
* summary="견적 생성",
* tags={"Estimate"},
* security={{"bearerAuth": {}}},
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* required={"model_set_id", "estimate_name", "parameters"},
* @OA\Property(property="model_set_id", type="integer", description="모델셋 ID"),
* @OA\Property(property="estimate_name", type="string", description="견적명"),
* @OA\Property(property="customer_name", type="string", description="고객명"),
* @OA\Property(property="project_name", type="string", description="프로젝트명"),
* @OA\Property(property="parameters", type="object", description="견적 파라미터"),
* @OA\Property(property="notes", type="string", description="비고")
* )
* ),
* @OA\Response(response=201, description="생성 성공")
* )
*/
public function store(CreateEstimateRequest $request)
{
$estimate = $this->estimateService->createEstimate($request->validated());
return ApiResponse::success([
'estimate' => $estimate
], __('message.created'), 201);
}
/**
* @OA\Put(
* path="/v1/estimates/{id}",
* summary="견적 수정",
* tags={"Estimate"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="견적 ID", @OA\Schema(type="integer")),
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* @OA\Property(property="estimate_name", type="string", description="견적명"),
* @OA\Property(property="customer_name", type="string", description="고객명"),
* @OA\Property(property="project_name", type="string", description="프로젝트명"),
* @OA\Property(property="parameters", type="object", description="견적 파라미터"),
* @OA\Property(property="status", type="string", description="견적 상태"),
* @OA\Property(property="notes", type="string", description="비고")
* )
* ),
* @OA\Response(response=200, description="수정 성공")
* )
*/
public function update(UpdateEstimateRequest $request, $id)
{
$estimate = $this->estimateService->updateEstimate($id, $request->validated());
return ApiResponse::success([
'estimate' => $estimate
], __('message.updated'));
}
/**
* @OA\Delete(
* path="/v1/estimates/{id}",
* summary="견적 삭제",
* tags={"Estimate"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="견적 ID", @OA\Schema(type="integer")),
* @OA\Response(response=200, description="삭제 성공")
* )
*/
public function destroy($id)
{
$this->estimateService->deleteEstimate($id);
return ApiResponse::success([], __('message.deleted'));
}
/**
* @OA\Post(
* path="/v1/estimates/{id}/clone",
* summary="견적 복제",
* tags={"Estimate"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="견적 ID", @OA\Schema(type="integer")),
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* required={"estimate_name"},
* @OA\Property(property="estimate_name", type="string", description="새 견적명"),
* @OA\Property(property="customer_name", type="string", description="고객명"),
* @OA\Property(property="project_name", type="string", description="프로젝트명"),
* @OA\Property(property="notes", type="string", description="비고")
* )
* ),
* @OA\Response(response=201, description="복제 성공")
* )
*/
public function clone(Request $request, $id)
{
$request->validate([
'estimate_name' => 'required|string|max:255',
'customer_name' => 'nullable|string|max:255',
'project_name' => 'nullable|string|max:255',
'notes' => 'nullable|string|max:2000',
]);
$newEstimate = $this->estimateService->cloneEstimate($id, $request->all());
return ApiResponse::success([
'estimate' => $newEstimate
], __('message.estimate.cloned'), 201);
}
/**
* @OA\Put(
* path="/v1/estimates/{id}/status",
* summary="견적 상태 변경",
* tags={"Estimate"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, description="견적 ID", @OA\Schema(type="integer")),
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* required={"status"},
* @OA\Property(property="status", type="string", enum={"DRAFT","SENT","APPROVED","REJECTED","EXPIRED"}, description="변경할 상태"),
* @OA\Property(property="notes", type="string", description="상태 변경 사유")
* )
* ),
* @OA\Response(response=200, description="상태 변경 성공")
* )
*/
public function changeStatus(Request $request, $id)
{
$request->validate([
'status' => 'required|in:DRAFT,SENT,APPROVED,REJECTED,EXPIRED',
'notes' => 'nullable|string|max:1000',
]);
$estimate = $this->estimateService->changeEstimateStatus(
$id,
$request->status,
$request->notes
);
return ApiResponse::success([
'estimate' => $estimate
], __('message.estimate.status_changed'));
}
/**
* @OA\Get(
* path="/v1/estimates/form-schema/{model_set_id}",
* summary="견적 폼 스키마 조회",
* tags={"Estimate"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="model_set_id", in="path", required=true, description="모델셋 ID", @OA\Schema(type="integer")),
* @OA\Response(response=200, description="성공")
* )
*/
public function getFormSchema($modelSetId)
{
$schema = $this->estimateService->getEstimateFormSchema($modelSetId);
return ApiResponse::success([
'form_schema' => $schema
], __('message.fetched'));
}
/**
* @OA\Post(
* path="/v1/estimates/preview/{model_set_id}",
* summary="견적 계산 미리보기",
* tags={"Estimate"},
* security={{"bearerAuth": {}}},
* @OA\Parameter(name="model_set_id", in="path", required=true, description="모델셋 ID", @OA\Schema(type="integer")),
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* required={"parameters"},
* @OA\Property(property="parameters", type="object", description="견적 파라미터")
* )
* ),
* @OA\Response(response=200, description="계산 성공")
* )
*/
public function previewCalculation(Request $request, $modelSetId)
{
$request->validate([
'parameters' => 'required|array',
'parameters.*' => 'required',
]);
$calculation = $this->estimateService->previewCalculation(
$modelSetId,
$request->parameters
);
return ApiResponse::success([
'calculation' => $calculation
], __('message.calculated'));
}
}

View File

@@ -0,0 +1,137 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\ModelSet\CreateModelSetRequest;
use App\Http\Requests\ModelSet\UpdateModelSetRequest;
use App\Http\Requests\ModelSet\CloneModelSetRequest;
use App\Services\ModelSet\ModelSetService;
use App\Helpers\ApiResponse;
use Illuminate\Http\Request;
class ModelSetController extends Controller
{
protected ModelSetService $modelSetService;
public function __construct(ModelSetService $modelSetService)
{
$this->modelSetService = $modelSetService;
}
/**
* 모델셋 목록 조회
*/
public function index(Request $request)
{
$modelSets = $this->modelSetService->getModelSets($request->validated());
return ApiResponse::success([
'model_sets' => $modelSets
], __('message.fetched'));
}
/**
* 모델셋 상세 조회
*/
public function show($id)
{
$modelSet = $this->modelSetService->getModelSetDetail($id);
return ApiResponse::success([
'model_set' => $modelSet
], __('message.fetched'));
}
/**
* 새로운 모델셋 생성
*/
public function store(CreateModelSetRequest $request)
{
$modelSet = $this->modelSetService->createModelSet($request->validated());
return ApiResponse::success([
'model_set' => $modelSet
], __('message.created'));
}
/**
* 모델셋 수정
*/
public function update(UpdateModelSetRequest $request, $id)
{
$modelSet = $this->modelSetService->updateModelSet($id, $request->validated());
return ApiResponse::success([
'model_set' => $modelSet
], __('message.updated'));
}
/**
* 모델셋 삭제
*/
public function destroy($id)
{
$this->modelSetService->deleteModelSet($id);
return ApiResponse::success([], __('message.deleted'));
}
/**
* 모델셋 복제
*/
public function clone(CloneModelSetRequest $request, $id)
{
$newModelSet = $this->modelSetService->cloneModelSet($id, $request->validated());
return ApiResponse::success([
'model_set' => $newModelSet
], __('message.model_set.cloned'));
}
/**
* 모델셋의 카테고리별 필드 구조 조회
*/
public function getCategoryFields($id)
{
$fields = $this->modelSetService->getModelSetCategoryFields($id);
return ApiResponse::success([
'category_fields' => $fields
], __('message.fetched'));
}
/**
* 모델셋의 BOM 템플릿 목록
*/
public function getBomTemplates($id)
{
$templates = $this->modelSetService->getModelSetBomTemplates($id);
return ApiResponse::success([
'bom_templates' => $templates
], __('message.fetched'));
}
/**
* 모델셋 기반 견적 파라미터 조회
*/
public function getEstimateParameters($id, Request $request)
{
$parameters = $this->modelSetService->getEstimateParameters($id, $request->all());
return ApiResponse::success([
'parameters' => $parameters
], __('message.fetched'));
}
/**
* 모델셋 기반 BOM 계산
*/
public function calculateBom($id, Request $request)
{
$result = $this->modelSetService->calculateModelSetBom($id, $request->all());
return ApiResponse::success($result, __('message.calculated'));
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Requests\Estimate;
use Illuminate\Foundation\Http\FormRequest;
class CreateEstimateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'model_set_id' => 'required|exists:categories,id',
'estimate_name' => 'required|string|max:255',
'customer_name' => 'nullable|string|max:255',
'project_name' => 'nullable|string|max:255',
'parameters' => 'required|array',
'parameters.*' => 'required',
'notes' => 'nullable|string|max:2000',
];
}
public function messages(): array
{
return [
'model_set_id.required' => __('validation.required', ['attribute' => '모델셋']),
'model_set_id.exists' => __('validation.exists', ['attribute' => '모델셋']),
'estimate_name.required' => __('validation.required', ['attribute' => '견적명']),
'customer_name.max' => __('validation.max.string', ['attribute' => '고객명', 'max' => 255]),
'parameters.required' => __('validation.required', ['attribute' => '견적 파라미터']),
'parameters.array' => __('validation.array', ['attribute' => '견적 파라미터']),
];
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Http\Requests\Estimate;
use Illuminate\Foundation\Http\FormRequest;
class UpdateEstimateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'estimate_name' => 'sometimes|required|string|max:255',
'customer_name' => 'nullable|string|max:255',
'project_name' => 'nullable|string|max:255',
'parameters' => 'sometimes|required|array',
'parameters.*' => 'required',
'notes' => 'nullable|string|max:2000',
'status' => 'sometimes|in:DRAFT,SENT,APPROVED,REJECTED,EXPIRED',
];
}
public function messages(): array
{
return [
'estimate_name.required' => __('validation.required', ['attribute' => '견적명']),
'parameters.required' => __('validation.required', ['attribute' => '견적 파라미터']),
'parameters.array' => __('validation.array', ['attribute' => '견적 파라미터']),
'status.in' => __('validation.in', ['attribute' => '상태']),
];
}
}

View File

@@ -0,0 +1,108 @@
<?php
namespace App\Models\Estimate;
use App\Models\Commons\Category;
use App\Models\Contracts\BelongsToTenant;
use App\Traits\BelongsToTenantTrait;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Estimate extends Model implements BelongsToTenant
{
use HasFactory, SoftDeletes, BelongsToTenantTrait;
protected $fillable = [
'tenant_id',
'model_set_id',
'estimate_no',
'estimate_name',
'customer_name',
'project_name',
'parameters',
'calculated_results',
'bom_data',
'total_amount',
'status',
'notes',
'valid_until',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'parameters' => 'array',
'calculated_results' => 'array',
'bom_data' => 'array',
'total_amount' => 'decimal:2',
'valid_until' => 'date',
'created_at' => 'datetime',
'updated_at' => 'datetime',
'deleted_at' => 'datetime',
];
/**
* 모델셋 관계 (카테고리)
*/
public function modelSet(): BelongsTo
{
return $this->belongsTo(Category::class, 'model_set_id');
}
/**
* 견적 항목들
*/
public function items(): HasMany
{
return $this->hasMany(EstimateItem::class);
}
/**
* 견적 번호 자동 생성
*/
public static function generateEstimateNo(int $tenantId): string
{
$prefix = 'EST';
$date = now()->format('Ymd');
$lastEstimate = self::where('tenant_id', $tenantId)
->whereDate('created_at', today())
->orderBy('id', 'desc')
->first();
$sequence = $lastEstimate ? (int) substr($lastEstimate->estimate_no, -3) + 1 : 1;
return $prefix . $date . str_pad($sequence, 3, '0', STR_PAD_LEFT);
}
/**
* 견적 상태별 스코프
*/
public function scopeDraft($query)
{
return $query->where('status', 'DRAFT');
}
public function scopeSent($query)
{
return $query->where('status', 'SENT');
}
public function scopeApproved($query)
{
return $query->where('status', 'APPROVED');
}
/**
* 만료된 견적 스코프
*/
public function scopeExpired($query)
{
return $query->whereNotNull('valid_until')
->where('valid_until', '<', now());
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Models\Estimate;
use App\Models\Contracts\BelongsToTenant;
use App\Traits\BelongsToTenantTrait;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class EstimateItem extends Model implements BelongsToTenant
{
use HasFactory, SoftDeletes, BelongsToTenantTrait;
protected $fillable = [
'tenant_id',
'estimate_id',
'sequence',
'item_name',
'item_description',
'parameters',
'calculated_values',
'unit_price',
'quantity',
'total_price',
'bom_components',
'notes',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'parameters' => 'array',
'calculated_values' => 'array',
'unit_price' => 'decimal:2',
'quantity' => 'decimal:2',
'total_price' => 'decimal:2',
'bom_components' => 'array',
'created_at' => 'datetime',
'updated_at' => 'datetime',
'deleted_at' => 'datetime',
];
/**
* 견적 관계
*/
public function estimate(): BelongsTo
{
return $this->belongsTo(Estimate::class);
}
/**
* 순번별 정렬
*/
public function scopeOrdered($query)
{
return $query->orderBy('sequence');
}
/**
* 총 금액 계산
*/
public function calculateTotalPrice(): float
{
return $this->unit_price * $this->quantity;
}
/**
* 저장 시 총 금액 자동 계산
*/
protected static function boot()
{
parent::boot();
static::saving(function ($item) {
$item->total_price = $item->calculateTotalPrice();
});
}
}

View File

@@ -102,6 +102,11 @@ protected function executePreDefinedFunction(string $formula, array $variables):
return ['result' => 5]; // 최대값
}
// 5130 시스템 브라켓 사이즈 계산 (중량+인치 기반)
if ($formula === 'motor_bracket_size') {
return $this->calculateMotorBracketSize($variables);
}
// 환봉 수량 계산
if ($formula === 'round_bar_quantity') {
$W1 = $variables['W1'] ?? 0;
@@ -220,6 +225,89 @@ protected function evaluateCondition(string $condition, array $variables): bool
return eval("return {$expression};");
}
/**
* 5130 시스템 모터 브라켓 사이즈 계산 (중량+인치 기반)
*/
protected function calculateMotorBracketSize(array $variables): array
{
$weight = floatval($variables['weight'] ?? 0);
$inch = is_numeric($variables['inch'] ?? null) ? intval($variables['inch']) : 0;
$motorCapacity = 0;
if ($inch > 0) {
// 중량 + 인치 기준 판단 (철재 기준)
if (
($inch == 4 && $weight <= 300) ||
($inch == 5 && $weight <= 246) ||
($inch == 6 && $weight <= 208)
) {
$motorCapacity = 300;
} elseif (
($inch == 4 && $weight > 300 && $weight <= 400) ||
($inch == 5 && $weight > 246 && $weight <= 327) ||
($inch == 6 && $weight > 208 && $weight <= 277)
) {
$motorCapacity = 400;
} elseif (
($inch == 5 && $weight > 327 && $weight <= 500) ||
($inch == 6 && $weight > 277 && $weight <= 424) ||
($inch == 8 && $weight <= 324)
) {
$motorCapacity = 500;
} elseif (
($inch == 5 && $weight > 500 && $weight <= 600) ||
($inch == 6 && $weight > 424 && $weight <= 508) ||
($inch == 8 && $weight > 324 && $weight <= 388)
) {
$motorCapacity = 600;
} elseif (
($inch == 6 && $weight > 600 && $weight <= 800) ||
($inch == 6 && $weight > 508 && $weight <= 800) ||
($inch == 8 && $weight > 388 && $weight <= 611)
) {
$motorCapacity = 800;
} elseif (
($inch == 6 && $weight > 800 && $weight <= 1000) ||
($inch == 8 && $weight > 611 && $weight <= 1000)
) {
$motorCapacity = 1000;
}
} else {
// 인치가 없으면 중량만으로 판단
if ($weight <= 300) {
$motorCapacity = 300;
} elseif ($weight <= 400) {
$motorCapacity = 400;
} elseif ($weight <= 500) {
$motorCapacity = 500;
} elseif ($weight <= 600) {
$motorCapacity = 600;
} elseif ($weight <= 800) {
$motorCapacity = 800;
} elseif ($weight <= 1000) {
$motorCapacity = 1000;
}
}
// 용량별 브라켓 사이즈 매핑
$bracketSize = '530*320'; // 기본값
if (in_array($motorCapacity, [300, 400])) {
$bracketSize = '530*320';
} elseif (in_array($motorCapacity, [500, 600])) {
$bracketSize = '600*350';
} elseif (in_array($motorCapacity, [800, 1000])) {
$bracketSize = '690*390';
}
return [
'bracket_size' => $bracketSize,
'motor_capacity' => $motorCapacity,
'calculated_weight' => $weight,
'shaft_inch' => $inch
];
}
/**
* 미리 정의된 함수인지 확인
*/
@@ -230,6 +318,7 @@ protected function isPreDefinedFunction(string $formula): bool
'kyungdong_steel_size',
'screen_weight_calculation',
'bracket_quantity',
'motor_bracket_size',
'round_bar_quantity',
'shaft_size_determination',
'motor_capacity_determination'

View File

@@ -0,0 +1,346 @@
<?php
namespace App\Services\Estimate;
use App\Models\Commons\Category;
use App\Models\Estimate\Estimate;
use App\Models\Estimate\EstimateItem;
use App\Services\ModelSet\ModelSetService;
use App\Services\Service;
use App\Services\Calculation\CalculationEngine;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Pagination\LengthAwarePaginator;
class EstimateService extends Service
{
protected ModelSetService $modelSetService;
protected CalculationEngine $calculationEngine;
public function __construct(
ModelSetService $modelSetService,
CalculationEngine $calculationEngine
) {
parent::__construct();
$this->modelSetService = $modelSetService;
$this->calculationEngine = $calculationEngine;
}
/**
* 견적 목록 조회
*/
public function getEstimates(array $filters = []): LengthAwarePaginator
{
$query = Estimate::with(['modelSet', 'items'])
->where('tenant_id', $this->tenantId());
// 필터링
if (!empty($filters['status'])) {
$query->where('status', $filters['status']);
}
if (!empty($filters['customer_name'])) {
$query->where('customer_name', 'like', '%' . $filters['customer_name'] . '%');
}
if (!empty($filters['model_set_id'])) {
$query->where('model_set_id', $filters['model_set_id']);
}
if (!empty($filters['date_from'])) {
$query->whereDate('created_at', '>=', $filters['date_from']);
}
if (!empty($filters['date_to'])) {
$query->whereDate('created_at', '<=', $filters['date_to']);
}
if (!empty($filters['search'])) {
$searchTerm = $filters['search'];
$query->where(function ($q) use ($searchTerm) {
$q->where('estimate_name', 'like', '%' . $searchTerm . '%')
->orWhere('estimate_no', 'like', '%' . $searchTerm . '%')
->orWhere('project_name', 'like', '%' . $searchTerm . '%');
});
}
return $query->orderBy('created_at', 'desc')
->paginate($filters['per_page'] ?? 20);
}
/**
* 견적 상세 조회
*/
public function getEstimateDetail($estimateId): array
{
$estimate = Estimate::with(['modelSet.fields', 'items'])
->where('tenant_id', $this->tenantId())
->findOrFail($estimateId);
return [
'estimate' => $estimate,
'model_set_schema' => $this->modelSetService->getModelSetCategoryFields($estimate->model_set_id),
'calculation_summary' => $this->summarizeCalculations($estimate),
];
}
/**
* 견적 생성
*/
public function createEstimate(array $data): array
{
return DB::transaction(function () use ($data) {
// 견적번호 생성
$estimateNo = Estimate::generateEstimateNo($this->tenantId());
// 모델셋 기반 BOM 계산
$bomCalculation = $this->modelSetService->calculateModelSetBom(
$data['model_set_id'],
$data['parameters']
);
// 견적 생성
$estimate = Estimate::create([
'tenant_id' => $this->tenantId(),
'model_set_id' => $data['model_set_id'],
'estimate_no' => $estimateNo,
'estimate_name' => $data['estimate_name'],
'customer_name' => $data['customer_name'] ?? null,
'project_name' => $data['project_name'] ?? null,
'parameters' => $data['parameters'],
'calculated_results' => $bomCalculation['calculated_values'] ?? [],
'bom_data' => $bomCalculation,
'total_amount' => $bomCalculation['total_amount'] ?? 0,
'notes' => $data['notes'] ?? null,
'valid_until' => now()->addDays(30), // 기본 30일 유효
'created_by' => $this->apiUserId(),
]);
// 견적 항목 생성 (BOM 기반)
if (!empty($bomCalculation['bom_items'])) {
$this->createEstimateItems($estimate, $bomCalculation['bom_items']);
}
return $this->getEstimateDetail($estimate->id);
});
}
/**
* 견적 수정
*/
public function updateEstimate($estimateId, array $data): array
{
return DB::transaction(function () use ($estimateId, $data) {
$estimate = Estimate::where('tenant_id', $this->tenantId())
->findOrFail($estimateId);
// 파라미터가 변경되면 재계산
if (isset($data['parameters'])) {
$bomCalculation = $this->modelSetService->calculateModelSetBom(
$estimate->model_set_id,
$data['parameters']
);
$data['calculated_results'] = $bomCalculation['calculated_values'] ?? [];
$data['bom_data'] = $bomCalculation;
$data['total_amount'] = $bomCalculation['total_amount'] ?? 0;
// 기존 견적 항목 삭제 후 재생성
$estimate->items()->delete();
if (!empty($bomCalculation['bom_items'])) {
$this->createEstimateItems($estimate, $bomCalculation['bom_items']);
}
}
$estimate->update([
...$data,
'updated_by' => $this->apiUserId(),
]);
return $this->getEstimateDetail($estimate->id);
});
}
/**
* 견적 삭제
*/
public function deleteEstimate($estimateId): void
{
DB::transaction(function () use ($estimateId) {
$estimate = Estimate::where('tenant_id', $this->tenantId())
->findOrFail($estimateId);
// 진행 중인 견적은 삭제 불가
if (in_array($estimate->status, ['SENT', 'APPROVED'])) {
throw new \Exception(__('error.estimate.cannot_delete_sent_or_approved'));
}
$estimate->update(['deleted_by' => $this->apiUserId()]);
$estimate->delete();
});
}
/**
* 견적 복제
*/
public function cloneEstimate($estimateId, array $data): array
{
return DB::transaction(function () use ($estimateId, $data) {
$originalEstimate = Estimate::with('items')
->where('tenant_id', $this->tenantId())
->findOrFail($estimateId);
// 새 견적번호 생성
$newEstimateNo = Estimate::generateEstimateNo($this->tenantId());
// 견적 복제
$newEstimate = Estimate::create([
'tenant_id' => $this->tenantId(),
'model_set_id' => $originalEstimate->model_set_id,
'estimate_no' => $newEstimateNo,
'estimate_name' => $data['estimate_name'],
'customer_name' => $data['customer_name'] ?? $originalEstimate->customer_name,
'project_name' => $data['project_name'] ?? $originalEstimate->project_name,
'parameters' => $originalEstimate->parameters,
'calculated_results' => $originalEstimate->calculated_results,
'bom_data' => $originalEstimate->bom_data,
'total_amount' => $originalEstimate->total_amount,
'notes' => $data['notes'] ?? $originalEstimate->notes,
'valid_until' => now()->addDays(30),
'created_by' => $this->apiUserId(),
]);
// 견적 항목 복제
foreach ($originalEstimate->items as $item) {
EstimateItem::create([
'tenant_id' => $this->tenantId(),
'estimate_id' => $newEstimate->id,
'sequence' => $item->sequence,
'item_name' => $item->item_name,
'item_description' => $item->item_description,
'parameters' => $item->parameters,
'calculated_values' => $item->calculated_values,
'unit_price' => $item->unit_price,
'quantity' => $item->quantity,
'total_price' => $item->total_price,
'bom_components' => $item->bom_components,
'notes' => $item->notes,
'created_by' => $this->apiUserId(),
]);
}
return $this->getEstimateDetail($newEstimate->id);
});
}
/**
* 견적 상태 변경
*/
public function changeEstimateStatus($estimateId, string $status, ?string $notes = null): array
{
$estimate = Estimate::where('tenant_id', $this->tenantId())
->findOrFail($estimateId);
$validTransitions = [
'DRAFT' => ['SENT', 'REJECTED'],
'SENT' => ['APPROVED', 'REJECTED', 'EXPIRED'],
'APPROVED' => ['EXPIRED'],
'REJECTED' => ['DRAFT'],
'EXPIRED' => ['DRAFT'],
];
if (!in_array($status, $validTransitions[$estimate->status] ?? [])) {
throw new \Exception(__('error.estimate.invalid_status_transition'));
}
$estimate->update([
'status' => $status,
'notes' => $notes ? ($estimate->notes . "\n\n" . $notes) : $estimate->notes,
'updated_by' => $this->apiUserId(),
]);
return $this->getEstimateDetail($estimate->id);
}
/**
* 동적 견적 폼 스키마 조회
*/
public function getEstimateFormSchema($modelSetId): array
{
$parameters = $this->modelSetService->getEstimateParameters($modelSetId);
return [
'model_set' => $parameters['category'],
'form_schema' => [
'input_fields' => $parameters['input_fields'],
'calculated_fields' => $parameters['calculated_fields'],
],
'calculation_schema' => $parameters['calculation_schema'],
];
}
/**
* 견적 파라미터 미리보기 계산
*/
public function previewCalculation($modelSetId, array $parameters): array
{
return $this->modelSetService->calculateModelSetBom($modelSetId, $parameters);
}
/**
* 견적 항목 생성
*/
protected function createEstimateItems(Estimate $estimate, array $bomItems): void
{
foreach ($bomItems as $index => $bomItem) {
EstimateItem::create([
'tenant_id' => $this->tenantId(),
'estimate_id' => $estimate->id,
'sequence' => $index + 1,
'item_name' => $bomItem['name'] ?? '견적 항목 ' . ($index + 1),
'item_description' => $bomItem['description'] ?? '',
'parameters' => $bomItem['parameters'] ?? [],
'calculated_values' => $bomItem['calculated_values'] ?? [],
'unit_price' => $bomItem['unit_price'] ?? 0,
'quantity' => $bomItem['quantity'] ?? 1,
'bom_components' => $bomItem['components'] ?? [],
'created_by' => $this->apiUserId(),
]);
}
}
/**
* 계산 결과 요약
*/
protected function summarizeCalculations(Estimate $estimate): array
{
$summary = [
'total_items' => $estimate->items->count(),
'total_amount' => $estimate->total_amount,
'key_calculations' => [],
];
// 주요 계산 결과 추출
if (!empty($estimate->calculated_results)) {
$results = $estimate->calculated_results;
if (isset($results['W1'], $results['H1'])) {
$summary['key_calculations']['제작사이즈'] = $results['W1'] . ' × ' . $results['H1'] . ' mm';
}
if (isset($results['weight'])) {
$summary['key_calculations']['중량'] = $results['weight'] . ' kg';
}
if (isset($results['area'])) {
$summary['key_calculations']['면적'] = $results['area'] . ' ㎡';
}
if (isset($results['bracket_size'])) {
$summary['key_calculations']['모터브라켓'] = $results['bracket_size'];
}
}
return $summary;
}
}

View File

@@ -0,0 +1,488 @@
<?php
namespace App\Services\ModelSet;
use App\Models\Commons\Category;
use App\Models\Commons\CategoryField;
use App\Models\Design\Model;
use App\Models\Design\ModelVersion;
use App\Models\Design\BomTemplate;
use App\Models\Products\Product;
use App\Services\Service;
use App\Services\Calculation\CalculationEngine;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class ModelSetService extends Service
{
protected CalculationEngine $calculationEngine;
public function __construct(CalculationEngine $calculationEngine)
{
parent::__construct();
$this->calculationEngine = $calculationEngine;
}
/**
* 모델셋 목록 조회 (카테고리 기반)
*/
public function getModelSets(array $filters = []): Collection
{
$query = Category::with(['fields', 'children'])
->where('tenant_id', $this->tenantId())
->where('code_group', 'estimate')
->where('level', '>=', 2); // 루트 카테고리 제외
if (!empty($filters['category_type'])) {
$query->where('code', $filters['category_type']);
}
if (!empty($filters['is_active'])) {
$query->where('is_active', $filters['is_active']);
}
return $query->orderBy('sort_order')->get();
}
/**
* 모델셋 상세 조회
*/
public function getModelSetDetail($categoryId): array
{
$category = Category::with(['fields', 'parent', 'children'])
->where('tenant_id', $this->tenantId())
->findOrFail($categoryId);
// 해당 카테고리의 제품들
$products = Product::where('tenant_id', $this->tenantId())
->where('category_id', $categoryId)
->with(['components'])
->get();
// 해당 카테고리의 모델 및 BOM 템플릿들
$models = $this->getRelatedModels($categoryId);
return [
'category' => $category,
'products' => $products,
'models' => $models,
'field_schema' => $this->generateFieldSchema($category->fields),
];
}
/**
* 새로운 모델셋 생성
*/
public function createModelSet(array $data): array
{
return DB::transaction(function () use ($data) {
// 1. 카테고리 생성
$category = Category::create([
'tenant_id' => $this->tenantId(),
'parent_id' => $data['parent_id'] ?? null,
'code_group' => 'estimate',
'code' => $data['code'],
'name' => $data['name'],
'description' => $data['description'] ?? '',
'level' => $data['level'] ?? 2,
'sort_order' => $data['sort_order'] ?? 999,
'profile_code' => $data['profile_code'] ?? 'custom_category',
'is_active' => $data['is_active'] ?? true,
'created_by' => $this->apiUserId(),
]);
// 2. 동적 필드 생성
if (!empty($data['fields'])) {
foreach ($data['fields'] as $fieldData) {
CategoryField::create([
'tenant_id' => $this->tenantId(),
'category_id' => $category->id,
'field_key' => $fieldData['key'],
'field_name' => $fieldData['name'],
'field_type' => $fieldData['type'],
'is_required' => $fieldData['required'] ?? false,
'sort_order' => $fieldData['order'] ?? 999,
'default_value' => $fieldData['default'] ?? null,
'options' => $fieldData['options'] ?? null,
'description' => $fieldData['description'] ?? '',
'created_by' => $this->apiUserId(),
]);
}
}
// 3. 모델 및 BOM 템플릿 생성 (선택사항)
if (!empty($data['create_model'])) {
$this->createDefaultModel($category, $data['model_data'] ?? []);
}
return $this->getModelSetDetail($category->id);
});
}
/**
* 모델셋 수정
*/
public function updateModelSet($categoryId, array $data): array
{
return DB::transaction(function () use ($categoryId, $data) {
$category = Category::where('tenant_id', $this->tenantId())
->findOrFail($categoryId);
// 카테고리 정보 업데이트
$category->update([
'name' => $data['name'] ?? $category->name,
'description' => $data['description'] ?? $category->description,
'sort_order' => $data['sort_order'] ?? $category->sort_order,
'is_active' => $data['is_active'] ?? $category->is_active,
'updated_by' => $this->apiUserId(),
]);
// 필드 업데이트 (기존 필드 삭제 후 재생성)
if (isset($data['fields'])) {
CategoryField::where('category_id', $categoryId)->delete();
foreach ($data['fields'] as $fieldData) {
CategoryField::create([
'tenant_id' => $this->tenantId(),
'category_id' => $categoryId,
'field_key' => $fieldData['key'],
'field_name' => $fieldData['name'],
'field_type' => $fieldData['type'],
'is_required' => $fieldData['required'] ?? false,
'sort_order' => $fieldData['order'] ?? 999,
'default_value' => $fieldData['default'] ?? null,
'options' => $fieldData['options'] ?? null,
'description' => $fieldData['description'] ?? '',
'created_by' => $this->apiUserId(),
]);
}
}
return $this->getModelSetDetail($categoryId);
});
}
/**
* 모델셋 삭제
*/
public function deleteModelSet($categoryId): void
{
DB::transaction(function () use ($categoryId) {
$category = Category::where('tenant_id', $this->tenantId())
->findOrFail($categoryId);
// 연관된 데이터들 확인
$hasProducts = Product::where('category_id', $categoryId)->exists();
$hasChildren = Category::where('parent_id', $categoryId)->exists();
if ($hasProducts || $hasChildren) {
throw new \Exception(__('error.modelset.has_dependencies'));
}
// 필드 삭제
CategoryField::where('category_id', $categoryId)->delete();
// 카테고리 삭제 (소프트 삭제)
$category->update(['deleted_by' => $this->apiUserId()]);
$category->delete();
});
}
/**
* 모델셋 복제
*/
public function cloneModelSet($categoryId, array $data): array
{
return DB::transaction(function () use ($categoryId, $data) {
$originalCategory = Category::with('fields')
->where('tenant_id', $this->tenantId())
->findOrFail($categoryId);
// 새로운 카테고리 생성
$newCategory = Category::create([
'tenant_id' => $this->tenantId(),
'parent_id' => $originalCategory->parent_id,
'code_group' => $originalCategory->code_group,
'code' => $data['code'],
'name' => $data['name'],
'description' => $data['description'] ?? $originalCategory->description,
'level' => $originalCategory->level,
'sort_order' => $data['sort_order'] ?? 999,
'profile_code' => $originalCategory->profile_code,
'is_active' => $data['is_active'] ?? true,
'created_by' => $this->apiUserId(),
]);
// 필드 복제
foreach ($originalCategory->fields as $field) {
CategoryField::create([
'tenant_id' => $this->tenantId(),
'category_id' => $newCategory->id,
'field_key' => $field->field_key,
'field_name' => $field->field_name,
'field_type' => $field->field_type,
'is_required' => $field->is_required,
'sort_order' => $field->sort_order,
'default_value' => $field->default_value,
'options' => $field->options,
'description' => $field->description,
'created_by' => $this->apiUserId(),
]);
}
return $this->getModelSetDetail($newCategory->id);
});
}
/**
* 모델셋의 카테고리 필드 구조 조회
*/
public function getModelSetCategoryFields($categoryId): array
{
$category = Category::with('fields')
->where('tenant_id', $this->tenantId())
->findOrFail($categoryId);
return $this->generateFieldSchema($category->fields);
}
/**
* 모델셋의 BOM 템플릿 목록
*/
public function getModelSetBomTemplates($categoryId): Collection
{
// 해당 카테고리와 연관된 모델들의 BOM 템플릿들
$models = $this->getRelatedModels($categoryId);
$bomTemplates = collect();
foreach ($models as $model) {
foreach ($model['versions'] as $version) {
$bomTemplates = $bomTemplates->merge($version['bom_templates']);
}
}
return $bomTemplates;
}
/**
* 견적 파라미터 조회 (동적 필드 기반)
*/
public function getEstimateParameters($categoryId, array $filters = []): array
{
$category = Category::with('fields')
->where('tenant_id', $this->tenantId())
->findOrFail($categoryId);
// 입력 파라미터 (사용자가 입력해야 하는 필드들)
$inputFields = $category->fields
->filter(function ($field) {
return in_array($field->field_key, [
'open_width', 'open_height', 'quantity',
'model_name', 'guide_rail_type', 'shutter_box'
]);
})
->map(function ($field) {
return [
'key' => $field->field_key,
'name' => $field->field_name,
'type' => $field->field_type,
'required' => $field->is_required,
'default' => $field->default_value,
'options' => $field->options,
'description' => $field->description,
];
});
// 계산 결과 필드 (자동으로 계산되는 필드들)
$calculatedFields = $category->fields
->filter(function ($field) {
return in_array($field->field_key, [
'make_width', 'make_height', 'calculated_weight',
'calculated_area', 'motor_bracket_size', 'motor_capacity'
]);
})
->map(function ($field) {
return [
'key' => $field->field_key,
'name' => $field->field_name,
'type' => $field->field_type,
'description' => $field->description,
];
});
return [
'category' => [
'id' => $category->id,
'name' => $category->name,
'code' => $category->code,
],
'input_fields' => $inputFields->values(),
'calculated_fields' => $calculatedFields->values(),
'calculation_schema' => $this->getCalculationSchema($category->code),
];
}
/**
* 모델셋 기반 BOM 계산
*/
public function calculateModelSetBom($categoryId, array $parameters): array
{
$category = Category::where('tenant_id', $this->tenantId())
->findOrFail($categoryId);
// BOM 템플릿 찾기 (기본 템플릿 사용)
$bomTemplate = $this->findDefaultBomTemplate($categoryId, $parameters);
if (!$bomTemplate) {
throw new \Exception(__('error.bom_template.not_found'));
}
// 기존 BOM 계산 엔진 사용
return $this->calculationEngine->calculateBOM(
$bomTemplate->id,
$parameters,
$this->getCompanyName($category)
);
}
/**
* 카테고리와 연관된 모델들 조회
*/
protected function getRelatedModels($categoryId): Collection
{
// 카테고리 코드 기반으로 모델 찾기
$category = Category::findOrFail($categoryId);
return Model::with(['versions.bomTemplates'])
->where('tenant_id', $this->tenantId())
->where('code', 'like', $category->code . '%')
->get()
->map(function ($model) {
return [
'id' => $model->id,
'code' => $model->code,
'name' => $model->name,
'versions' => $model->versions->map(function ($version) {
return [
'id' => $version->id,
'version_no' => $version->version_no,
'status' => $version->status,
'bom_templates' => $version->bomTemplates,
];
}),
];
});
}
/**
* 필드 스키마 생성
*/
protected function generateFieldSchema(Collection $fields): array
{
return $fields->map(function ($field) {
return [
'key' => $field->field_key,
'name' => $field->field_name,
'type' => $field->field_type,
'required' => $field->is_required,
'order' => $field->sort_order,
'default' => $field->default_value,
'options' => $field->options,
'description' => $field->description,
];
})->sortBy('order')->values()->toArray();
}
/**
* 기본 모델 생성
*/
protected function createDefaultModel(Category $category, array $modelData): void
{
$model = Model::create([
'tenant_id' => $this->tenantId(),
'code' => $modelData['code'] ?? $category->code . '_MODEL',
'name' => $modelData['name'] ?? $category->name . ' 기본 모델',
'description' => $modelData['description'] ?? '',
'status' => 'DRAFT',
'created_by' => $this->apiUserId(),
]);
$version = ModelVersion::create([
'tenant_id' => $this->tenantId(),
'model_id' => $model->id,
'version_no' => 'v1.0',
'status' => 'DRAFT',
'created_by' => $this->apiUserId(),
]);
BomTemplate::create([
'tenant_id' => $this->tenantId(),
'model_version_id' => $version->id,
'name' => $category->name . ' 기본 BOM',
'company_type' => $this->getCompanyName($category),
'formula_version' => 'v1.0',
'calculation_schema' => $this->getDefaultCalculationSchema($category),
'created_by' => $this->apiUserId(),
]);
}
/**
* 계산 스키마 조회
*/
protected function getCalculationSchema(string $categoryCode): array
{
if ($categoryCode === 'screen_product') {
return [
'size_calculation' => 'kyungdong_screen_size',
'weight_calculation' => 'screen_weight_calculation',
'bracket_calculation' => 'motor_bracket_size',
];
} elseif ($categoryCode === 'steel_product') {
return [
'size_calculation' => 'kyungdong_steel_size',
'bracket_calculation' => 'motor_bracket_size',
'round_bar_calculation' => 'round_bar_quantity',
];
}
return [];
}
/**
* 기본 BOM 템플릿 찾기
*/
protected function findDefaultBomTemplate($categoryId, array $parameters): ?BomTemplate
{
$models = $this->getRelatedModels($categoryId);
foreach ($models as $model) {
foreach ($model['versions'] as $version) {
if ($version['status'] === 'RELEASED' && $version['bom_templates']->isNotEmpty()) {
return $version['bom_templates']->first();
}
}
}
return null;
}
/**
* 업체명 조회
*/
protected function getCompanyName(Category $category): string
{
// 테넌트 정보에서 업체명 조회하거나 기본값 사용
return '경동기업'; // 임시 하드코딩
}
/**
* 기본 계산 스키마 생성
*/
protected function getDefaultCalculationSchema(Category $category): array
{
return [
'calculation_type' => $category->code,
'formulas' => $this->getCalculationSchema($category->code),
];
}
}

View File

@@ -0,0 +1,262 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
// 5130 시스템의 71개 컬럼을 동적 카테고리 필드로 전환
// 1. 견적 루트 카테고리 생성
$rootCategoryId = DB::table('categories')->insertGetId([
'tenant_id' => 1,
'parent_id' => null,
'code_group' => 'estimate',
'code' => 'fire_shutter_estimate',
'name' => '방화셔터 견적',
'description' => '방화셔터 견적 루트 카테고리',
'level' => 1,
'sort_order' => 1,
'profile_code' => 'estimate_root',
'is_active' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
// 2. 스크린 카테고리
$screenCategoryId = DB::table('categories')->insertGetId([
'tenant_id' => 1,
'parent_id' => $rootCategoryId,
'code_group' => 'estimate',
'code' => 'screen_product',
'name' => '스크린 제품',
'description' => '실리카/와이어 스크린 제품 카테고리',
'level' => 2,
'sort_order' => 1,
'profile_code' => 'screen_category',
'is_active' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
// 3. 철재 카테고리
$steelCategoryId = DB::table('categories')->insertGetId([
'tenant_id' => 1,
'parent_id' => $rootCategoryId,
'code_group' => 'estimate',
'code' => 'steel_product',
'name' => '철재 제품',
'description' => '철재스라트 제품 카테고리',
'level' => 2,
'sort_order' => 2,
'profile_code' => 'steel_category',
'is_active' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
// 4. 스크린 카테고리의 동적 필드들 (5130의 핵심 컬럼들)
$screenFields = [
// 기본 정보
['key' => 'model_name', 'name' => '모델명', 'type' => 'select', 'required' => true, 'order' => 1,
'options' => ['KSS01', 'KSS02', 'KSE01', 'KWE01', 'KDSS01', '스크린비인정'],
'desc' => '스크린 제품 모델 선택 (col4)'],
['key' => 'sequence', 'name' => '순번', 'type' => 'text', 'required' => false, 'order' => 2,
'desc' => '견적 항목 순번 (col1)'],
['key' => 'product_category', 'name' => '대분류', 'type' => 'text', 'required' => true, 'order' => 3,
'default' => '스크린', 'desc' => '제품 대분류 (col2)'],
['key' => 'sub_category', 'name' => '중분류', 'type' => 'text', 'required' => false, 'order' => 4,
'desc' => '제품 중분류 (col3)'],
// 사이즈 관련
['key' => 'open_width', 'name' => '오픈사이즈 가로(mm)', 'type' => 'number', 'required' => true, 'order' => 10,
'desc' => '개구부 가로 사이즈 W0'],
['key' => 'open_height', 'name' => '오픈사이즈 세로(mm)', 'type' => 'number', 'required' => true, 'order' => 11,
'desc' => '개구부 세로 사이즈 H0'],
['key' => 'make_width', 'name' => '제작사이즈 가로(mm)', 'type' => 'number', 'required' => false, 'order' => 12,
'desc' => '제작 가로 사이즈 W1 (자동계산, col10)'],
['key' => 'make_height', 'name' => '제작사이즈 세로(mm)', 'type' => 'number', 'required' => false, 'order' => 13,
'desc' => '제작 세로 사이즈 H1 (자동계산, col11)'],
['key' => 'quantity', 'name' => '수량', 'type' => 'number', 'required' => true, 'order' => 14,
'default' => '1', 'desc' => '제품 수량 (col14)'],
// 부품 관련
['key' => 'guide_rail_type', 'name' => '가이드레일 유형', 'type' => 'select', 'required' => true, 'order' => 20,
'options' => ['벽면형', '측면형', '혼합형'],
'desc' => '가이드레일 설치 방식 (col6)'],
['key' => 'shutter_box', 'name' => '셔터박스', 'type' => 'select', 'required' => false, 'order' => 30,
'options' => ['', '500*380', '500*350', 'custom'],
'desc' => '셔터박스 사이즈 선택 (col36)'],
['key' => 'shutter_box_custom', 'name' => '셔터박스 직접입력', 'type' => 'text', 'required' => false, 'order' => 31,
'desc' => '셔터박스 직접입력 시 사이즈'],
['key' => 'front_bottom', 'name' => '전면밑', 'type' => 'number', 'required' => false, 'order' => 32,
'default' => '50', 'desc' => '전면밑 치수 (mm)'],
['key' => 'rail_width', 'name' => '레일폭', 'type' => 'number', 'required' => false, 'order' => 33,
'default' => '70', 'desc' => '레일 폭 치수 (mm)'],
['key' => 'box_direction', 'name' => '박스방향', 'type' => 'select', 'required' => false, 'order' => 34,
'options' => ['양면', '밑면', '후면'],
'default' => '양면', 'desc' => '셔터박스 설치 방향'],
// 모터 관련
['key' => 'motor_bracket_size', 'name' => '모터브라켓 사이즈', 'type' => 'text', 'required' => false, 'order' => 40,
'desc' => '중량+인치 기반 자동계산 브라켓 사이즈'],
['key' => 'motor_capacity', 'name' => '모터 용량', 'type' => 'text', 'required' => false, 'order' => 41,
'desc' => '계산된 모터 용량'],
['key' => 'shaft_inch', 'name' => '샤프트 인치', 'type' => 'select', 'required' => false, 'order' => 42,
'options' => ['4', '5', '6', '8'],
'desc' => '샤프트 사이즈 (인치)'],
// 마구리 관련
['key' => 'maguri_length', 'name' => '마구리 길이', 'type' => 'number', 'required' => false, 'order' => 50,
'desc' => '마구리 길이 치수 (col45)'],
['key' => 'maguri_wing', 'name' => '마구리 윙', 'type' => 'number', 'required' => false, 'order' => 51,
'desc' => '마구리 윙 길이 치수'],
// 계산 결과
['key' => 'calculated_weight', 'name' => '계산 중량', 'type' => 'number', 'required' => false, 'order' => 60,
'desc' => '자동 계산된 중량 (kg)'],
['key' => 'calculated_area', 'name' => '계산 면적', 'type' => 'number', 'required' => false, 'order' => 61,
'desc' => '자동 계산된 면적 (㎡)'],
['key' => 'unit_price', 'name' => '단가', 'type' => 'number', 'required' => false, 'order' => 70,
'desc' => '제품 단가 (원)'],
['key' => 'total_price', 'name' => '금액', 'type' => 'number', 'required' => false, 'order' => 71,
'desc' => '총 금액 (원)'],
];
// 5. 철재 카테고리의 동적 필드들
$steelFields = [
// 기본 정보
['key' => 'model_name', 'name' => '모델명', 'type' => 'select', 'required' => true, 'order' => 1,
'options' => ['KQTS01', 'KTE01', '철재비인정'],
'desc' => '철재 제품 모델 선택'],
['key' => 'sequence', 'name' => '순번', 'type' => 'text', 'required' => false, 'order' => 2,
'desc' => '견적 항목 순번'],
['key' => 'product_category', 'name' => '대분류', 'type' => 'text', 'required' => true, 'order' => 3,
'default' => '철재', 'desc' => '제품 대분류'],
// 사이즈 관련 (철재는 다른 계산식)
['key' => 'open_width', 'name' => '오픈사이즈 가로(mm)', 'type' => 'number', 'required' => true, 'order' => 10,
'desc' => '개구부 가로 사이즈 W0'],
['key' => 'open_height', 'name' => '오픈사이즈 세로(mm)', 'type' => 'number', 'required' => true, 'order' => 11,
'desc' => '개구부 세로 사이즈 H0'],
['key' => 'make_width', 'name' => '제작사이즈 가로(mm)', 'type' => 'number', 'required' => false, 'order' => 12,
'desc' => '제작 가로 사이즈 W1 (W0+110)'],
['key' => 'make_height', 'name' => '제작사이즈 세로(mm)', 'type' => 'number', 'required' => false, 'order' => 13,
'desc' => '제작 세로 사이즈 H1 (H0+350)'],
['key' => 'quantity', 'name' => '수량', 'type' => 'number', 'required' => true, 'order' => 14,
'default' => '1', 'desc' => '제품 수량'],
// 철재 특화 필드
['key' => 'slat_thickness', 'name' => '스라트 두께', 'type' => 'select', 'required' => true, 'order' => 20,
'options' => ['0.8mm', '1.0mm', '1.2mm', '1.5mm', '2.0mm'],
'desc' => '철재 스라트 두께'],
['key' => 'bending_work', 'name' => '절곡 가공', 'type' => 'checkbox', 'required' => false, 'order' => 21,
'desc' => '절곡 가공 여부'],
['key' => 'welding_work', 'name' => '용접 가공', 'type' => 'checkbox', 'required' => false, 'order' => 22,
'desc' => '용접 가공 여부'],
// 환봉, 각파이프 등
['key' => 'round_bar_quantity', 'name' => '환봉 수량', 'type' => 'number', 'required' => false, 'order' => 30,
'desc' => '환봉 필요 수량 (자동계산)'],
['key' => 'square_pipe', 'name' => '각파이프', 'type' => 'text', 'required' => false, 'order' => 31,
'desc' => '각파이프 사양'],
// 모터 관련 (철재용)
['key' => 'motor_bracket_size', 'name' => '모터브라켓 사이즈', 'type' => 'text', 'required' => false, 'order' => 40,
'desc' => '중량+인치 기반 브라켓 사이즈'],
['key' => 'shaft_inch', 'name' => '샤프트 인치', 'type' => 'select', 'required' => false, 'order' => 41,
'options' => ['4', '5', '6', '8'],
'desc' => '샤프트 사이즈 (인치)'],
// 계산 결과
['key' => 'calculated_weight', 'name' => '계산 중량', 'type' => 'number', 'required' => false, 'order' => 60,
'desc' => '자동 계산된 중량 (kg)'],
['key' => 'unit_price', 'name' => '단가', 'type' => 'number', 'required' => false, 'order' => 70,
'desc' => '제품 단가 (원)'],
['key' => 'total_price', 'name' => '금액', 'type' => 'number', 'required' => false, 'order' => 71,
'desc' => '총 금액 (원)'],
];
// 6. 스크린 카테고리 필드 생성
foreach ($screenFields as $field) {
DB::table('category_fields')->insert([
'tenant_id' => 1,
'category_id' => $screenCategoryId,
'field_key' => $field['key'],
'field_name' => $field['name'],
'field_type' => $field['type'],
'is_required' => $field['required'],
'sort_order' => $field['order'],
'default_value' => $field['default'] ?? null,
'options' => isset($field['options']) ? json_encode($field['options']) : null,
'description' => $field['desc'],
'created_at' => now(),
'updated_at' => now(),
]);
}
// 7. 철재 카테고리 필드 생성
foreach ($steelFields as $field) {
DB::table('category_fields')->insert([
'tenant_id' => 1,
'category_id' => $steelCategoryId,
'field_key' => $field['key'],
'field_name' => $field['name'],
'field_type' => $field['type'],
'is_required' => $field['required'],
'sort_order' => $field['order'],
'default_value' => $field['default'] ?? null,
'options' => isset($field['options']) ? json_encode($field['options']) : null,
'description' => $field['desc'],
'created_at' => now(),
'updated_at' => now(),
]);
}
}
public function down(): void
{
// 생성된 견적 관련 카테고리와 필드들 삭제
$categoryIds = DB::table('categories')
->where('tenant_id', 1)
->where('code_group', 'estimate')
->pluck('id');
DB::table('category_fields')->whereIn('category_id', $categoryIds)->delete();
DB::table('categories')->whereIn('id', $categoryIds)->delete();
}
};

View File

@@ -0,0 +1,103 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('estimates', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
$table->unsignedBigInteger('model_set_id')->comment('모델셋(카테고리) ID');
$table->string('estimate_no', 50)->comment('견적번호 (자동생성)');
$table->string('estimate_name')->comment('견적명');
$table->string('customer_name')->nullable()->comment('고객명');
$table->string('project_name')->nullable()->comment('프로젝트명');
// 견적 파라미터 (사용자 입력값들)
$table->json('parameters')->comment('견적 파라미터 (W0, H0, 수량 등)');
// 계산 결과 (W1, H1, 중량, 면적 등)
$table->json('calculated_results')->nullable()->comment('계산 결과값들');
// BOM 데이터 (계산된 BOM 정보)
$table->json('bom_data')->nullable()->comment('BOM 계산 결과');
$table->decimal('total_amount', 15, 2)->nullable()->comment('총 견적금액');
$table->enum('status', ['DRAFT', 'SENT', 'APPROVED', 'REJECTED', 'EXPIRED'])
->default('DRAFT')->comment('견적 상태');
$table->text('notes')->nullable()->comment('비고');
$table->date('valid_until')->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', 'status']);
$table->index(['tenant_id', 'created_at']);
$table->index(['tenant_id', 'model_set_id']);
$table->unique(['tenant_id', 'estimate_no']);
// 외래키
$table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('cascade');
$table->foreign('model_set_id')->references('id')->on('categories')->onDelete('restrict');
});
Schema::create('estimate_items', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
$table->unsignedBigInteger('estimate_id')->comment('견적 ID');
$table->integer('sequence')->default(1)->comment('항목 순번');
$table->string('item_name')->comment('항목명');
$table->text('item_description')->nullable()->comment('항목 설명');
// 항목별 파라미터 (개별 제품 파라미터)
$table->json('parameters')->comment('항목별 파라미터');
// 항목별 계산 결과
$table->json('calculated_values')->nullable()->comment('항목별 계산값');
$table->decimal('unit_price', 12, 2)->default(0)->comment('단가');
$table->decimal('quantity', 8, 2)->default(1)->comment('수량');
$table->decimal('total_price', 15, 2)->default(0)->comment('총 가격 (단가 × 수량)');
// BOM 구성품 정보
$table->json('bom_components')->nullable()->comment('BOM 구성품 목록');
$table->text('notes')->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', 'estimate_id']);
$table->index(['estimate_id', 'sequence']);
// 외래키
$table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('cascade');
$table->foreign('estimate_id')->references('id')->on('estimates')->onDelete('cascade');
});
}
public function down(): void
{
Schema::dropIfExists('estimate_items');
Schema::dropIfExists('estimates');
}
};

View File

@@ -28,4 +28,20 @@
// 서버 오류
'server_error' => '서버 처리 중 오류가 발생했습니다.', // 5xx 일반
// 견적 관련 에러
'estimate' => [
'cannot_delete_sent_or_approved' => '발송되었거나 승인된 견적은 삭제할 수 없습니다.',
'invalid_status_transition' => '현재 상태에서는 변경할 수 없습니다.',
],
// BOM 템플릿 관련
'bom_template' => [
'not_found' => '적용 가능한 BOM 템플릿을 찾을 수 없습니다.',
],
// 모델셋 관련
'modelset' => [
'has_dependencies' => '연관된 제품 또는 하위 카테고리가 있어 삭제할 수 없습니다.',
],
];

View File

@@ -55,4 +55,17 @@
'template_cloned' => 'BOM 템플릿이 복제되었습니다.',
'template_diff' => 'BOM 템플릿 차이를 계산했습니다.',
],
'model_set' => [
'cloned' => '모델셋이 복제되었습니다.',
'calculated' => 'BOM 계산이 완료되었습니다.',
],
'estimate' => [
'cloned' => '견적이 복제되었습니다.',
'status_changed' => '견적 상태가 변경되었습니다.',
],
// 계산 관련
'calculated' => '계산 완료',
];

View File

@@ -37,6 +37,10 @@
use App\Http\Controllers\Api\V1\Design\AuditLogController as DesignAuditLogController;
use App\Http\Controllers\Api\V1\Design\BomCalculationController;
// 모델셋 관리 (견적 시스템)
use App\Http\Controllers\Api\V1\ModelSetController;
use App\Http\Controllers\Api\V1\EstimateController;
// error test
Route::get('/test-error', function () {
throw new \Exception('슬랙 전송 테스트 예외');
@@ -370,6 +374,37 @@
Route::post ('/formulas/test', [BomCalculationController::class, 'testFormula'])->name('v1.design.formulas.test');
});
// 모델셋 관리 API (견적 시스템)
Route::prefix('model-sets')->group(function () {
Route::get ('/', [ModelSetController::class, 'index'])->name('v1.model-sets.index'); // 모델셋 목록
Route::post ('/', [ModelSetController::class, 'store'])->name('v1.model-sets.store'); // 모델셋 생성
Route::get ('/{id}', [ModelSetController::class, 'show'])->name('v1.model-sets.show'); // 모델셋 상세
Route::put ('/{id}', [ModelSetController::class, 'update'])->name('v1.model-sets.update'); // 모델셋 수정
Route::delete('/{id}', [ModelSetController::class, 'destroy'])->name('v1.model-sets.destroy'); // 모델셋 삭제
Route::post ('/{id}/clone', [ModelSetController::class, 'clone'])->name('v1.model-sets.clone'); // 모델셋 복제
// 모델셋 세부 기능
Route::get ('/{id}/fields', [ModelSetController::class, 'getCategoryFields'])->name('v1.model-sets.fields'); // 카테고리 필드 조회
Route::get ('/{id}/bom-templates', [ModelSetController::class, 'getBomTemplates'])->name('v1.model-sets.bom-templates'); // BOM 템플릿 조회
Route::get ('/{id}/estimate-parameters', [ModelSetController::class, 'getEstimateParameters'])->name('v1.model-sets.estimate-parameters'); // 견적 파라미터
Route::post ('/{id}/calculate-bom', [ModelSetController::class, 'calculateBom'])->name('v1.model-sets.calculate-bom'); // BOM 계산
});
// 견적 관리 API
Route::prefix('estimates')->group(function () {
Route::get ('/', [EstimateController::class, 'index'])->name('v1.estimates.index'); // 견적 목록
Route::post ('/', [EstimateController::class, 'store'])->name('v1.estimates.store'); // 견적 생성
Route::get ('/{id}', [EstimateController::class, 'show'])->name('v1.estimates.show'); // 견적 상세
Route::put ('/{id}', [EstimateController::class, 'update'])->name('v1.estimates.update'); // 견적 수정
Route::delete('/{id}', [EstimateController::class, 'destroy'])->name('v1.estimates.destroy'); // 견적 삭제
Route::post ('/{id}/clone', [EstimateController::class, 'clone'])->name('v1.estimates.clone'); // 견적 복제
Route::put ('/{id}/status', [EstimateController::class, 'changeStatus'])->name('v1.estimates.status'); // 견적 상태 변경
// 견적 폼 및 계산 기능
Route::get ('/form-schema/{model_set_id}', [EstimateController::class, 'getFormSchema'])->name('v1.estimates.form-schema'); // 견적 폼 스키마
Route::post ('/preview/{model_set_id}', [EstimateController::class, 'previewCalculation'])->name('v1.estimates.preview'); // 견적 계산 미리보기
});
});
});