feat: DB 연결 오버라이딩 및 대시보드 통계 위젯 추가

- DB 연결: 로컬/Docker 환경 오버라이딩 설정 (.env)
- 테넌트 위젯: redirect 버그 수정 (TenantSelectorWidget)
- 통계 위젯: 사용자/제품/자재/주문 카드 추가 (StatsOverviewWidget)
- 리소스 한국어화: Product, Material 모델 레이블 추가
- 대시보드: 위젯 등록 및 캐시 최적화

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-30 23:31:14 +09:00
parent d94ab59fd1
commit bf8036a64b
81 changed files with 22632 additions and 102 deletions

View File

@@ -0,0 +1,335 @@
<?php
namespace App\Http\Controllers\Api\V1\Design;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\BomConditionRuleService;
use App\Http\Requests\Api\V1\Design\BomConditionRuleFormRequest;
use App\Http\Requests\Api\V1\BomConditionRule\IndexBomConditionRuleRequest;
/**
* @OA\Tag(name="BOM Condition Rules", description="BOM condition rule management APIs")
*/
class BomConditionRuleController extends Controller
{
public function __construct(
protected BomConditionRuleService $service
) {}
/**
* @OA\Get(
* path="/v1/design/models/{modelId}/condition-rules",
* summary="Get BOM condition rules",
* description="Retrieve all condition rules for a specific model",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
* tags={"BOM Condition Rules"},
* @OA\Parameter(
* name="modelId",
* description="Model ID",
* in="path",
* required=true,
* @OA\Schema(type="integer", example=1)
* ),
* @OA\Parameter(
* name="page",
* description="Page number for pagination",
* in="query",
* required=false,
* @OA\Schema(type="integer", example=1)
* ),
* @OA\Parameter(
* name="per_page",
* description="Items per page",
* in="query",
* required=false,
* @OA\Schema(type="integer", example=20)
* ),
* @OA\Parameter(
* name="search",
* description="Search by rule name or condition",
* in="query",
* required=false,
* @OA\Schema(type="string", example="bracket_selection")
* ),
* @OA\Parameter(
* name="priority",
* description="Filter by priority level",
* in="query",
* required=false,
* @OA\Schema(type="integer", example=1)
* ),
* @OA\Response(
* response=200,
* description="BOM condition rules retrieved successfully",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
* @OA\Property(
* property="data",
* @OA\Property(
* property="data",
* type="array",
* @OA\Items(ref="#/components/schemas/BomConditionRuleResource")
* ),
* @OA\Property(property="current_page", type="integer", example=1),
* @OA\Property(property="last_page", type="integer", example=2),
* @OA\Property(property="per_page", type="integer", example=20),
* @OA\Property(property="total", type="integer", example=30)
* )
* )
* }
* )
* ),
* @OA\Response(ref="#/components/responses/ErrorResponse", response=400),
* @OA\Response(ref="#/components/responses/UnauthorizedResponse", response=401),
* @OA\Response(ref="#/components/responses/NotFoundResponse", response=404)
* )
*/
public function index(IndexBomConditionRuleRequest $request, int $modelId)
{
return ApiResponse::handle(function () use ($request, $modelId) {
return $this->service->getModelConditionRules($modelId, $request->validated());
}, __('message.fetched'));
}
/**
* @OA\Post(
* path="/v1/design/models/{modelId}/condition-rules",
* summary="Create BOM condition rule",
* description="Create a new condition rule for a specific model",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
* tags={"BOM Condition Rules"},
* @OA\Parameter(
* name="modelId",
* description="Model ID",
* in="path",
* required=true,
* @OA\Schema(type="integer", example=1)
* ),
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(ref="#/components/schemas/CreateBomConditionRuleRequest")
* ),
* @OA\Response(
* response=201,
* description="BOM condition rule created successfully",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
* @OA\Property(property="data", ref="#/components/schemas/BomConditionRuleResource")
* )
* }
* )
* ),
* @OA\Response(ref="#/components/responses/ValidationErrorResponse", response=422),
* @OA\Response(ref="#/components/responses/ErrorResponse", response=400),
* @OA\Response(ref="#/components/responses/UnauthorizedResponse", response=401)
* )
*/
public function store(BomConditionRuleFormRequest $request, int $modelId)
{
return ApiResponse::handle(function () use ($request, $modelId) {
return $this->service->createConditionRule($modelId, $request->validated());
}, __('message.created'));
}
/**
* @OA\Put(
* path="/v1/design/models/{modelId}/condition-rules/{ruleId}",
* summary="Update BOM condition rule",
* description="Update a specific BOM condition rule",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
* tags={"BOM Condition Rules"},
* @OA\Parameter(
* name="modelId",
* description="Model ID",
* in="path",
* required=true,
* @OA\Schema(type="integer", example=1)
* ),
* @OA\Parameter(
* name="ruleId",
* description="Rule ID",
* in="path",
* required=true,
* @OA\Schema(type="integer", example=1)
* ),
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(ref="#/components/schemas/UpdateBomConditionRuleRequest")
* ),
* @OA\Response(
* response=200,
* description="BOM condition rule updated successfully",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
* @OA\Property(property="data", ref="#/components/schemas/BomConditionRuleResource")
* )
* }
* )
* ),
* @OA\Response(ref="#/components/responses/ValidationErrorResponse", response=422),
* @OA\Response(ref="#/components/responses/ErrorResponse", response=400),
* @OA\Response(ref="#/components/responses/UnauthorizedResponse", response=401),
* @OA\Response(ref="#/components/responses/NotFoundResponse", response=404)
* )
*/
public function update(BomConditionRuleFormRequest $request, int $modelId, int $ruleId)
{
return ApiResponse::handle(function () use ($request, $modelId, $ruleId) {
return $this->service->updateConditionRule($modelId, $ruleId, $request->validated());
}, __('message.updated'));
}
/**
* @OA\Delete(
* path="/v1/design/models/{modelId}/condition-rules/{ruleId}",
* summary="Delete BOM condition rule",
* description="Delete a specific BOM condition rule",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
* tags={"BOM Condition Rules"},
* @OA\Parameter(
* name="modelId",
* description="Model ID",
* in="path",
* required=true,
* @OA\Schema(type="integer", example=1)
* ),
* @OA\Parameter(
* name="ruleId",
* description="Rule ID",
* in="path",
* required=true,
* @OA\Schema(type="integer", example=1)
* ),
* @OA\Response(
* response=200,
* description="BOM condition rule deleted successfully",
* @OA\JsonContent(ref="#/components/schemas/ApiResponse")
* ),
* @OA\Response(ref="#/components/responses/ErrorResponse", response=400),
* @OA\Response(ref="#/components/responses/UnauthorizedResponse", response=401),
* @OA\Response(ref="#/components/responses/NotFoundResponse", response=404)
* )
*/
public function destroy(int $modelId, int $ruleId)
{
return ApiResponse::handle(function () use ($modelId, $ruleId) {
$this->service->deleteConditionRule($modelId, $ruleId);
return null;
}, __('message.deleted'));
}
/**
* @OA\Post(
* path="/v1/design/models/{modelId}/condition-rules/{ruleId}/validate",
* summary="Validate BOM condition rule",
* description="Validate condition rule expression and logic",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
* tags={"BOM Condition Rules"},
* @OA\Parameter(
* name="modelId",
* description="Model ID",
* in="path",
* required=true,
* @OA\Schema(type="integer", example=1)
* ),
* @OA\Parameter(
* name="ruleId",
* description="Rule ID",
* in="path",
* required=true,
* @OA\Schema(type="integer", example=1)
* ),
* @OA\Response(
* response=200,
* description="Condition rule validation result",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
* @OA\Property(
* property="data",
* @OA\Property(property="is_valid", type="boolean", example=true),
* @OA\Property(
* property="validation_errors",
* type="array",
* @OA\Items(type="string", example="Invalid condition syntax")
* ),
* @OA\Property(
* property="tested_scenarios",
* type="array",
* @OA\Items(
* @OA\Property(property="scenario", type="string", example="W0=1000, H0=800"),
* @OA\Property(property="result", type="boolean", example=true)
* )
* )
* )
* )
* }
* )
* ),
* @OA\Response(ref="#/components/responses/ErrorResponse", response=400),
* @OA\Response(ref="#/components/responses/UnauthorizedResponse", response=401),
* @OA\Response(ref="#/components/responses/NotFoundResponse", response=404)
* )
*/
public function validate(int $modelId, int $ruleId)
{
return ApiResponse::handle(function () use ($modelId, $ruleId) {
return $this->service->validateConditionRule($modelId, $ruleId);
}, __('message.condition_rule.validated'));
}
/**
* @OA\Post(
* path="/v1/design/models/{modelId}/condition-rules/reorder",
* summary="Reorder BOM condition rules",
* description="Change the priority order of condition rules",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
* tags={"BOM Condition Rules"},
* @OA\Parameter(
* name="modelId",
* description="Model ID",
* in="path",
* required=true,
* @OA\Schema(type="integer", example=1)
* ),
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* @OA\Property(
* property="rule_orders",
* type="array",
* @OA\Items(
* @OA\Property(property="rule_id", type="integer", example=1),
* @OA\Property(property="priority", type="integer", example=1)
* )
* )
* )
* ),
* @OA\Response(
* response=200,
* description="BOM condition rules reordered successfully",
* @OA\JsonContent(ref="#/components/schemas/ApiResponse")
* ),
* @OA\Response(ref="#/components/responses/ValidationErrorResponse", response=422),
* @OA\Response(ref="#/components/responses/ErrorResponse", response=400),
* @OA\Response(ref="#/components/responses/UnauthorizedResponse", response=401),
* @OA\Response(ref="#/components/responses/NotFoundResponse", response=404)
* )
*/
public function reorder(int $modelId)
{
return ApiResponse::handle(function () use ($modelId) {
$ruleOrders = request()->input('rule_orders', []);
$this->service->reorderConditionRules($modelId, $ruleOrders);
return null;
}, __('message.reordered'));
}
}

View File

@@ -0,0 +1,281 @@
<?php
namespace App\Http\Controllers\Api\V1\Design;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\BomResolverService;
use App\Http\Requests\Api\V1\Design\BomResolverFormRequest;
/**
* @OA\Tag(name="BOM Resolver", description="BOM resolution and preview APIs")
*/
class BomResolverController extends Controller
{
public function __construct(
protected BomResolverService $service
) {}
/**
* @OA\Post(
* path="/v1/design/models/{modelId}/resolve-bom",
* summary="Resolve BOM preview",
* description="Generate real-time BOM preview based on input parameters without creating actual products",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
* tags={"BOM Resolver"},
* @OA\Parameter(
* name="modelId",
* description="Model ID",
* in="path",
* required=true,
* @OA\Schema(type="integer", example=1)
* ),
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(ref="#/components/schemas/ResolvePreviewRequest")
* ),
* @OA\Response(
* response=200,
* description="BOM preview generated successfully",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
* @OA\Property(property="data", ref="#/components/schemas/BomPreviewResponse")
* )
* }
* )
* ),
* @OA\Response(ref="#/components/responses/ValidationErrorResponse", response=422),
* @OA\Response(ref="#/components/responses/ErrorResponse", response=400),
* @OA\Response(ref="#/components/responses/UnauthorizedResponse", response=401),
* @OA\Response(ref="#/components/responses/NotFoundResponse", response=404)
* )
*/
public function resolveBom(BomResolverFormRequest $request, int $modelId)
{
return ApiResponse::handle(function () use ($request, $modelId) {
return $this->service->generatePreview($modelId, $request->validated());
}, __('message.bom.preview_generated'));
}
/**
* @OA\Post(
* path="/v1/design/models/{modelId}/validate-parameters",
* summary="Validate model parameters",
* description="Validate input parameters against model constraints before BOM resolution",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
* tags={"BOM Resolver"},
* @OA\Parameter(
* name="modelId",
* description="Model ID",
* in="path",
* required=true,
* @OA\Schema(type="integer", example=1)
* ),
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* @OA\Property(
* property="input_parameters",
* description="Input parameter values to validate",
* type="object",
* additionalProperties=@OA\Property(oneOf={
* @OA\Schema(type="number"),
* @OA\Schema(type="string"),
* @OA\Schema(type="boolean")
* }),
* example={"W0": 1000, "H0": 800, "installation_type": "A"}
* )
* )
* ),
* @OA\Response(
* response=200,
* description="Parameter validation result",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
* @OA\Property(
* property="data",
* @OA\Property(property="is_valid", type="boolean", example=true),
* @OA\Property(
* property="validation_errors",
* type="array",
* @OA\Items(
* @OA\Property(property="parameter", type="string", example="W0"),
* @OA\Property(property="error", type="string", example="Value must be between 500 and 2000")
* )
* ),
* @OA\Property(
* property="warnings",
* type="array",
* @OA\Items(
* @OA\Property(property="parameter", type="string", example="H0"),
* @OA\Property(property="warning", type="string", example="Recommended range is 600-1500")
* )
* )
* )
* )
* }
* )
* ),
* @OA\Response(ref="#/components/responses/ValidationErrorResponse", response=422),
* @OA\Response(ref="#/components/responses/ErrorResponse", response=400),
* @OA\Response(ref="#/components/responses/UnauthorizedResponse", response=401),
* @OA\Response(ref="#/components/responses/NotFoundResponse", response=404)
* )
*/
public function validateParameters(int $modelId)
{
return ApiResponse::handle(function () use ($modelId) {
$inputParameters = request()->input('input_parameters', []);
return $this->service->validateParameters($modelId, $inputParameters);
}, __('message.parameters.validated'));
}
/**
* @OA\Get(
* path="/v1/design/models/{modelId}/parameter-schema",
* summary="Get model parameter schema",
* description="Retrieve the input parameter schema for a specific model",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
* tags={"BOM Resolver"},
* @OA\Parameter(
* name="modelId",
* description="Model ID",
* in="path",
* required=true,
* @OA\Schema(type="integer", example=1)
* ),
* @OA\Response(
* response=200,
* description="Model parameter schema retrieved successfully",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
* @OA\Property(
* property="data",
* @OA\Property(
* property="input_parameters",
* type="array",
* @OA\Items(
* @OA\Property(property="name", type="string", example="W0"),
* @OA\Property(property="label", type="string", example="Width"),
* @OA\Property(property="data_type", type="string", example="INTEGER"),
* @OA\Property(property="unit", type="string", example="mm"),
* @OA\Property(property="min_value", type="number", example=500),
* @OA\Property(property="max_value", type="number", example=3000),
* @OA\Property(property="default_value", type="string", example="1000"),
* @OA\Property(property="is_required", type="boolean", example=true),
* @OA\Property(property="description", type="string", example="Product width in millimeters")
* )
* ),
* @OA\Property(
* property="output_parameters",
* type="array",
* @OA\Items(
* @OA\Property(property="name", type="string", example="W1"),
* @OA\Property(property="label", type="string", example="Actual Width"),
* @OA\Property(property="data_type", type="string", example="INTEGER"),
* @OA\Property(property="unit", type="string", example="mm"),
* @OA\Property(property="description", type="string", example="Calculated actual width")
* )
* )
* )
* )
* }
* )
* ),
* @OA\Response(ref="#/components/responses/ErrorResponse", response=400),
* @OA\Response(ref="#/components/responses/UnauthorizedResponse", response=401),
* @OA\Response(ref="#/components/responses/NotFoundResponse", response=404)
* )
*/
public function getParameterSchema(int $modelId)
{
return ApiResponse::handle(function () use ($modelId) {
return $this->service->getParameterSchema($modelId);
}, __('message.fetched'));
}
/**
* @OA\Post(
* path="/v1/design/models/{modelId}/calculate-preview",
* summary="Calculate output values preview",
* description="Calculate output parameter values based on input parameters using formulas",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
* tags={"BOM Resolver"},
* @OA\Parameter(
* name="modelId",
* description="Model ID",
* in="path",
* required=true,
* @OA\Schema(type="integer", example=1)
* ),
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* @OA\Property(
* property="input_parameters",
* description="Input parameter values",
* type="object",
* additionalProperties=@OA\Property(oneOf={
* @OA\Schema(type="number"),
* @OA\Schema(type="string"),
* @OA\Schema(type="boolean")
* }),
* example={"W0": 1000, "H0": 800, "installation_type": "A"}
* )
* )
* ),
* @OA\Response(
* response=200,
* description="Output values calculated successfully",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
* @OA\Property(
* property="data",
* @OA\Property(
* property="calculated_values",
* type="object",
* additionalProperties=@OA\Property(oneOf={
* @OA\Schema(type="number"),
* @OA\Schema(type="string")
* }),
* example={"W1": 1050, "H1": 850, "area": 892500, "weight": 45.5}
* ),
* @OA\Property(
* property="calculation_steps",
* type="array",
* @OA\Items(
* @OA\Property(property="parameter", type="string", example="W1"),
* @OA\Property(property="formula", type="string", example="W0 + 50"),
* @OA\Property(property="result", oneOf={
* @OA\Schema(type="number"),
* @OA\Schema(type="string")
* }, example=1050)
* )
* )
* )
* )
* }
* )
* ),
* @OA\Response(ref="#/components/responses/ValidationErrorResponse", response=422),
* @OA\Response(ref="#/components/responses/ErrorResponse", response=400),
* @OA\Response(ref="#/components/responses/UnauthorizedResponse", response=401),
* @OA\Response(ref="#/components/responses/NotFoundResponse", response=404)
* )
*/
public function calculatePreview(int $modelId)
{
return ApiResponse::handle(function () use ($modelId) {
$inputParameters = request()->input('input_parameters', []);
return $this->service->calculatePreview($modelId, $inputParameters);
}, __('message.calculated'));
}
}

View File

@@ -0,0 +1,278 @@
<?php
namespace App\Http\Controllers\Api\V1\Design;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\ModelFormulaService;
use App\Http\Requests\Api\V1\Design\ModelFormulaFormRequest;
use App\Http\Requests\Api\V1\ModelFormula\IndexModelFormulaRequest;
/**
* @OA\Tag(name="Model Formulas", description="Model formula management APIs")
*/
class ModelFormulaController extends Controller
{
public function __construct(
protected ModelFormulaService $service
) {}
/**
* @OA\Get(
* path="/v1/design/models/{modelId}/formulas",
* summary="Get model formulas",
* description="Retrieve all formulas for a specific model",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
* tags={"Model Formulas"},
* @OA\Parameter(
* name="modelId",
* description="Model ID",
* in="path",
* required=true,
* @OA\Schema(type="integer", example=1)
* ),
* @OA\Parameter(
* name="page",
* description="Page number for pagination",
* in="query",
* required=false,
* @OA\Schema(type="integer", example=1)
* ),
* @OA\Parameter(
* name="per_page",
* description="Items per page",
* in="query",
* required=false,
* @OA\Schema(type="integer", example=20)
* ),
* @OA\Parameter(
* name="search",
* description="Search by formula name or target parameter",
* in="query",
* required=false,
* @OA\Schema(type="string", example="calculate_area")
* ),
* @OA\Response(
* response=200,
* description="Model formulas retrieved successfully",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
* @OA\Property(
* property="data",
* @OA\Property(
* property="data",
* type="array",
* @OA\Items(ref="#/components/schemas/ModelFormulaResource")
* ),
* @OA\Property(property="current_page", type="integer", example=1),
* @OA\Property(property="last_page", type="integer", example=2),
* @OA\Property(property="per_page", type="integer", example=20),
* @OA\Property(property="total", type="integer", example=25)
* )
* )
* }
* )
* ),
* @OA\Response(ref="#/components/responses/ErrorResponse", response=400),
* @OA\Response(ref="#/components/responses/UnauthorizedResponse", response=401),
* @OA\Response(ref="#/components/responses/NotFoundResponse", response=404)
* )
*/
public function index(IndexModelFormulaRequest $request, int $modelId)
{
return ApiResponse::handle(function () use ($request, $modelId) {
return $this->service->getModelFormulas($modelId, $request->validated());
}, __('message.fetched'));
}
/**
* @OA\Post(
* path="/v1/design/models/{modelId}/formulas",
* summary="Create model formula",
* description="Create a new formula for a specific model",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
* tags={"Model Formulas"},
* @OA\Parameter(
* name="modelId",
* description="Model ID",
* in="path",
* required=true,
* @OA\Schema(type="integer", example=1)
* ),
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(ref="#/components/schemas/CreateModelFormulaRequest")
* ),
* @OA\Response(
* response=201,
* description="Model formula created successfully",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
* @OA\Property(property="data", ref="#/components/schemas/ModelFormulaResource")
* )
* }
* )
* ),
* @OA\Response(ref="#/components/responses/ValidationErrorResponse", response=422),
* @OA\Response(ref="#/components/responses/ErrorResponse", response=400),
* @OA\Response(ref="#/components/responses/UnauthorizedResponse", response=401)
* )
*/
public function store(ModelFormulaFormRequest $request, int $modelId)
{
return ApiResponse::handle(function () use ($request, $modelId) {
return $this->service->createFormula($modelId, $request->validated());
}, __('message.created'));
}
/**
* @OA\Put(
* path="/v1/design/models/{modelId}/formulas/{formulaId}",
* summary="Update model formula",
* description="Update a specific model formula",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
* tags={"Model Formulas"},
* @OA\Parameter(
* name="modelId",
* description="Model ID",
* in="path",
* required=true,
* @OA\Schema(type="integer", example=1)
* ),
* @OA\Parameter(
* name="formulaId",
* description="Formula ID",
* in="path",
* required=true,
* @OA\Schema(type="integer", example=1)
* ),
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(ref="#/components/schemas/UpdateModelFormulaRequest")
* ),
* @OA\Response(
* response=200,
* description="Model formula updated successfully",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
* @OA\Property(property="data", ref="#/components/schemas/ModelFormulaResource")
* )
* }
* )
* ),
* @OA\Response(ref="#/components/responses/ValidationErrorResponse", response=422),
* @OA\Response(ref="#/components/responses/ErrorResponse", response=400),
* @OA\Response(ref="#/components/responses/UnauthorizedResponse", response=401),
* @OA\Response(ref="#/components/responses/NotFoundResponse", response=404)
* )
*/
public function update(ModelFormulaFormRequest $request, int $modelId, int $formulaId)
{
return ApiResponse::handle(function () use ($request, $modelId, $formulaId) {
return $this->service->updateFormula($modelId, $formulaId, $request->validated());
}, __('message.updated'));
}
/**
* @OA\Delete(
* path="/v1/design/models/{modelId}/formulas/{formulaId}",
* summary="Delete model formula",
* description="Delete a specific model formula",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
* tags={"Model Formulas"},
* @OA\Parameter(
* name="modelId",
* description="Model ID",
* in="path",
* required=true,
* @OA\Schema(type="integer", example=1)
* ),
* @OA\Parameter(
* name="formulaId",
* description="Formula ID",
* in="path",
* required=true,
* @OA\Schema(type="integer", example=1)
* ),
* @OA\Response(
* response=200,
* description="Model formula deleted successfully",
* @OA\JsonContent(ref="#/components/schemas/ApiResponse")
* ),
* @OA\Response(ref="#/components/responses/ErrorResponse", response=400),
* @OA\Response(ref="#/components/responses/UnauthorizedResponse", response=401),
* @OA\Response(ref="#/components/responses/NotFoundResponse", response=404)
* )
*/
public function destroy(int $modelId, int $formulaId)
{
return ApiResponse::handle(function () use ($modelId, $formulaId) {
$this->service->deleteFormula($modelId, $formulaId);
return null;
}, __('message.deleted'));
}
/**
* @OA\Post(
* path="/v1/design/models/{modelId}/formulas/{formulaId}/validate",
* summary="Validate model formula",
* description="Validate formula expression and dependencies",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
* tags={"Model Formulas"},
* @OA\Parameter(
* name="modelId",
* description="Model ID",
* in="path",
* required=true,
* @OA\Schema(type="integer", example=1)
* ),
* @OA\Parameter(
* name="formulaId",
* description="Formula ID",
* in="path",
* required=true,
* @OA\Schema(type="integer", example=1)
* ),
* @OA\Response(
* response=200,
* description="Formula validation result",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
* @OA\Property(
* property="data",
* @OA\Property(property="is_valid", type="boolean", example=true),
* @OA\Property(
* property="validation_errors",
* type="array",
* @OA\Items(type="string", example="Unknown variable: unknown_var")
* ),
* @OA\Property(
* property="dependency_chain",
* type="array",
* @OA\Items(type="string", example="W0")
* )
* )
* )
* }
* )
* ),
* @OA\Response(ref="#/components/responses/ErrorResponse", response=400),
* @OA\Response(ref="#/components/responses/UnauthorizedResponse", response=401),
* @OA\Response(ref="#/components/responses/NotFoundResponse", response=404)
* )
*/
public function validate(int $modelId, int $formulaId)
{
return ApiResponse::handle(function () use ($modelId, $formulaId) {
return $this->service->validateFormula($modelId, $formulaId);
}, __('message.formula.validated'));
}
}

View File

@@ -0,0 +1,227 @@
<?php
namespace App\Http\Controllers\Api\V1\Design;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\ModelParameterService;
use App\Http\Requests\Api\V1\Design\ModelParameterFormRequest;
use App\Http\Requests\Api\V1\ModelParameter\IndexModelParameterRequest;
/**
* @OA\Tag(name="Model Parameters", description="Model parameter management APIs")
*/
class ModelParameterController extends Controller
{
public function __construct(
protected ModelParameterService $service
) {}
/**
* @OA\Get(
* path="/v1/design/models/{modelId}/parameters",
* summary="Get model parameters",
* description="Retrieve all parameters for a specific model",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
* tags={"Model Parameters"},
* @OA\Parameter(
* name="modelId",
* description="Model ID",
* in="path",
* required=true,
* @OA\Schema(type="integer", example=1)
* ),
* @OA\Parameter(
* name="page",
* description="Page number for pagination",
* in="query",
* required=false,
* @OA\Schema(type="integer", example=1)
* ),
* @OA\Parameter(
* name="per_page",
* description="Items per page",
* in="query",
* required=false,
* @OA\Schema(type="integer", example=20)
* ),
* @OA\Parameter(
* name="search",
* description="Search by parameter name or label",
* in="query",
* required=false,
* @OA\Schema(type="string", example="width")
* ),
* @OA\Parameter(
* name="type",
* description="Filter by parameter type",
* in="query",
* required=false,
* @OA\Schema(type="string", enum={"INPUT", "OUTPUT"}, example="INPUT")
* ),
* @OA\Response(
* response=200,
* description="Model parameters retrieved successfully",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
* @OA\Property(
* property="data",
* @OA\Property(
* property="data",
* type="array",
* @OA\Items(ref="#/components/schemas/ModelParameterResource")
* ),
* @OA\Property(property="current_page", type="integer", example=1),
* @OA\Property(property="last_page", type="integer", example=3),
* @OA\Property(property="per_page", type="integer", example=20),
* @OA\Property(property="total", type="integer", example=45)
* )
* )
* }
* )
* ),
* @OA\Response(ref="#/components/responses/ErrorResponse", response=400),
* @OA\Response(ref="#/components/responses/UnauthorizedResponse", response=401),
* @OA\Response(ref="#/components/responses/NotFoundResponse", response=404)
* )
*/
public function index(IndexModelParameterRequest $request, int $modelId)
{
return ApiResponse::handle(function () use ($request, $modelId) {
return $this->service->getModelParameters($modelId, $request->validated());
}, __('message.fetched'));
}
/**
* @OA\Post(
* path="/v1/design/models/{modelId}/parameters",
* summary="Create model parameter",
* description="Create a new parameter for a specific model",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
* tags={"Model Parameters"},
* @OA\Parameter(
* name="modelId",
* description="Model ID",
* in="path",
* required=true,
* @OA\Schema(type="integer", example=1)
* ),
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(ref="#/components/schemas/CreateModelParameterRequest")
* ),
* @OA\Response(
* response=201,
* description="Model parameter created successfully",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
* @OA\Property(property="data", ref="#/components/schemas/ModelParameterResource")
* )
* }
* )
* ),
* @OA\Response(ref="#/components/responses/ValidationErrorResponse", response=422),
* @OA\Response(ref="#/components/responses/ErrorResponse", response=400),
* @OA\Response(ref="#/components/responses/UnauthorizedResponse", response=401)
* )
*/
public function store(ModelParameterFormRequest $request, int $modelId)
{
return ApiResponse::handle(function () use ($request, $modelId) {
return $this->service->createParameter($modelId, $request->validated());
}, __('message.created'));
}
/**
* @OA\Put(
* path="/v1/design/models/{modelId}/parameters/{parameterId}",
* summary="Update model parameter",
* description="Update a specific model parameter",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
* tags={"Model Parameters"},
* @OA\Parameter(
* name="modelId",
* description="Model ID",
* in="path",
* required=true,
* @OA\Schema(type="integer", example=1)
* ),
* @OA\Parameter(
* name="parameterId",
* description="Parameter ID",
* in="path",
* required=true,
* @OA\Schema(type="integer", example=1)
* ),
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(ref="#/components/schemas/UpdateModelParameterRequest")
* ),
* @OA\Response(
* response=200,
* description="Model parameter updated successfully",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
* @OA\Property(property="data", ref="#/components/schemas/ModelParameterResource")
* )
* }
* )
* ),
* @OA\Response(ref="#/components/responses/ValidationErrorResponse", response=422),
* @OA\Response(ref="#/components/responses/ErrorResponse", response=400),
* @OA\Response(ref="#/components/responses/UnauthorizedResponse", response=401),
* @OA\Response(ref="#/components/responses/NotFoundResponse", response=404)
* )
*/
public function update(ModelParameterFormRequest $request, int $modelId, int $parameterId)
{
return ApiResponse::handle(function () use ($request, $modelId, $parameterId) {
return $this->service->updateParameter($modelId, $parameterId, $request->validated());
}, __('message.updated'));
}
/**
* @OA\Delete(
* path="/v1/design/models/{modelId}/parameters/{parameterId}",
* summary="Delete model parameter",
* description="Delete a specific model parameter",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
* tags={"Model Parameters"},
* @OA\Parameter(
* name="modelId",
* description="Model ID",
* in="path",
* required=true,
* @OA\Schema(type="integer", example=1)
* ),
* @OA\Parameter(
* name="parameterId",
* description="Parameter ID",
* in="path",
* required=true,
* @OA\Schema(type="integer", example=1)
* ),
* @OA\Response(
* response=200,
* description="Model parameter deleted successfully",
* @OA\JsonContent(ref="#/components/schemas/ApiResponse")
* ),
* @OA\Response(ref="#/components/responses/ErrorResponse", response=400),
* @OA\Response(ref="#/components/responses/UnauthorizedResponse", response=401),
* @OA\Response(ref="#/components/responses/NotFoundResponse", response=404)
* )
*/
public function destroy(int $modelId, int $parameterId)
{
return ApiResponse::handle(function () use ($modelId, $parameterId) {
$this->service->deleteParameter($modelId, $parameterId);
return null;
}, __('message.deleted'));
}
}

View File

