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:
269
app/Http/Controllers/Api/V1/EstimateController.php
Normal file
269
app/Http/Controllers/Api/V1/EstimateController.php
Normal 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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
137
app/Http/Controllers/Api/V1/ModelSetController.php
Normal file
137
app/Http/Controllers/Api/V1/ModelSetController.php
Normal 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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/Http/Requests/Estimate/CreateEstimateRequest.php
Normal file
38
app/Http/Requests/Estimate/CreateEstimateRequest.php
Normal 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' => '견적 파라미터']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/Http/Requests/Estimate/UpdateEstimateRequest.php
Normal file
36
app/Http/Requests/Estimate/UpdateEstimateRequest.php
Normal 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' => '상태']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
108
app/Models/Estimate/Estimate.php
Normal file
108
app/Models/Estimate/Estimate.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
81
app/Models/Estimate/EstimateItem.php
Normal file
81
app/Models/Estimate/EstimateItem.php
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,6 +102,11 @@ protected function executePreDefinedFunction(string $formula, array $variables):
|
|||||||
return ['result' => 5]; // 최대값
|
return ['result' => 5]; // 최대값
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5130 시스템 브라켓 사이즈 계산 (중량+인치 기반)
|
||||||
|
if ($formula === 'motor_bracket_size') {
|
||||||
|
return $this->calculateMotorBracketSize($variables);
|
||||||
|
}
|
||||||
|
|
||||||
// 환봉 수량 계산
|
// 환봉 수량 계산
|
||||||
if ($formula === 'round_bar_quantity') {
|
if ($formula === 'round_bar_quantity') {
|
||||||
$W1 = $variables['W1'] ?? 0;
|
$W1 = $variables['W1'] ?? 0;
|
||||||
@@ -220,6 +225,89 @@ protected function evaluateCondition(string $condition, array $variables): bool
|
|||||||
return eval("return {$expression};");
|
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',
|
'kyungdong_steel_size',
|
||||||
'screen_weight_calculation',
|
'screen_weight_calculation',
|
||||||
'bracket_quantity',
|
'bracket_quantity',
|
||||||
|
'motor_bracket_size',
|
||||||
'round_bar_quantity',
|
'round_bar_quantity',
|
||||||
'shaft_size_determination',
|
'shaft_size_determination',
|
||||||
'motor_capacity_determination'
|
'motor_capacity_determination'
|
||||||
|
|||||||
346
app/Services/Estimate/EstimateService.php
Normal file
346
app/Services/Estimate/EstimateService.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
488
app/Services/ModelSet/ModelSetService.php
Normal file
488
app/Services/ModelSet/ModelSetService.php
Normal 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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
};
|
||||||
103
database/migrations/2025_09_24_000003_create_estimates_table.php
Normal file
103
database/migrations/2025_09_24_000003_create_estimates_table.php
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -28,4 +28,20 @@
|
|||||||
|
|
||||||
// 서버 오류
|
// 서버 오류
|
||||||
'server_error' => '서버 처리 중 오류가 발생했습니다.', // 5xx 일반
|
'server_error' => '서버 처리 중 오류가 발생했습니다.', // 5xx 일반
|
||||||
|
|
||||||
|
// 견적 관련 에러
|
||||||
|
'estimate' => [
|
||||||
|
'cannot_delete_sent_or_approved' => '발송되었거나 승인된 견적은 삭제할 수 없습니다.',
|
||||||
|
'invalid_status_transition' => '현재 상태에서는 변경할 수 없습니다.',
|
||||||
|
],
|
||||||
|
|
||||||
|
// BOM 템플릿 관련
|
||||||
|
'bom_template' => [
|
||||||
|
'not_found' => '적용 가능한 BOM 템플릿을 찾을 수 없습니다.',
|
||||||
|
],
|
||||||
|
|
||||||
|
// 모델셋 관련
|
||||||
|
'modelset' => [
|
||||||
|
'has_dependencies' => '연관된 제품 또는 하위 카테고리가 있어 삭제할 수 없습니다.',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -55,4 +55,17 @@
|
|||||||
'template_cloned' => 'BOM 템플릿이 복제되었습니다.',
|
'template_cloned' => 'BOM 템플릿이 복제되었습니다.',
|
||||||
'template_diff' => 'BOM 템플릿 차이를 계산했습니다.',
|
'template_diff' => 'BOM 템플릿 차이를 계산했습니다.',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'model_set' => [
|
||||||
|
'cloned' => '모델셋이 복제되었습니다.',
|
||||||
|
'calculated' => 'BOM 계산이 완료되었습니다.',
|
||||||
|
],
|
||||||
|
|
||||||
|
'estimate' => [
|
||||||
|
'cloned' => '견적이 복제되었습니다.',
|
||||||
|
'status_changed' => '견적 상태가 변경되었습니다.',
|
||||||
|
],
|
||||||
|
|
||||||
|
// 계산 관련
|
||||||
|
'calculated' => '계산 완료',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -37,6 +37,10 @@
|
|||||||
use App\Http\Controllers\Api\V1\Design\AuditLogController as DesignAuditLogController;
|
use App\Http\Controllers\Api\V1\Design\AuditLogController as DesignAuditLogController;
|
||||||
use App\Http\Controllers\Api\V1\Design\BomCalculationController;
|
use App\Http\Controllers\Api\V1\Design\BomCalculationController;
|
||||||
|
|
||||||
|
// 모델셋 관리 (견적 시스템)
|
||||||
|
use App\Http\Controllers\Api\V1\ModelSetController;
|
||||||
|
use App\Http\Controllers\Api\V1\EstimateController;
|
||||||
|
|
||||||
// error test
|
// error test
|
||||||
Route::get('/test-error', function () {
|
Route::get('/test-error', function () {
|
||||||
throw new \Exception('슬랙 전송 테스트 예외');
|
throw new \Exception('슬랙 전송 테스트 예외');
|
||||||
@@ -370,6 +374,37 @@
|
|||||||
Route::post ('/formulas/test', [BomCalculationController::class, 'testFormula'])->name('v1.design.formulas.test');
|
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'); // 견적 계산 미리보기
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user