Files
sam-api/app/Models/ItemMaster/EntityRelationship.php
권혁성 189b38c936 feat: Auditable 트레이트 구현 및 97개 모델 적용
- Auditable 트레이트 신규 생성 (bootAuditable 패턴)
  - creating: created_by/updated_by 자동 채우기
  - updating: updated_by 자동 채우기
  - deleting: deleted_by 채우기 + saveQuietly()
  - created/updated/deleted: audit_logs 자동 기록
- 기존 AuditLogger 패턴과 동일한 try/catch 조용한 실패
- 변경된 필드만 before/after 기록 (updated 이벤트)
- auditExclude 프로퍼티로 모델별 제외 필드 설정 가능
- 제외 대상: Attendance, StockTransaction, TodayIssue 등 고빈도/시스템 모델

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 15:33:54 +09:00

250 lines
6.7 KiB
PHP

<?php
namespace App\Models\ItemMaster;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
/**
* EntityRelationship - 엔티티 간 관계를 관리하는 링크 테이블 모델
*
* 지원하는 관계 유형:
* - page-section: 페이지와 섹션 연결
* - page-field: 페이지와 필드 직접 연결
* - section-field: 섹션과 필드 연결
* - section-bom: 섹션과 BOM 항목 연결
*/
class EntityRelationship extends Model
{
use Auditable, BelongsToTenant, ModelTrait;
protected $fillable = [
'tenant_id',
'group_id',
'parent_type',
'parent_id',
'child_type',
'child_id',
'order_no',
'metadata',
'is_locked',
'locked_by',
'locked_at',
'created_by',
'updated_by',
];
protected $casts = [
'group_id' => 'integer',
'parent_id' => 'integer',
'child_id' => 'integer',
'order_no' => 'integer',
'metadata' => 'array',
'is_locked' => 'boolean',
'locked_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
// 엔티티 타입 상수
public const TYPE_PAGE = 'page';
public const TYPE_SECTION = 'section';
public const TYPE_FIELD = 'field';
public const TYPE_BOM = 'bom';
// 그룹 ID 상수
public const GROUP_ITEM_MASTER = 1;
/**
* 부모 엔티티 조회 (Polymorphic)
*/
public function parent()
{
return $this->morphTo('parent', 'parent_type', 'parent_id');
}
/**
* 자식 엔티티 조회 (Polymorphic)
*/
public function child()
{
return $this->morphTo('child', 'child_type', 'child_id');
}
/**
* 부모 타입에 따른 모델 클래스 반환
*/
public static function getModelClass(string $type): ?string
{
return match ($type) {
self::TYPE_PAGE => ItemPage::class,
self::TYPE_SECTION => ItemSection::class,
self::TYPE_FIELD => ItemField::class,
self::TYPE_BOM => ItemBomItem::class,
default => null,
};
}
/**
* 특정 부모의 자식 관계 조회
*/
public static function getChildren(string $parentType, int $parentId, ?string $childType = null)
{
$query = self::where('parent_type', $parentType)
->where('parent_id', $parentId)
->orderBy('order_no');
if ($childType) {
$query->where('child_type', $childType);
}
return $query;
}
/**
* 특정 자식의 부모 관계 조회
*/
public static function getParents(string $childType, int $childId, ?string $parentType = null)
{
$query = self::where('child_type', $childType)
->where('child_id', $childId);
if ($parentType) {
$query->where('parent_type', $parentType);
}
return $query;
}
/**
* 관계 생성 또는 업데이트
*/
public static function link(
int $tenantId,
string $parentType,
int $parentId,
string $childType,
int $childId,
int $orderNo = 0,
?array $metadata = null,
int $groupId = self::GROUP_ITEM_MASTER
): self {
return self::updateOrCreate(
[
'tenant_id' => $tenantId,
'group_id' => $groupId,
'parent_type' => $parentType,
'parent_id' => $parentId,
'child_type' => $childType,
'child_id' => $childId,
],
[
'order_no' => $orderNo,
'metadata' => $metadata,
]
);
}
/**
* 관계 해제
*
* @throws \App\Exceptions\BusinessException 잠금된 연결인 경우
*/
public static function unlink(
int $tenantId,
string $parentType,
int $parentId,
string $childType,
int $childId,
int $groupId = self::GROUP_ITEM_MASTER
): bool {
$relationship = self::where([
'tenant_id' => $tenantId,
'group_id' => $groupId,
'parent_type' => $parentType,
'parent_id' => $parentId,
'child_type' => $childType,
'child_id' => $childId,
])->first();
if (! $relationship) {
return false;
}
// 잠금 체크
if ($relationship->is_locked) {
throw new \App\Exceptions\BusinessException(__('error.relationship_locked'));
}
return $relationship->delete();
}
/**
* 특정 부모의 모든 자식 관계 해제 (잠금되지 않은 것만)
*
* @throws \App\Exceptions\BusinessException 잠금된 연결이 있는 경우
*/
public static function unlinkAllChildren(
int $tenantId,
string $parentType,
int $parentId,
?string $childType = null,
int $groupId = self::GROUP_ITEM_MASTER
): int {
$query = self::where([
'tenant_id' => $tenantId,
'group_id' => $groupId,
'parent_type' => $parentType,
'parent_id' => $parentId,
]);
if ($childType) {
$query->where('child_type', $childType);
}
// 잠금된 연결이 있는지 확인
if ($query->clone()->where('is_locked', true)->exists()) {
throw new \App\Exceptions\BusinessException(__('error.has_locked_relationships'));
}
return $query->delete();
}
/**
* 특정 자식 엔티티가 잠금된 연결을 가지고 있는지 확인
*/
public static function hasLockedParentRelationship(string $childType, int $childId): bool
{
return self::where('child_type', $childType)
->where('child_id', $childId)
->where('is_locked', true)
->exists();
}
/**
* 특정 자식 엔티티의 잠금 상태 조회 (computed)
* 연결이 잠겨있으면 엔티티도 잠금 상태로 간주
*/
public static function getChildLockStatus(string $childType, int $childId): array
{
$lockedRelationships = self::where('child_type', $childType)
->where('child_id', $childId)
->where('is_locked', true)
->get(['id', 'parent_type', 'parent_id']);
return [
'is_locked' => $lockedRelationships->isNotEmpty(),
'locked_by_relationships' => $lockedRelationships->map(fn ($rel) => [
'relationship_id' => $rel->id,
'parent_type' => $rel->parent_type,
'parent_id' => $rel->parent_id,
])->toArray(),
];
}
}