@@ -0,0 +1,258 @@
<?php
namespace App\Http\Controllers\Api\V1\Design;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\ProductFromModelService;
use App\Http\Requests\Api\V1\Design\ProductFromModelFormRequest;
/**
* @OA\Tag(name="Product from Model", description="Product creation from model APIs")
*/
class ProductFromModelController extends Controller
{
public function __construct(
protected ProductFromModelService $service
) {}
/**
* @OA\Post(
* path="/v1/design/models/{modelId}/create-product",
* summary="Create product from model",
* description="Create actual product with resolved BOM based on model and input parameters",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
* tags={"Product from Model"},
* @OA\Parameter(
* name="modelId",
* description="Model ID",
* in="path",
* required=true,
* @OA\Schema(type="integer", example=1)
* ),
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(ref="#/components/schemas/CreateProductFromModelRequest")
* ),
* @OA\Response(
* response=201,
* description="Product created successfully with resolved BOM",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
* @OA\Property(property="data", ref="#/components/schemas/ProductWithBomResponse")
* )
* }
* )
* ),
* @OA\Response(ref="#/components/responses/ValidationErrorResponse", response=422),
* @OA\Response(ref="#/components/responses/ErrorResponse", response=400),
* @OA\Response(ref="#/components/responses/UnauthorizedResponse", response=401),
* @OA\Response(ref="#/components/responses/NotFoundResponse", response=404)
* )
*/
public function createProduct(ProductFromModelFormRequest $request, int $modelId)
{
return ApiResponse::handle(function () use ($request, $modelId) {
$data = $request->validated();
$data['model_id'] = $modelId;
return $this->service->createProductWithBom($data);
}, __('message.product.created_from_model'));
}
/**
* @OA\Get(
* path="/v1/products/{productId}/parameters",
* summary="Get product parameters",
* description="Retrieve parameters used to create this product",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
* tags={"Product from Model"},
* @OA\Parameter(
* name="productId",
* description="Product ID",
* in="path",
* required=true,
* @OA\Schema(type="integer", example=1)
* ),
* @OA\Response(
* response=200,
* description="Product parameters retrieved successfully",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
* @OA\Property(property="data", ref="#/components/schemas/ProductParametersResponse")
* )
* }
* )
* ),
* @OA\Response(ref="#/components/responses/ErrorResponse", response=400),
* @OA\Response(ref="#/components/responses/UnauthorizedResponse", response=401),
* @OA\Response(ref="#/components/responses/NotFoundResponse", response=404)
* )
*/
public function getProductParameters(int $productId)
{
return ApiResponse::handle(function () use ($productId) {
return $this->service->getProductParameters($productId);
}, __('message.fetched'));
}
/**
* @OA\Get(
* path="/v1/products/{productId}/calculated-values",
* summary="Get product calculated values",
* description="Retrieve calculated output values for this product",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
* tags={"Product from Model"},
* @OA\Parameter(
* name="productId",
* description="Product ID",
* in="path",
* required=true,
* @OA\Schema(type="integer", example=1)
* ),
* @OA\Response(
* response=200,
* description="Product calculated values retrieved successfully",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
* @OA\Property(property="data", ref="#/components/schemas/ProductCalculatedValuesResponse")
* )
* }
* )
* ),
* @OA\Response(ref="#/components/responses/ErrorResponse", response=400),
* @OA\Response(ref="#/components/responses/UnauthorizedResponse", response=401),
* @OA\Response(ref="#/components/responses/NotFoundResponse", response=404)
* )
*/
public function getCalculatedValues(int $productId)
{
return ApiResponse::handle(function () use ($productId) {
return $this->service->getCalculatedValues($productId);
}, __('message.fetched'));
}
/**
* @OA\Post(
* path="/v1/products/{productId}/recalculate",
* summary="Recalculate product values",
* description="Recalculate product BOM and values with updated parameters",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
* tags={"Product from Model"},
* @OA\Parameter(
* name="productId",
* description="Product ID",
* in="path",
* required=true,
* @OA\Schema(type="integer", example=1)
* ),
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* @OA\Property(
* property="input_parameters",
* description="Updated input parameter values",
* type="object",
* additionalProperties=@OA\Property(oneOf={
* @OA\Schema(type="number"),
* @OA\Schema(type="string"),
* @OA\Schema(type="boolean")
* }),
* example={"W0": 1200, "H0": 900, "installation_type": "B"}
* ),
* @OA\Property(
* property="update_bom",
* description="Whether to update the BOM components",
* type="boolean",
* example=true
* )
* )
* ),
* @OA\Response(
* response=200,
* description="Product recalculated successfully",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
* @OA\Property(property="data", ref="#/components/schemas/ProductWithBomResponse")
* )
* }
* )
* ),
* @OA\Response(ref="#/components/responses/ValidationErrorResponse", response=422),
* @OA\Response(ref="#/components/responses/ErrorResponse", response=400),
* @OA\Response(ref="#/components/responses/UnauthorizedResponse", response=401),
* @OA\Response(ref="#/components/responses/NotFoundResponse", response=404)
* )
*/
public function recalculate(int $productId)
{
return ApiResponse::handle(function () use ($productId) {
$inputParameters = request()->input('input_parameters', []);
$updateBom = request()->boolean('update_bom', true);
return $this->service->recalculateProduct($productId, $inputParameters, $updateBom);
}, __('message.product.recalculated'));
}
/**
* @OA\Get(
* path="/v1/products/{productId}/model-info",
* summary="Get product model information",
* description="Retrieve the model information used to create this product",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
* tags={"Product from Model"},
* @OA\Parameter(
* name="productId",
* description="Product ID",
* in="path",
* required=true,
* @OA\Schema(type="integer", example=1)
* ),
* @OA\Response(
* response=200,
* description="Product model information retrieved successfully",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
* @OA\Property(
* property="data",
* @OA\Property(property="model_id", type="integer", example=1),
* @OA\Property(property="model_code", type="string", example="KSS01"),
* @OA\Property(property="model_name", type="string", example="Standard Screen"),
* @OA\Property(property="model_version", type="string", example="v1.0"),
* @OA\Property(property="creation_timestamp", type="string", format="date-time"),
* @OA\Property(
* property="input_parameters_used",
* type="object",
* additionalProperties=@OA\Property(oneOf={
* @OA\Schema(type="number"),
* @OA\Schema(type="string"),
* @OA\Schema(type="boolean")
* }),
* example={"W0": 1000, "H0": 800, "installation_type": "A"}
* )
* )
* )
* }
* )
* ),
* @OA\Response(ref="#/components/responses/ErrorResponse", response=400),
* @OA\Response(ref="#/components/responses/UnauthorizedResponse", response=401),
* @OA\Response(ref="#/components/responses/NotFoundResponse", response=404)
* )
*/
public function getModelInfo(int $productId)
{
return ApiResponse::handle(function () use ($productId) {
return $this->service->getProductModelInfo($productId);
}, __('message.fetched'));
}
}

View File

@@ -0,0 +1,133 @@
<?php
namespace App\Http\Controllers\Api\V1\Schemas;
use OpenApi\Attributes as OA;
/**
* BOM Condition Rule related Swagger schemas
*/
#[OA\Schema(
schema: 'BomConditionRuleResource',
description: 'BOM condition rule resource',
properties: [
new OA\Property(property: 'id', type: 'integer', example: 1),
new OA\Property(property: 'bom_template_id', type: 'integer', example: 1),
new OA\Property(property: 'name', type: 'string', example: '브라켓 선택 규칙'),
new OA\Property(property: 'ref_type', type: 'string', enum: ['MATERIAL', 'PRODUCT'], example: 'MATERIAL'),
new OA\Property(property: 'ref_id', type: 'integer', example: 101),
new OA\Property(
property: 'condition_expression',
type: 'string',
example: 'W1 >= 1000 && installation_type == "A"',
description: 'Boolean expression to determine if this rule applies'
),
new OA\Property(
property: 'quantity_expression',
type: 'string',
nullable: true,
example: 'ceiling(W1 / 500)',
description: 'Expression to calculate required quantity'
),
new OA\Property(
property: 'waste_rate_expression',
type: 'string',
nullable: true,
example: '0.05',
description: 'Expression to calculate waste rate (0.0-1.0)'
),
new OA\Property(property: 'description', type: 'string', nullable: true, example: '가로 1000mm 이상, A타입 설치시 브라켓 적용'),
new OA\Property(property: 'priority', type: 'integer', example: 1),
new OA\Property(property: 'is_active', type: 'boolean', example: true),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time', example: '2024-01-01T00:00:00Z'),
new OA\Property(property: 'updated_at', type: 'string', format: 'date-time', example: '2024-01-01T00:00:00Z'),
new OA\Property(
property: 'reference',
type: 'object',
description: 'Referenced material or product information',
properties: [
new OA\Property(property: 'id', type: 'integer', example: 101),
new OA\Property(property: 'code', type: 'string', example: 'BR-001'),
new OA\Property(property: 'name', type: 'string', example: '표준 브라켓'),
new OA\Property(property: 'unit', type: 'string', example: 'EA')
]
)
]
)]
class BomConditionRuleResource {}
#[OA\Schema(
schema: 'CreateBomConditionRuleRequest',
description: 'Request schema for creating BOM condition rule',
required: ['name', 'ref_type', 'ref_id', 'condition_expression'],
properties: [
new OA\Property(property: 'name', type: 'string', maxLength: 100, example: '브라켓 선택 규칙'),
new OA\Property(property: 'ref_type', type: 'string', enum: ['MATERIAL', 'PRODUCT'], example: 'MATERIAL'),
new OA\Property(property: 'ref_id', type: 'integer', minimum: 1, example: 101),
new OA\Property(
property: 'condition_expression',
type: 'string',
maxLength: 1000,
example: 'W1 >= 1000 && installation_type == "A"',
description: 'Boolean expression to determine if this rule applies'
),
new OA\Property(
property: 'quantity_expression',
type: 'string',
maxLength: 500,
nullable: true,
example: 'ceiling(W1 / 500)',
description: 'Expression to calculate required quantity'
),
new OA\Property(
property: 'waste_rate_expression',
type: 'string',
maxLength: 500,
nullable: true,
example: '0.05',
description: 'Expression to calculate waste rate (0.0-1.0)'
),
new OA\Property(property: 'description', type: 'string', maxLength: 500, nullable: true, example: '가로 1000mm 이상, A타입 설치시 브라켓 적용'),
new OA\Property(property: 'priority', type: 'integer', minimum: 0, example: 1),
new OA\Property(property: 'is_active', type: 'boolean', example: true)
]
)]
class CreateBomConditionRuleRequest {}
#[OA\Schema(
schema: 'UpdateBomConditionRuleRequest',
description: 'Request schema for updating BOM condition rule',
properties: [
new OA\Property(property: 'name', type: 'string', maxLength: 100, example: '브라켓 선택 규칙'),
new OA\Property(property: 'ref_type', type: 'string', enum: ['MATERIAL', 'PRODUCT'], example: 'MATERIAL'),
new OA\Property(property: 'ref_id', type: 'integer', minimum: 1, example: 101),
new OA\Property(
property: 'condition_expression',
type: 'string',
maxLength: 1000,
example: 'W1 >= 1000 && installation_type == "A"',
description: 'Boolean expression to determine if this rule applies'
),
new OA\Property(
property: 'quantity_expression',
type: 'string',
maxLength: 500,
nullable: true,
example: 'ceiling(W1 / 500)',
description: 'Expression to calculate required quantity'
),
new OA\Property(
property: 'waste_rate_expression',
type: 'string',
maxLength: 500,
nullable: true,
example: '0.05',
description: 'Expression to calculate waste rate (0.0-1.0)'
),
new OA\Property(property: 'description', type: 'string', maxLength: 500, nullable: true, example: '가로 1000mm 이상, A타입 설치시 브라켓 적용'),
new OA\Property(property: 'priority', type: 'integer', minimum: 0, example: 1),
new OA\Property(property: 'is_active', type: 'boolean', example: true)
]
)]
class UpdateBomConditionRuleRequest {}

View File

@@ -0,0 +1,314 @@
<?php
namespace App\Http\Controllers\Api\V1\Schemas;
use OpenApi\Attributes as OA;
/**
* BOM Resolver related Swagger schemas
*/
#[OA\Schema(
schema: 'ResolvePreviewRequest',
description: 'Request schema for BOM preview resolution',
required: ['input_parameters'],
properties: [
new OA\Property(
property: 'input_parameters',
type: 'object',
additionalProperties: new OA\Property(oneOf: [
new OA\Schema(type: 'number'),
new OA\Schema(type: 'string'),
new OA\Schema(type: 'boolean')
]),
example: [
'W0' => 1000,
'H0' => 800,
'installation_type' => 'A',
'power_source' => '220V'
],
description: 'Input parameter values for BOM resolution'
),
new OA\Property(property: 'bom_template_id', type: 'integer', minimum: 1, nullable: true, example: 1),
new OA\Property(property: 'include_calculated_values', type: 'boolean', example: true),
new OA\Property(property: 'include_bom_items', type: 'boolean', example: true)
]
)]
class ResolvePreviewRequest {}
#[OA\Schema(
schema: 'CreateProductFromModelRequest',
description: 'Request schema for creating product from model',
required: ['model_id', 'input_parameters', 'product_code', 'product_name'],
properties: [
new OA\Property(property: 'model_id', type: 'integer', minimum: 1, example: 1),
new OA\Property(
property: 'input_parameters',
type: 'object',
additionalProperties: new OA\Property(oneOf: [
new OA\Schema(type: 'number'),
new OA\Schema(type: 'string'),
new OA\Schema(type: 'boolean')
]),
example: [
'W0' => 1000,
'H0' => 800,
'installation_type' => 'A',
'power_source' => '220V'
],
description: 'Input parameter values for BOM resolution'
),
new OA\Property(property: 'bom_template_id', type: 'integer', minimum: 1, nullable: true, example: 1),
new OA\Property(
property: 'product_code',
type: 'string',
maxLength: 50,
example: 'KSS01-1000x800-A',
description: 'Product code (uppercase letters, numbers, underscore, hyphen only)'
),
new OA\Property(property: 'product_name', type: 'string', maxLength: 100, example: 'KSS01 스크린 1000x800 A타입'),
new OA\Property(property: 'category_id', type: 'integer', minimum: 1, nullable: true, example: 1),
new OA\Property(property: 'description', type: 'string', maxLength: 1000, nullable: true, example: '매개변수 기반으로 생성된 맞춤형 스크린'),
new OA\Property(property: 'unit', type: 'string', maxLength: 20, nullable: true, example: 'EA'),
new OA\Property(property: 'min_order_qty', type: 'number', minimum: 0, nullable: true, example: 1),
new OA\Property(property: 'lead_time_days', type: 'integer', minimum: 0, nullable: true, example: 7),
new OA\Property(property: 'is_active', type: 'boolean', example: true),
new OA\Property(property: 'create_bom_items', type: 'boolean', example: true),
new OA\Property(property: 'validate_bom', type: 'boolean', example: true)
]
)]
class CreateProductFromModelRequest {}
#[OA\Schema(
schema: 'BomPreviewResponse',
description: 'BOM preview resolution response',
properties: [
new OA\Property(
property: 'input_parameters',
type: 'object',
additionalProperties: new OA\Property(oneOf: [
new OA\Schema(type: 'number'),
new OA\Schema(type: 'string'),
new OA\Schema(type: 'boolean')
]),
example: [
'W0' => 1000,
'H0' => 800,
'installation_type' => 'A',
'power_source' => '220V'
]
),
new OA\Property(
property: 'calculated_values',
type: 'object',
additionalProperties: new OA\Property(type: 'number'),
example: [
'W1' => 1050,
'H1' => 850,
'area' => 892500,
'weight' => 25.5,
'motor_power' => 120
]
),
new OA\Property(
property: 'bom_items',
type: 'array',
items: new OA\Items(ref: '#/components/schemas/BomItemPreview')
),
new OA\Property(
property: 'summary',
type: 'object',
properties: [
new OA\Property(property: 'total_materials', type: 'integer', example: 15),
new OA\Property(property: 'total_cost', type: 'number', example: 125000.50),
new OA\Property(property: 'estimated_weight', type: 'number', example: 25.5)
]
),
new OA\Property(
property: 'validation_warnings',
type: 'array',
items: new OA\Items(
properties: [
new OA\Property(property: 'type', type: 'string', example: 'PARAMETER_OUT_OF_RANGE'),
new OA\Property(property: 'message', type: 'string', example: 'W0 값이 권장 범위를 벗어났습니다'),
new OA\Property(property: 'parameter', type: 'string', example: 'W0'),
new OA\Property(property: 'current_value', type: 'number', example: 1000),
new OA\Property(property: 'recommended_range', type: 'string', example: '600-900')
],
type: 'object'
)
)
]
)]
class BomPreviewResponse {}
#[OA\Schema(
schema: 'BomItemPreview',
description: 'BOM item preview',
properties: [
new OA\Property(property: 'ref_type', type: 'string', enum: ['MATERIAL', 'PRODUCT'], example: 'MATERIAL'),
new OA\Property(property: 'ref_id', type: 'integer', example: 101),
new OA\Property(property: 'ref_code', type: 'string', example: 'BR-001'),
new OA\Property(property: 'ref_name', type: 'string', example: '표준 브라켓'),
new OA\Property(property: 'quantity', type: 'number', example: 2.0),
new OA\Property(property: 'waste_rate', type: 'number', example: 0.05),
new OA\Property(property: 'total_quantity', type: 'number', example: 2.1),
new OA\Property(property: 'unit', type: 'string', example: 'EA'),
new OA\Property(property: 'unit_cost', type: 'number', nullable: true, example: 5000.0),
new OA\Property(property: 'total_cost', type: 'number', nullable: true, example: 10500.0),
new OA\Property(property: 'applied_rule', type: 'string', nullable: true, example: '브라켓 선택 규칙'),
new OA\Property(
property: 'calculation_details',
type: 'object',
properties: [
new OA\Property(property: 'condition_matched', type: 'boolean', example: true),
new OA\Property(property: 'quantity_expression', type: 'string', example: 'ceiling(W1 / 500)'),
new OA\Property(property: 'quantity_calculation', type: 'string', example: 'ceiling(1050 / 500) = 3')
]
)
]
)]
class BomItemPreview {}
#[OA\Schema(
schema: 'ProductWithBomResponse',
description: 'Product created with BOM response',
properties: [
new OA\Property(
property: 'product',
type: 'object',
properties: [
new OA\Property(property: 'id', type: 'integer', example: 123),
new OA\Property(property: 'code', type: 'string', example: 'KSS01-1000x800-A'),
new OA\Property(property: 'name', type: 'string', example: 'KSS01 스크린 1000x800 A타입'),
new OA\Property(property: 'type', type: 'string', example: 'PRODUCT'),
new OA\Property(property: 'category_id', type: 'integer', nullable: true, example: 1),
new OA\Property(property: 'description', type: 'string', nullable: true, example: '매개변수 기반으로 생성된 맞춤형 스크린'),
new OA\Property(property: 'unit', type: 'string', nullable: true, example: 'EA'),
new OA\Property(property: 'min_order_qty', type: 'number', nullable: true, example: 1),
new OA\Property(property: 'lead_time_days', type: 'integer', nullable: true, example: 7),
new OA\Property(property: 'is_active', type: 'boolean', example: true),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time', example: '2024-01-01T00:00:00Z')
]
),
new OA\Property(
property: 'input_parameters',
type: 'object',
additionalProperties: new OA\Property(oneOf: [
new OA\Schema(type: 'number'),
new OA\Schema(type: 'string'),
new OA\Schema(type: 'boolean')
]),
example: [
'W0' => 1000,
'H0' => 800,
'installation_type' => 'A',
'power_source' => '220V'
]
),
new OA\Property(
property: 'calculated_values',
type: 'object',
additionalProperties: new OA\Property(type: 'number'),
example: [
'W1' => 1050,
'H1' => 850,
'area' => 892500,
'weight' => 25.5
]
),
new OA\Property(
property: 'bom_items',
type: 'array',
items: new OA\Items(
properties: [
new OA\Property(property: 'id', type: 'integer', example: 1),
new OA\Property(property: 'ref_type', type: 'string', enum: ['MATERIAL', 'PRODUCT'], example: 'MATERIAL'),
new OA\Property(property: 'ref_id', type: 'integer', example: 101),
new OA\Property(property: 'ref_code', type: 'string', example: 'BR-001'),
new OA\Property(property: 'ref_name', type: 'string', example: '표준 브라켓'),
new OA\Property(property: 'quantity', type: 'number', example: 2.0),
new OA\Property(property: 'waste_rate', type: 'number', example: 0.05),
new OA\Property(property: 'unit', type: 'string', example: 'EA')
],
type: 'object'
)
),
new OA\Property(
property: 'summary',
type: 'object',
properties: [
new OA\Property(property: 'total_bom_items', type: 'integer', example: 15),
new OA\Property(property: 'model_id', type: 'integer', example: 1),
new OA\Property(property: 'bom_template_id', type: 'integer', nullable: true, example: 1)
]
)
]
)]
class ProductWithBomResponse {}
#[OA\Schema(
schema: 'ProductParametersResponse',
description: 'Product parameters response',
properties: [
new OA\Property(property: 'product_id', type: 'integer', example: 123),
new OA\Property(property: 'model_id', type: 'integer', example: 1),
new OA\Property(
property: 'input_parameters',
type: 'object',
additionalProperties: new OA\Property(oneOf: [
new OA\Schema(type: 'number'),
new OA\Schema(type: 'string'),
new OA\Schema(type: 'boolean')
]),
example: [
'W0' => 1000,
'H0' => 800,
'installation_type' => 'A',
'power_source' => '220V'
]
),
new OA\Property(
property: 'parameter_definitions',
type: 'array',
items: new OA\Items(ref: '#/components/schemas/ModelParameterResource')
)
]
)]
class ProductParametersResponse {}
#[OA\Schema(
schema: 'ProductCalculatedValuesResponse',
description: 'Product calculated values response',
properties: [
new OA\Property(property: 'product_id', type: 'integer', example: 123),
new OA\Property(property: 'model_id', type: 'integer', example: 1),
new OA\Property(
property: 'calculated_values',
type: 'object',
additionalProperties: new OA\Property(type: 'number'),
example: [
'W1' => 1050,
'H1' => 850,
'area' => 892500,
'weight' => 25.5,
'motor_power' => 120
]
),
new OA\Property(
property: 'formula_applications',
type: 'array',
items: new OA\Items(
properties: [
new OA\Property(property: 'formula_name', type: 'string', example: '최종 가로 크기 계산'),
new OA\Property(property: 'target_parameter', type: 'string', example: 'W1'),
new OA\Property(property: 'expression', type: 'string', example: 'W0 + (installation_type == "A" ? 50 : 30)'),
new OA\Property(property: 'calculated_value', type: 'number', example: 1050),
new OA\Property(property: 'execution_order', type: 'integer', example: 1)
],
type: 'object'
)
)
]
)]
class ProductCalculatedValuesResponse {}

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Http\Controllers\Api\V1\Schemas;
use OpenApi\Attributes as OA;
/**
* Model Formula related Swagger schemas
*/
#[OA\Schema(
schema: 'ModelFormulaResource',
description: 'Model formula resource',
properties: [
new OA\Property(property: 'id', type: 'integer', example: 1),
new OA\Property(property: 'model_id', type: 'integer', example: 1),
new OA\Property(property: 'name', type: 'string', example: '최종 가로 크기 계산'),
new OA\Property(property: 'target_parameter', type: 'string', example: 'W1'),
new OA\Property(property: 'expression', type: 'string', example: 'W0 + (installation_type == "A" ? 50 : 30)'),
new OA\Property(property: 'description', type: 'string', nullable: true, example: '설치 타입에 따른 최종 가로 크기 계산'),
new OA\Property(property: 'is_active', type: 'boolean', example: true),
new OA\Property(property: 'execution_order', type: 'integer', example: 1),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time', example: '2024-01-01T00:00:00Z'),
new OA\Property(property: 'updated_at', type: 'string', format: 'date-time', example: '2024-01-01T00:00:00Z')
]
)]
class ModelFormulaResource {}
#[OA\Schema(
schema: 'CreateModelFormulaRequest',
description: 'Request schema for creating model formula',
required: ['name', 'target_parameter', 'expression'],
properties: [
new OA\Property(property: 'name', type: 'string', maxLength: 100, example: '최종 가로 크기 계산'),
new OA\Property(
property: 'target_parameter',
type: 'string',
maxLength: 50,
example: 'W1',
description: 'Target parameter name (alphanumeric with underscore, must start with letter)'
),
new OA\Property(
property: 'expression',
type: 'string',
maxLength: 1000,
example: 'W0 + (installation_type == "A" ? 50 : 30)',
description: 'Mathematical expression for calculating the target parameter'
),
new OA\Property(property: 'description', type: 'string', maxLength: 500, nullable: true, example: '설치 타입에 따른 최종 가로 크기 계산'),
new OA\Property(property: 'is_active', type: 'boolean', example: true),
new OA\Property(property: 'execution_order', type: 'integer', minimum: 0, example: 1)
]
)]
class CreateModelFormulaRequest {}
#[OA\Schema(
schema: 'UpdateModelFormulaRequest',
description: 'Request schema for updating model formula',
properties: [
new OA\Property(property: 'name', type: 'string', maxLength: 100, example: '최종 가로 크기 계산'),
new OA\Property(
property: 'target_parameter',
type: 'string',
maxLength: 50,
example: 'W1',
description: 'Target parameter name (alphanumeric with underscore, must start with letter)'
),
new OA\Property(
property: 'expression',
type: 'string',
maxLength: 1000,
example: 'W0 + (installation_type == "A" ? 50 : 30)',
description: 'Mathematical expression for calculating the target parameter'
),
new OA\Property(property: 'description', type: 'string', maxLength: 500, nullable: true, example: '설치 타입에 따른 최종 가로 크기 계산'),
new OA\Property(property: 'is_active', type: 'boolean', example: true),
new OA\Property(property: 'execution_order', type: 'integer', minimum: 0, example: 1)
]
)]
class UpdateModelFormulaRequest {}

View File

