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