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,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;
}
}