@@ -0,0 +1,107 @@
<?php
namespace App\Http\Controllers\Api\V1\Schemas;
use OpenApi\Attributes as OA;
/**
* Model Parameter related Swagger schemas
*/
#[OA\Schema(
schema: 'ModelParameterResource',
description: 'Model parameter resource',
properties: [
new OA\Property(property: 'id', type: 'integer', example: 1),
new OA\Property(property: 'model_id', type: 'integer', example: 1),
new OA\Property(property: 'name', type: 'string', example: 'W0'),
new OA\Property(property: 'label', type: 'string', example: '가로 크기'),
new OA\Property(property: 'type', type: 'string', enum: ['INPUT', 'OUTPUT'], example: 'INPUT'),
new OA\Property(property: 'data_type', type: 'string', enum: ['INTEGER', 'DECIMAL', 'STRING', 'BOOLEAN'], example: 'DECIMAL'),
new OA\Property(property: 'unit', type: 'string', nullable: true, example: 'mm'),
new OA\Property(property: 'default_value', type: 'string', nullable: true, example: '1000'),
new OA\Property(property: 'min_value', type: 'number', nullable: true, example: 500),
new OA\Property(property: 'max_value', type: 'number', nullable: true, example: 2000),
new OA\Property(
property: 'enum_values',
type: 'array',
items: new OA\Items(type: 'string'),
nullable: true,
example: ['A', 'B', 'C']
),
new OA\Property(property: 'validation_rules', type: 'string', nullable: true, example: 'required|numeric|min:500'),
new OA\Property(property: 'description', type: 'string', nullable: true, example: '제품의 가로 크기를 입력하세요'),
new OA\Property(property: 'is_required', type: 'boolean', example: true),
new OA\Property(property: 'display_order', type: 'integer', example: 1),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time', example: '2024-01-01T00:00:00Z'),
new OA\Property(property: 'updated_at', type: 'string', format: 'date-time', example: '2024-01-01T00:00:00Z')
]
)]
class ModelParameterResource {}
#[OA\Schema(
schema: 'CreateModelParameterRequest',
description: 'Request schema for creating model parameter',
required: ['name', 'label', 'type', 'data_type'],
properties: [
new OA\Property(
property: 'name',
type: 'string',
maxLength: 50,
example: 'W0',
description: 'Parameter name (alphanumeric with underscore, must start with letter)'
),
new OA\Property(property: 'label', type: 'string', maxLength: 100, example: '가로 크기'),
new OA\Property(property: 'type', type: 'string', enum: ['INPUT', 'OUTPUT'], example: 'INPUT'),
new OA\Property(property: 'data_type', type: 'string', enum: ['INTEGER', 'DECIMAL', 'STRING', 'BOOLEAN'], example: 'DECIMAL'),
new OA\Property(property: 'unit', type: 'string', maxLength: 20, nullable: true, example: 'mm'),
new OA\Property(property: 'default_value', type: 'string', maxLength: 255, nullable: true, example: '1000'),
new OA\Property(property: 'min_value', type: 'number', nullable: true, example: 500),
new OA\Property(property: 'max_value', type: 'number', nullable: true, example: 2000),
new OA\Property(
property: 'enum_values',
type: 'array',
items: new OA\Items(type: 'string'),
nullable: true,
example: ['A', 'B', 'C']
),
new OA\Property(property: 'validation_rules', type: 'string', maxLength: 500, nullable: true, example: 'required|numeric|min:500'),
new OA\Property(property: 'description', type: 'string', maxLength: 500, nullable: true, example: '제품의 가로 크기를 입력하세요'),
new OA\Property(property: 'is_required', type: 'boolean', example: true),
new OA\Property(property: 'display_order', type: 'integer', minimum: 0, example: 1)
]
)]
class CreateModelParameterRequest {}
#[OA\Schema(
schema: 'UpdateModelParameterRequest',
description: 'Request schema for updating model parameter',
properties: [
new OA\Property(
property: 'name',
type: 'string',
maxLength: 50,
example: 'W0',
description: 'Parameter name (alphanumeric with underscore, must start with letter)'
),
new OA\Property(property: 'label', type: 'string', maxLength: 100, example: '가로 크기'),
new OA\Property(property: 'type', type: 'string', enum: ['INPUT', 'OUTPUT'], example: 'INPUT'),
new OA\Property(property: 'data_type', type: 'string', enum: ['INTEGER', 'DECIMAL', 'STRING', 'BOOLEAN'], example: 'DECIMAL'),
new OA\Property(property: 'unit', type: 'string', maxLength: 20, nullable: true, example: 'mm'),
new OA\Property(property: 'default_value', type: 'string', maxLength: 255, nullable: true, example: '1000'),
new OA\Property(property: 'min_value', type: 'number', nullable: true, example: 500),
new OA\Property(property: 'max_value', type: 'number', nullable: true, example: 2000),
new OA\Property(
property: 'enum_values',
type: 'array',
items: new OA\Items(type: 'string'),
nullable: true,
example: ['A', 'B', 'C']
),
new OA\Property(property: 'validation_rules', type: 'string', maxLength: 500, nullable: true, example: 'required|numeric|min:500'),
new OA\Property(property: 'description', type: 'string', maxLength: 500, nullable: true, example: '제품의 가로 크기를 입력하세요'),
new OA\Property(property: 'is_required', type: 'boolean', example: true),
new OA\Property(property: 'display_order', type: 'integer', minimum: 0, example: 1)
]
)]
class UpdateModelParameterRequest {}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Http\Requests\Api\V1\BomConditionRule;
use Illuminate\Foundation\Http\FormRequest;
class CreateBomConditionRuleRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:100'],
'ref_type' => ['required', 'string', 'in:MATERIAL,PRODUCT'],
'ref_id' => ['required', 'integer', 'min:1'],
'condition_expression' => ['required', 'string', 'max:1000'],
'quantity_expression' => ['nullable', 'string', 'max:500'],
'waste_rate_expression' => ['nullable', 'string', 'max:500'],
'description' => ['nullable', 'string', 'max:500'],
'priority' => ['integer', 'min:0'],
'is_active' => ['boolean'],
];
}
/**
* Get custom attribute names for validator errors.
*/
public function attributes(): array
{
return [
'name' => __('validation.attributes.rule_name'),
'ref_type' => __('validation.attributes.ref_type'),
'ref_id' => __('validation.attributes.ref_id'),
'condition_expression' => __('validation.attributes.condition_expression'),
'quantity_expression' => __('validation.attributes.quantity_expression'),
'waste_rate_expression' => __('validation.attributes.waste_rate_expression'),
'description' => __('validation.attributes.description'),
'priority' => __('validation.attributes.priority'),
'is_active' => __('validation.attributes.is_active'),
];
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation(): void
{
$this->merge([
'is_active' => $this->boolean('is_active', true),
'priority' => $this->integer('priority', 0),
]);
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Requests\Api\V1\BomConditionRule;
use App\Http\Requests\Api\V1\PaginateRequest;
class IndexBomConditionRuleRequest extends PaginateRequest
{
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return array_merge(parent::rules(), [
'search' => ['sometimes', 'string', 'max:255'],
'ref_type' => ['sometimes', 'string', 'in:MATERIAL,PRODUCT'],
'is_active' => ['sometimes', 'boolean'],
]);
}
/**
* Get custom attribute names for validator errors.
*/
public function attributes(): array
{
return array_merge(parent::attributes(), [
'search' => __('validation.attributes.search'),
'ref_type' => __('validation.attributes.ref_type'),
'is_active' => __('validation.attributes.is_active'),
]);
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation(): void
{
parent::prepareForValidation();
if ($this->has('is_active')) {
$this->merge(['is_active' => $this->boolean('is_active')]);
}
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Http\Requests\Api\V1\BomConditionRule;
use Illuminate\Foundation\Http\FormRequest;
class UpdateBomConditionRuleRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'name' => ['sometimes', 'string', 'max:100'],
'ref_type' => ['sometimes', 'string', 'in:MATERIAL,PRODUCT'],
'ref_id' => ['sometimes', 'integer', 'min:1'],
'condition_expression' => ['sometimes', 'string', 'max:1000'],
'quantity_expression' => ['nullable', 'string', 'max:500'],
'waste_rate_expression' => ['nullable', 'string', 'max:500'],
'description' => ['nullable', 'string', 'max:500'],
'priority' => ['sometimes', 'integer', 'min:0'],
'is_active' => ['sometimes', 'boolean'],
];
}
/**
* Get custom attribute names for validator errors.
*/
public function attributes(): array
{
return [
'name' => __('validation.attributes.rule_name'),
'ref_type' => __('validation.attributes.ref_type'),
'ref_id' => __('validation.attributes.ref_id'),
'condition_expression' => __('validation.attributes.condition_expression'),
'quantity_expression' => __('validation.attributes.quantity_expression'),
'waste_rate_expression' => __('validation.attributes.waste_rate_expression'),
'description' => __('validation.attributes.description'),
'priority' => __('validation.attributes.priority'),
'is_active' => __('validation.attributes.is_active'),
];
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation(): void
{
if ($this->has('is_active')) {
$this->merge(['is_active' => $this->boolean('is_active')]);
}
if ($this->has('priority')) {
$this->merge(['priority' => $this->integer('priority')]);
}
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Http\Requests\Api\V1\BomResolver;
use Illuminate\Foundation\Http\FormRequest;
class CreateProductFromModelRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'model_id' => ['required', 'integer', 'min:1'],
'input_parameters' => ['required', 'array', 'min:1'],
'input_parameters.*' => ['required'],
'bom_template_id' => ['sometimes', 'integer', 'min:1'],
// Product data
'product_code' => ['required', 'string', 'max:50', 'regex:/^[A-Z0-9_-]+$/'],
'product_name' => ['required', 'string', 'max:100'],
'category_id' => ['sometimes', 'integer', 'min:1'],
'description' => ['nullable', 'string', 'max:1000'],
// Product attributes
'unit' => ['nullable', 'string', 'max:20'],
'min_order_qty' => ['nullable', 'numeric', 'min:0'],
'lead_time_days' => ['nullable', 'integer', 'min:0'],
'is_active' => ['boolean'],
// Additional options
'create_bom_items' => ['boolean'],
'validate_bom' => ['boolean'],
];
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'product_code.regex' => __('validation.product.code_format'),
];
}
/**
* Get custom attribute names for validator errors.
*/
public function attributes(): array
{
return [
'model_id' => __('validation.attributes.model_id'),
'input_parameters' => __('validation.attributes.input_parameters'),
'bom_template_id' => __('validation.attributes.bom_template_id'),
'product_code' => __('validation.attributes.product_code'),
'product_name' => __('validation.attributes.product_name'),
'category_id' => __('validation.attributes.category_id'),
'description' => __('validation.attributes.description'),
'unit' => __('validation.attributes.unit'),
'min_order_qty' => __('validation.attributes.min_order_qty'),
'lead_time_days' => __('validation.attributes.lead_time_days'),
'is_active' => __('validation.attributes.is_active'),
'create_bom_items' => __('validation.attributes.create_bom_items'),
'validate_bom' => __('validation.attributes.validate_bom'),
];
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation(): void
{
$this->merge([
'is_active' => $this->boolean('is_active', true),
'create_bom_items' => $this->boolean('create_bom_items', true),
'validate_bom' => $this->boolean('validate_bom', true),
]);
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Http\Requests\Api\V1\BomResolver;
use Illuminate\Foundation\Http\FormRequest;
class ResolvePreviewRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'input_parameters' => ['required', 'array', 'min:1'],
'input_parameters.*' => ['required'],
'bom_template_id' => ['sometimes', 'integer', 'min:1'],
'include_calculated_values' => ['boolean'],
'include_bom_items' => ['boolean'],
];
}
/**
* Get custom attribute names for validator errors.
*/
public function attributes(): array
{
return [
'input_parameters' => __('validation.attributes.input_parameters'),
'bom_template_id' => __('validation.attributes.bom_template_id'),
'include_calculated_values' => __('validation.attributes.include_calculated_values'),
'include_bom_items' => __('validation.attributes.include_bom_items'),
];
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation(): void
{
$this->merge([
'include_calculated_values' => $this->boolean('include_calculated_values', true),
'include_bom_items' => $this->boolean('include_bom_items', true),
]);
}
}

View File

@@ -0,0 +1,296 @@
<?php
namespace App\Http\Requests\Api\V1\Design;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use App\Models\Design\ModelParameter;
use App\Models\Product;
use App\Models\Material;
class BomConditionRuleFormRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true; // Authorization handled by middleware
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
$modelId = $this->route('modelId');
$ruleId = $this->route('ruleId');
$rules = [
'rule_name' => [
'required',
'string',
'max:100',
Rule::unique('bom_condition_rules')
->where('model_id', $modelId)
->where('tenant_id', auth()->user()?->currentTenant?->id)
->whereNull('deleted_at')
],
'condition_expression' => ['required', 'string', 'max:1000'],
'action_type' => ['required', 'string', 'in:INCLUDE,EXCLUDE,MODIFY_QUANTITY'],
'target_type' => ['required', 'string', 'in:MATERIAL,PRODUCT'],
'target_id' => ['required', 'integer', 'min:1'],
'quantity_multiplier' => ['nullable', 'numeric', 'min:0'],
'is_active' => ['boolean'],
'priority' => ['integer', 'min:0'],
'description' => ['nullable', 'string', 'max:500'],
];
// For update requests, ignore current record in unique validation
if ($ruleId) {
$rules['rule_name'][3] = $rules['rule_name'][3]->ignore($ruleId);
}
return $rules;
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'rule_name.required' => '규칙 이름은 필수입니다.',
'rule_name.unique' => '해당 모델에 이미 동일한 규칙 이름이 존재합니다.',
'condition_expression.required' => '조건 표현식은 필수입니다.',
'condition_expression.max' => '조건 표현식은 1000자를 초과할 수 없습니다.',
'action_type.required' => '액션 타입은 필수입니다.',
'action_type.in' => '액션 타입은 INCLUDE, EXCLUDE, MODIFY_QUANTITY 중 하나여야 합니다.',
'target_type.required' => '대상 타입은 필수입니다.',
'target_type.in' => '대상 타입은 MATERIAL 또는 PRODUCT여야 합니다.',
'target_id.required' => '대상 ID는 필수입니다.',
'target_id.min' => '대상 ID는 1 이상이어야 합니다.',
'quantity_multiplier.numeric' => '수량 배수는 숫자여야 합니다.',
'quantity_multiplier.min' => '수량 배수는 0 이상이어야 합니다.',
'priority.min' => '우선순위는 0 이상이어야 합니다.',
'description.max' => '설명은 500자를 초과할 수 없습니다.',
];
}
/**
* Get custom attribute names for validator errors.
*/
public function attributes(): array
{
return [
'rule_name' => '규칙 이름',
'condition_expression' => '조건 표현식',
'action_type' => '액션 타입',
'target_type' => '대상 타입',
'target_id' => '대상 ID',
'quantity_multiplier' => '수량 배수',
'is_active' => '활성 상태',
'priority' => '우선순위',
'description' => '설명',
];
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation(): void
{
$this->merge([
'is_active' => $this->boolean('is_active', true),
'priority' => $this->integer('priority', 0),
]);
// Set default quantity_multiplier for actions that require it
if ($this->input('action_type') === 'MODIFY_QUANTITY' && !$this->has('quantity_multiplier')) {
$this->merge(['quantity_multiplier' => 1.0]);
}
// Clean up condition expression
if ($this->has('condition_expression')) {
$expression = preg_replace('/\s+/', ' ', trim($this->input('condition_expression')));
$this->merge(['condition_expression' => $expression]);
}
}
/**
* Configure the validator instance.
*/
public function withValidator($validator)
{
$validator->after(function ($validator) {
$this->validateConditionExpression($validator);
$this->validateTargetExists($validator);
$this->validateActionRequirements($validator);
});
}
/**
* Validate condition expression syntax and variables.
*/
private function validateConditionExpression($validator): void
{
$expression = $this->input('condition_expression');
if (!$expression) {
return;
}
// Check for potentially dangerous characters or functions
$dangerousPatterns = [
'/\b(eval|exec|system|shell_exec|passthru|file_get_contents|file_put_contents|fopen|fwrite)\b/i',
'/[;{}]/', // Semicolons and braces
'/\$[a-zA-Z_]/', // PHP variables
'/\bfunction\s*\(/i', // Function definitions
];
foreach ($dangerousPatterns as $pattern) {
if (preg_match($pattern, $expression)) {
$validator->errors()->add('condition_expression', '조건 표현식에 허용되지 않는 문자나 함수가 포함되어 있습니다.');
return;
}
}
// Validate condition expression format
if (!$this->isValidConditionExpression($expression)) {
$validator->errors()->add('condition_expression', '조건 표현식의 형식이 올바르지 않습니다.');
return;
}
// Validate variables in expression exist as parameters
$this->validateConditionVariables($validator, $expression);
}
/**
* Check if condition expression has valid syntax.
*/
private function isValidConditionExpression(string $expression): bool
{
// Allow comparison operators, logical operators, variables, numbers, strings
$patterns = [
'/^.*(==|!=|>=|<=|>|<|\sIN\s|\sNOT\sIN\s|\sAND\s|\sOR\s).*$/i',
'/^(true|false|[0-9]+)$/i', // Simple boolean or number
];
foreach ($patterns as $pattern) {
if (preg_match($pattern, $expression)) {
return true;
}
}
return false;
}
/**
* Validate that variables in condition exist as model parameters.
*/
private function validateConditionVariables($validator, string $expression): void
{
$modelId = $this->route('modelId');
// Extract variable names from expression (exclude operators and values)
preg_match_all('/\b[a-zA-Z][a-zA-Z0-9_]*\b/', $expression, $matches);
$variables = $matches[0];
// Remove logical operators and reserved words
$reservedWords = ['AND', 'OR', 'IN', 'NOT', 'TRUE', 'FALSE', 'true', 'false'];
$variables = array_diff($variables, $reservedWords);
if (empty($variables)) {
return;
}
// Get existing parameters for this model
$existingParameters = ModelParameter::where('model_id', $modelId)
->where('tenant_id', auth()->user()?->currentTenant?->id)
->whereNull('deleted_at')
->pluck('parameter_name')
->toArray();
// Check for undefined variables
$undefinedVariables = array_diff($variables, $existingParameters);
if (!empty($undefinedVariables)) {
$validator->errors()->add('condition_expression',
'조건식에 정의되지 않은 변수가 사용되었습니다: ' . implode(', ', $undefinedVariables)
);
}
}
/**
* Validate that the target (MATERIAL or PRODUCT) exists.
*/
private function validateTargetExists($validator): void
{
$targetType = $this->input('target_type');
$targetId = $this->input('target_id');
if (!$targetType || !$targetId) {
return;
}
$tenantId = auth()->user()?->currentTenant?->id;
switch ($targetType) {
case 'MATERIAL':
$exists = Material::where('id', $targetId)
->where('tenant_id', $tenantId)
->whereNull('deleted_at')
->exists();
if (!$exists) {
$validator->errors()->add('target_id', '지정된 자재가 존재하지 않습니다.');
}
break;
case 'PRODUCT':
$exists = Product::where('id', $targetId)
->where('tenant_id', $tenantId)
->whereNull('deleted_at')
->exists();
if (!$exists) {
$validator->errors()->add('target_id', '지정된 제품이 존재하지 않습니다.');
}
break;
}
}
/**
* Validate action-specific requirements.
*/
private function validateActionRequirements($validator): void
{
$actionType = $this->input('action_type');
$quantityMultiplier = $this->input('quantity_multiplier');
switch ($actionType) {
case 'MODIFY_QUANTITY':
// MODIFY_QUANTITY action requires quantity_multiplier
if ($quantityMultiplier === null || $quantityMultiplier === '') {
$validator->errors()->add('quantity_multiplier', 'MODIFY_QUANTITY 액션에는 수량 배수가 필요합니다.');
} elseif ($quantityMultiplier <= 0) {
$validator->errors()->add('quantity_multiplier', 'MODIFY_QUANTITY 액션의 수량 배수는 0보다 커야 합니다.');
}
break;
case 'INCLUDE':
// INCLUDE action can optionally have quantity_multiplier (default to 1)
if ($quantityMultiplier !== null && $quantityMultiplier <= 0) {
$validator->errors()->add('quantity_multiplier', 'INCLUDE 액션의 수량 배수는 0보다 커야 합니다.');
}
break;
case 'EXCLUDE':
// EXCLUDE action doesn't need quantity_multiplier
if ($quantityMultiplier !== null) {
$validator->errors()->add('quantity_multiplier', 'EXCLUDE 액션에는 수량 배수가 필요하지 않습니다.');
}
break;
}
}
}

View File

@@ -0,0 +1,274 @@
<?php
namespace App\Http\Requests\Api\V1\Design;
use Illuminate\Foundation\Http\FormRequest;
use App\Models\Design\ModelParameter;
use App\Models\Design\BomTemplate;
class BomResolverFormRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true; // Authorization handled by middleware
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'input_parameters' => ['required', 'array', 'min:1'],
'input_parameters.*' => ['required'],
'bom_template_id' => ['sometimes', 'integer', 'min:1'],
'include_calculated_values' => ['boolean'],
'include_bom_items' => ['boolean'],
'include_condition_rules' => ['boolean'],
'validate_before_resolve' => ['boolean'],
'calculation_precision' => ['integer', 'min:0', 'max:10'],
];
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'input_parameters.required' => '입력 매개변수는 필수입니다.',
'input_parameters.array' => '입력 매개변수는 배열 형태여야 합니다.',
'input_parameters.min' => '최소 하나 이상의 입력 매개변수가 필요합니다.',
'input_parameters.*.required' => '모든 입력 매개변수 값은 필수입니다.',
'bom_template_id.integer' => 'BOM 템플릿 ID는 정수여야 합니다.',
'bom_template_id.min' => 'BOM 템플릿 ID는 1 이상이어야 합니다.',
'calculation_precision.integer' => '계산 정밀도는 정수여야 합니다.',
'calculation_precision.min' => '계산 정밀도는 0 이상이어야 합니다.',
'calculation_precision.max' => '계산 정밀도는 10 이하여야 합니다.',
];
}
/**
* Get custom attribute names for validator errors.
*/
public function attributes(): array
{
return [
'input_parameters' => '입력 매개변수',
'bom_template_id' => 'BOM 템플릿 ID',
'include_calculated_values' => '계산값 포함 여부',
'include_bom_items' => 'BOM 아이템 포함 여부',
'include_condition_rules' => '조건 규칙 포함 여부',
'validate_before_resolve' => '해결 전 유효성 검사',
'calculation_precision' => '계산 정밀도',
];
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation(): void
{
$this->merge([
'include_calculated_values' => $this->boolean('include_calculated_values', true),
'include_bom_items' => $this->boolean('include_bom_items', true),
'include_condition_rules' => $this->boolean('include_condition_rules', true),
'validate_before_resolve' => $this->boolean('validate_before_resolve', true),
'calculation_precision' => $this->integer('calculation_precision', 2),
]);
// Ensure input_parameters is an array
if ($this->has('input_parameters') && !is_array($this->input('input_parameters'))) {
$params = json_decode($this->input('input_parameters'), true);
if (json_last_error() === JSON_ERROR_NONE) {
$this->merge(['input_parameters' => $params]);
}
}
}
/**
* Configure the validator instance.
*/
public function withValidator($validator)
{
$validator->after(function ($validator) {
$this->validateInputParameters($validator);
$this->validateBomTemplate($validator);
$this->validateParameterValues($validator);
});
}
/**
* Validate input parameters against model parameter definitions.
*/
private function validateInputParameters($validator): void
{
$modelId = $this->route('modelId');
$inputParameters = $this->input('input_parameters', []);
if (empty($inputParameters)) {
return;
}
// Get model's INPUT parameters
$modelParameters = ModelParameter::where('model_id', $modelId)
->where('parameter_type', 'INPUT')
->where('tenant_id', auth()->user()?->currentTenant?->id)
->whereNull('deleted_at')
->get()
->keyBy('parameter_name');
// Check for required parameters
$requiredParams = $modelParameters->where('is_required', true)->pluck('parameter_name')->toArray();
$providedParams = array_keys($inputParameters);
$missingRequired = array_diff($requiredParams, $providedParams);
if (!empty($missingRequired)) {
$validator->errors()->add('input_parameters',
'다음 필수 매개변수가 누락되었습니다: ' . implode(', ', $missingRequired)
);
}
// Check for unknown parameters
$knownParams = $modelParameters->pluck('parameter_name')->toArray();
$unknownParams = array_diff($providedParams, $knownParams);
if (!empty($unknownParams)) {
$validator->errors()->add('input_parameters',
'알 수 없는 매개변수가 포함되어 있습니다: ' . implode(', ', $unknownParams)
);
}
}
/**
* Validate BOM template exists and belongs to the model.
*/
private function validateBomTemplate($validator): void
{
$bomTemplateId = $this->input('bom_template_id');
$modelId = $this->route('modelId');
if (!$bomTemplateId) {
return;
}
$template = BomTemplate::where('id', $bomTemplateId)
->where('tenant_id', auth()->user()?->currentTenant?->id)
->whereNull('deleted_at')
->first();
if (!$template) {
$validator->errors()->add('bom_template_id', '지정된 BOM 템플릿이 존재하지 않습니다.');
return;
}
// Check if template belongs to the model (through model_version)
if ($template->modelVersion && $template->modelVersion->model_id != $modelId) {
$validator->errors()->add('bom_template_id', 'BOM 템플릿이 해당 모델에 속하지 않습니다.');
}
}
/**
* Validate parameter values against their constraints.
*/
private function validateParameterValues($validator): void
{
$modelId = $this->route('modelId');
$inputParameters = $this->input('input_parameters', []);
if (empty($inputParameters)) {
return;
}
// Get model parameter definitions
$modelParameters = ModelParameter::where('model_id', $modelId)
->where('parameter_type', 'INPUT')
->where('tenant_id', auth()->user()?->currentTenant?->id)
->whereNull('deleted_at')
->get()
->keyBy('parameter_name');
foreach ($inputParameters as $paramName => $value) {
$parameter = $modelParameters->get($paramName);
if (!$parameter) {
continue; // Unknown parameter already handled above
}
// Validate value against parameter constraints
$this->validateParameterValue($validator, $parameter, $paramName, $value);
}
}
/**
* Validate individual parameter value.
*/
private function validateParameterValue($validator, $parameter, string $paramName, $value): void
{
// Check for null/empty required values
if ($parameter->is_required && ($value === null || $value === '')) {
$validator->errors()->add("input_parameters.{$paramName}", "{$paramName}은(는) 필수 매개변수입니다.");
return;
}
// Validate data type
switch ($parameter->data_type ?? 'STRING') {
case 'INTEGER':
if (!is_numeric($value) || (int)$value != $value) {
$validator->errors()->add("input_parameters.{$paramName}", "{$paramName}은(는) 정수여야 합니다.");
return;
}
$value = (int)$value;
break;
case 'DECIMAL':
if (!is_numeric($value)) {
$validator->errors()->add("input_parameters.{$paramName}", "{$paramName}은(는) 숫자여야 합니다.");
return;
}
$value = (float)$value;
break;
case 'BOOLEAN':
if (!is_bool($value) && !in_array($value, [0, 1, '0', '1', 'true', 'false'])) {
$validator->errors()->add("input_parameters.{$paramName}", "{$paramName}은(는) 불린 값이어야 합니다.");
return;
}
break;
case 'STRING':
if (!is_string($value) && !is_numeric($value)) {
$validator->errors()->add("input_parameters.{$paramName}", "{$paramName}은(는) 문자열이어야 합니다.");
return;
}
$value = (string)$value;
break;
}
// Validate min/max values for numeric types
if (in_array($parameter->data_type, ['INTEGER', 'DECIMAL']) && is_numeric($value)) {
if ($parameter->min_value !== null && $value < $parameter->min_value) {
$validator->errors()->add("input_parameters.{$paramName}",
"{$paramName}은(는) {$parameter->min_value} 이상이어야 합니다."
);
}
if ($parameter->max_value !== null && $value > $parameter->max_value) {
$validator->errors()->add("input_parameters.{$paramName}",
"{$paramName}은(는) {$parameter->max_value} 이하여야 합니다."
);
}
}
// Validate options for select type parameters
if (!empty($parameter->options) && !in_array($value, $parameter->options)) {
$validOptions = implode(', ', $parameter->options);
$validator->errors()->add("input_parameters.{$paramName}",
"{$paramName}의 값은 다음 중 하나여야 합니다: {$validOptions}"
);
}
}
}

View File

@@ -0,0 +1,264 @@
<?php
namespace App\Http\Requests\Api\V1\Design;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use App\Models\Design\ModelParameter;
class ModelFormulaFormRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true; // Authorization handled by middleware
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
$modelId = $this->route('modelId');
$formulaId = $this->route('formulaId');
$rules = [
'formula_name' => [
'required',
'string',
'max:100',
'regex:/^[a-zA-Z][a-zA-Z0-9_\s]*$/',
Rule::unique('model_formulas')
->where('model_id', $modelId)
->where('tenant_id', auth()->user()?->currentTenant?->id)
->whereNull('deleted_at')
],
'formula_expression' => ['required', 'string', 'max:1000'],
'unit' => ['nullable', 'string', 'max:20'],
'description' => ['nullable', 'string', 'max:500'],
'calculation_order' => ['integer', 'min:0'],
'dependencies' => ['nullable', 'array'],
'dependencies.*' => ['string', 'max:50', 'regex:/^[a-zA-Z][a-zA-Z0-9_]*$/'],
];
// For update requests, ignore current record in unique validation
if ($formulaId) {
$rules['formula_name'][4] = $rules['formula_name'][4]->ignore($formulaId);
}
return $rules;
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'formula_name.required' => '공식 이름은 필수입니다.',
'formula_name.regex' => '공식 이름은 영문자로 시작하고 영문자, 숫자, 언더스코어, 공백만 사용할 수 있습니다.',
'formula_name.unique' => '해당 모델에 이미 동일한 공식 이름이 존재합니다.',
'formula_expression.required' => '공식 표현식은 필수입니다.',
'formula_expression.max' => '공식 표현식은 1000자를 초과할 수 없습니다.',
'unit.max' => '단위는 20자를 초과할 수 없습니다.',
'description.max' => '설명은 500자를 초과할 수 없습니다.',
'calculation_order.min' => '계산 순서는 0 이상이어야 합니다.',
'dependencies.array' => '의존성은 배열 형태여야 합니다.',
'dependencies.*.regex' => '의존성 변수명은 영문자로 시작하고 영문자, 숫자, 언더스코어만 사용할 수 있습니다.',
];
}
/**
* Get custom attribute names for validator errors.
*/
public function attributes(): array
{
return [
'formula_name' => '공식 이름',
'formula_expression' => '공식 표현식',
'unit' => '단위',
'description' => '설명',
'calculation_order' => '계산 순서',
'dependencies' => '의존성',
];
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation(): void
{
$this->merge([
'calculation_order' => $this->integer('calculation_order', 0),
]);
// Convert dependencies to array if it's a string
if ($this->has('dependencies') && is_string($this->input('dependencies'))) {
$dependencies = json_decode($this->input('dependencies'), true);
if (json_last_error() === JSON_ERROR_NONE) {
$this->merge(['dependencies' => $dependencies]);
}
}
// Clean up formula expression - remove extra whitespace
if ($this->has('formula_expression')) {
$expression = preg_replace('/\s+/', ' ', trim($this->input('formula_expression')));
$this->merge(['formula_expression' => $expression]);
}
}
/**
* Configure the validator instance.
*/
public function withValidator($validator)
{
$validator->after(function ($validator) {
$this->validateFormulaExpression($validator);
$this->validateDependencies($validator);
$this->validateNoCircularDependency($validator);
});
}
/**
* Validate formula expression syntax.
*/
private function validateFormulaExpression($validator): void
{
$expression = $this->input('formula_expression');
if (!$expression) {
return;
}
// Check for potentially dangerous characters or functions
$dangerousPatterns = [
'/\b(eval|exec|system|shell_exec|passthru|file_get_contents|file_put_contents|fopen|fwrite)\b/i',
'/[;{}]/', // Semicolons and braces
'/\$[a-zA-Z_]/', // PHP variables
'/\bfunction\s*\(/i', // Function definitions
];
foreach ($dangerousPatterns as $pattern) {
if (preg_match($pattern, $expression)) {
$validator->errors()->add('formula_expression', '공식 표현식에 허용되지 않는 문자나 함수가 포함되어 있습니다.');
return;
}
}
// Validate mathematical expression format
if (!$this->isValidMathExpression($expression)) {
$validator->errors()->add('formula_expression', '공식 표현식의 형식이 올바르지 않습니다. 수학 연산자와 변수명만 사용할 수 있습니다.');
}
// Extract variables from expression and validate they exist as parameters
$this->validateExpressionVariables($validator, $expression);
}
/**
* Check if expression contains valid mathematical operations.
*/
private function isValidMathExpression(string $expression): bool
{
// Allow: numbers, variables, basic math operators, parentheses, math functions
$allowedPattern = '/^[a-zA-Z0-9_\s\+\-\*\/\(\)\.\,]+$/';
// Allow common math functions
$mathFunctions = ['sin', 'cos', 'tan', 'log', 'exp', 'sqrt', 'pow', 'abs', 'ceil', 'floor', 'round', 'max', 'min'];
$functionPattern = '/\b(' . implode('|', $mathFunctions) . ')\s*\(/i';
// Remove math functions for basic pattern check
$cleanExpression = preg_replace($functionPattern, '', $expression);
return preg_match($allowedPattern, $cleanExpression);
}
/**
* Validate that variables in expression exist as model parameters.
*/
private function validateExpressionVariables($validator, string $expression): void
{
$modelId = $this->route('modelId');
// Extract variable names from expression
preg_match_all('/\b[a-zA-Z][a-zA-Z0-9_]*\b/', $expression, $matches);
$variables = $matches[0];
// Remove math functions from variables
$mathFunctions = ['sin', 'cos', 'tan', 'log', 'exp', 'sqrt', 'pow', 'abs', 'ceil', 'floor', 'round', 'max', 'min'];
$variables = array_diff($variables, $mathFunctions);
if (empty($variables)) {
return;
}
// Get existing parameters for this model
$existingParameters = ModelParameter::where('model_id', $modelId)
->where('tenant_id', auth()->user()?->currentTenant?->id)
->whereNull('deleted_at')
->pluck('parameter_name')
->toArray();
// Check for undefined variables
$undefinedVariables = array_diff($variables, $existingParameters);
if (!empty($undefinedVariables)) {
$validator->errors()->add('formula_expression',
'공식에 정의되지 않은 변수가 사용되었습니다: ' . implode(', ', $undefinedVariables)
);
}
}
/**
* Validate dependencies array.
*/
private function validateDependencies($validator): void
{
$dependencies = $this->input('dependencies', []);
$modelId = $this->route('modelId');
if (empty($dependencies)) {
return;
}
// Get existing parameters for this model
$existingParameters = ModelParameter::where('model_id', $modelId)
->where('tenant_id', auth()->user()?->currentTenant?->id)
->whereNull('deleted_at')
->pluck('parameter_name')
->toArray();
// Check that all dependencies exist as parameters
$invalidDependencies = array_diff($dependencies, $existingParameters);
if (!empty($invalidDependencies)) {
$validator->errors()->add('dependencies',
'존재하지 않는 매개변수가 의존성에 포함되어 있습니다: ' . implode(', ', $invalidDependencies)
);
}
}
/**
* Validate there's no circular dependency.
*/
private function validateNoCircularDependency($validator): void
{
$dependencies = $this->input('dependencies', []);
$formulaName = $this->input('formula_name');
if (empty($dependencies) || !$formulaName) {
return;
}
// Check for direct self-reference
if (in_array($formulaName, $dependencies)) {
$validator->errors()->add('dependencies', '공식이 자기 자신을 참조할 수 없습니다.');
return;
}
// For more complex circular dependency check, this would require
// analyzing all formulas in the model - simplified version here
// In production, implement full dependency graph analysis
}
}

View File

@@ -0,0 +1,172 @@
<?php
namespace App\Http\Requests\Api\V1\Design;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ModelParameterFormRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true; // Authorization handled by middleware
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
$modelId = $this->route('modelId');
$parameterId = $this->route('parameterId');
$rules = [
'parameter_name' => [
'required',
'string',
'max:50',
'regex:/^[a-zA-Z][a-zA-Z0-9_]*$/',
Rule::unique('model_parameters')
->where('model_id', $modelId)
->where('tenant_id', auth()->user()?->currentTenant?->id)
->whereNull('deleted_at')
],
'parameter_type' => ['required', 'string', 'in:INPUT,OUTPUT'],
'is_required' => ['boolean'],
'default_value' => ['nullable', 'string', 'max:255'],
'min_value' => ['nullable', 'numeric'],
'max_value' => ['nullable', 'numeric', 'gte:min_value'],
'unit' => ['nullable', 'string', 'max:20'],
'options' => ['nullable', 'array'],
'options.*' => ['string', 'max:100'],
'description' => ['nullable', 'string', 'max:500'],
'sort_order' => ['integer', 'min:0'],
];
// For update requests, ignore current record in unique validation
if ($parameterId) {
$rules['parameter_name'][4] = $rules['parameter_name'][4]->ignore($parameterId);
}
return $rules;
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'parameter_name.required' => '매개변수 이름은 필수입니다.',
'parameter_name.regex' => '매개변수 이름은 영문자로 시작하고 영문자, 숫자, 언더스코어만 사용할 수 있습니다.',
'parameter_name.unique' => '해당 모델에 이미 동일한 매개변수 이름이 존재합니다.',
'parameter_type.required' => '매개변수 타입은 필수입니다.',
'parameter_type.in' => '매개변수 타입은 INPUT 또는 OUTPUT이어야 합니다.',
'min_value.numeric' => '최소값은 숫자여야 합니다.',
'max_value.numeric' => '최대값은 숫자여야 합니다.',
'max_value.gte' => '최대값은 최소값보다 크거나 같아야 합니다.',
'unit.max' => '단위는 20자를 초과할 수 없습니다.',
'description.max' => '설명은 500자를 초과할 수 없습니다.',
'sort_order.min' => '정렬 순서는 0 이상이어야 합니다.',
];
}
/**
* Get custom attribute names for validator errors.
*/
public function attributes(): array
{
return [
'parameter_name' => '매개변수 이름',
'parameter_type' => '매개변수 타입',
'is_required' => '필수 여부',
'default_value' => '기본값',
'min_value' => '최소값',
'max_value' => '최대값',
'unit' => '단위',
'options' => '옵션 목록',
'description' => '설명',
'sort_order' => '정렬 순서',
];
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation(): void
{
$this->merge([
'is_required' => $this->boolean('is_required', false),
'sort_order' => $this->integer('sort_order', 0),
]);
// Convert options to array if it's a string
if ($this->has('options') && is_string($this->input('options'))) {
$options = json_decode($this->input('options'), true);
if (json_last_error() === JSON_ERROR_NONE) {
$this->merge(['options' => $options]);
}
}
}
/**
* Configure the validator instance.
*/
public function withValidator($validator)
{
$validator->after(function ($validator) {
// Validate that INPUT parameters can have default values and constraints
if ($this->input('parameter_type') === 'INPUT') {
$this->validateInputParameterConstraints($validator);
}
// Validate that OUTPUT parameters don't have input-specific fields
if ($this->input('parameter_type') === 'OUTPUT') {
$this->validateOutputParameterConstraints($validator);
}
// Validate min/max value relationship
$this->validateMinMaxValues($validator);
});
}
/**
* Validate INPUT parameter specific constraints.
*/
private function validateInputParameterConstraints($validator): void
{
// INPUT parameters can have all constraints
// No additional validation needed for INPUT type
}
/**
* Validate OUTPUT parameter specific constraints.
*/
private function validateOutputParameterConstraints($validator): void
{
// OUTPUT parameters should not have certain input-specific fields
if ($this->filled('is_required') && $this->input('is_required')) {
$validator->errors()->add('is_required', 'OUTPUT 매개변수는 필수 항목이 될 수 없습니다.');
}
if ($this->filled('default_value')) {
$validator->errors()->add('default_value', 'OUTPUT 매개변수는 기본값을 가질 수 없습니다.');
}
}
/**
* Validate min/max value relationship.
*/
private function validateMinMaxValues($validator): void
{
$minValue = $this->input('min_value');
$maxValue = $this->input('max_value');
if ($minValue !== null && $maxValue !== null && $minValue > $maxValue) {
$validator->errors()->add('max_value', '최대값은 최소값보다 크거나 같아야 합니다.');
}
}
}

View File

@@ -0,0 +1,388 @@
<?php
namespace App\Http\Requests\Api\V1\Design;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use App\Models\Design\ModelParameter;
use App\Models\Design\BomTemplate;
use App\Models\Category;
use App\Models\Product;
class ProductFromModelFormRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true; // Authorization handled by middleware
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
$tenantId = auth()->user()?->currentTenant?->id;
return [
// Model and BOM configuration
'input_parameters' => ['required', 'array', 'min:1'],
'input_parameters.*' => ['required'],
'bom_template_id' => ['sometimes', 'integer', 'min:1'],
// Product basic information
'product_code' => [
'required',
'string',
'max:50',
'regex:/^[A-Z0-9_-]+$/',
Rule::unique('products')
->where('tenant_id', $tenantId)
->whereNull('deleted_at')
],
'product_name' => ['required', 'string', 'max:100'],
'description' => ['nullable', 'string', 'max:1000'],
// Product categorization
'category_id' => ['sometimes', 'integer', 'min:1'],
'product_type' => ['nullable', 'string', 'in:PRODUCT,PART,SUBASSEMBLY'],
// Product specifications
'unit' => ['nullable', 'string', 'max:20'],
'min_order_qty' => ['nullable', 'numeric', 'min:0'],
'lead_time_days' => ['nullable', 'integer', 'min:0', 'max:365'],
'is_active' => ['boolean'],
// Product creation options
'create_bom_items' => ['boolean'],
'validate_bom' => ['boolean'],
'save_parameters' => ['boolean'],
'auto_generate_variants' => ['boolean'],
// Pricing and cost
'base_cost' => ['nullable', 'numeric', 'min:0'],
'markup_percentage' => ['nullable', 'numeric', 'min:0', 'max:1000'],
// Additional attributes (dynamic based on category)
'attributes' => ['sometimes', 'array'],
'attributes.*' => ['nullable'],
// Tags and classification
'tags' => ['sometimes', 'array'],
'tags.*' => ['string', 'max:50'],
];
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'input_parameters.required' => '입력 매개변수는 필수입니다.',
'input_parameters.array' => '입력 매개변수는 배열 형태여야 합니다.',
'input_parameters.min' => '최소 하나 이상의 입력 매개변수가 필요합니다.',
'input_parameters.*.required' => '모든 입력 매개변수 값은 필수입니다.',
'product_code.required' => '제품 코드는 필수입니다.',
'product_code.regex' => '제품 코드는 대문자, 숫자, 언더스코어, 하이픈만 사용할 수 있습니다.',
'product_code.unique' => '이미 존재하는 제품 코드입니다.',
'product_name.required' => '제품명은 필수입니다.',
'product_name.max' => '제품명은 100자를 초과할 수 없습니다.',
'description.max' => '설명은 1000자를 초과할 수 없습니다.',
'category_id.integer' => '카테고리 ID는 정수여야 합니다.',
'category_id.min' => '카테고리 ID는 1 이상이어야 합니다.',
'product_type.in' => '제품 타입은 PRODUCT, PART, SUBASSEMBLY 중 하나여야 합니다.',
'unit.max' => '단위는 20자를 초과할 수 없습니다.',
'min_order_qty.numeric' => '최소 주문 수량은 숫자여야 합니다.',
'min_order_qty.min' => '최소 주문 수량은 0 이상이어야 합니다.',
'lead_time_days.integer' => '리드타임은 정수여야 합니다.',
'lead_time_days.min' => '리드타임은 0 이상이어야 합니다.',
'lead_time_days.max' => '리드타임은 365일을 초과할 수 없습니다.',
'base_cost.numeric' => '기본 원가는 숫자여야 합니다.',
'base_cost.min' => '기본 원가는 0 이상이어야 합니다.',
'markup_percentage.numeric' => '마크업 비율은 숫자여야 합니다.',
'markup_percentage.min' => '마크업 비율은 0 이상이어야 합니다.',
'markup_percentage.max' => '마크업 비율은 1000%를 초과할 수 없습니다.',
'bom_template_id.integer' => 'BOM 템플릿 ID는 정수여야 합니다.',
'bom_template_id.min' => 'BOM 템플릿 ID는 1 이상이어야 합니다.',
'tags.array' => '태그는 배열 형태여야 합니다.',
'tags.*.max' => '각 태그는 50자를 초과할 수 없습니다.',
];
}
/**
* Get custom attribute names for validator errors.
*/
public function attributes(): array
{
return [
'input_parameters' => '입력 매개변수',
'bom_template_id' => 'BOM 템플릿 ID',
'product_code' => '제품 코드',
'product_name' => '제품명',
'description' => '설명',
'category_id' => '카테고리 ID',
'product_type' => '제품 타입',
'unit' => '단위',
'min_order_qty' => '최소 주문 수량',
'lead_time_days' => '리드타임',
'is_active' => '활성 상태',
'create_bom_items' => 'BOM 아이템 생성',
'validate_bom' => 'BOM 유효성 검사',
'save_parameters' => '매개변수 저장',
'auto_generate_variants' => '자동 변형 생성',
'base_cost' => '기본 원가',
'markup_percentage' => '마크업 비율',
'attributes' => '추가 속성',
'tags' => '태그',
];
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation(): void
{
$this->merge([
'is_active' => $this->boolean('is_active', true),
'create_bom_items' => $this->boolean('create_bom_items', true),
'validate_bom' => $this->boolean('validate_bom', true),
'save_parameters' => $this->boolean('save_parameters', true),
'auto_generate_variants' => $this->boolean('auto_generate_variants', false),
]);
// Ensure input_parameters is an array
if ($this->has('input_parameters') && !is_array($this->input('input_parameters'))) {
$params = json_decode($this->input('input_parameters'), true);
if (json_last_error() === JSON_ERROR_NONE) {
$this->merge(['input_parameters' => $params]);
}
}
// Ensure attributes is an array
if ($this->has('attributes') && !is_array($this->input('attributes'))) {
$attributes = json_decode($this->input('attributes'), true);
if (json_last_error() === JSON_ERROR_NONE) {
$this->merge(['attributes' => $attributes]);
}
}
// Ensure tags is an array
if ($this->has('tags') && !is_array($this->input('tags'))) {
$tags = json_decode($this->input('tags'), true);
if (json_last_error() === JSON_ERROR_NONE) {
$this->merge(['tags' => $tags]);
}
}
// Set default product_type if not provided
if (!$this->has('product_type')) {
$this->merge(['product_type' => 'PRODUCT']);
}
// Convert product_code to uppercase
if ($this->has('product_code')) {
$this->merge(['product_code' => strtoupper($this->input('product_code'))]);
}
}
/**
* Configure the validator instance.
*/
public function withValidator($validator)
{
$validator->after(function ($validator) {
$this->validateInputParameters($validator);
$this->validateBomTemplate($validator);
$this->validateCategory($validator);
$this->validateParameterValues($validator);
$this->validateBusinessRules($validator);
});
}
/**
* Validate input parameters against model parameter definitions.
*/
private function validateInputParameters($validator): void
{
$modelId = $this->route('modelId');
$inputParameters = $this->input('input_parameters', []);
if (empty($inputParameters)) {
return;
}
// Get model's INPUT parameters
$modelParameters = ModelParameter::where('model_id', $modelId)
->where('parameter_type', 'INPUT')
->where('tenant_id', auth()->user()?->currentTenant?->id)
->whereNull('deleted_at')
->get()
->keyBy('parameter_name');
// Check for required parameters
$requiredParams = $modelParameters->where('is_required', true)->pluck('parameter_name')->toArray();
$providedParams = array_keys($inputParameters);
$missingRequired = array_diff($requiredParams, $providedParams);
if (!empty($missingRequired)) {
$validator->errors()->add('input_parameters',
'다음 필수 매개변수가 누락되었습니다: ' . implode(', ', $missingRequired)
);
}
// Check for unknown parameters
$knownParams = $modelParameters->pluck('parameter_name')->toArray();
$unknownParams = array_diff($providedParams, $knownParams);
if (!empty($unknownParams)) {
$validator->errors()->add('input_parameters',
'알 수 없는 매개변수가 포함되어 있습니다: ' . implode(', ', $unknownParams)
);
}
}
/**
* Validate BOM template exists and belongs to the model.
*/
private function validateBomTemplate($validator): void
{
$bomTemplateId = $this->input('bom_template_id');
$modelId = $this->route('modelId');
if (!$bomTemplateId) {
return;
}
$template = BomTemplate::where('id', $bomTemplateId)
->where('tenant_id', auth()->user()?->currentTenant?->id)
->whereNull('deleted_at')
->first();
if (!$template) {
$validator->errors()->add('bom_template_id', '지정된 BOM 템플릿이 존재하지 않습니다.');
return;
}
// Check if template belongs to the model (through model_version)
if ($template->modelVersion && $template->modelVersion->model_id != $modelId) {
$validator->errors()->add('bom_template_id', 'BOM 템플릿이 해당 모델에 속하지 않습니다.');
}
}
/**
* Validate category exists and is accessible.
*/
private function validateCategory($validator): void
{
$categoryId = $this->input('category_id');
if (!$categoryId) {
return;
}
$category = Category::where('id', $categoryId)
->where('tenant_id', auth()->user()?->currentTenant?->id)
->whereNull('deleted_at')
->first();
if (!$category) {
$validator->errors()->add('category_id', '지정된 카테고리가 존재하지 않습니다.');
}
}
/**
* Validate parameter values against their constraints.
*/
private function validateParameterValues($validator): void
{
$modelId = $this->route('modelId');
$inputParameters = $this->input('input_parameters', []);
if (empty($inputParameters)) {
return;
}
// Get model parameter definitions
$modelParameters = ModelParameter::where('model_id', $modelId)
->where('parameter_type', 'INPUT')
->where('tenant_id', auth()->user()?->currentTenant?->id)
->whereNull('deleted_at')
->get()
->keyBy('parameter_name');
foreach ($inputParameters as $paramName => $value) {
$parameter = $modelParameters->get($paramName);
if (!$parameter) {
continue; // Unknown parameter already handled above
}
// Validate value against parameter constraints
$this->validateParameterValue($validator, $parameter, $paramName, $value);
}
}
/**
* Validate individual parameter value.
*/
private function validateParameterValue($validator, $parameter, string $paramName, $value): void
{
// Check for null/empty required values
if ($parameter->is_required && ($value === null || $value === '')) {
$validator->errors()->add("input_parameters.{$paramName}", "{$paramName}은(는) 필수 매개변수입니다.");
return;
}
// Skip validation for empty optional parameters
if (!$parameter->is_required && ($value === null || $value === '')) {
return;
}
// Use model's validation method if available
if (method_exists($parameter, 'validateValue') && !$parameter->validateValue($value)) {
$validator->errors()->add("input_parameters.{$paramName}", "{$paramName}의 값이 유효하지 않습니다.");
}
}
/**
* Validate business rules specific to product creation.
*/
private function validateBusinessRules($validator): void
{
// If validate_bom is true, ensure we have enough data for BOM validation
if ($this->input('validate_bom', true) && !$this->input('bom_template_id')) {
// Could check if model has default BOM template or condition rules
// For now, just warn
}
// If auto_generate_variants is true, validate variant generation is possible
if ($this->input('auto_generate_variants', false)) {
// Check if model has variant-generating parameters
// This would require checking parameter configurations
}
// Validate pricing logic
$baseCost = $this->input('base_cost');
$markupPercentage = $this->input('markup_percentage');
if ($baseCost !== null && $markupPercentage !== null) {
if ($baseCost == 0 && $markupPercentage > 0) {
$validator->errors()->add('markup_percentage', '기본 원가가 0인 경우 마크업을 설정할 수 없습니다.');
}
}
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Http\Requests\Api\V1\ModelFormula;
use Illuminate\Foundation\Http\FormRequest;
class CreateModelFormulaRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:100'],
'target_parameter' => ['required', 'string', 'max:50', 'regex:/^[a-zA-Z][a-zA-Z0-9_]*$/'],
'expression' => ['required', 'string', 'max:1000'],
'description' => ['nullable', 'string', 'max:500'],
'is_active' => ['boolean'],
'execution_order' => ['integer', 'min:0'],
];
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'target_parameter.regex' => __('validation.model_formula.target_parameter_format'),
];
}
/**
* Get custom attribute names for validator errors.
*/
public function attributes(): array
{
return [
'name' => __('validation.attributes.formula_name'),
'target_parameter' => __('validation.attributes.target_parameter'),
'expression' => __('validation.attributes.formula_expression'),
'description' => __('validation.attributes.description'),
'is_active' => __('validation.attributes.is_active'),
'execution_order' => __('validation.attributes.execution_order'),
];
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation(): void
{
$this->merge([
'is_active' => $this->boolean('is_active', true),
'execution_order' => $this->integer('execution_order', 0),
]);
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\Api\V1\ModelFormula;
use App\Http\Requests\Api\V1\PaginateRequest;
class IndexModelFormulaRequest extends PaginateRequest
{
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return array_merge(parent::rules(), [
'search' => ['sometimes', 'string', 'max:255'],
'target_parameter' => ['sometimes', 'string', 'max:50'],
]);
}
/**
* Get custom attribute names for validator errors.
*/
public function attributes(): array
{
return array_merge(parent::attributes(), [
'search' => __('validation.attributes.search'),
'target_parameter' => __('validation.attributes.target_parameter'),
]);
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Http\Requests\Api\V1\ModelFormula;
use Illuminate\Foundation\Http\FormRequest;
class UpdateModelFormulaRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'name' => ['sometimes', 'string', 'max:100'],
'target_parameter' => ['sometimes', 'string', 'max:50', 'regex:/^[a-zA-Z][a-zA-Z0-9_]*$/'],
'expression' => ['sometimes', 'string', 'max:1000'],
'description' => ['nullable', 'string', 'max:500'],
'is_active' => ['sometimes', 'boolean'],
'execution_order' => ['sometimes', 'integer', 'min:0'],
];
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'target_parameter.regex' => __('validation.model_formula.target_parameter_format'),
];
}
/**
* Get custom attribute names for validator errors.
*/
public function attributes(): array
{
return [
'name' => __('validation.attributes.formula_name'),
'target_parameter' => __('validation.attributes.target_parameter'),
'expression' => __('validation.attributes.formula_expression'),
'description' => __('validation.attributes.description'),
'is_active' => __('validation.attributes.is_active'),
'execution_order' => __('validation.attributes.execution_order'),
];
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation(): void
{
if ($this->has('is_active')) {
$this->merge(['is_active' => $this->boolean('is_active')]);
}
if ($this->has('execution_order')) {
$this->merge(['execution_order' => $this->integer('execution_order')]);
}
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Http\Requests\Api\V1\ModelParameter;
use Illuminate\Foundation\Http\FormRequest;
class CreateModelParameterRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:50', 'regex:/^[a-zA-Z][a-zA-Z0-9_]*$/'],
'label' => ['required', 'string', 'max:100'],
'type' => ['required', 'string', 'in:INPUT,OUTPUT'],
'data_type' => ['required', 'string', 'in:INTEGER,DECIMAL,STRING,BOOLEAN'],
'unit' => ['nullable', 'string', 'max:20'],
'default_value' => ['nullable', 'string', 'max:255'],
'min_value' => ['nullable', 'numeric'],
'max_value' => ['nullable', 'numeric', 'gte:min_value'],
'enum_values' => ['nullable', 'array'],
'enum_values.*' => ['string', 'max:100'],
'validation_rules' => ['nullable', 'string', 'max:500'],
'description' => ['nullable', 'string', 'max:500'],
'is_required' => ['boolean'],
'display_order' => ['integer', 'min:0'],
];
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'name.regex' => __('validation.model_parameter.name_format'),
'max_value.gte' => __('validation.model_parameter.max_value_gte_min'),
];
}
/**
* Get custom attribute names for validator errors.
*/
public function attributes(): array
{
return [
'name' => __('validation.attributes.parameter_name'),
'label' => __('validation.attributes.parameter_label'),
'type' => __('validation.attributes.parameter_type'),
'data_type' => __('validation.attributes.data_type'),
'unit' => __('validation.attributes.unit'),
'default_value' => __('validation.attributes.default_value'),
'min_value' => __('validation.attributes.min_value'),
'max_value' => __('validation.attributes.max_value'),
'enum_values' => __('validation.attributes.enum_values'),
'validation_rules' => __('validation.attributes.validation_rules'),
'description' => __('validation.attributes.description'),
'is_required' => __('validation.attributes.is_required'),
'display_order' => __('validation.attributes.display_order'),
];
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation(): void
{
$this->merge([
'is_required' => $this->boolean('is_required'),
'display_order' => $this->integer('display_order', 0),
]);
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\Api\V1\ModelParameter;
use App\Http\Requests\Api\V1\PaginateRequest;
class IndexModelParameterRequest extends PaginateRequest
{
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return array_merge(parent::rules(), [
'search' => ['sometimes', 'string', 'max:255'],
'type' => ['sometimes', 'string', 'in:INPUT,OUTPUT'],
]);
}
/**
* Get custom attribute names for validator errors.
*/
public function attributes(): array
{
return array_merge(parent::attributes(), [
'search' => __('validation.attributes.search'),
'type' => __('validation.attributes.parameter_type'),
]);
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Http\Requests\Api\V1\ModelParameter;
use Illuminate\Foundation\Http\FormRequest;
class UpdateModelParameterRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'name' => ['sometimes', 'string', 'max:50', 'regex:/^[a-zA-Z][a-zA-Z0-9_]*$/'],
'label' => ['sometimes', 'string', 'max:100'],
'type' => ['sometimes', 'string', 'in:INPUT,OUTPUT'],
'data_type' => ['sometimes', 'string', 'in:INTEGER,DECIMAL,STRING,BOOLEAN'],
'unit' => ['nullable', 'string', 'max:20'],
'default_value' => ['nullable', 'string', 'max:255'],
'min_value' => ['nullable', 'numeric'],
'max_value' => ['nullable', 'numeric', 'gte:min_value'],
'enum_values' => ['nullable', 'array'],
'enum_values.*' => ['string', 'max:100'],
'validation_rules' => ['nullable', 'string', 'max:500'],
'description' => ['nullable', 'string', 'max:500'],
'is_required' => ['sometimes', 'boolean'],
'display_order' => ['sometimes', 'integer', 'min:0'],
];
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'name.regex' => __('validation.model_parameter.name_format'),
'max_value.gte' => __('validation.model_parameter.max_value_gte_min'),
];
}
/**
* Get custom attribute names for validator errors.
*/
public function attributes(): array
{
return [
'name' => __('validation.attributes.parameter_name'),
'label' => __('validation.attributes.parameter_label'),
'type' => __('validation.attributes.parameter_type'),
'data_type' => __('validation.attributes.data_type'),
'unit' => __('validation.attributes.unit'),
'default_value' => __('validation.attributes.default_value'),
'min_value' => __('validation.attributes.min_value'),
'max_value' => __('validation.attributes.max_value'),
'enum_values' => __('validation.attributes.enum_values'),
'validation_rules' => __('validation.attributes.validation_rules'),
'description' => __('validation.attributes.description'),
'is_required' => __('validation.attributes.is_required'),
'display_order' => __('validation.attributes.display_order'),
];
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation(): void
{
if ($this->has('is_required')) {
$this->merge(['is_required' => $this->boolean('is_required')]);
}
if ($this->has('display_order')) {
$this->merge(['display_order' => $this->integer('display_order')]);
}
}
}

View File

@@ -0,0 +1,169 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Shared\Models\Products\BomConditionRule;
class BomConditionRuleRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'model_id' => 'required|integer|exists:models,id',
'name' => 'required|string|max:100',
'condition_expression' => 'required|string|max:1000',
'action' => 'required|string|in:' . implode(',', BomConditionRule::ACTIONS),
'target_items' => 'required|array|min:1',
'target_items.*.product_id' => 'nullable|integer|exists:products,id',
'target_items.*.material_id' => 'nullable|integer|exists:materials,id',
'target_items.*.quantity' => 'nullable|numeric|min:0',
'target_items.*.waste_rate' => 'nullable|numeric|min:0|max:100',
'target_items.*.unit' => 'nullable|string|max:20',
'target_items.*.memo' => 'nullable|string|max:200',
'priority' => 'nullable|integer|min:1',
'description' => 'nullable|string|max:500',
'is_active' => 'boolean',
];
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'condition_expression.required' => __('error.condition_expression_required'),
'target_items.required' => __('error.target_items_required'),
'target_items.min' => __('error.target_items_required'),
'target_items.*.quantity.min' => __('error.quantity_must_be_positive'),
'target_items.*.waste_rate.max' => __('error.waste_rate_too_high'),
];
}
/**
* Configure the validator instance.
*/
public function withValidator($validator): void
{
$validator->after(function ($validator) {
$this->validateRuleNameUnique($validator);
$this->validateConditionExpression($validator);
$this->validateTargetItems($validator);
});
}
/**
* 규칙명 중복 검증
*/
private function validateRuleNameUnique($validator): void
{
if (!$this->input('model_id') || !$this->input('name')) {
return;
}
$query = BomConditionRule::where('model_id', $this->input('model_id'))
->where('name', $this->input('name'));
// 수정 시 자기 자신 제외
if ($this->route('id')) {
$query->where('id', '!=', $this->route('id'));
}
if ($query->exists()) {
$validator->errors()->add('name', __('error.rule_name_duplicate'));
}
}
/**
* 조건식 검증
*/
private function validateConditionExpression($validator): void
{
if (!$this->input('condition_expression')) {
return;
}
$tempRule = new BomConditionRule([
'condition_expression' => $this->input('condition_expression'),
'model_id' => $this->input('model_id'),
]);
$conditionErrors = $tempRule->validateConditionExpression();
if (!empty($conditionErrors)) {
foreach ($conditionErrors as $error) {
$validator->errors()->add('condition_expression', $error);
}
}
}
/**
* 대상 아이템 검증
*/
private function validateTargetItems($validator): void
{
$targetItems = $this->input('target_items', []);
$action = $this->input('action');
foreach ($targetItems as $index => $item) {
// 제품 또는 자재 참조 필수
if (empty($item['product_id']) && empty($item['material_id'])) {
$validator->errors()->add(
"target_items.{$index}",
__('error.target_item_missing_reference')
);
}
// 제품과 자재 동시 참조 불가
if (!empty($item['product_id']) && !empty($item['material_id'])) {
$validator->errors()->add(
"target_items.{$index}",
__('error.target_item_multiple_reference')
);
}
// REPLACE 액션의 경우 replace_from 필수
if ($action === BomConditionRule::ACTION_REPLACE && empty($item['replace_from'])) {
$validator->errors()->add(
"target_items.{$index}.replace_from",
__('error.replace_from_required')
);
}
// replace_from 검증
if (!empty($item['replace_from'])) {
if (empty($item['replace_from']['product_id']) && empty($item['replace_from']['material_id'])) {
$validator->errors()->add(
"target_items.{$index}.replace_from",
__('error.replace_from_missing_reference')
);
}
}
}
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation(): void
{
// JSON 문자열인 경우 배열로 변환
if ($this->has('target_items') && is_string($this->input('target_items'))) {
$this->merge([
'target_items' => json_decode($this->input('target_items'), true) ?? []
]);
}
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class BomResolveRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'model_id' => 'required|integer|exists:models,id',
'parameters' => 'required|array',
'parameters.*' => 'required',
'preview_only' => 'boolean',
'use_cache' => 'boolean',
];
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'model_id.required' => __('error.model_id_required'),
'model_id.exists' => __('error.model_not_found'),
'parameters.required' => __('error.parameters_required'),
'parameters.array' => __('error.parameters_must_be_array'),
];
}
/**
* Configure the validator instance.
*/
public function withValidator($validator): void
{
$validator->after(function ($validator) {
$this->validateParameters($validator);
});
}
/**
* 매개변수 검증
*/
private function validateParameters($validator): void
{
if (!$this->input('model_id') || !$this->input('parameters')) {
return;
}
try {
$parameterService = new \App\Services\ModelParameterService();
$errors = $parameterService->validateParameterValues(
$this->input('model_id'),
$this->input('parameters')
);
if (!empty($errors)) {
foreach ($errors as $paramName => $paramErrors) {
foreach ($paramErrors as $error) {
$validator->errors()->add("parameters.{$paramName}", $error);
}
}
}
} catch (\Throwable $e) {
$validator->errors()->add('parameters', __('error.parameter_validation_failed'));
}
}
}

View File

@@ -0,0 +1,214 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Shared\Models\Products\ModelFormula;
class ModelFormulaRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'model_id' => 'required|integer|exists:models,id',
'name' => 'required|string|max:50|regex:/^[a-zA-Z][a-zA-Z0-9_]*$/',
'label' => 'required|string|max:100',
'expression' => 'required|string|max:1000',
'unit' => 'nullable|string|max:20',
'order' => 'nullable|integer|min:1',
'description' => 'nullable|string|max:500',
'is_active' => 'boolean',
];
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'name.regex' => __('error.formula_name_format'),
'expression.required' => __('error.formula_expression_required'),
];
}
/**
* Configure the validator instance.
*/
public function withValidator($validator): void
{
$validator->after(function ($validator) {
$this->validateFormulaNameUnique($validator);
$this->validateFormulaExpression($validator);
$this->validateDependencies($validator);
});
}
/**
* 공식명 중복 검증
*/
private function validateFormulaNameUnique($validator): void
{
if (!$this->input('model_id') || !$this->input('name')) {
return;
}
$query = ModelFormula::where('model_id', $this->input('model_id'))
->where('name', $this->input('name'));
// 수정 시 자기 자신 제외
if ($this->route('id')) {
$query->where('id', '!=', $this->route('id'));
}
if ($query->exists()) {
$validator->errors()->add('name', __('error.formula_name_duplicate'));
}
// 매개변수명과 중복 검증
$parameterExists = \Shared\Models\Products\ModelParameter::where('model_id', $this->input('model_id'))
->where('name', $this->input('name'))
->exists();
if ($parameterExists) {
$validator->errors()->add('name', __('error.formula_name_conflicts_with_parameter'));
}
}
/**
* 공식 표현식 검증
*/
private function validateFormulaExpression($validator): void
{
if (!$this->input('expression')) {
return;
}
$tempFormula = new ModelFormula([
'expression' => $this->input('expression'),
'model_id' => $this->input('model_id'),
]);
$expressionErrors = $tempFormula->validateExpression();
if (!empty($expressionErrors)) {
foreach ($expressionErrors as $error) {
$validator->errors()->add('expression', $error);
}
}
}
/**
* 의존성 검증
*/
private function validateDependencies($validator): void
{
if (!$this->input('model_id') || !$this->input('expression')) {
return;
}
$tempFormula = new ModelFormula([
'expression' => $this->input('expression'),
'model_id' => $this->input('model_id'),
]);
$dependencies = $tempFormula->extractVariables();
if (empty($dependencies)) {
return;
}
// 매개변수 목록 가져오기
$parameters = \Shared\Models\Products\ModelParameter::where('model_id', $this->input('model_id'))
->active()
->pluck('name')
->toArray();
// 기존 공식 목록 가져오기 (자기 자신 제외)
$formulasQuery = ModelFormula::where('model_id', $this->input('model_id'))
->active();
if ($this->route('id')) {
$formulasQuery->where('id', '!=', $this->route('id'));
}
$formulas = $formulasQuery->pluck('name')->toArray();
$validNames = array_merge($parameters, $formulas);
foreach ($dependencies as $dep) {
if (!in_array($dep, $validNames)) {
$validator->errors()->add('expression', __('error.dependency_not_found', ['name' => $dep]));
}
}
// 순환 의존성 검증
$this->validateCircularDependency($validator, $dependencies);
}
/**
* 순환 의존성 검증
*/
private function validateCircularDependency($validator, array $dependencies): void
{
if (!$this->input('model_id') || !$this->input('name')) {
return;
}
$allFormulasQuery = ModelFormula::where('model_id', $this->input('model_id'))
->active();
if ($this->route('id')) {
$allFormulasQuery->where('id', '!=', $this->route('id'));
}
$allFormulas = $allFormulasQuery->get();
// 현재 공식을 임시로 추가
$tempFormula = new ModelFormula([
'name' => $this->input('name'),
'dependencies' => $dependencies,
]);
$allFormulas->push($tempFormula);
if ($this->hasCircularDependency($tempFormula, $allFormulas->toArray())) {
$validator->errors()->add('expression', __('error.circular_dependency_detected'));
}
}
/**
* 순환 의존성 검사
*/
private function hasCircularDependency(ModelFormula $formula, array $allFormulas, array $visited = []): bool
{
if (in_array($formula->name, $visited)) {
return true;
}
$visited[] = $formula->name;
foreach ($formula->dependencies ?? [] as $dep) {
foreach ($allFormulas as $depFormula) {
if ($depFormula->name === $dep) {
if ($this->hasCircularDependency($depFormula, $allFormulas, $visited)) {
return true;
}
break;
}
}
}
return false;
}
}

View File

@@ -0,0 +1,127 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Shared\Models\Products\ModelParameter;
class ModelParameterRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
$rules = [
'model_id' => 'required|integer|exists:models,id',
'name' => 'required|string|max:50|regex:/^[a-zA-Z][a-zA-Z0-9_]*$/',
'label' => 'required|string|max:100',
'type' => 'required|string|in:' . implode(',', ModelParameter::TYPES),
'unit' => 'nullable|string|max:20',
'validation_rules' => 'nullable|array',
'options' => 'nullable|array',
'default_value' => 'nullable',
'order' => 'nullable|integer|min:1',
'description' => 'nullable|string|max:500',
'is_required' => 'boolean',
'is_active' => 'boolean',
];
// 타입별 세부 검증
if ($this->input('type') === ModelParameter::TYPE_NUMBER) {
$rules['validation_rules.min'] = 'nullable|numeric';
$rules['validation_rules.max'] = 'nullable|numeric|gte:validation_rules.min';
$rules['default_value'] = 'nullable|numeric';
}
if ($this->input('type') === ModelParameter::TYPE_SELECT) {
$rules['options'] = 'required|array|min:1';
$rules['options.*'] = 'required|string|max:100';
$rules['default_value'] = 'nullable|string|in_array:options';
}
if ($this->input('type') === ModelParameter::TYPE_BOOLEAN) {
$rules['default_value'] = 'nullable|boolean';
}
if ($this->input('type') === ModelParameter::TYPE_TEXT) {
$rules['validation_rules.max_length'] = 'nullable|integer|min:1|max:1000';
$rules['validation_rules.pattern'] = 'nullable|string|max:200';
$rules['default_value'] = 'nullable|string';
}
return $rules;
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'name.regex' => __('error.parameter_name_format'),
'validation_rules.max.gte' => __('error.max_must_be_greater_than_min'),
'options.required' => __('error.select_type_requires_options'),
'options.min' => __('error.select_type_requires_options'),
'default_value.in_array' => __('error.default_value_not_in_options'),
];
}
/**
* Configure the validator instance.
*/
public function withValidator($validator): void
{
$validator->after(function ($validator) {
// 추가 검증 로직
$this->validateParameterNameUnique($validator);
$this->validateValidationRules($validator);
});
}
/**
* 매개변수명 중복 검증
*/
private function validateParameterNameUnique($validator): void
{
if (!$this->input('model_id') || !$this->input('name')) {
return;
}
$query = ModelParameter::where('model_id', $this->input('model_id'))
->where('name', $this->input('name'));
// 수정 시 자기 자신 제외
if ($this->route('id')) {
$query->where('id', '!=', $this->route('id'));
}
if ($query->exists()) {
$validator->errors()->add('name', __('error.parameter_name_duplicate'));
}
}
/**
* 검증 규칙 유효성 검증
*/
private function validateValidationRules($validator): void
{
$type = $this->input('type');
$validationRules = $this->input('validation_rules', []);
if ($type === ModelParameter::TYPE_TEXT && isset($validationRules['pattern'])) {
// 정규식 패턴 검증
if (@preg_match($validationRules['pattern'], '') === false) {
$validator->errors()->add('validation_rules.pattern', __('error.invalid_regex_pattern'));
}
}
}
}

View File

@@ -0,0 +1,138 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class ProductFromModelRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'model_id' => 'required|integer|exists:models,id',
'parameters' => 'required|array',
'parameters.*' => 'required',
'product_data' => 'nullable|array',
'product_data.name' => 'nullable|string|max:200',
'product_data.code' => 'nullable|string|max:100|unique:products,code',
'product_data.description' => 'nullable|string|max:1000',
'product_data.category_id' => 'nullable|integer|exists:categories,id',
'product_data.memo' => 'nullable|string|max:500',
];
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'model_id.required' => __('error.model_id_required'),
'model_id.exists' => __('error.model_not_found'),
'parameters.required' => __('error.parameters_required'),
'parameters.array' => __('error.parameters_must_be_array'),
'product_data.code.unique' => __('error.product_code_duplicate'),
];
}
/**
* Configure the validator instance.
*/
public function withValidator($validator): void
{
$validator->after(function ($validator) {
$this->validateParameters($validator);
$this->validateProductData($validator);
});
}
/**
* 매개변수 검증
*/
private function validateParameters($validator): void
{
if (!$this->input('model_id') || !$this->input('parameters')) {
return;
}
try {
$parameterService = new \App\Services\ModelParameterService();
$errors = $parameterService->validateParameterValues(
$this->input('model_id'),
$this->input('parameters')
);
if (!empty($errors)) {
foreach ($errors as $paramName => $paramErrors) {
foreach ($paramErrors as $error) {
$validator->errors()->add("parameters.{$paramName}", $error);
}
}
}
} catch (\Throwable $e) {
$validator->errors()->add('parameters', __('error.parameter_validation_failed'));
}
}
/**
* 제품 데이터 검증
*/
private function validateProductData($validator): void
{
$productData = $this->input('product_data', []);
// 제품 코드 중복 검증 (수정 시 제외)
if (!empty($productData['code'])) {
$query = \Shared\Models\Products\Product::where('code', $productData['code']);
if ($this->route('id')) {
$query->where('id', '!=', $this->route('id'));
}
if ($query->exists()) {
$validator->errors()->add('product_data.code', __('error.product_code_duplicate'));
}
}
// 카테고리 존재 확인
if (!empty($productData['category_id'])) {
$categoryExists = \Shared\Models\Products\Category::where('id', $productData['category_id'])
->where('is_active', true)
->exists();
if (!$categoryExists) {
$validator->errors()->add('product_data.category_id', __('error.category_not_found'));
}
}
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation(): void
{
// JSON 문자열인 경우 배열로 변환
if ($this->has('parameters') && is_string($this->input('parameters'))) {
$this->merge([
'parameters' => json_decode($this->input('parameters'), true) ?? []
]);
}
if ($this->has('product_data') && is_string($this->input('product_data'))) {
$this->merge([
'product_data' => json_decode($this->input('product_data'), true) ?? []
]);
}
}
}

View File

@@ -0,0 +1,179 @@
<?php
namespace App\Models\Design;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Traits\BelongsToTenant;
class BomConditionRule extends Model
{
use SoftDeletes, BelongsToTenant;
protected $table = 'bom_condition_rules';
protected $fillable = [
'tenant_id',
'model_id',
'rule_name',
'condition_expression',
'action_type',
'target_type',
'target_id',
'quantity_multiplier',
'is_active',
'priority',
'description',
];
protected $casts = [
'quantity_multiplier' => 'decimal:6',
'is_active' => 'boolean',
'priority' => 'integer',
];
/**
* 조건 규칙이 속한 모델
*/
public function designModel()
{
return $this->belongsTo(DesignModel::class, 'model_id');
}
/**
* 조건식 평가
*/
public function evaluateCondition(array $parameters): bool
{
$expression = $this->condition_expression;
// 매개변수 값으로 치환
foreach ($parameters as $param => $value) {
// 문자열 값은 따옴표로 감싸기
if (is_string($value)) {
$value = "'" . addslashes($value) . "'";
} elseif (is_bool($value)) {
$value = $value ? 'true' : 'false';
}
$expression = str_replace($param, (string) $value, $expression);
}
// 안전한 조건식 평가
return $this->evaluateSimpleCondition($expression);
}
/**
* 간단한 조건식 평가기
*/
private function evaluateSimpleCondition(string $expression): bool
{
// 공백 제거
$expression = trim($expression);
// 간단한 비교 연산자들 처리
$operators = ['==', '!=', '>=', '<=', '>', '<'];
foreach ($operators as $operator) {
if (strpos($expression, $operator) !== false) {
$parts = explode($operator, $expression, 2);
if (count($parts) === 2) {
$left = trim($parts[0]);
$right = trim($parts[1]);
// 따옴표 제거
$left = trim($left, "'\"");
$right = trim($right, "'\"");
// 숫자 변환 시도
if (is_numeric($left)) $left = (float) $left;
if (is_numeric($right)) $right = (float) $right;
switch ($operator) {
case '==':
return $left == $right;
case '!=':
return $left != $right;
case '>=':
return $left >= $right;
case '<=':
return $left <= $right;
case '>':
return $left > $right;
case '<':
return $left < $right;
}
}
}
}
// IN 연산자 처리
if (preg_match('/(.+)\s+IN\s+\((.+)\)/i', $expression, $matches)) {
$value = trim($matches[1], "'\"");
$list = array_map('trim', explode(',', $matches[2]));
$list = array_map(function($item) {
return trim($item, "'\"");
}, $list);
return in_array($value, $list);
}
// NOT IN 연산자 처리
if (preg_match('/(.+)\s+NOT\s+IN\s+\((.+)\)/i', $expression, $matches)) {
$value = trim($matches[1], "'\"");
$list = array_map('trim', explode(',', $matches[2]));
$list = array_map(function($item) {
return trim($item, "'\"");
}, $list);
return !in_array($value, $list);
}
// 불린 값 처리
if (in_array(strtolower($expression), ['true', '1'])) {
return true;
}
if (in_array(strtolower($expression), ['false', '0'])) {
return false;
}
throw new \InvalidArgumentException('Invalid condition expression: ' . $this->condition_expression);
}
/**
* 조건 규칙 액션 실행
*/
public function executeAction(array $currentBom): array
{
switch ($this->action_type) {
case 'INCLUDE':
// 아이템 포함
$currentBom[] = [
'target_type' => $this->target_type,
'target_id' => $this->target_id,
'quantity' => $this->quantity_multiplier ?? 1,
'reason' => $this->rule_name,
];
break;
case 'EXCLUDE':
// 아이템 제외
$currentBom = array_filter($currentBom, function($item) {
return !($item['target_type'] === $this->target_type && $item['target_id'] === $this->target_id);
});
break;
case 'MODIFY_QUANTITY':
// 수량 변경
foreach ($currentBom as &$item) {
if ($item['target_type'] === $this->target_type && $item['target_id'] === $this->target_id) {
$item['quantity'] = ($item['quantity'] ?? 1) * ($this->quantity_multiplier ?? 1);
$item['reason'] = $this->rule_name;
}
}
break;
}
return array_values($currentBom); // 인덱스 재정렬
}
}

View File

@@ -0,0 +1,122 @@
<?php
namespace App\Models\Design;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Traits\BelongsToTenant;
class ModelFormula extends Model
{
use SoftDeletes, BelongsToTenant;
protected $table = 'model_formulas';
protected $fillable = [
'tenant_id',
'model_id',
'formula_name',
'formula_expression',
'unit',
'description',
'calculation_order',
'dependencies',
];
protected $casts = [
'calculation_order' => 'integer',
'dependencies' => 'array',
];
/**
* 공식이 속한 모델
*/
public function designModel()
{
return $this->belongsTo(DesignModel::class, 'model_id');
}
/**
* 공식에서 변수 추출
*/
public function extractVariables(): array
{
// 간단한 변수 추출 (영문자로 시작하는 단어들)
preg_match_all('/\b[a-zA-Z][a-zA-Z0-9_]*\b/', $this->formula_expression, $matches);
// 수학 함수 제외
$mathFunctions = ['sin', 'cos', 'tan', 'log', 'exp', 'sqrt', 'pow', 'abs', 'ceil', 'floor', 'round', 'max', 'min'];
$variables = array_diff($matches[0], $mathFunctions);
return array_unique($variables);
}
/**
* 공식 계산 (안전한 eval 대신 간단한 파서 사용)
*/
public function calculate(array $values): float
{
$expression = $this->formula_expression;
// 변수를 값으로 치환
foreach ($values as $variable => $value) {
$expression = str_replace($variable, (string) $value, $expression);
}
// 간단한 수식 계산 (보안상 eval 사용 금지)
return $this->evaluateSimpleExpression($expression);
}
/**
* 간단한 수식 계산기 (덧셈, 뺄셈, 곱셈, 나눗셈)
*/
private function evaluateSimpleExpression(string $expression): float
{
// 공백 제거
$expression = preg_replace('/\s+/', '', $expression);
// 간단한 사칙연산만 허용
if (!preg_match('/^[0-9+\-*\/\(\)\.]+$/', $expression)) {
throw new \InvalidArgumentException('Invalid expression: ' . $expression);
}
// 안전한 계산을 위해 제한된 연산만 허용
try {
// 실제 프로덕션에서는 더 안전한 수식 파서 라이브러리 사용 권장
return (float) eval("return $expression;");
} catch (\Throwable $e) {
throw new \InvalidArgumentException('Formula calculation error: ' . $e->getMessage());
}
}
/**
* 의존성 순환 체크
*/
public function hasCircularDependency(array $allFormulas): bool
{
$visited = [];
$recursionStack = [];
return $this->dfsCheckCircular($allFormulas, $visited, $recursionStack);
}
private function dfsCheckCircular(array $allFormulas, array &$visited, array &$recursionStack): bool
{
$visited[$this->formula_name] = true;
$recursionStack[$this->formula_name] = true;
foreach ($this->dependencies as $dependency) {
if (!isset($visited[$dependency])) {
$dependentFormula = collect($allFormulas)->firstWhere('formula_name', $dependency);
if ($dependentFormula && $dependentFormula->dfsCheckCircular($allFormulas, $visited, $recursionStack)) {
return true;
}
} elseif (isset($recursionStack[$dependency])) {
return true; // 순환 의존성 발견
}
}
unset($recursionStack[$this->formula_name]);
return false;
}
}

View File

@@ -0,0 +1,94 @@
<?php
namespace App\Models\Design;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Traits\BelongsToTenant;
class ModelParameter extends Model
{
use SoftDeletes, BelongsToTenant;
protected $table = 'model_parameters';
protected $fillable = [
'tenant_id',
'model_id',
'parameter_name',
'parameter_type',
'is_required',
'default_value',
'min_value',
'max_value',
'unit',
'options',
'description',
'sort_order',
];
protected $casts = [
'is_required' => 'boolean',
'min_value' => 'decimal:6',
'max_value' => 'decimal:6',
'options' => 'array',
'sort_order' => 'integer',
];
/**
* 매개변수가 속한 모델
*/
public function designModel()
{
return $this->belongsTo(DesignModel::class, 'model_id');
}
/**
* 매개변수 타입별 검증
*/
public function validateValue($value)
{
switch ($this->parameter_type) {
case 'NUMBER':
if (!is_numeric($value)) {
return false;
}
if ($this->min_value !== null && $value < $this->min_value) {
return false;
}
if ($this->max_value !== null && $value > $this->max_value) {
return false;
}
return true;
case 'SELECT':
return in_array($value, $this->options ?? []);
case 'BOOLEAN':
return is_bool($value) || in_array($value, [0, 1, '0', '1', 'true', 'false']);
case 'TEXT':
return is_string($value);
default:
return true;
}
}
/**
* 매개변수 값 형변환
*/
public function castValue($value)
{
switch ($this->parameter_type) {
case 'NUMBER':
return (float) $value;
case 'BOOLEAN':
return (bool) $value;
case 'TEXT':
case 'SELECT':
default:
return (string) $value;
}
}
}

View File

@@ -0,0 +1,436 @@
<?php
namespace App\Services;
use App\Services\Service;
use Shared\Models\Products\BomConditionRule;
use Shared\Models\Products\ModelMaster;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\LengthAwarePaginator;
/**
* BOM Condition Rule Service
* BOM 조건 규칙 관리 서비스
*/
class BomConditionRuleService extends Service
{
/**
* 모델별 조건 규칙 목록 조회
*/
public function getRulesByModel(int $modelId, bool $paginate = false, int $perPage = 15): Collection|LengthAwarePaginator
{
$this->validateModelAccess($modelId);
$query = BomConditionRule::where('model_id', $modelId)
->active()
->byPriority()
->with('model');
if ($paginate) {
return $query->paginate($perPage);
}
return $query->get();
}
/**
* 조건 규칙 상세 조회
*/
public function getRule(int $id): BomConditionRule
{
$rule = BomConditionRule::where('tenant_id', $this->tenantId())
->findOrFail($id);
$this->validateModelAccess($rule->model_id);
return $rule;
}
/**
* 조건 규칙 생성
*/
public function createRule(array $data): BomConditionRule
{
$this->validateModelAccess($data['model_id']);
// 기본값 설정
$data['tenant_id'] = $this->tenantId();
$data['created_by'] = $this->apiUserId();
// 우선순위가 지정되지 않은 경우 마지막으로 설정
if (!isset($data['priority'])) {
$maxPriority = BomConditionRule::where('tenant_id', $this->tenantId())
->where('model_id', $data['model_id'])
->max('priority') ?? 0;
$data['priority'] = $maxPriority + 1;
}
// 규칙명 중복 체크
$this->validateRuleNameUnique($data['model_id'], $data['name']);
// 조건식 검증
$rule = new BomConditionRule($data);
$conditionErrors = $rule->validateConditionExpression();
if (!empty($conditionErrors)) {
throw new \InvalidArgumentException(__('error.invalid_condition_expression') . ': ' . implode(', ', $conditionErrors));
}
// 대상 아이템 처리
if (isset($data['target_items']) && is_string($data['target_items'])) {
$data['target_items'] = json_decode($data['target_items'], true);
}
// 대상 아이템 검증
$this->validateTargetItems($data['target_items'] ?? [], $data['action']);
$rule = BomConditionRule::create($data);
return $rule->fresh();
}
/**
* 조건 규칙 수정
*/
public function updateRule(int $id, array $data): BomConditionRule
{
$rule = $this->getRule($id);
// 규칙명 변경 시 중복 체크
if (isset($data['name']) && $data['name'] !== $rule->name) {
$this->validateRuleNameUnique($rule->model_id, $data['name'], $id);
}
// 조건식 변경 시 검증
if (isset($data['condition_expression'])) {
$tempRule = new BomConditionRule(array_merge($rule->toArray(), $data));
$conditionErrors = $tempRule->validateConditionExpression();
if (!empty($conditionErrors)) {
throw new \InvalidArgumentException(__('error.invalid_condition_expression') . ': ' . implode(', ', $conditionErrors));
}
}
// 대상 아이템 처리
if (isset($data['target_items']) && is_string($data['target_items'])) {
$data['target_items'] = json_decode($data['target_items'], true);
}
// 대상 아이템 검증
if (isset($data['target_items']) || isset($data['action'])) {
$action = $data['action'] ?? $rule->action;
$targetItems = $data['target_items'] ?? $rule->target_items;
$this->validateTargetItems($targetItems, $action);
}
$data['updated_by'] = $this->apiUserId();
$rule->update($data);
return $rule->fresh();
}
/**
* 조건 규칙 삭제
*/
public function deleteRule(int $id): bool
{
$rule = $this->getRule($id);
$rule->update(['deleted_by' => $this->apiUserId()]);
$rule->delete();
return true;
}
/**
* 조건 규칙 우선순위 변경
*/
public function reorderRules(int $modelId, array $orderData): bool
{
$this->validateModelAccess($modelId);
foreach ($orderData as $item) {
BomConditionRule::where('tenant_id', $this->tenantId())
->where('model_id', $modelId)
->where('id', $item['id'])
->update([
'priority' => $item['priority'],
'updated_by' => $this->apiUserId()
]);
}
return true;
}
/**
* 조건 규칙 복사 (다른 모델로)
*/
public function copyRulesToModel(int $sourceModelId, int $targetModelId): Collection
{
$this->validateModelAccess($sourceModelId);
$this->validateModelAccess($targetModelId);
$sourceRules = $this->getRulesByModel($sourceModelId);
$copiedRules = collect();
foreach ($sourceRules as $sourceRule) {
$data = $sourceRule->toArray();
unset($data['id'], $data['created_at'], $data['updated_at'], $data['deleted_at']);
$data['model_id'] = $targetModelId;
$data['created_by'] = $this->apiUserId();
// 이름 중복 시 수정
$originalName = $data['name'];
$counter = 1;
while ($this->isRuleNameExists($targetModelId, $data['name'])) {
$data['name'] = $originalName . '_' . $counter;
$counter++;
}
$copiedRule = BomConditionRule::create($data);
$copiedRules->push($copiedRule);
}
return $copiedRules;
}
/**
* 조건 평가 및 적용할 규칙 찾기
*/
public function getApplicableRules(int $modelId, array $variables): Collection
{
$this->validateModelAccess($modelId);
$rules = $this->getRulesByModel($modelId);
$applicableRules = collect();
foreach ($rules as $rule) {
if ($rule->evaluateCondition($variables)) {
$applicableRules->push($rule);
}
}
return $applicableRules;
}
/**
* 조건 규칙을 BOM 아이템에 적용
*/
public function applyRulesToBomItems(int $modelId, array $bomItems, array $variables): array
{
$applicableRules = $this->getApplicableRules($modelId, $variables);
// 우선순위 순서대로 규칙 적용
foreach ($applicableRules as $rule) {
$bomItems = $rule->applyAction($bomItems);
}
return $bomItems;
}
/**
* 조건식 검증 (문법 체크)
*/
public function validateConditionExpression(int $modelId, string $expression): array
{
$this->validateModelAccess($modelId);
$tempRule = new BomConditionRule([
'condition_expression' => $expression,
'model_id' => $modelId
]);
return $tempRule->validateConditionExpression();
}
/**
* 조건식 테스트 (실제 변수값으로 평가)
*/
public function testConditionExpression(int $modelId, string $expression, array $variables): array
{
$this->validateModelAccess($modelId);
$result = [
'valid' => false,
'result' => null,
'error' => null
];
try {
$tempRule = new BomConditionRule([
'condition_expression' => $expression,
'model_id' => $modelId
]);
$validationErrors = $tempRule->validateConditionExpression();
if (!empty($validationErrors)) {
$result['error'] = implode(', ', $validationErrors);
return $result;
}
$evaluationResult = $tempRule->evaluateCondition($variables);
$result['valid'] = true;
$result['result'] = $evaluationResult;
} catch (\Throwable $e) {
$result['error'] = $e->getMessage();
}
return $result;
}
/**
* 모델의 사용 가능한 변수 목록 조회 (매개변수 + 공식)
*/
public function getAvailableVariables(int $modelId): array
{
$this->validateModelAccess($modelId);
$parameterService = new ModelParameterService();
$formulaService = new ModelFormulaService();
$parameters = $parameterService->getParametersByModel($modelId);
$formulas = $formulaService->getFormulasByModel($modelId);
$variables = [];
foreach ($parameters as $parameter) {
$variables[] = [
'name' => $parameter->name,
'label' => $parameter->label,
'type' => $parameter->type,
'source' => 'parameter'
];
}
foreach ($formulas as $formula) {
$variables[] = [
'name' => $formula->name,
'label' => $formula->label,
'type' => 'NUMBER', // 공식 결과는 숫자
'source' => 'formula'
];
}
return $variables;
}
/**
* 모델 접근 권한 검증
*/
private function validateModelAccess(int $modelId): void
{
$model = ModelMaster::where('tenant_id', $this->tenantId())
->findOrFail($modelId);
}
/**
* 규칙명 중복 검증
*/
private function validateRuleNameUnique(int $modelId, string $name, ?int $excludeId = null): void
{
$query = BomConditionRule::where('tenant_id', $this->tenantId())
->where('model_id', $modelId)
->where('name', $name);
if ($excludeId) {
$query->where('id', '!=', $excludeId);
}
if ($query->exists()) {
throw new \InvalidArgumentException(__('error.rule_name_duplicate'));
}
}
/**
* 규칙명 존재 여부 확인
*/
private function isRuleNameExists(int $modelId, string $name): bool
{
return BomConditionRule::where('tenant_id', $this->tenantId())
->where('model_id', $modelId)
->where('name', $name)
->exists();
}
/**
* 대상 아이템 검증
*/
private function validateTargetItems(array $targetItems, string $action): void
{
if (empty($targetItems)) {
throw new \InvalidArgumentException(__('error.target_items_required'));
}
foreach ($targetItems as $index => $item) {
if (!isset($item['product_id']) && !isset($item['material_id'])) {
throw new \InvalidArgumentException(__('error.target_item_missing_reference', ['index' => $index]));
}
// REPLACE 액션의 경우 replace_from 필요
if ($action === BomConditionRule::ACTION_REPLACE && !isset($item['replace_from'])) {
throw new \InvalidArgumentException(__('error.replace_from_required', ['index' => $index]));
}
// 수량 검증
if (isset($item['quantity']) && (!is_numeric($item['quantity']) || $item['quantity'] <= 0)) {
throw new \InvalidArgumentException(__('error.invalid_quantity', ['index' => $index]));
}
// 낭비율 검증
if (isset($item['waste_rate']) && (!is_numeric($item['waste_rate']) || $item['waste_rate'] < 0 || $item['waste_rate'] > 100)) {
throw new \InvalidArgumentException(__('error.invalid_waste_rate', ['index' => $index]));
}
}
}
/**
* 규칙 활성화/비활성화
*/
public function toggleRuleStatus(int $id): BomConditionRule
{
$rule = $this->getRule($id);
$rule->update([
'is_active' => !$rule->is_active,
'updated_by' => $this->apiUserId()
]);
return $rule->fresh();
}
/**
* 규칙 실행 로그 (디버깅용)
*/
public function getRuleExecutionLog(int $modelId, array $variables): array
{
$this->validateModelAccess($modelId);
$rules = $this->getRulesByModel($modelId);
$log = [];
foreach ($rules as $rule) {
$logEntry = [
'rule_id' => $rule->id,
'rule_name' => $rule->name,
'condition' => $rule->condition_expression,
'priority' => $rule->priority,
'evaluated' => false,
'result' => false,
'action' => $rule->action,
'error' => null
];
try {
$logEntry['evaluated'] = true;
$logEntry['result'] = $rule->evaluateCondition($variables);
} catch (\Throwable $e) {
$logEntry['error'] = $e->getMessage();
}
$log[] = $logEntry;
}
return $log;
}
}

View File

@@ -0,0 +1,505 @@
<?php
namespace App\Services;
use App\Services\Service;
use Shared\Models\Products\ModelMaster;
use Shared\Models\Products\BomTemplate;
use Shared\Models\Products\BomTemplateItem;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
/**
* BOM Resolver Service
* 매개변수 기반 BOM 해석 및 생성 엔진
*/
class BomResolverService extends Service
{
private ModelParameterService $parameterService;
private ModelFormulaService $formulaService;
private BomConditionRuleService $conditionRuleService;
public function __construct()
{
parent::__construct();
$this->parameterService = new ModelParameterService();
$this->formulaService = new ModelFormulaService();
$this->conditionRuleService = new BomConditionRuleService();
}
/**
* 매개변수 기반 BOM 해석 (미리보기)
*/
public function resolveBom(int $modelId, array $inputParameters): array
{
$this->validateModelAccess($modelId);
// 1. 매개변수 검증 및 타입 변환
$validatedParameters = $this->validateAndCastParameters($modelId, $inputParameters);
// 2. 공식 계산
$calculatedValues = $this->calculateFormulas($modelId, $validatedParameters);
// 3. 기본 BOM 템플릿 가져오기
$baseBomItems = $this->getBaseBomItems($modelId);
// 4. 조건 규칙 적용
$resolvedBomItems = $this->applyConditionRules($modelId, $baseBomItems, $calculatedValues);
// 5. 수량 및 공식 계산 적용
$finalBomItems = $this->calculateItemQuantities($resolvedBomItems, $calculatedValues);
return [
'model_id' => $modelId,
'input_parameters' => $validatedParameters,
'calculated_values' => $calculatedValues,
'bom_items' => $finalBomItems,
'summary' => $this->generateBomSummary($finalBomItems),
'resolved_at' => now()->toISOString(),
];
}
/**
* 실시간 미리보기 (캐시 활용)
*/
public function previewBom(int $modelId, array $inputParameters): array
{
// 캐시 키 생성
$cacheKey = $this->generateCacheKey($modelId, $inputParameters);
return Cache::remember($cacheKey, 300, function () use ($modelId, $inputParameters) {
return $this->resolveBom($modelId, $inputParameters);
});
}
/**
* BOM 검증 (오류 체크)
*/
public function validateBom(int $modelId, array $inputParameters): array
{
$errors = [];
$warnings = [];
try {
// 매개변수 검증
$parameterErrors = $this->parameterService->validateParameterValues($modelId, $inputParameters);
if (!empty($parameterErrors)) {
$errors['parameters'] = $parameterErrors;
}
// 기본 BOM 템플릿 존재 확인
if (!$this->hasBaseBomTemplate($modelId)) {
$warnings[] = 'No base BOM template found for this model';
}
// 공식 계산 가능 여부 확인
try {
$validatedParameters = $this->validateAndCastParameters($modelId, $inputParameters);
$this->calculateFormulas($modelId, $validatedParameters);
} catch (\Throwable $e) {
$errors['formulas'] = ['Formula calculation failed: ' . $e->getMessage()];
}
// 조건 규칙 평가 가능 여부 확인
try {
$calculatedValues = $this->calculateFormulas($modelId, $validatedParameters ?? []);
$this->conditionRuleService->getApplicableRules($modelId, $calculatedValues);
} catch (\Throwable $e) {
$errors['condition_rules'] = ['Condition rule evaluation failed: ' . $e->getMessage()];
}
} catch (\Throwable $e) {
$errors['general'] = [$e->getMessage()];
}
return [
'valid' => empty($errors),
'errors' => $errors,
'warnings' => $warnings,
];
}
/**
* BOM 비교 (다른 매개변수값과 비교)
*/
public function compareBom(int $modelId, array $parameters1, array $parameters2): array
{
$bom1 = $this->resolveBom($modelId, $parameters1);
$bom2 = $this->resolveBom($modelId, $parameters2);
return [
'parameters_diff' => $this->compareParameters($bom1['calculated_values'], $bom2['calculated_values']),
'bom_items_diff' => $this->compareBomItems($bom1['bom_items'], $bom2['bom_items']),
'summary_diff' => $this->compareSummary($bom1['summary'], $bom2['summary']),
];
}
/**
* 대량 BOM 해석 (여러 매개변수 조합)
*/
public function resolveBomBatch(int $modelId, array $parameterSets): array
{
$results = [];
DB::transaction(function () use ($modelId, $parameterSets, &$results) {
foreach ($parameterSets as $index => $parameters) {
try {
$results[$index] = $this->resolveBom($modelId, $parameters);
} catch (\Throwable $e) {
$results[$index] = [
'error' => $e->getMessage(),
'parameters' => $parameters,
];
}
}
});
return $results;
}
/**
* BOM 성능 최적화 제안
*/
public function getOptimizationSuggestions(int $modelId, array $inputParameters): array
{
$resolvedBom = $this->resolveBom($modelId, $inputParameters);
$suggestions = [];
// 1. 불필요한 조건 규칙 탐지
$unusedRules = $this->findUnusedRules($modelId, $resolvedBom['calculated_values']);
if (!empty($unusedRules)) {
$suggestions[] = [
'type' => 'unused_rules',
'message' => 'Found unused condition rules that could be removed',
'details' => $unusedRules,
];
}
// 2. 복잡한 공식 탐지
$complexFormulas = $this->findComplexFormulas($modelId);
if (!empty($complexFormulas)) {
$suggestions[] = [
'type' => 'complex_formulas',
'message' => 'Found complex formulas that might impact performance',
'details' => $complexFormulas,
];
}
// 3. 중복 BOM 아이템 탐지
$duplicateItems = $this->findDuplicateBomItems($resolvedBom['bom_items']);
if (!empty($duplicateItems)) {
$suggestions[] = [
'type' => 'duplicate_items',
'message' => 'Found duplicate BOM items that could be consolidated',
'details' => $duplicateItems,
];
}
return $suggestions;
}
/**
* 매개변수 검증 및 타입 변환
*/
private function validateAndCastParameters(int $modelId, array $inputParameters): array
{
$errors = $this->parameterService->validateParameterValues($modelId, $inputParameters);
if (!empty($errors)) {
throw new \InvalidArgumentException(__('error.invalid_parameters') . ': ' . json_encode($errors));
}
return $this->parameterService->castParameterValues($modelId, $inputParameters);
}
/**
* 공식 계산
*/
private function calculateFormulas(int $modelId, array $parameters): array
{
return $this->formulaService->calculateFormulas($modelId, $parameters);
}
/**
* 기본 BOM 템플릿 아이템 가져오기
*/
private function getBaseBomItems(int $modelId): array
{
$model = ModelMaster::where('tenant_id', $this->tenantId())
->findOrFail($modelId);
// 현재 활성 버전의 BOM 템플릿 가져오기
$bomTemplate = BomTemplate::where('tenant_id', $this->tenantId())
->where('model_id', $modelId)
->where('is_active', true)
->orderBy('created_at', 'desc')
->first();
if (!$bomTemplate) {
return [];
}
$bomItems = BomTemplateItem::where('tenant_id', $this->tenantId())
->where('bom_template_id', $bomTemplate->id)
->orderBy('order')
->get()
->map(function ($item) {
return [
'id' => $item->id,
'product_id' => $item->product_id,
'material_id' => $item->material_id,
'ref_type' => $item->ref_type,
'quantity' => $item->quantity,
'quantity_formula' => $item->quantity_formula,
'waste_rate' => $item->waste_rate,
'unit' => $item->unit,
'memo' => $item->memo,
'order' => $item->order,
];
})
->toArray();
return $bomItems;
}
/**
* 조건 규칙 적용
*/
private function applyConditionRules(int $modelId, array $bomItems, array $calculatedValues): array
{
return $this->conditionRuleService->applyRulesToBomItems($modelId, $bomItems, $calculatedValues);
}
/**
* 아이템별 수량 계산
*/
private function calculateItemQuantities(array $bomItems, array $calculatedValues): array
{
foreach ($bomItems as &$item) {
// 수량 공식이 있는 경우 계산
if (!empty($item['quantity_formula'])) {
$calculatedQuantity = $this->evaluateQuantityFormula($item['quantity_formula'], $calculatedValues);
if ($calculatedQuantity !== null) {
$item['calculated_quantity'] = $calculatedQuantity;
}
} else {
$item['calculated_quantity'] = $item['quantity'] ?? 1;
}
// 낭비율 적용
if (isset($item['waste_rate']) && $item['waste_rate'] > 0) {
$item['total_quantity'] = $item['calculated_quantity'] * (1 + $item['waste_rate'] / 100);
} else {
$item['total_quantity'] = $item['calculated_quantity'];
}
// 반올림 (소수점 3자리)
$item['calculated_quantity'] = round($item['calculated_quantity'], 3);
$item['total_quantity'] = round($item['total_quantity'], 3);
}
return $bomItems;
}
/**
* 수량 공식 계산
*/
private function evaluateQuantityFormula(string $formula, array $variables): ?float
{
try {
$expression = $formula;
// 변수값 치환
foreach ($variables as $name => $value) {
if (is_numeric($value)) {
$expression = preg_replace('/\b' . preg_quote($name, '/') . '\b/', $value, $expression);
}
}
// 안전한 계산 실행
if (preg_match('/^[0-9+\-*\/().,\s]+$/', $expression)) {
return eval("return $expression;");
}
return null;
} catch (\Throwable $e) {
return null;
}
}
/**
* BOM 요약 정보 생성
*/
private function generateBomSummary(array $bomItems): array
{
$summary = [
'total_items' => count($bomItems),
'materials_count' => 0,
'products_count' => 0,
'total_cost' => 0, // 향후 가격 정보 추가 시
];
foreach ($bomItems as $item) {
if (!empty($item['material_id'])) {
$summary['materials_count']++;
} else {
$summary['products_count']++;
}
}
return $summary;
}
/**
* 캐시 키 생성
*/
private function generateCacheKey(int $modelId, array $parameters): string
{
ksort($parameters); // 매개변수 순서 정규화
$hash = md5(json_encode($parameters));
return "bom_preview_{$this->tenantId()}_{$modelId}_{$hash}";
}
/**
* 기본 BOM 템플릿 존재 여부 확인
*/
private function hasBaseBomTemplate(int $modelId): bool
{
return BomTemplate::where('tenant_id', $this->tenantId())
->where('model_id', $modelId)
->where('is_active', true)
->exists();
}
/**
* 모델 접근 권한 검증
*/
private function validateModelAccess(int $modelId): void
{
$model = ModelMaster::where('tenant_id', $this->tenantId())
->findOrFail($modelId);
}
/**
* 매개변수 비교
*/
private function compareParameters(array $params1, array $params2): array
{
$diff = [];
$allKeys = array_unique(array_merge(array_keys($params1), array_keys($params2)));
foreach ($allKeys as $key) {
$value1 = $params1[$key] ?? null;
$value2 = $params2[$key] ?? null;
if ($value1 !== $value2) {
$diff[$key] = [
'set1' => $value1,
'set2' => $value2,
];
}
}
return $diff;
}
/**
* BOM 아이템 비교
*/
private function compareBomItems(array $items1, array $items2): array
{
// 간단한 비교 로직 (실제로는 더 정교한 비교 필요)
return [
'count_diff' => count($items1) - count($items2),
'items_only_in_set1' => array_diff_key($items1, $items2),
'items_only_in_set2' => array_diff_key($items2, $items1),
];
}
/**
* 요약 정보 비교
*/
private function compareSummary(array $summary1, array $summary2): array
{
$diff = [];
foreach ($summary1 as $key => $value) {
if (isset($summary2[$key]) && $value !== $summary2[$key]) {
$diff[$key] = [
'set1' => $value,
'set2' => $summary2[$key],
'diff' => $value - $summary2[$key],
];
}
}
return $diff;
}
/**
* 사용되지 않는 규칙 찾기
*/
private function findUnusedRules(int $modelId, array $calculatedValues): array
{
$allRules = $this->conditionRuleService->getRulesByModel($modelId);
$applicableRules = $this->conditionRuleService->getApplicableRules($modelId, $calculatedValues);
$unusedRules = $allRules->diff($applicableRules);
return $unusedRules->map(function ($rule) {
return [
'id' => $rule->id,
'name' => $rule->name,
'condition' => $rule->condition_expression,
];
})->toArray();
}
/**
* 복잡한 공식 찾기
*/
private function findComplexFormulas(int $modelId): array
{
$formulas = $this->formulaService->getFormulasByModel($modelId);
return $formulas->filter(function ($formula) {
// 복잡성 기준 (의존성 수, 표현식 길이 등)
$dependencyCount = count($formula->dependencies ?? []);
$expressionLength = strlen($formula->expression);
return $dependencyCount > 5 || $expressionLength > 100;
})->map(function ($formula) {
return [
'id' => $formula->id,
'name' => $formula->name,
'complexity_score' => strlen($formula->expression) + count($formula->dependencies ?? []) * 10,
];
})->toArray();
}
/**
* 중복 BOM 아이템 찾기
*/
private function findDuplicateBomItems(array $bomItems): array
{
$seen = [];
$duplicates = [];
foreach ($bomItems as $item) {
$key = ($item['product_id'] ?? 'null') . '_' . ($item['material_id'] ?? 'null');
if (isset($seen[$key])) {
$duplicates[] = [
'product_id' => $item['product_id'],
'material_id' => $item['material_id'],
'occurrences' => ++$seen[$key],
];
} else {
$seen[$key] = 1;
}
}
return $duplicates;
}
}

View File

@@ -0,0 +1,492 @@
<?php
namespace App\Services\Design;
use App\Models\Design\BomConditionRule;
use App\Models\Design\DesignModel;
use App\Models\Product;
use App\Models\Material;
use App\Services\Service;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class BomConditionRuleService extends Service
{
/**
* 모델의 조건 규칙 목록 조회
*/
public function listByModel(int $modelId, string $q = '', int $page = 1, int $size = 20): LengthAwarePaginator
{
$tenantId = $this->tenantId();
// 모델 존재 확인
$model = DesignModel::where('tenant_id', $tenantId)->where('id', $modelId)->first();
if (!$model) {
throw new NotFoundHttpException(__('error.not_found'));
}
$query = BomConditionRule::query()
->where('tenant_id', $tenantId)
->where('model_id', $modelId);
if ($q !== '') {
$query->where(function ($w) use ($q) {
$w->where('rule_name', 'like', "%{$q}%")
->orWhere('condition_expression', 'like', "%{$q}%")
->orWhere('description', 'like', "%{$q}%");
});
}
return $query->orderBy('priority')->orderBy('id')->paginate($size, ['*'], 'page', $page);
}
/**
* 조건 규칙 조회
*/
public function show(int $ruleId): BomConditionRule
{
$tenantId = $this->tenantId();
$rule = BomConditionRule::where('tenant_id', $tenantId)->where('id', $ruleId)->first();
if (!$rule) {
throw new NotFoundHttpException(__('error.not_found'));
}
// 연관된 타겟 정보도 함께 조회
$rule->load(['designModel']);
return $rule;
}
/**
* 조건 규칙 생성
*/
public function create(array $data): BomConditionRule
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// 모델 존재 확인
$model = DesignModel::where('tenant_id', $tenantId)->where('id', $data['model_id'])->first();
if (!$model) {
throw new NotFoundHttpException(__('error.not_found'));
}
// 타겟 존재 확인
$this->validateTarget($data['target_type'], $data['target_id']);
// 같은 모델 내에서 규칙명 중복 체크
$exists = BomConditionRule::query()
->where('tenant_id', $tenantId)
->where('model_id', $data['model_id'])
->where('rule_name', $data['rule_name'])
->exists();
if ($exists) {
throw ValidationException::withMessages(['rule_name' => __('error.duplicate')]);
}
// 조건식 문법 검증
$this->validateConditionExpression($data['condition_expression']);
return DB::transaction(function () use ($tenantId, $userId, $data) {
// priority가 없으면 자동 설정
if (!isset($data['priority'])) {
$maxPriority = BomConditionRule::where('tenant_id', $tenantId)
->where('model_id', $data['model_id'])
->max('priority') ?? 0;
$data['priority'] = $maxPriority + 1;
}
$payload = array_merge($data, [
'tenant_id' => $tenantId,
'created_by' => $userId,
'updated_by' => $userId,
]);
return BomConditionRule::create($payload);
});
}
/**
* 조건 규칙 수정
*/
public function update(int $ruleId, array $data): BomConditionRule
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$rule = BomConditionRule::where('tenant_id', $tenantId)->where('id', $ruleId)->first();
if (!$rule) {
throw new NotFoundHttpException(__('error.not_found'));
}
// 규칙명 변경 시 중복 체크
if (isset($data['rule_name']) && $data['rule_name'] !== $rule->rule_name) {
$exists = BomConditionRule::query()
->where('tenant_id', $tenantId)
->where('model_id', $rule->model_id)
->where('rule_name', $data['rule_name'])
->where('id', '!=', $ruleId)
->exists();
if ($exists) {
throw ValidationException::withMessages(['rule_name' => __('error.duplicate')]);
}
}
// 타겟 변경 시 존재 확인
if (isset($data['target_type']) || isset($data['target_id'])) {
$targetType = $data['target_type'] ?? $rule->target_type;
$targetId = $data['target_id'] ?? $rule->target_id;
$this->validateTarget($targetType, $targetId);
}
// 조건식 변경 시 문법 검증
if (isset($data['condition_expression'])) {
$this->validateConditionExpression($data['condition_expression']);
}
return DB::transaction(function () use ($rule, $userId, $data) {
$payload = array_merge($data, ['updated_by' => $userId]);
$rule->update($payload);
return $rule->fresh();
});
}
/**
* 조건 규칙 삭제
*/
public function delete(int $ruleId): bool
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$rule = BomConditionRule::where('tenant_id', $tenantId)->where('id', $ruleId)->first();
if (!$rule) {
throw new NotFoundHttpException(__('error.not_found'));
}
return DB::transaction(function () use ($rule, $userId) {
$rule->update(['deleted_by' => $userId]);
return $rule->delete();
});
}
/**
* 조건 규칙 활성화/비활성화
*/
public function toggle(int $ruleId): BomConditionRule
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$rule = BomConditionRule::where('tenant_id', $tenantId)->where('id', $ruleId)->first();
if (!$rule) {
throw new NotFoundHttpException(__('error.not_found'));
}
return DB::transaction(function () use ($rule, $userId) {
$rule->update([
'is_active' => !$rule->is_active,
'updated_by' => $userId,
]);
return $rule->fresh();
});
}
/**
* 조건 규칙 우선순위 변경
*/
public function reorder(int $modelId, array $ruleIds): array
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// 모델 존재 확인
$model = DesignModel::where('tenant_id', $tenantId)->where('id', $modelId)->first();
if (!$model) {
throw new NotFoundHttpException(__('error.not_found'));
}
return DB::transaction(function () use ($tenantId, $userId, $modelId, $ruleIds) {
$priority = 1;
$updated = [];
foreach ($ruleIds as $ruleId) {
$rule = BomConditionRule::query()
->where('tenant_id', $tenantId)
->where('model_id', $modelId)
->where('id', $ruleId)
->first();
if ($rule) {
$rule->update([
'priority' => $priority,
'updated_by' => $userId,
]);
$updated[] = $rule->fresh();
$priority++;
}
}
return $updated;
});
}
/**
* 조건 규칙 대량 저장 (upsert)
*/
public function bulkUpsert(int $modelId, array $rules): array
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// 모델 존재 확인
$model = DesignModel::where('tenant_id', $tenantId)->where('id', $modelId)->first();
if (!$model) {
throw new NotFoundHttpException(__('error.not_found'));
}
return DB::transaction(function () use ($tenantId, $userId, $modelId, $rules) {
$result = [];
foreach ($rules as $index => $ruleData) {
$ruleData['model_id'] = $modelId;
// 타겟 및 조건식 검증
if (isset($ruleData['target_type']) && isset($ruleData['target_id'])) {
$this->validateTarget($ruleData['target_type'], $ruleData['target_id']);
}
if (isset($ruleData['condition_expression'])) {
$this->validateConditionExpression($ruleData['condition_expression']);
}
// ID가 있으면 업데이트, 없으면 생성
if (isset($ruleData['id']) && $ruleData['id']) {
$rule = BomConditionRule::query()
->where('tenant_id', $tenantId)
->where('model_id', $modelId)
->where('id', $ruleData['id'])
->first();
if ($rule) {
$rule->update(array_merge($ruleData, ['updated_by' => $userId]));
$result[] = $rule->fresh();
}
} else {
// 새로운 규칙 생성
$exists = BomConditionRule::query()
->where('tenant_id', $tenantId)
->where('model_id', $modelId)
->where('rule_name', $ruleData['rule_name'])
->exists();
if (!$exists) {
if (!isset($ruleData['priority'])) {
$ruleData['priority'] = $index + 1;
}
$payload = array_merge($ruleData, [
'tenant_id' => $tenantId,
'created_by' => $userId,
'updated_by' => $userId,
]);
$result[] = BomConditionRule::create($payload);
}
}
}
return $result;
});
}
/**
* 조건 규칙 평가 실행
*/
public function evaluateRules(int $modelId, array $parameters): array
{
$tenantId = $this->tenantId();
// 활성 조건 규칙들을 우선순위 순으로 조회
$rules = BomConditionRule::query()
->where('tenant_id', $tenantId)
->where('model_id', $modelId)
->where('is_active', true)
->orderBy('priority')
->get();
$matchedRules = [];
$bomActions = [];
foreach ($rules as $rule) {
try {
if ($rule->evaluateCondition($parameters)) {
$matchedRules[] = [
'rule_id' => $rule->id,
'rule_name' => $rule->rule_name,
'action_type' => $rule->action_type,
'target_type' => $rule->target_type,
'target_id' => $rule->target_id,
'quantity_multiplier' => $rule->quantity_multiplier,
];
$bomActions[] = [
'action_type' => $rule->action_type,
'target_type' => $rule->target_type,
'target_id' => $rule->target_id,
'quantity_multiplier' => $rule->quantity_multiplier,
'rule_name' => $rule->rule_name,
];
}
} catch (\Exception $e) {
// 조건 평가 실패 시 로그 남기고 건너뜀
\Log::warning("Rule evaluation failed: {$rule->rule_name}", [
'error' => $e->getMessage(),
'parameters' => $parameters,
]);
}
}
return [
'matched_rules' => $matchedRules,
'bom_actions' => $bomActions,
];
}
/**
* 조건식 테스트
*/
public function testCondition(int $ruleId, array $parameters): array
{
$tenantId = $this->tenantId();
$rule = BomConditionRule::where('tenant_id', $tenantId)->where('id', $ruleId)->first();
if (!$rule) {
throw new NotFoundHttpException(__('error.not_found'));
}
try {
$result = $rule->evaluateCondition($parameters);
return [
'rule_name' => $rule->rule_name,
'condition_expression' => $rule->condition_expression,
'parameters' => $parameters,
'result' => $result,
'action_type' => $rule->action_type,
'target_type' => $rule->target_type,
'target_id' => $rule->target_id,
];
} catch (\Exception $e) {
throw ValidationException::withMessages([
'condition_expression' => __('error.condition_evaluation_failed', ['error' => $e->getMessage()])
]);
}
}
/**
* 타겟 유효성 검증
*/
private function validateTarget(string $targetType, int $targetId): void
{
$tenantId = $this->tenantId();
switch ($targetType) {
case 'MATERIAL':
$exists = Material::where('tenant_id', $tenantId)->where('id', $targetId)->exists();
if (!$exists) {
throw ValidationException::withMessages(['target_id' => __('error.material_not_found')]);
}
break;
case 'PRODUCT':
$exists = Product::where('tenant_id', $tenantId)->where('id', $targetId)->exists();
if (!$exists) {
throw ValidationException::withMessages(['target_id' => __('error.product_not_found')]);
}
break;
default:
throw ValidationException::withMessages(['target_type' => __('error.invalid_target_type')]);
}
}
/**
* 조건식 문법 검증
*/
private function validateConditionExpression(string $expression): void
{
// 기본적인 문법 검증
$expression = trim($expression);
if (empty($expression)) {
throw ValidationException::withMessages(['condition_expression' => __('error.condition_expression_required')]);
}
// 허용된 패턴들 검증
$allowedPatterns = [
'/^.+\s*(==|!=|>=|<=|>|<)\s*.+$/', // 비교 연산자
'/^.+\s+IN\s+\(.+\)$/i', // IN 연산자
'/^.+\s+NOT\s+IN\s+\(.+\)$/i', // NOT IN 연산자
'/^(true|false|1|0)$/i', // 불린 값
];
$isValid = false;
foreach ($allowedPatterns as $pattern) {
if (preg_match($pattern, $expression)) {
$isValid = true;
break;
}
}
if (!$isValid) {
throw ValidationException::withMessages([
'condition_expression' => __('error.invalid_condition_expression')
]);
}
}
/**
* 모델의 규칙 템플릿 조회 (자주 사용되는 패턴들)
*/
public function getRuleTemplates(): array
{
return [
[
'name' => '크기별 브라켓 개수',
'description' => '폭/높이에 따른 브라켓 개수 결정',
'condition_example' => 'W1 > 1000',
'action_type' => 'INCLUDE',
'target_type' => 'MATERIAL',
],
[
'name' => '스크린 타입별 자재',
'description' => '스크린 종류에 따른 자재 선택',
'condition_example' => "screen_type == 'STEEL'",
'action_type' => 'INCLUDE',
'target_type' => 'MATERIAL',
],
[
'name' => '설치 방식별 부품',
'description' => '설치 타입에 따른 추가 부품',
'condition_example' => "install_type IN ('CEILING', 'WALL')",
'action_type' => 'INCLUDE',
'target_type' => 'PRODUCT',
],
[
'name' => '면적별 수량 배수',
'description' => '면적에 비례하는 자재 수량',
'condition_example' => 'area > 10',
'action_type' => 'MODIFY_QUANTITY',
'target_type' => 'MATERIAL',
],
];
}
}

View File

@@ -0,0 +1,471 @@
<?php
namespace App\Services\Design;
use App\Models\Design\DesignModel;
use App\Models\Design\ModelParameter;
use App\Models\Design\ModelFormula;
use App\Models\Design\BomConditionRule;
use App\Models\Design\BomTemplate;
use App\Models\Design\BomTemplateItem;
use App\Models\Product;
use App\Models\Material;
use App\Services\Service;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class BomResolverService extends Service
{
protected ModelParameterService $parameterService;
protected ModelFormulaService $formulaService;
protected BomConditionRuleService $ruleService;
public function __construct(
ModelParameterService $parameterService,
ModelFormulaService $formulaService,
BomConditionRuleService $ruleService
) {
$this->parameterService = $parameterService;
$this->formulaService = $formulaService;
$this->ruleService = $ruleService;
}
/**
* 매개변수 기반 BOM 해석 (전체 프로세스)
*/
public function resolveBom(int $modelId, array $inputParameters, ?int $templateId = null): array
{
$tenantId = $this->tenantId();
// 1. 모델 존재 확인
$model = DesignModel::where('tenant_id', $tenantId)->where('id', $modelId)->first();
if (!$model) {
throw new NotFoundHttpException(__('error.model_not_found'));
}
// 2. 매개변수 검증 및 기본값 적용
$validatedParameters = $this->parameterService->validateParameters($modelId, $inputParameters);
// 3. 공식 계산 실행
$calculatedValues = $this->formulaService->calculateFormulas($modelId, $validatedParameters);
// 4. 조건 규칙 평가
$ruleResults = $this->ruleService->evaluateRules($modelId, $calculatedValues);
// 5. 기본 BOM 템플릿 조회 (지정된 템플릿 또는 최신 버전)
$baseBom = $this->getBaseBomTemplate($modelId, $templateId);
// 6. 조건 규칙 적용으로 BOM 변환
$resolvedBom = $this->applyRulesToBom($baseBom, $ruleResults['bom_actions'], $calculatedValues);
// 7. BOM 아이템 정보 보강 (재료/제품 세부정보)
$enrichedBom = $this->enrichBomItems($resolvedBom);
return [
'model' => [
'id' => $model->id,
'code' => $model->code,
'name' => $model->name,
],
'input_parameters' => $validatedParameters,
'calculated_values' => $calculatedValues,
'matched_rules' => $ruleResults['matched_rules'],
'base_bom_template_id' => $baseBom['template_id'] ?? null,
'resolved_bom' => $enrichedBom,
'summary' => $this->generateBomSummary($enrichedBom),
];
}
/**
* BOM 해석 미리보기 (저장하지 않음)
*/
public function previewBom(int $modelId, array $inputParameters, ?int $templateId = null): array
{
return $this->resolveBom($modelId, $inputParameters, $templateId);
}
/**
* 매개변수 변경에 따른 BOM 차이 분석
*/
public function compareBomByParameters(int $modelId, array $parameters1, array $parameters2, ?int $templateId = null): array
{
// 두 매개변수 세트로 각각 BOM 해석
$bom1 = $this->resolveBom($modelId, $parameters1, $templateId);
$bom2 = $this->resolveBom($modelId, $parameters2, $templateId);
return [
'parameters_diff' => [
'set1' => $parameters1,
'set2' => $parameters2,
'changed' => array_diff_assoc($parameters2, $parameters1),
],
'calculated_values_diff' => [
'set1' => $bom1['calculated_values'],
'set2' => $bom2['calculated_values'],
'changed' => array_diff_assoc($bom2['calculated_values'], $bom1['calculated_values']),
],
'bom_diff' => $this->compareBomItems($bom1['resolved_bom'], $bom2['resolved_bom']),
'summary_diff' => [
'set1' => $bom1['summary'],
'set2' => $bom2['summary'],
],
];
}
/**
* 기본 BOM 템플릿 조회
*/
private function getBaseBomTemplate(int $modelId, ?int $templateId = null): array
{
$tenantId = $this->tenantId();
if ($templateId) {
// 지정된 템플릿 사용
$template = BomTemplate::where('tenant_id', $tenantId)
->where('id', $templateId)
->first();
} else {
// 해당 모델의 최신 버전에서 BOM 템플릿 찾기
$template = BomTemplate::query()
->where('tenant_id', $tenantId)
->whereHas('modelVersion', function ($q) use ($modelId) {
$q->where('model_id', $modelId)
->where('status', 'RELEASED');
})
->orderByDesc('created_at')
->first();
}
if (!$template) {
// 기본 템플릿이 없으면 빈 BOM 반환
return [
'template_id' => null,
'items' => [],
];
}
$items = BomTemplateItem::query()
->where('bom_template_id', $template->id)
->orderBy('order')
->get()
->map(function ($item) {
return [
'target_type' => $item->ref_type,
'target_id' => $item->ref_id,
'quantity' => $item->quantity,
'waste_rate' => $item->waste_rate,
'reason' => 'base_template',
];
})
->toArray();
return [
'template_id' => $template->id,
'items' => $items,
];
}
/**
* 조건 규칙을 BOM에 적용
*/
private function applyRulesToBom(array $baseBom, array $bomActions, array $calculatedValues): array
{
$currentBom = $baseBom['items'];
foreach ($bomActions as $action) {
switch ($action['action_type']) {
case 'INCLUDE':
// 새 아이템 추가 (중복 체크)
$exists = collect($currentBom)->contains(function ($item) use ($action) {
return $item['target_type'] === $action['target_type'] &&
$item['target_id'] === $action['target_id'];
});
if (!$exists) {
$currentBom[] = [
'target_type' => $action['target_type'],
'target_id' => $action['target_id'],
'quantity' => $action['quantity_multiplier'] ?? 1,
'waste_rate' => 0,
'reason' => $action['rule_name'],
];
}
break;
case 'EXCLUDE':
// 아이템 제외
$currentBom = array_filter($currentBom, function ($item) use ($action) {
return !($item['target_type'] === $action['target_type'] &&
$item['target_id'] === $action['target_id']);
});
break;
case 'MODIFY_QUANTITY':
// 수량 변경
foreach ($currentBom as &$item) {
if ($item['target_type'] === $action['target_type'] &&
$item['target_id'] === $action['target_id']) {
$multiplier = $action['quantity_multiplier'] ?? 1;
// 공식으로 계산된 값이 있으면 그것을 사용
if (isset($calculatedValues['quantity_' . $action['target_id']])) {
$item['quantity'] = $calculatedValues['quantity_' . $action['target_id']];
} else {
$item['quantity'] = $item['quantity'] * $multiplier;
}
$item['reason'] = $action['rule_name'];
}
}
break;
}
}
return array_values($currentBom); // 인덱스 재정렬
}
/**
* BOM 아이템 정보 보강
*/
private function enrichBomItems(array $bomItems): array
{
$tenantId = $this->tenantId();
$enriched = [];
foreach ($bomItems as $item) {
$enrichedItem = $item;
if ($item['target_type'] === 'MATERIAL') {
$material = Material::where('tenant_id', $tenantId)
->where('id', $item['target_id'])
->first();
if ($material) {
$enrichedItem['target_info'] = [
'id' => $material->id,
'code' => $material->code,
'name' => $material->name,
'unit' => $material->unit,
'type' => 'material',
];
}
} elseif ($item['target_type'] === 'PRODUCT') {
$product = Product::where('tenant_id', $tenantId)
->where('id', $item['target_id'])
->first();
if ($product) {
$enrichedItem['target_info'] = [
'id' => $product->id,
'code' => $product->code,
'name' => $product->name,
'unit' => $product->unit,
'type' => 'product',
];
}
}
// 실제 필요 수량 계산 (폐기율 적용)
$baseQuantity = $enrichedItem['quantity'];
$wasteRate = $enrichedItem['waste_rate'] ?? 0;
$enrichedItem['actual_quantity'] = $baseQuantity * (1 + $wasteRate / 100);
$enriched[] = $enrichedItem;
}
return $enriched;
}
/**
* BOM 요약 정보 생성
*/
private function generateBomSummary(array $bomItems): array
{
$totalItems = count($bomItems);
$materialCount = 0;
$productCount = 0;
$totalValue = 0; // 나중에 가격 정보 추가 시 사용
foreach ($bomItems as $item) {
if ($item['target_type'] === 'MATERIAL') {
$materialCount++;
} elseif ($item['target_type'] === 'PRODUCT') {
$productCount++;
}
}
return [
'total_items' => $totalItems,
'material_count' => $materialCount,
'product_count' => $productCount,
'total_estimated_value' => $totalValue,
'generated_at' => now()->toISOString(),
];
}
/**
* BOM 아이템 비교
*/
private function compareBomItems(array $bom1, array $bom2): array
{
$added = [];
$removed = [];
$modified = [];
// BOM1에 있던 아이템들 체크
foreach ($bom1 as $item1) {
$key = $item1['target_type'] . '_' . $item1['target_id'];
$found = false;
foreach ($bom2 as $item2) {
if ($item2['target_type'] === $item1['target_type'] &&
$item2['target_id'] === $item1['target_id']) {
$found = true;
// 수량이 변경되었는지 체크
if ($item1['quantity'] != $item2['quantity']) {
$modified[] = [
'target_type' => $item1['target_type'],
'target_id' => $item1['target_id'],
'target_info' => $item1['target_info'] ?? null,
'old_quantity' => $item1['quantity'],
'new_quantity' => $item2['quantity'],
'change' => $item2['quantity'] - $item1['quantity'],
];
}
break;
}
}
if (!$found) {
$removed[] = $item1;
}
}
// BOM2에 새로 추가된 아이템들
foreach ($bom2 as $item2) {
$found = false;
foreach ($bom1 as $item1) {
if ($item1['target_type'] === $item2['target_type'] &&
$item1['target_id'] === $item2['target_id']) {
$found = true;
break;
}
}
if (!$found) {
$added[] = $item2;
}
}
return [
'added' => $added,
'removed' => $removed,
'modified' => $modified,
'summary' => [
'added_count' => count($added),
'removed_count' => count($removed),
'modified_count' => count($modified),
],
];
}
/**
* BOM 해석 결과 저장 (향후 주문/견적 연계용)
*/
public function saveBomResolution(int $modelId, array $inputParameters, array $bomResolution, string $purpose = 'ESTIMATION'): array
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
return DB::transaction(function () use ($tenantId, $userId, $modelId, $inputParameters, $bomResolution, $purpose) {
// BOM 해석 결과를 데이터베이스에 저장
// 향후 order_items, quotation_items 등과 연계할 수 있도록 구조 준비
$resolutionRecord = [
'tenant_id' => $tenantId,
'model_id' => $modelId,
'input_parameters' => json_encode($inputParameters),
'calculated_values' => json_encode($bomResolution['calculated_values']),
'resolved_bom' => json_encode($bomResolution['resolved_bom']),
'matched_rules' => json_encode($bomResolution['matched_rules']),
'summary' => json_encode($bomResolution['summary']),
'purpose' => $purpose,
'created_by' => $userId,
'created_at' => now(),
];
// 실제 테이블이 있다면 저장, 없으면 파일이나 캐시에 임시 저장
$resolutionId = md5(json_encode($resolutionRecord));
// 임시로 캐시에 저장 (1시간)
cache()->put("bom_resolution_{$resolutionId}", $resolutionRecord, 3600);
return [
'resolution_id' => $resolutionId,
'saved_at' => now()->toISOString(),
'purpose' => $purpose,
];
});
}
/**
* 저장된 BOM 해석 결과 조회
*/
public function getBomResolution(string $resolutionId): ?array
{
return cache()->get("bom_resolution_{$resolutionId}");
}
/**
* KSS01 시나리오 테스트용 빠른 실행
*/
public function resolveKSS01(array $parameters): array
{
// KSS01 모델이 있다고 가정하고 하드코딩된 로직
$defaults = [
'W0' => 800,
'H0' => 600,
'screen_type' => 'FABRIC',
'install_type' => 'WALL',
];
$params = array_merge($defaults, $parameters);
// 공식 계산 시뮬레이션
$calculated = [
'W1' => $params['W0'] + 100,
'H1' => $params['H0'] + 100,
];
$calculated['area'] = ($calculated['W1'] * $calculated['H1']) / 1000000;
// 조건 규칙 시뮬레이션
$bom = [];
// 스크린 타입에 따른 자재
if ($params['screen_type'] === 'FABRIC') {
$bom[] = ['target_type' => 'MATERIAL', 'target_id' => 1, 'quantity' => $calculated['area'], 'target_info' => ['name' => '패브릭 스크린']];
} else {
$bom[] = ['target_type' => 'MATERIAL', 'target_id' => 2, 'quantity' => $calculated['area'], 'target_info' => ['name' => '스틸 스크린']];
}
// 브라켓 개수 (폭에 따라)
$bracketCount = $calculated['W1'] > 1000 ? 3 : 2;
$bom[] = ['target_type' => 'PRODUCT', 'target_id' => 10, 'quantity' => $bracketCount, 'target_info' => ['name' => '브라켓']];
// 가이드레일
$railLength = ($calculated['W1'] + $calculated['H1']) * 2 / 1000; // m 단위
$bom[] = ['target_type' => 'MATERIAL', 'target_id' => 3, 'quantity' => $railLength, 'target_info' => ['name' => '가이드레일']];
return [
'model' => ['code' => 'KSS01', 'name' => '기본 스크린 시스템'],
'input_parameters' => $params,
'calculated_values' => $calculated,
'resolved_bom' => $bom,
'summary' => ['total_items' => count($bom)],
];
}
}

View File

@@ -0,0 +1,461 @@
<?php
namespace App\Services\Design;
use App\Models\Design\ModelFormula;
use App\Models\Design\DesignModel;
use App\Models\Design\ModelParameter;
use App\Services\Service;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ModelFormulaService extends Service
{
/**
* 모델의 공식 목록 조회
*/
public function listByModel(int $modelId, string $q = '', int $page = 1, int $size = 20): LengthAwarePaginator
{
$tenantId = $this->tenantId();
// 모델 존재 확인
$model = DesignModel::where('tenant_id', $tenantId)->where('id', $modelId)->first();
if (!$model) {
throw new NotFoundHttpException(__('error.not_found'));
}
$query = ModelFormula::query()
->where('tenant_id', $tenantId)
->where('model_id', $modelId);
if ($q !== '') {
$query->where(function ($w) use ($q) {
$w->where('formula_name', 'like', "%{$q}%")
->orWhere('formula_expression', 'like', "%{$q}%")
->orWhere('description', 'like', "%{$q}%");
});
}
return $query->orderBy('calculation_order')->orderBy('id')->paginate($size, ['*'], 'page', $page);
}
/**
* 공식 조회
*/
public function show(int $formulaId): ModelFormula
{
$tenantId = $this->tenantId();
$formula = ModelFormula::where('tenant_id', $tenantId)->where('id', $formulaId)->first();
if (!$formula) {
throw new NotFoundHttpException(__('error.not_found'));
}
return $formula;
}
/**
* 공식 생성
*/
public function create(array $data): ModelFormula
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// 모델 존재 확인
$model = DesignModel::where('tenant_id', $tenantId)->where('id', $data['model_id'])->first();
if (!$model) {
throw new NotFoundHttpException(__('error.not_found'));
}
// 같은 모델 내에서 공식명 중복 체크
$exists = ModelFormula::query()
->where('tenant_id', $tenantId)
->where('model_id', $data['model_id'])
->where('formula_name', $data['formula_name'])
->exists();
if ($exists) {
throw ValidationException::withMessages(['formula_name' => __('error.duplicate')]);
}
return DB::transaction(function () use ($tenantId, $userId, $data) {
// calculation_order가 없으면 자동 설정
if (!isset($data['calculation_order'])) {
$maxOrder = ModelFormula::where('tenant_id', $tenantId)
->where('model_id', $data['model_id'])
->max('calculation_order') ?? 0;
$data['calculation_order'] = $maxOrder + 1;
}
// 공식에서 변수 추출 및 의존성 설정
$tempFormula = new ModelFormula(['formula_expression' => $data['formula_expression']]);
$variables = $tempFormula->extractVariables();
$data['dependencies'] = $variables;
// 의존성 순환 체크
$this->validateNoDependencyLoop($data['model_id'], $data['formula_name'], $variables);
$payload = array_merge($data, [
'tenant_id' => $tenantId,
'created_by' => $userId,
'updated_by' => $userId,
]);
return ModelFormula::create($payload);
});
}
/**
* 공식 수정
*/
public function update(int $formulaId, array $data): ModelFormula
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$formula = ModelFormula::where('tenant_id', $tenantId)->where('id', $formulaId)->first();
if (!$formula) {
throw new NotFoundHttpException(__('error.not_found'));
}
// 공식명 변경 시 중복 체크
if (isset($data['formula_name']) && $data['formula_name'] !== $formula->formula_name) {
$exists = ModelFormula::query()
->where('tenant_id', $tenantId)
->where('model_id', $formula->model_id)
->where('formula_name', $data['formula_name'])
->where('id', '!=', $formulaId)
->exists();
if ($exists) {
throw ValidationException::withMessages(['formula_name' => __('error.duplicate')]);
}
}
return DB::transaction(function () use ($formula, $userId, $data) {
// 공식 표현식이 변경되면 의존성 재계산
if (isset($data['formula_expression'])) {
$tempFormula = new ModelFormula(['formula_expression' => $data['formula_expression']]);
$variables = $tempFormula->extractVariables();
$data['dependencies'] = $variables;
// 의존성 순환 체크 (자기 자신 제외)
$formulaName = $data['formula_name'] ?? $formula->formula_name;
$this->validateNoDependencyLoop($formula->model_id, $formulaName, $variables, $formula->id);
}
$payload = array_merge($data, ['updated_by' => $userId]);
$formula->update($payload);
return $formula->fresh();
});
}
/**
* 공식 삭제
*/
public function delete(int $formulaId): bool
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$formula = ModelFormula::where('tenant_id', $tenantId)->where('id', $formulaId)->first();
if (!$formula) {
throw new NotFoundHttpException(__('error.not_found'));
}
// 다른 공식에서 이 공식을 의존하는지 체크
$dependentFormulas = ModelFormula::query()
->where('tenant_id', $tenantId)
->where('model_id', $formula->model_id)
->where('id', '!=', $formulaId)
->get()
->filter(function ($f) use ($formula) {
return in_array($formula->formula_name, $f->dependencies ?? []);
});
if ($dependentFormulas->isNotEmpty()) {
$dependentNames = $dependentFormulas->pluck('formula_name')->implode(', ');
throw ValidationException::withMessages([
'formula_name' => __('error.formula_in_use', ['formulas' => $dependentNames])
]);
}
return DB::transaction(function () use ($formula, $userId) {
$formula->update(['deleted_by' => $userId]);
return $formula->delete();
});
}
/**
* 공식 계산 순서 변경
*/
public function reorder(int $modelId, array $formulaIds): array
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// 모델 존재 확인
$model = DesignModel::where('tenant_id', $tenantId)->where('id', $modelId)->first();
if (!$model) {
throw new NotFoundHttpException(__('error.not_found'));
}
return DB::transaction(function () use ($tenantId, $userId, $modelId, $formulaIds) {
$order = 1;
$updated = [];
foreach ($formulaIds as $formulaId) {
$formula = ModelFormula::query()
->where('tenant_id', $tenantId)
->where('model_id', $modelId)
->where('id', $formulaId)
->first();
if ($formula) {
$formula->update([
'calculation_order' => $order,
'updated_by' => $userId,
]);
$updated[] = $formula->fresh();
$order++;
}
}
return $updated;
});
}
/**
* 공식 대량 저장 (upsert)
*/
public function bulkUpsert(int $modelId, array $formulas): array
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// 모델 존재 확인
$model = DesignModel::where('tenant_id', $tenantId)->where('id', $modelId)->first();
if (!$model) {
throw new NotFoundHttpException(__('error.not_found'));
}
return DB::transaction(function () use ($tenantId, $userId, $modelId, $formulas) {
$result = [];
foreach ($formulas as $index => $formulaData) {
$formulaData['model_id'] = $modelId;
// 공식에서 의존성 추출
if (isset($formulaData['formula_expression'])) {
$tempFormula = new ModelFormula(['formula_expression' => $formulaData['formula_expression']]);
$formulaData['dependencies'] = $tempFormula->extractVariables();
}
// ID가 있으면 업데이트, 없으면 생성
if (isset($formulaData['id']) && $formulaData['id']) {
$formula = ModelFormula::query()
->where('tenant_id', $tenantId)
->where('model_id', $modelId)
->where('id', $formulaData['id'])
->first();
if ($formula) {
$formula->update(array_merge($formulaData, ['updated_by' => $userId]));
$result[] = $formula->fresh();
}
} else {
// 새로운 공식 생성
$exists = ModelFormula::query()
->where('tenant_id', $tenantId)
->where('model_id', $modelId)
->where('formula_name', $formulaData['formula_name'])
->exists();
if (!$exists) {
if (!isset($formulaData['calculation_order'])) {
$formulaData['calculation_order'] = $index + 1;
}
$payload = array_merge($formulaData, [
'tenant_id' => $tenantId,
'created_by' => $userId,
'updated_by' => $userId,
]);
$result[] = ModelFormula::create($payload);
}
}
}
return $result;
});
}
/**
* 공식 계산 실행
*/
public function calculateFormulas(int $modelId, array $inputValues): array
{
$tenantId = $this->tenantId();
// 모델의 모든 공식을 계산 순서대로 조회
$formulas = ModelFormula::query()
->where('tenant_id', $tenantId)
->where('model_id', $modelId)
->orderBy('calculation_order')
->get();
$results = $inputValues; // 입력값을 결과에 포함
$errors = [];
foreach ($formulas as $formula) {
try {
// 의존하는 변수들이 모두 준비되었는지 확인
$dependencies = $formula->dependencies ?? [];
$hasAllDependencies = true;
foreach ($dependencies as $dependency) {
if (!array_key_exists($dependency, $results)) {
$hasAllDependencies = false;
break;
}
}
if (!$hasAllDependencies) {
$errors[$formula->formula_name] = __('error.missing_dependencies');
continue;
}
// 공식 계산 실행
$calculatedValue = $formula->calculate($results);
$results[$formula->formula_name] = $calculatedValue;
} catch (\Exception $e) {
$errors[$formula->formula_name] = $e->getMessage();
}
}
if (!empty($errors)) {
throw ValidationException::withMessages($errors);
}
return $results;
}
/**
* 의존성 순환 검증
*/
private function validateNoDependencyLoop(int $modelId, string $formulaName, array $dependencies, ?int $excludeFormulaId = null): void
{
$tenantId = $this->tenantId();
// 해당 모델의 모든 공식 조회 (수정 중인 공식 제외)
$query = ModelFormula::query()
->where('tenant_id', $tenantId)
->where('model_id', $modelId);
if ($excludeFormulaId) {
$query->where('id', '!=', $excludeFormulaId);
}
$allFormulas = $query->get()->toArray();
// 새로운 공식을 임시로 추가
$allFormulas[] = [
'formula_name' => $formulaName,
'dependencies' => $dependencies,
];
// 각 의존성에 대해 순환 검사
foreach ($dependencies as $dependency) {
if ($this->hasCircularDependency($formulaName, $dependency, $allFormulas, [])) {
throw ValidationException::withMessages([
'formula_expression' => __('error.circular_dependency', ['dependency' => $dependency])
]);
}
}
}
/**
* 순환 의존성 검사 (DFS)
*/
private function hasCircularDependency(string $startFormula, string $currentFormula, array $allFormulas, array $visited): bool
{
if ($currentFormula === $startFormula) {
return true; // 순환 발견
}
if (in_array($currentFormula, $visited)) {
return false; // 이미 방문한 노드
}
$visited[] = $currentFormula;
// 현재 공식의 의존성들을 확인
$currentFormulaData = collect($allFormulas)->firstWhere('formula_name', $currentFormula);
if (!$currentFormulaData || empty($currentFormulaData['dependencies'])) {
return false;
}
foreach ($currentFormulaData['dependencies'] as $dependency) {
if ($this->hasCircularDependency($startFormula, $dependency, $allFormulas, $visited)) {
return true;
}
}
return false;
}
/**
* 모델의 공식 의존성 그래프 조회
*/
public function getDependencyGraph(int $modelId): array
{
$tenantId = $this->tenantId();
// 모델 존재 확인
$model = DesignModel::where('tenant_id', $tenantId)->where('id', $modelId)->first();
if (!$model) {
throw new NotFoundHttpException(__('error.not_found'));
}
$formulas = ModelFormula::query()
->where('tenant_id', $tenantId)
->where('model_id', $modelId)
->orderBy('calculation_order')
->get();
$graph = [
'nodes' => [],
'edges' => [],
];
// 노드 생성 (공식들)
foreach ($formulas as $formula) {
$graph['nodes'][] = [
'id' => $formula->formula_name,
'label' => $formula->formula_name,
'expression' => $formula->formula_expression,
'order' => $formula->calculation_order,
];
}
// 엣지 생성 (의존성들)
foreach ($formulas as $formula) {
if (!empty($formula->dependencies)) {
foreach ($formula->dependencies as $dependency) {
$graph['edges'][] = [
'from' => $dependency,
'to' => $formula->formula_name,
];
}
}
}
return $graph;
}
}

View File

@@ -0,0 +1,344 @@
<?php
namespace App\Services\Design;
use App\Models\Design\ModelParameter;
use App\Models\Design\DesignModel;
use App\Services\Service;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ModelParameterService extends Service
{
/**
* 모델의 매개변수 목록 조회
*/
public function listByModel(int $modelId, string $q = '', int $page = 1, int $size = 20): LengthAwarePaginator
{
$tenantId = $this->tenantId();
// 모델 존재 확인
$model = DesignModel::where('tenant_id', $tenantId)->where('id', $modelId)->first();
if (!$model) {
throw new NotFoundHttpException(__('error.not_found'));
}
$query = ModelParameter::query()
->where('tenant_id', $tenantId)
->where('model_id', $modelId);
if ($q !== '') {
$query->where(function ($w) use ($q) {
$w->where('parameter_name', 'like', "%{$q}%")
->orWhere('description', 'like', "%{$q}%");
});
}
return $query->orderBy('sort_order')->orderBy('id')->paginate($size, ['*'], 'page', $page);
}
/**
* 매개변수 조회
*/
public function show(int $parameterId): ModelParameter
{
$tenantId = $this->tenantId();
$parameter = ModelParameter::where('tenant_id', $tenantId)->where('id', $parameterId)->first();
if (!$parameter) {
throw new NotFoundHttpException(__('error.not_found'));
}
return $parameter;
}
/**
* 매개변수 생성
*/
public function create(array $data): ModelParameter
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// 모델 존재 확인
$model = DesignModel::where('tenant_id', $tenantId)->where('id', $data['model_id'])->first();
if (!$model) {
throw new NotFoundHttpException(__('error.not_found'));
}
// 같은 모델 내에서 매개변수명 중복 체크
$exists = ModelParameter::query()
->where('tenant_id', $tenantId)
->where('model_id', $data['model_id'])
->where('parameter_name', $data['parameter_name'])
->exists();
if ($exists) {
throw ValidationException::withMessages(['parameter_name' => __('error.duplicate')]);
}
return DB::transaction(function () use ($tenantId, $userId, $data) {
// sort_order가 없으면 자동 설정
if (!isset($data['sort_order'])) {
$maxOrder = ModelParameter::where('tenant_id', $tenantId)
->where('model_id', $data['model_id'])
->max('sort_order') ?? 0;
$data['sort_order'] = $maxOrder + 1;
}
$payload = array_merge($data, [
'tenant_id' => $tenantId,
'created_by' => $userId,
'updated_by' => $userId,
]);
return ModelParameter::create($payload);
});
}
/**
* 매개변수 수정
*/
public function update(int $parameterId, array $data): ModelParameter
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$parameter = ModelParameter::where('tenant_id', $tenantId)->where('id', $parameterId)->first();
if (!$parameter) {
throw new NotFoundHttpException(__('error.not_found'));
}
// 매개변수명 변경 시 중복 체크
if (isset($data['parameter_name']) && $data['parameter_name'] !== $parameter->parameter_name) {
$exists = ModelParameter::query()
->where('tenant_id', $tenantId)
->where('model_id', $parameter->model_id)
->where('parameter_name', $data['parameter_name'])
->where('id', '!=', $parameterId)
->exists();
if ($exists) {
throw ValidationException::withMessages(['parameter_name' => __('error.duplicate')]);
}
}
return DB::transaction(function () use ($parameter, $userId, $data) {
$payload = array_merge($data, ['updated_by' => $userId]);
$parameter->update($payload);
return $parameter->fresh();
});
}
/**
* 매개변수 삭제
*/
public function delete(int $parameterId): bool
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$parameter = ModelParameter::where('tenant_id', $tenantId)->where('id', $parameterId)->first();
if (!$parameter) {
throw new NotFoundHttpException(__('error.not_found'));
}
return DB::transaction(function () use ($parameter, $userId) {
$parameter->update(['deleted_by' => $userId]);
return $parameter->delete();
});
}
/**
* 매개변수 순서 변경
*/
public function reorder(int $modelId, array $parameterIds): array
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// 모델 존재 확인
$model = DesignModel::where('tenant_id', $tenantId)->where('id', $modelId)->first();
if (!$model) {
throw new NotFoundHttpException(__('error.not_found'));
}
return DB::transaction(function () use ($tenantId, $userId, $modelId, $parameterIds) {
$order = 1;
$updated = [];
foreach ($parameterIds as $parameterId) {
$parameter = ModelParameter::query()
->where('tenant_id', $tenantId)
->where('model_id', $modelId)
->where('id', $parameterId)
->first();
if ($parameter) {
$parameter->update([
'sort_order' => $order,
'updated_by' => $userId,
]);
$updated[] = $parameter->fresh();
$order++;
}
}
return $updated;
});
}
/**
* 매개변수 대량 저장 (upsert)
*/
public function bulkUpsert(int $modelId, array $parameters): array
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// 모델 존재 확인
$model = DesignModel::where('tenant_id', $tenantId)->where('id', $modelId)->first();
if (!$model) {
throw new NotFoundHttpException(__('error.not_found'));
}
return DB::transaction(function () use ($tenantId, $userId, $modelId, $parameters) {
$result = [];
foreach ($parameters as $index => $paramData) {
$paramData['model_id'] = $modelId;
// ID가 있으면 업데이트, 없으면 생성
if (isset($paramData['id']) && $paramData['id']) {
$parameter = ModelParameter::query()
->where('tenant_id', $tenantId)
->where('model_id', $modelId)
->where('id', $paramData['id'])
->first();
if ($parameter) {
$parameter->update(array_merge($paramData, ['updated_by' => $userId]));
$result[] = $parameter->fresh();
}
} else {
// 새로운 매개변수 생성
$exists = ModelParameter::query()
->where('tenant_id', $tenantId)
->where('model_id', $modelId)
->where('parameter_name', $paramData['parameter_name'])
->exists();
if (!$exists) {
if (!isset($paramData['sort_order'])) {
$paramData['sort_order'] = $index + 1;
}
$payload = array_merge($paramData, [
'tenant_id' => $tenantId,
'created_by' => $userId,
'updated_by' => $userId,
]);
$result[] = ModelParameter::create($payload);
}
}
}
return $result;
});
}
/**
* 매개변수 값 검증
*/
public function validateParameters(int $modelId, array $values): array
{
$tenantId = $this->tenantId();
// 모델의 모든 매개변수 조회
$parameters = ModelParameter::query()
->where('tenant_id', $tenantId)
->where('model_id', $modelId)
->orderBy('sort_order')
->get();
$errors = [];
$validated = [];
foreach ($parameters as $parameter) {
$paramName = $parameter->parameter_name;
$value = $values[$paramName] ?? null;
// 필수 매개변수 체크
if ($parameter->is_required && ($value === null || $value === '')) {
$errors[$paramName] = __('error.required');
continue;
}
// 값이 없고 필수가 아니면 기본값 사용
if ($value === null || $value === '') {
$validated[$paramName] = $parameter->default_value;
continue;
}
// 타입별 검증
if (!$parameter->validateValue($value)) {
$errors[$paramName] = __('error.invalid_value');
continue;
}
// 형변환 후 저장
$validated[$paramName] = $parameter->castValue($value);
}
if (!empty($errors)) {
throw ValidationException::withMessages($errors);
}
return $validated;
}
/**
* 모델의 매개변수 스키마 조회 (API용)
*/
public function getParameterSchema(int $modelId): array
{
$tenantId = $this->tenantId();
// 모델 존재 확인
$model = DesignModel::where('tenant_id', $tenantId)->where('id', $modelId)->first();
if (!$model) {
throw new NotFoundHttpException(__('error.not_found'));
}
$parameters = ModelParameter::query()
->where('tenant_id', $tenantId)
->where('model_id', $modelId)
->orderBy('sort_order')
->get();
return [
'model' => [
'id' => $model->id,
'code' => $model->code,
'name' => $model->name,
],
'parameters' => $parameters->map(function ($param) {
return [
'name' => $param->parameter_name,
'type' => $param->parameter_type,
'required' => $param->is_required,
'default' => $param->default_value,
'min' => $param->min_value,
'max' => $param->max_value,
'unit' => $param->unit,
'options' => $param->options,
'description' => $param->description,
];
})->toArray(),
];
}
}

View File

@@ -0,0 +1,422 @@
<?php
namespace App\Services\Design;
use App\Models\Design\DesignModel;
use App\Models\Product;
use App\Models\ProductComponent;
use App\Models\Category;
use App\Services\Service;
use App\Services\ProductService;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ProductFromModelService extends Service
{
protected BomResolverService $bomResolverService;
protected ProductService $productService;
public function __construct(
BomResolverService $bomResolverService,
ProductService $productService
) {
$this->bomResolverService = $bomResolverService;
$this->productService = $productService;
}
/**
* 모델과 매개변수로부터 제품 생성
*/
public function createProductFromModel(
int $modelId,
array $parameters,
array $productData,
bool $includeComponents = true
): Product {
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// 모델 존재 확인
$model = DesignModel::where('tenant_id', $tenantId)->where('id', $modelId)->first();
if (!$model) {
throw new NotFoundHttpException(__('error.model_not_found'));
}
// BOM 해석 실행
$bomResolution = $this->bomResolverService->resolveBom($modelId, $parameters);
return DB::transaction(function () use (
$tenantId,
$userId,
$model,
$parameters,
$productData,
$bomResolution,
$includeComponents
) {
// 제품 기본 정보 설정
$productPayload = array_merge([
'tenant_id' => $tenantId,
'created_by' => $userId,
'updated_by' => $userId,
'product_type' => 'PRODUCT',
'is_active' => true,
], $productData);
// 제품명에 모델 정보 포함 (명시적으로 지정되지 않은 경우)
if (!isset($productData['name'])) {
$productPayload['name'] = $model->name . ' (' . $this->formatParametersForName($parameters) . ')';
}
// 제품 코드 자동 생성 (명시적으로 지정되지 않은 경우)
if (!isset($productData['code'])) {
$productPayload['code'] = $this->generateProductCode($model, $parameters);
}
// 제품 생성
$product = Product::create($productPayload);
// BOM 구성요소를 ProductComponent로 생성
if ($includeComponents && !empty($bomResolution['resolved_bom'])) {
$this->createProductComponents($product, $bomResolution['resolved_bom']);
}
// 모델 기반 제품임을 표시하는 메타 정보 저장
$this->saveModelMetadata($product, $model, $parameters, $bomResolution);
return $product->load('components');
});
}
/**
* 기존 제품의 BOM 업데이트
*/
public function updateProductBom(int $productId, array $newParameters): Product
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$product = Product::where('tenant_id', $tenantId)->where('id', $productId)->first();
if (!$product) {
throw new NotFoundHttpException(__('error.product_not_found'));
}
// 제품이 모델 기반으로 생성된 것인지 확인
$metadata = $this->getModelMetadata($product);
if (!$metadata) {
throw ValidationException::withMessages([
'product_id' => __('error.product_not_model_based')
]);
}
$modelId = $metadata['model_id'];
// 새로운 매개변수로 BOM 해석
$bomResolution = $this->bomResolverService->resolveBom($modelId, $newParameters);
return DB::transaction(function () use ($product, $userId, $newParameters, $bomResolution, $metadata) {
// 기존 ProductComponent 삭제
ProductComponent::where('product_id', $product->id)->delete();
// 새로운 BOM으로 ProductComponent 생성
$this->createProductComponents($product, $bomResolution['resolved_bom']);
// 메타데이터 업데이트
$this->updateModelMetadata($product, $newParameters, $bomResolution);
$product->update(['updated_by' => $userId]);
return $product->fresh()->load('components');
});
}
/**
* 모델 기반 제품 복사 (매개변수 변경)
*/
public function cloneProductWithParameters(
int $sourceProductId,
array $newParameters,
array $productData = []
): Product {
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$sourceProduct = Product::where('tenant_id', $tenantId)->where('id', $sourceProductId)->first();
if (!$sourceProduct) {
throw new NotFoundHttpException(__('error.product_not_found'));
}
// 원본 제품의 모델 메타데이터 조회
$metadata = $this->getModelMetadata($sourceProduct);
if (!$metadata) {
throw ValidationException::withMessages([
'product_id' => __('error.product_not_model_based')
]);
}
// 원본 제품 정보를 기반으로 새 제품 데이터 구성
$newProductData = array_merge([
'name' => $sourceProduct->name . ' (복사본)',
'description' => $sourceProduct->description,
'category_id' => $sourceProduct->category_id,
'unit' => $sourceProduct->unit,
'product_type' => $sourceProduct->product_type,
], $productData);
return $this->createProductFromModel(
$metadata['model_id'],
$newParameters,
$newProductData,
true
);
}
/**
* 모델 매개변수 변경에 따른 제품들 일괄 업데이트
*/
public function updateProductsByModel(int $modelId, array $updatedParameters = null): array
{
$tenantId = $this->tenantId();
// 해당 모델로 생성된 제품들 조회
$modelBasedProducts = $this->getProductsByModel($modelId);
$results = [];
foreach ($modelBasedProducts as $product) {
try {
$metadata = $this->getModelMetadata($product);
$parameters = $updatedParameters ?? $metadata['parameters'];
$updatedProduct = $this->updateProductBom($product->id, $parameters);
$results[] = [
'product_id' => $product->id,
'product_code' => $product->code,
'status' => 'updated',
'bom_items_count' => $updatedProduct->components->count(),
];
} catch (\Exception $e) {
$results[] = [
'product_id' => $product->id,
'product_code' => $product->code,
'status' => 'failed',
'error' => $e->getMessage(),
];
}
}
return $results;
}
/**
* ProductComponent 생성
*/
private function createProductComponents(Product $product, array $bomItems): void
{
$order = 1;
foreach ($bomItems as $item) {
ProductComponent::create([
'product_id' => $product->id,
'ref_type' => $item['target_type'],
'ref_id' => $item['target_id'],
'quantity' => $item['actual_quantity'] ?? $item['quantity'],
'waste_rate' => $item['waste_rate'] ?? 0,
'order' => $order,
'note' => $item['reason'] ?? null,
]);
$order++;
}
}
/**
* 모델 메타데이터 저장
*/
private function saveModelMetadata(Product $product, DesignModel $model, array $parameters, array $bomResolution): void
{
$metadata = [
'model_id' => $model->id,
'model_code' => $model->code,
'parameters' => $parameters,
'calculated_values' => $bomResolution['calculated_values'],
'bom_resolution_summary' => $bomResolution['summary'],
'created_at' => now()->toISOString(),
];
// 실제로는 product_metadata 테이블이나 products 테이블의 metadata 컬럼에 저장
// 임시로 캐시 사용
cache()->put("product_model_metadata_{$product->id}", $metadata, 86400); // 24시간
}
/**
* 모델 메타데이터 업데이트
*/
private function updateModelMetadata(Product $product, array $newParameters, array $bomResolution): void
{
$metadata = $this->getModelMetadata($product);
if ($metadata) {
$metadata['parameters'] = $newParameters;
$metadata['calculated_values'] = $bomResolution['calculated_values'];
$metadata['bom_resolution_summary'] = $bomResolution['summary'];
$metadata['updated_at'] = now()->toISOString();
cache()->put("product_model_metadata_{$product->id}", $metadata, 86400);
}
}
/**
* 모델 메타데이터 조회
*/
private function getModelMetadata(Product $product): ?array
{
return cache()->get("product_model_metadata_{$product->id}");
}
/**
* 매개변수를 제품명용 문자열로 포맷
*/
private function formatParametersForName(array $parameters): string
{
$formatted = [];
foreach ($parameters as $key => $value) {
if (is_numeric($value)) {
$formatted[] = "{$key}:{$value}";
} else {
$formatted[] = "{$key}:{$value}";
}
}
return implode(', ', array_slice($formatted, 0, 3)); // 최대 3개 매개변수만
}
/**
* 제품 코드 자동 생성
*/
private function generateProductCode(DesignModel $model, array $parameters): string
{
$tenantId = $this->tenantId();
// 모델 코드 + 매개변수 해시
$paramHash = substr(md5(json_encode($parameters)), 0, 6);
$baseCode = $model->code . '-' . strtoupper($paramHash);
// 중복 체크 후 순번 추가
$counter = 1;
$finalCode = $baseCode;
while (Product::where('tenant_id', $tenantId)->where('code', $finalCode)->exists()) {
$finalCode = $baseCode . '-' . str_pad($counter, 2, '0', STR_PAD_LEFT);
$counter++;
}
return $finalCode;
}
/**
* 모델로 생성된 제품들 조회
*/
private function getProductsByModel(int $modelId): \Illuminate\Support\Collection
{
$tenantId = $this->tenantId();
$products = Product::where('tenant_id', $tenantId)->get();
return $products->filter(function ($product) use ($modelId) {
$metadata = $this->getModelMetadata($product);
return $metadata && $metadata['model_id'] == $modelId;
});
}
/**
* 제품의 모델 정보 조회 (API용)
*/
public function getProductModelInfo(int $productId): ?array
{
$tenantId = $this->tenantId();
$product = Product::where('tenant_id', $tenantId)->where('id', $productId)->first();
if (!$product) {
throw new NotFoundHttpException(__('error.product_not_found'));
}
$metadata = $this->getModelMetadata($product);
if (!$metadata) {
return null;
}
// 모델 정보 추가 조회
$model = DesignModel::where('tenant_id', $tenantId)->where('id', $metadata['model_id'])->first();
return [
'product' => [
'id' => $product->id,
'code' => $product->code,
'name' => $product->name,
],
'model' => [
'id' => $model->id,
'code' => $model->code,
'name' => $model->name,
],
'parameters' => $metadata['parameters'],
'calculated_values' => $metadata['calculated_values'],
'bom_summary' => $metadata['bom_resolution_summary'],
'created_at' => $metadata['created_at'],
'updated_at' => $metadata['updated_at'] ?? null,
];
}
/**
* 모델 기반 제품 재생성 (모델/공식/규칙 변경 후)
*/
public function regenerateProduct(int $productId, bool $preserveCustomizations = false): Product
{
$tenantId = $this->tenantId();
$product = Product::where('tenant_id', $tenantId)->where('id', $productId)->first();
if (!$product) {
throw new NotFoundHttpException(__('error.product_not_found'));
}
$metadata = $this->getModelMetadata($product);
if (!$metadata) {
throw ValidationException::withMessages([
'product_id' => __('error.product_not_model_based')
]);
}
// 사용자 정의 변경사항 보존 로직
$customComponents = [];
if ($preserveCustomizations) {
// 기존 컴포넌트 중 규칙에서 나온 것이 아닌 수동 추가 항목들 식별
$customComponents = ProductComponent::where('product_id', $productId)
->whereNull('note') // rule_name이 없는 항목들
->get()
->toArray();
}
// 제품을 새로운 매개변수로 재생성
$regeneratedProduct = $this->updateProductBom($productId, $metadata['parameters']);
// 커스텀 컴포넌트 복원
if (!empty($customComponents)) {
foreach ($customComponents as $customComponent) {
ProductComponent::create([
'product_id' => $productId,
'ref_type' => $customComponent['ref_type'],
'ref_id' => $customComponent['ref_id'],
'quantity' => $customComponent['quantity'],
'waste_rate' => $customComponent['waste_rate'],
'order' => $customComponent['order'],
'note' => 'custom_preserved',
]);
}
}
return $regeneratedProduct->fresh()->load('components');
}
}

View File

@@ -0,0 +1,505 @@
<?php
namespace App\Services;
use App\Services\Service;
use Shared\Models\Products\ModelFormula;
use Shared\Models\Products\ModelParameter;
use Shared\Models\Products\ModelMaster;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\LengthAwarePaginator;
/**
* Model Formula Service
* 모델 공식 관리 서비스
*/
class ModelFormulaService extends Service
{
/**
* 모델별 공식 목록 조회
*/
public function getFormulasByModel(int $modelId, bool $paginate = false, int $perPage = 15): Collection|LengthAwarePaginator
{
$this->validateModelAccess($modelId);
$query = ModelFormula::where('model_id', $modelId)
->active()
->ordered()
->with('model');
if ($paginate) {
return $query->paginate($perPage);
}
return $query->get();
}
/**
* 공식 상세 조회
*/
public function getFormula(int $id): ModelFormula
{
$formula = ModelFormula::where('tenant_id', $this->tenantId())
->findOrFail($id);
$this->validateModelAccess($formula->model_id);
return $formula;
}
/**
* 공식 생성
*/
public function createFormula(array $data): ModelFormula
{
$this->validateModelAccess($data['model_id']);
// 기본값 설정
$data['tenant_id'] = $this->tenantId();
$data['created_by'] = $this->apiUserId();
// 순서가 지정되지 않은 경우 마지막으로 설정
if (!isset($data['order'])) {
$maxOrder = ModelFormula::where('tenant_id', $this->tenantId())
->where('model_id', $data['model_id'])
->max('order') ?? 0;
$data['order'] = $maxOrder + 1;
}
// 공식명 중복 체크
$this->validateFormulaNameUnique($data['model_id'], $data['name']);
// 공식 검증 및 의존성 추출
$formula = new ModelFormula($data);
$expressionErrors = $formula->validateExpression();
if (!empty($expressionErrors)) {
throw new \InvalidArgumentException(__('error.invalid_formula_expression') . ': ' . implode(', ', $expressionErrors));
}
// 의존성 검증
$dependencies = $formula->extractVariables();
$this->validateDependencies($data['model_id'], $dependencies);
$data['dependencies'] = $dependencies;
// 순환 의존성 체크
$this->validateCircularDependency($data['model_id'], $data['name'], $dependencies);
$formula = ModelFormula::create($data);
// 계산 순서 재정렬
$this->recalculateOrder($data['model_id']);
return $formula->fresh();
}
/**
* 공식 수정
*/
public function updateFormula(int $id, array $data): ModelFormula
{
$formula = $this->getFormula($id);
// 공식명 변경 시 중복 체크
if (isset($data['name']) && $data['name'] !== $formula->name) {
$this->validateFormulaNameUnique($formula->model_id, $data['name'], $id);
}
// 공식 표현식 변경 시 검증
if (isset($data['expression'])) {
$tempFormula = new ModelFormula(array_merge($formula->toArray(), $data));
$expressionErrors = $tempFormula->validateExpression();
if (!empty($expressionErrors)) {
throw new \InvalidArgumentException(__('error.invalid_formula_expression') . ': ' . implode(', ', $expressionErrors));
}
// 의존성 검증
$dependencies = $tempFormula->extractVariables();
$this->validateDependencies($formula->model_id, $dependencies);
$data['dependencies'] = $dependencies;
// 순환 의존성 체크 (기존 공식 제외)
$this->validateCircularDependency($formula->model_id, $data['name'] ?? $formula->name, $dependencies, $id);
}
$data['updated_by'] = $this->apiUserId();
$formula->update($data);
// 의존성이 변경된 경우 계산 순서 재정렬
if (isset($data['expression'])) {
$this->recalculateOrder($formula->model_id);
}
return $formula->fresh();
}
/**
* 공식 삭제
*/
public function deleteFormula(int $id): bool
{
$formula = $this->getFormula($id);
// 다른 공식에서 사용 중인지 확인
$this->validateFormulaNotInUse($formula->model_id, $formula->name);
$formula->update(['deleted_by' => $this->apiUserId()]);
$formula->delete();
// 계산 순서 재정렬
$this->recalculateOrder($formula->model_id);
return true;
}
/**
* 공식 복사 (다른 모델로)
*/
public function copyFormulasToModel(int $sourceModelId, int $targetModelId): Collection
{
$this->validateModelAccess($sourceModelId);
$this->validateModelAccess($targetModelId);
$sourceFormulas = $this->getFormulasByModel($sourceModelId);
$copiedFormulas = collect();
// 의존성 순서대로 복사
$orderedFormulas = $this->sortFormulasByDependency($sourceFormulas);
foreach ($orderedFormulas as $sourceFormula) {
$data = $sourceFormula->toArray();
unset($data['id'], $data['created_at'], $data['updated_at'], $data['deleted_at']);
$data['model_id'] = $targetModelId;
$data['created_by'] = $this->apiUserId();
// 이름 중복 시 수정
$originalName = $data['name'];
$counter = 1;
while ($this->isFormulaNameExists($targetModelId, $data['name'])) {
$data['name'] = $originalName . '_' . $counter;
$counter++;
}
// 대상 모델의 매개변수/공식에 맞게 의존성 재검증
$dependencies = $this->extractVariablesFromExpression($data['expression']);
$validDependencies = $this->getValidDependencies($targetModelId, $dependencies);
$data['dependencies'] = $validDependencies;
$copiedFormula = ModelFormula::create($data);
$copiedFormulas->push($copiedFormula);
}
// 복사 완료 후 계산 순서 재정렬
$this->recalculateOrder($targetModelId);
return $copiedFormulas;
}
/**
* 공식 계산 실행
*/
public function calculateFormulas(int $modelId, array $inputValues): array
{
$this->validateModelAccess($modelId);
$formulas = $this->getFormulasByModel($modelId);
$results = $inputValues; // 매개변수 값으로 시작
// 의존성 순서대로 계산
$orderedFormulas = $this->sortFormulasByDependency($formulas);
foreach ($orderedFormulas as $formula) {
try {
$result = $formula->calculate($results);
if ($result !== null) {
$results[$formula->name] = $result;
}
} catch (\Throwable $e) {
// 계산 실패 시 null로 설정
$results[$formula->name] = null;
}
}
return $results;
}
/**
* 공식 검증 (문법 및 의존성)
*/
public function validateFormula(int $modelId, string $name, string $expression): array
{
$this->validateModelAccess($modelId);
$errors = [];
// 임시 공식 객체로 문법 검증
$tempFormula = new ModelFormula([
'name' => $name,
'expression' => $expression,
'model_id' => $modelId
]);
$expressionErrors = $tempFormula->validateExpression();
if (!empty($expressionErrors)) {
$errors['expression'] = $expressionErrors;
}
// 의존성 검증
$dependencies = $tempFormula->extractVariables();
$dependencyErrors = $this->validateDependencies($modelId, $dependencies, false);
if (!empty($dependencyErrors)) {
$errors['dependencies'] = $dependencyErrors;
}
// 순환 의존성 체크
try {
$this->validateCircularDependency($modelId, $name, $dependencies);
} catch (\InvalidArgumentException $e) {
$errors['circular_dependency'] = [$e->getMessage()];
}
return $errors;
}
/**
* 의존성 순서대로 공식 정렬
*/
public function sortFormulasByDependency(Collection $formulas): Collection
{
$sorted = collect();
$remaining = $formulas->keyBy('name');
$processed = [];
while ($remaining->count() > 0) {
$progress = false;
foreach ($remaining as $formula) {
$dependencies = $formula->dependencies ?? [];
$canProcess = true;
// 의존성이 모두 처리되었는지 확인
foreach ($dependencies as $dep) {
if (!in_array($dep, $processed) && $remaining->has($dep)) {
$canProcess = false;
break;
}
}
if ($canProcess) {
$sorted->push($formula);
$processed[] = $formula->name;
$remaining->forget($formula->name);
$progress = true;
}
}
// 진행이 없으면 순환 의존성
if (!$progress && $remaining->count() > 0) {
break;
}
}
// 순환 의존성으로 처리되지 않은 공식들도 추가
return $sorted->concat($remaining->values());
}
/**
* 모델 접근 권한 검증
*/
private function validateModelAccess(int $modelId): void
{
$model = ModelMaster::where('tenant_id', $this->tenantId())
->findOrFail($modelId);
}
/**
* 공식명 중복 검증
*/
private function validateFormulaNameUnique(int $modelId, string $name, ?int $excludeId = null): void
{
$query = ModelFormula::where('tenant_id', $this->tenantId())
->where('model_id', $modelId)
->where('name', $name);
if ($excludeId) {
$query->where('id', '!=', $excludeId);
}
if ($query->exists()) {
throw new \InvalidArgumentException(__('error.formula_name_duplicate'));
}
// 매개변수명과도 중복되지 않아야 함
$parameterExists = ModelParameter::where('tenant_id', $this->tenantId())
->where('model_id', $modelId)
->where('name', $name)
->exists();
if ($parameterExists) {
throw new \InvalidArgumentException(__('error.formula_name_conflicts_with_parameter'));
}
}
/**
* 공식명 존재 여부 확인
*/
private function isFormulaNameExists(int $modelId, string $name): bool
{
return ModelFormula::where('tenant_id', $this->tenantId())
->where('model_id', $modelId)
->where('name', $name)
->exists();
}
/**
* 의존성 검증
*/
private function validateDependencies(int $modelId, array $dependencies, bool $throwException = true): array
{
$errors = [];
// 매개변수 목록 가져오기
$parameters = ModelParameter::where('tenant_id', $this->tenantId())
->where('model_id', $modelId)
->active()
->pluck('name')
->toArray();
// 기존 공식 목록 가져오기
$formulas = ModelFormula::where('tenant_id', $this->tenantId())
->where('model_id', $modelId)
->active()
->pluck('name')
->toArray();
$validNames = array_merge($parameters, $formulas);
foreach ($dependencies as $dep) {
if (!in_array($dep, $validNames)) {
$errors[] = "Dependency '{$dep}' not found in model parameters or formulas";
}
}
if (!empty($errors) && $throwException) {
throw new \InvalidArgumentException(__('error.invalid_formula_dependencies') . ': ' . implode(', ', $errors));
}
return $errors;
}
/**
* 순환 의존성 검증
*/
private function validateCircularDependency(int $modelId, string $formulaName, array $dependencies, ?int $excludeId = null): void
{
$allFormulas = ModelFormula::where('tenant_id', $this->tenantId())
->where('model_id', $modelId)
->active();
if ($excludeId) {
$allFormulas->where('id', '!=', $excludeId);
}
$allFormulas = $allFormulas->get();
// 현재 공식을 임시로 추가하여 순환 의존성 검사
$tempFormula = new ModelFormula([
'name' => $formulaName,
'dependencies' => $dependencies
]);
$allFormulas->push($tempFormula);
if ($this->hasCircularDependency($tempFormula, $allFormulas->toArray())) {
throw new \InvalidArgumentException(__('error.circular_dependency_detected'));
}
}
/**
* 순환 의존성 검사
*/
private function hasCircularDependency(ModelFormula $formula, array $allFormulas, array $visited = []): bool
{
if (in_array($formula->name, $visited)) {
return true;
}
$visited[] = $formula->name;
foreach ($formula->dependencies ?? [] as $dep) {
foreach ($allFormulas as $depFormula) {
if ($depFormula->name === $dep) {
if ($this->hasCircularDependency($depFormula, $allFormulas, $visited)) {
return true;
}
break;
}
}
}
return false;
}
/**
* 공식이 다른 공식에서 사용 중인지 확인
*/
private function validateFormulaNotInUse(int $modelId, string $formulaName): void
{
$usageCount = ModelFormula::where('tenant_id', $this->tenantId())
->where('model_id', $modelId)
->whereJsonContains('dependencies', $formulaName)
->count();
if ($usageCount > 0) {
throw new \InvalidArgumentException(__('error.formula_in_use'));
}
}
/**
* 계산 순서 재정렬
*/
private function recalculateOrder(int $modelId): void
{
$formulas = $this->getFormulasByModel($modelId);
$orderedFormulas = $this->sortFormulasByDependency($formulas);
foreach ($orderedFormulas as $index => $formula) {
$formula->update([
'order' => $index + 1,
'updated_by' => $this->apiUserId()
]);
}
}
/**
* 표현식에서 변수 추출
*/
private function extractVariablesFromExpression(string $expression): array
{
$tempFormula = new ModelFormula(['expression' => $expression]);
return $tempFormula->extractVariables();
}
/**
* 유효한 의존성만 필터링
*/
private function getValidDependencies(int $modelId, array $dependencies): array
{
$parameters = ModelParameter::where('tenant_id', $this->tenantId())
->where('model_id', $modelId)
->active()
->pluck('name')
->toArray();
$formulas = ModelFormula::where('tenant_id', $this->tenantId())
->where('model_id', $modelId)
->active()
->pluck('name')
->toArray();
$validNames = array_merge($parameters, $formulas);
return array_intersect($dependencies, $validNames);
}
}

View File

@@ -0,0 +1,278 @@
<?php
namespace App\Services;
use App\Services\Service;
use Shared\Models\Products\ModelParameter;
use Shared\Models\Products\ModelMaster;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\LengthAwarePaginator;
/**
* Model Parameter Service
* 모델 매개변수 관리 서비스
*/
class ModelParameterService extends Service
{
/**
* 모델별 매개변수 목록 조회
*/
public function getParametersByModel(int $modelId, bool $paginate = false, int $perPage = 15): Collection|LengthAwarePaginator
{
$this->validateModelAccess($modelId);
$query = ModelParameter::where('model_id', $modelId)
->active()
->ordered()
->with('model');
if ($paginate) {
return $query->paginate($perPage);
}
return $query->get();
}
/**
* 매개변수 상세 조회
*/
public function getParameter(int $id): ModelParameter
{
$parameter = ModelParameter::where('tenant_id', $this->tenantId())
->findOrFail($id);
$this->validateModelAccess($parameter->model_id);
return $parameter;
}
/**
* 매개변수 생성
*/
public function createParameter(array $data): ModelParameter
{
$this->validateModelAccess($data['model_id']);
// 기본값 설정
$data['tenant_id'] = $this->tenantId();
$data['created_by'] = $this->apiUserId();
// 순서가 지정되지 않은 경우 마지막으로 설정
if (!isset($data['order'])) {
$maxOrder = ModelParameter::where('tenant_id', $this->tenantId())
->where('model_id', $data['model_id'])
->max('order') ?? 0;
$data['order'] = $maxOrder + 1;
}
// 매개변수명 중복 체크
$this->validateParameterNameUnique($data['model_id'], $data['name']);
// 검증 규칙 처리
if (isset($data['validation_rules']) && is_string($data['validation_rules'])) {
$data['validation_rules'] = json_decode($data['validation_rules'], true);
}
// 옵션 처리 (SELECT 타입)
if (isset($data['options']) && is_string($data['options'])) {
$data['options'] = json_decode($data['options'], true);
}
$parameter = ModelParameter::create($data);
return $parameter->fresh();
}
/**
* 매개변수 수정
*/
public function updateParameter(int $id, array $data): ModelParameter
{
$parameter = $this->getParameter($id);
// 매개변수명 변경 시 중복 체크
if (isset($data['name']) && $data['name'] !== $parameter->name) {
$this->validateParameterNameUnique($parameter->model_id, $data['name'], $id);
}
// 검증 규칙 처리
if (isset($data['validation_rules']) && is_string($data['validation_rules'])) {
$data['validation_rules'] = json_decode($data['validation_rules'], true);
}
// 옵션 처리
if (isset($data['options']) && is_string($data['options'])) {
$data['options'] = json_decode($data['options'], true);
}
$data['updated_by'] = $this->apiUserId();
$parameter->update($data);
return $parameter->fresh();
}
/**
* 매개변수 삭제
*/
public function deleteParameter(int $id): bool
{
$parameter = $this->getParameter($id);
// 다른 공식에서 사용 중인지 확인
$this->validateParameterNotInUse($parameter->model_id, $parameter->name);
$parameter->update(['deleted_by' => $this->apiUserId()]);
$parameter->delete();
return true;
}
/**
* 매개변수 순서 변경
*/
public function reorderParameters(int $modelId, array $orderData): bool
{
$this->validateModelAccess($modelId);
foreach ($orderData as $item) {
ModelParameter::where('tenant_id', $this->tenantId())
->where('model_id', $modelId)
->where('id', $item['id'])
->update([
'order' => $item['order'],
'updated_by' => $this->apiUserId()
]);
}
return true;
}
/**
* 매개변수 복사 (다른 모델로)
*/
public function copyParametersToModel(int $sourceModelId, int $targetModelId): Collection
{
$this->validateModelAccess($sourceModelId);
$this->validateModelAccess($targetModelId);
$sourceParameters = $this->getParametersByModel($sourceModelId);
$copiedParameters = collect();
foreach ($sourceParameters as $sourceParam) {
$data = $sourceParam->toArray();
unset($data['id'], $data['created_at'], $data['updated_at'], $data['deleted_at']);
$data['model_id'] = $targetModelId;
$data['created_by'] = $this->apiUserId();
// 이름 중복 시 수정
$originalName = $data['name'];
$counter = 1;
while ($this->isParameterNameExists($targetModelId, $data['name'])) {
$data['name'] = $originalName . '_' . $counter;
$counter++;
}
$copiedParameter = ModelParameter::create($data);
$copiedParameters->push($copiedParameter);
}
return $copiedParameters;
}
/**
* 매개변수 값 검증
*/
public function validateParameterValues(int $modelId, array $values): array
{
$this->validateModelAccess($modelId);
$parameters = $this->getParametersByModel($modelId);
$errors = [];
foreach ($parameters as $parameter) {
$value = $values[$parameter->name] ?? null;
$paramErrors = $parameter->validateValue($value);
if (!empty($paramErrors)) {
$errors[$parameter->name] = $paramErrors;
}
}
return $errors;
}
/**
* 매개변수 값을 적절한 타입으로 변환
*/
public function castParameterValues(int $modelId, array $values): array
{
$this->validateModelAccess($modelId);
$parameters = $this->getParametersByModel($modelId);
$castedValues = [];
foreach ($parameters as $parameter) {
$value = $values[$parameter->name] ?? null;
$castedValues[$parameter->name] = $parameter->castValue($value);
}
return $castedValues;
}
/**
* 모델 접근 권한 검증
*/
private function validateModelAccess(int $modelId): void
{
$model = ModelMaster::where('tenant_id', $this->tenantId())
->findOrFail($modelId);
}
/**
* 매개변수명 중복 검증
*/
private function validateParameterNameUnique(int $modelId, string $name, ?int $excludeId = null): void
{
$query = ModelParameter::where('tenant_id', $this->tenantId())
->where('model_id', $modelId)
->where('name', $name);
if ($excludeId) {
$query->where('id', '!=', $excludeId);
}
if ($query->exists()) {
throw new \InvalidArgumentException(__('error.parameter_name_duplicate'));
}
}
/**
* 매개변수명 존재 여부 확인
*/
private function isParameterNameExists(int $modelId, string $name): bool
{
return ModelParameter::where('tenant_id', $this->tenantId())
->where('model_id', $modelId)
->where('name', $name)
->exists();
}
/**
* 매개변수가 다른 공식에서 사용 중인지 확인
*/
private function validateParameterNotInUse(int $modelId, string $parameterName): void
{
$formulaService = new ModelFormulaService();
$formulas = $formulaService->getFormulasByModel($modelId);
foreach ($formulas as $formula) {
if (in_array($parameterName, $formula->dependencies ?? [])) {
throw new \InvalidArgumentException(__('error.parameter_in_use_by_formula', [
'parameter' => $parameterName,
'formula' => $formula->name
]));
}
}
}
}

View File

@@ -0,0 +1,496 @@
<?php
namespace App\Services;
use App\Services\Service;
use Shared\Models\Products\ModelMaster;
use Shared\Models\Products\Product;
use Shared\Models\Products\ProductComponent;
use Shared\Models\Products\BomTemplate;
use Shared\Models\Products\BomTemplateItem;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* Product From Model Service
* 모델 기반 제품 생성 서비스
*/
class ProductFromModelService extends Service
{
private BomResolverService $bomResolverService;
public function __construct()
{
parent::__construct();
$this->bomResolverService = new BomResolverService();
}
/**
* 모델에서 제품 생성
*/
public function createProductFromModel(int $modelId, array $inputParameters, array $productData = []): Product
{
$this->validateModelAccess($modelId);
return DB::transaction(function () use ($modelId, $inputParameters, $productData) {
// 1. BOM 해석
$resolvedBom = $this->bomResolverService->resolveBom($modelId, $inputParameters);
// 2. 제품 기본 정보 생성
$product = $this->createProduct($modelId, $inputParameters, $resolvedBom, $productData);
// 3. BOM 스냅샷 저장
$this->saveBomSnapshot($product->id, $resolvedBom);
// 4. 매개변수 및 계산값 저장
$this->saveParameterSnapshot($product->id, $resolvedBom);
return $product->fresh(['components']);
});
}
/**
* 제품 코드 생성 미리보기
*/
public function previewProductCode(int $modelId, array $inputParameters): string
{
$this->validateModelAccess($modelId);
$model = ModelMaster::where('tenant_id', $this->tenantId())
->findOrFail($modelId);
// BOM 해석하여 계산값 가져오기
$resolvedBom = $this->bomResolverService->resolveBom($modelId, $inputParameters);
return $this->generateProductCode($model, $resolvedBom['calculated_values']);
}
/**
* 제품 사양서 생성
*/
public function generateProductSpecification(int $modelId, array $inputParameters): array
{
$this->validateModelAccess($modelId);
$model = ModelMaster::where('tenant_id', $this->tenantId())
->findOrFail($modelId);
$resolvedBom = $this->bomResolverService->resolveBom($modelId, $inputParameters);
return [
'model' => [
'id' => $model->id,
'code' => $model->code,
'name' => $model->name,
'version' => $model->current_version,
],
'parameters' => $this->formatParameters($resolvedBom['input_parameters']),
'calculated_values' => $this->formatCalculatedValues($resolvedBom['calculated_values']),
'bom_summary' => $resolvedBom['summary'],
'specifications' => $this->generateSpecifications($model, $resolvedBom),
'generated_at' => now()->toISOString(),
];
}
/**
* 대량 제품 생성
*/
public function createProductsBatch(int $modelId, array $parameterSets, array $baseProductData = []): array
{
$this->validateModelAccess($modelId);
$results = [];
DB::transaction(function () use ($modelId, $parameterSets, $baseProductData, &$results) {
foreach ($parameterSets as $index => $parameters) {
try {
$productData = array_merge($baseProductData, [
'name' => ($baseProductData['name'] ?? '') . ' #' . ($index + 1),
]);
$product = $this->createProductFromModel($modelId, $parameters, $productData);
$results[$index] = [
'success' => true,
'product_id' => $product->id,
'product_code' => $product->code,
'parameters' => $parameters,
];
} catch (\Throwable $e) {
$results[$index] = [
'success' => false,
'error' => $e->getMessage(),
'parameters' => $parameters,
];
}
}
});
return $results;
}
/**
* 기존 제품의 BOM 업데이트
*/
public function updateProductBom(int $productId, array $newParameters): Product
{
$product = Product::where('tenant_id', $this->tenantId())
->findOrFail($productId);
if (!$product->source_model_id) {
throw new \InvalidArgumentException(__('error.product_not_from_model'));
}
return DB::transaction(function () use ($product, $newParameters) {
// 1. 새 BOM 해석
$resolvedBom = $this->bomResolverService->resolveBom($product->source_model_id, $newParameters);
// 2. 기존 BOM 컴포넌트 삭제
ProductComponent::where('product_id', $product->id)->delete();
// 3. 새 BOM 스냅샷 저장
$this->saveBomSnapshot($product->id, $resolvedBom);
// 4. 매개변수 업데이트
$this->saveParameterSnapshot($product->id, $resolvedBom, true);
// 5. 제품 정보 업데이트
$product->update([
'updated_by' => $this->apiUserId(),
]);
return $product->fresh(['components']);
});
}
/**
* 제품 버전 생성 (기존 제품의 파라미터 변경)
*/
public function createProductVersion(int $productId, array $newParameters, string $versionNote = ''): Product
{
$originalProduct = Product::where('tenant_id', $this->tenantId())
->findOrFail($productId);
if (!$originalProduct->source_model_id) {
throw new \InvalidArgumentException(__('error.product_not_from_model'));
}
return DB::transaction(function () use ($originalProduct, $newParameters, $versionNote) {
// 원본 제품 복제
$productData = $originalProduct->toArray();
unset($productData['id'], $productData['created_at'], $productData['updated_at'], $productData['deleted_at']);
// 버전 정보 추가
$productData['name'] = $originalProduct->name . ' (v' . now()->format('YmdHis') . ')';
$productData['parent_product_id'] = $originalProduct->id;
$productData['version_note'] = $versionNote;
$newProduct = $this->createProductFromModel(
$originalProduct->source_model_id,
$newParameters,
$productData
);
return $newProduct;
});
}
/**
* 제품 생성
*/
private function createProduct(int $modelId, array $inputParameters, array $resolvedBom, array $productData): Product
{
$model = ModelMaster::where('tenant_id', $this->tenantId())
->findOrFail($modelId);
// 기본 제품 데이터 설정
$defaultData = [
'tenant_id' => $this->tenantId(),
'code' => $this->generateProductCode($model, $resolvedBom['calculated_values']),
'name' => $productData['name'] ?? $this->generateProductName($model, $resolvedBom['calculated_values']),
'type' => 'PRODUCT',
'source_model_id' => $modelId,
'model_version' => $model->current_version,
'is_active' => true,
'created_by' => $this->apiUserId(),
];
$finalData = array_merge($defaultData, $productData);
return Product::create($finalData);
}
/**
* BOM 스냅샷 저장
*/
private function saveBomSnapshot(int $productId, array $resolvedBom): void
{
foreach ($resolvedBom['bom_items'] as $index => $item) {
ProductComponent::create([
'tenant_id' => $this->tenantId(),
'product_id' => $productId,
'ref_type' => $item['ref_type'] ?? ($item['material_id'] ? 'MATERIAL' : 'PRODUCT'),
'product_id_ref' => $item['product_id'] ?? null,
'material_id' => $item['material_id'] ?? null,
'quantity' => $item['total_quantity'],
'base_quantity' => $item['calculated_quantity'],
'waste_rate' => $item['waste_rate'] ?? 0,
'unit' => $item['unit'] ?? 'ea',
'memo' => $item['memo'] ?? null,
'order' => $item['order'] ?? $index + 1,
'created_by' => $this->apiUserId(),
]);
}
}
/**
* 매개변수 스냅샷 저장
*/
private function saveParameterSnapshot(int $productId, array $resolvedBom, bool $update = false): void
{
$parameterData = [
'input_parameters' => $resolvedBom['input_parameters'],
'calculated_values' => $resolvedBom['calculated_values'],
'bom_summary' => $resolvedBom['summary'],
'resolved_at' => $resolvedBom['resolved_at'],
];
$product = Product::findOrFail($productId);
if ($update) {
$existingSnapshot = $product->parameter_snapshot ?? [];
$parameterData = array_merge($existingSnapshot, $parameterData);
}
$product->update([
'parameter_snapshot' => $parameterData,
'updated_by' => $this->apiUserId(),
]);
}
/**
* 제품 코드 생성
*/
private function generateProductCode(ModelMaster $model, array $calculatedValues): string
{
// 모델 코드 + 주요 치수값으로 코드 생성
$code = $model->code;
// 주요 치수값 추가 (W1, H1 등)
$keyDimensions = ['W1', 'H1', 'W0', 'H0'];
$dimensionParts = [];
foreach ($keyDimensions as $dim) {
if (isset($calculatedValues[$dim]) && is_numeric($calculatedValues[$dim])) {
$dimensionParts[] = $dim . (int) $calculatedValues[$dim];
}
}
if (!empty($dimensionParts)) {
$code .= '_' . implode('_', $dimensionParts);
}
// 고유성 보장을 위한 접미사
$suffix = Str::upper(Str::random(4));
$code .= '_' . $suffix;
return $code;
}
/**
* 제품명 생성
*/
private function generateProductName(ModelMaster $model, array $calculatedValues): string
{
$name = $model->name;
// 주요 치수값 추가
$keyDimensions = ['W1', 'H1'];
$dimensionParts = [];
foreach ($keyDimensions as $dim) {
if (isset($calculatedValues[$dim]) && is_numeric($calculatedValues[$dim])) {
$dimensionParts[] = (int) $calculatedValues[$dim];
}
}
if (!empty($dimensionParts)) {
$name .= ' (' . implode('×', $dimensionParts) . ')';
}
return $name;
}
/**
* 매개변수 포맷팅
*/
private function formatParameters(array $parameters): array
{
$formatted = [];
foreach ($parameters as $name => $value) {
$formatted[] = [
'name' => $name,
'value' => $value,
'type' => is_numeric($value) ? 'number' : (is_bool($value) ? 'boolean' : 'text'),
];
}
return $formatted;
}
/**
* 계산값 포맷팅
*/
private function formatCalculatedValues(array $calculatedValues): array
{
$formatted = [];
foreach ($calculatedValues as $name => $value) {
if (is_numeric($value)) {
$formatted[] = [
'name' => $name,
'value' => round((float) $value, 3),
'type' => 'calculated',
];
}
}
return $formatted;
}
/**
* 사양서 생성
*/
private function generateSpecifications(ModelMaster $model, array $resolvedBom): array
{
$specs = [];
// 기본 치수 사양
$dimensionSpecs = $this->generateDimensionSpecs($resolvedBom['calculated_values']);
if (!empty($dimensionSpecs)) {
$specs['dimensions'] = $dimensionSpecs;
}
// 무게/면적 사양
$physicalSpecs = $this->generatePhysicalSpecs($resolvedBom['calculated_values']);
if (!empty($physicalSpecs)) {
$specs['physical'] = $physicalSpecs;
}
// BOM 요약
$bomSpecs = $this->generateBomSpecs($resolvedBom['bom_items']);
if (!empty($bomSpecs)) {
$specs['components'] = $bomSpecs;
}
return $specs;
}
/**
* 치수 사양 생성
*/
private function generateDimensionSpecs(array $calculatedValues): array
{
$specs = [];
$dimensionKeys = ['W0', 'H0', 'W1', 'H1', 'D1', 'L1'];
foreach ($dimensionKeys as $key) {
if (isset($calculatedValues[$key]) && is_numeric($calculatedValues[$key])) {
$specs[$key] = [
'value' => round((float) $calculatedValues[$key], 1),
'unit' => 'mm',
'label' => $this->getDimensionLabel($key),
];
}
}
return $specs;
}
/**
* 물리적 사양 생성
*/
private function generatePhysicalSpecs(array $calculatedValues): array
{
$specs = [];
if (isset($calculatedValues['area']) && is_numeric($calculatedValues['area'])) {
$specs['area'] = [
'value' => round((float) $calculatedValues['area'], 3),
'unit' => 'm²',
'label' => '면적',
];
}
if (isset($calculatedValues['weight']) && is_numeric($calculatedValues['weight'])) {
$specs['weight'] = [
'value' => round((float) $calculatedValues['weight'], 2),
'unit' => 'kg',
'label' => '중량',
];
}
return $specs;
}
/**
* BOM 사양 생성
*/
private function generateBomSpecs(array $bomItems): array
{
$specs = [
'total_components' => count($bomItems),
'materials' => 0,
'products' => 0,
'major_components' => [],
];
foreach ($bomItems as $item) {
if (!empty($item['material_id'])) {
$specs['materials']++;
} else {
$specs['products']++;
}
// 주요 구성품 (수량이 많거나 중요한 것들)
if (($item['total_quantity'] ?? 0) >= 10 || !empty($item['memo'])) {
$specs['major_components'][] = [
'type' => !empty($item['material_id']) ? 'material' : 'product',
'id' => $item['material_id'] ?? $item['product_id'],
'quantity' => $item['total_quantity'] ?? 0,
'unit' => $item['unit'] ?? 'ea',
'memo' => $item['memo'] ?? '',
];
}
}
return $specs;
}
/**
* 치수 라벨 가져오기
*/
private function getDimensionLabel(string $key): string
{
$labels = [
'W0' => '입력 폭',
'H0' => '입력 높이',
'W1' => '실제 폭',
'H1' => '실제 높이',
'D1' => '깊이',
'L1' => '길이',
];
return $labels[$key] ?? $key;
}
/**
* 모델 접근 권한 검증
*/
private function validateModelAccess(int $modelId): void
{
$model = ModelMaster::where('tenant_id', $this->tenantId())
->findOrFail($modelId);
}
}