feat: [QMS] 1일차 기준/매뉴얼 심사 백엔드 구현 (Phase 2)
- 마이그레이션: audit_checklists, audit_checklist_categories, audit_checklist_items, audit_standard_documents (4테이블) - 모델 4개: AuditChecklist, AuditChecklistCategory, AuditChecklistItem, AuditStandardDocument - AuditChecklistService: CRUD, 완료처리, 항목 토글(lockForUpdate), 기준 문서 연결/해제, 카테고리+항목 일괄 동기화 - AuditChecklistController: 9개 엔드포인트 - FormRequest 2개: Store(카테고리+항목 중첩 검증), Update - 라우트 9개 등록 (/api/v1/qms/checklists, checklist-items) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
112
app/Http/Controllers/Api/V1/AuditChecklistController.php
Normal file
112
app/Http/Controllers/Api/V1/AuditChecklistController.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Qms\AuditChecklistStoreRequest;
|
||||
use App\Http\Requests\Qms\AuditChecklistUpdateRequest;
|
||||
use App\Services\AuditChecklistService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AuditChecklistController extends Controller
|
||||
{
|
||||
public function __construct(private AuditChecklistService $service) {}
|
||||
|
||||
/**
|
||||
* 점검표 목록
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->index($request->all());
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 점검표 생성 (카테고리+항목 일괄)
|
||||
*/
|
||||
public function store(AuditChecklistStoreRequest $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->store($request->validated());
|
||||
}, __('message.created'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 점검표 상세
|
||||
*/
|
||||
public function show(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->show($id);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 점검표 수정
|
||||
*/
|
||||
public function update(AuditChecklistUpdateRequest $request, int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request, $id) {
|
||||
return $this->service->update($id, $request->validated());
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 점검표 완료 처리
|
||||
*/
|
||||
public function complete(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->complete($id);
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 항목 완료/미완료 토글
|
||||
*/
|
||||
public function toggleItem(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->toggleItem($id);
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 항목별 기준 문서 조회
|
||||
*/
|
||||
public function itemDocuments(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->itemDocuments($id);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 기준 문서 연결
|
||||
*/
|
||||
public function attachDocument(Request $request, int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request, $id) {
|
||||
return $this->service->attachDocument($id, $request->validate([
|
||||
'title' => 'required|string|max:200',
|
||||
'version' => 'nullable|string|max:20',
|
||||
'date' => 'nullable|date',
|
||||
'document_id' => 'nullable|integer|exists:documents,id',
|
||||
]));
|
||||
}, __('message.created'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 기준 문서 연결 해제
|
||||
*/
|
||||
public function detachDocument(int $id, int $docId)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $docId) {
|
||||
$this->service->detachDocument($id, $docId);
|
||||
|
||||
return null;
|
||||
}, __('message.deleted'));
|
||||
}
|
||||
}
|
||||
39
app/Http/Requests/Qms/AuditChecklistStoreRequest.php
Normal file
39
app/Http/Requests/Qms/AuditChecklistStoreRequest.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Qms;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class AuditChecklistStoreRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'year' => 'required|integer|min:2020|max:2100',
|
||||
'quarter' => 'required|integer|in:1,2,3,4',
|
||||
'type' => 'nullable|string|max:30',
|
||||
'categories' => 'required|array|min:1',
|
||||
'categories.*.title' => 'required|string|max:200',
|
||||
'categories.*.sort_order' => 'nullable|integer|min:0',
|
||||
'categories.*.items' => 'required|array|min:1',
|
||||
'categories.*.items.*.name' => 'required|string|max:200',
|
||||
'categories.*.items.*.description' => 'nullable|string',
|
||||
'categories.*.items.*.sort_order' => 'nullable|integer|min:0',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'categories.required' => __('validation.required', ['attribute' => '카테고리']),
|
||||
'categories.*.title.required' => __('validation.required', ['attribute' => '카테고리 제목']),
|
||||
'categories.*.items.required' => __('validation.required', ['attribute' => '점검 항목']),
|
||||
'categories.*.items.*.name.required' => __('validation.required', ['attribute' => '항목명']),
|
||||
];
|
||||
}
|
||||
}
|
||||
28
app/Http/Requests/Qms/AuditChecklistUpdateRequest.php
Normal file
28
app/Http/Requests/Qms/AuditChecklistUpdateRequest.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Qms;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class AuditChecklistUpdateRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'categories' => 'sometimes|array|min:1',
|
||||
'categories.*.id' => 'nullable|integer|exists:audit_checklist_categories,id',
|
||||
'categories.*.title' => 'required|string|max:200',
|
||||
'categories.*.sort_order' => 'nullable|integer|min:0',
|
||||
'categories.*.items' => 'required|array|min:1',
|
||||
'categories.*.items.*.id' => 'nullable|integer|exists:audit_checklist_items,id',
|
||||
'categories.*.items.*.name' => 'required|string|max:200',
|
||||
'categories.*.items.*.description' => 'nullable|string',
|
||||
'categories.*.items.*.sort_order' => 'nullable|integer|min:0',
|
||||
];
|
||||
}
|
||||
}
|
||||
57
app/Models/Qualitys/AuditChecklist.php
Normal file
57
app/Models/Qualitys/AuditChecklist.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Qualitys;
|
||||
|
||||
use App\Models\Traits\Auditable;
|
||||
use App\Models\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class AuditChecklist extends Model
|
||||
{
|
||||
use Auditable, BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $table = 'audit_checklists';
|
||||
|
||||
const STATUS_DRAFT = 'draft';
|
||||
|
||||
const STATUS_IN_PROGRESS = 'in_progress';
|
||||
|
||||
const STATUS_COMPLETED = 'completed';
|
||||
|
||||
const TYPE_STANDARD_MANUAL = 'standard_manual';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'year',
|
||||
'quarter',
|
||||
'type',
|
||||
'status',
|
||||
'options',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'deleted_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'year' => 'integer',
|
||||
'quarter' => 'integer',
|
||||
'options' => 'array',
|
||||
];
|
||||
|
||||
public function categories(): HasMany
|
||||
{
|
||||
return $this->hasMany(AuditChecklistCategory::class, 'checklist_id')->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public function isDraft(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_DRAFT;
|
||||
}
|
||||
|
||||
public function isCompleted(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_COMPLETED;
|
||||
}
|
||||
}
|
||||
35
app/Models/Qualitys/AuditChecklistCategory.php
Normal file
35
app/Models/Qualitys/AuditChecklistCategory.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Qualitys;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class AuditChecklistCategory extends Model
|
||||
{
|
||||
protected $table = 'audit_checklist_categories';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'checklist_id',
|
||||
'title',
|
||||
'sort_order',
|
||||
'options',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sort_order' => 'integer',
|
||||
'options' => 'array',
|
||||
];
|
||||
|
||||
public function checklist(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AuditChecklist::class, 'checklist_id');
|
||||
}
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(AuditChecklistItem::class, 'category_id')->orderBy('sort_order');
|
||||
}
|
||||
}
|
||||
47
app/Models/Qualitys/AuditChecklistItem.php
Normal file
47
app/Models/Qualitys/AuditChecklistItem.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Qualitys;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class AuditChecklistItem extends Model
|
||||
{
|
||||
protected $table = 'audit_checklist_items';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'category_id',
|
||||
'name',
|
||||
'description',
|
||||
'is_completed',
|
||||
'completed_at',
|
||||
'completed_by',
|
||||
'sort_order',
|
||||
'options',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_completed' => 'boolean',
|
||||
'completed_at' => 'datetime',
|
||||
'sort_order' => 'integer',
|
||||
'options' => 'array',
|
||||
];
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AuditChecklistCategory::class, 'category_id');
|
||||
}
|
||||
|
||||
public function completedByUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'completed_by');
|
||||
}
|
||||
|
||||
public function standardDocuments(): HasMany
|
||||
{
|
||||
return $this->hasMany(AuditStandardDocument::class, 'checklist_item_id');
|
||||
}
|
||||
}
|
||||
37
app/Models/Qualitys/AuditStandardDocument.php
Normal file
37
app/Models/Qualitys/AuditStandardDocument.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Qualitys;
|
||||
|
||||
use App\Models\Documents\Document;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AuditStandardDocument extends Model
|
||||
{
|
||||
protected $table = 'audit_standard_documents';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'checklist_item_id',
|
||||
'title',
|
||||
'version',
|
||||
'date',
|
||||
'document_id',
|
||||
'options',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'date' => 'date',
|
||||
'options' => 'array',
|
||||
];
|
||||
|
||||
public function checklistItem(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AuditChecklistItem::class, 'checklist_item_id');
|
||||
}
|
||||
|
||||
public function document(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Document::class);
|
||||
}
|
||||
}
|
||||
392
app/Services/AuditChecklistService.php
Normal file
392
app/Services/AuditChecklistService.php
Normal file
@@ -0,0 +1,392 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Qualitys\AuditChecklist;
|
||||
use App\Models\Qualitys\AuditChecklistCategory;
|
||||
use App\Models\Qualitys\AuditChecklistItem;
|
||||
use App\Models\Qualitys\AuditStandardDocument;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
class AuditChecklistService extends Service
|
||||
{
|
||||
/**
|
||||
* 점검표 목록 (year, quarter 필터)
|
||||
*/
|
||||
public function index(array $params): array
|
||||
{
|
||||
$query = AuditChecklist::with(['categories.items']);
|
||||
|
||||
if (! empty($params['year'])) {
|
||||
$query->where('year', (int) $params['year']);
|
||||
}
|
||||
if (! empty($params['quarter'])) {
|
||||
$query->where('quarter', (int) $params['quarter']);
|
||||
}
|
||||
if (! empty($params['type'])) {
|
||||
$query->where('type', $params['type']);
|
||||
}
|
||||
|
||||
$query->orderByDesc('year')->orderByDesc('quarter');
|
||||
$perPage = (int) ($params['per_page'] ?? 20);
|
||||
$paginated = $query->paginate($perPage);
|
||||
|
||||
$items = $paginated->getCollection()->map(fn ($checklist) => $this->transformListItem($checklist));
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'current_page' => $paginated->currentPage(),
|
||||
'last_page' => $paginated->lastPage(),
|
||||
'per_page' => $paginated->perPage(),
|
||||
'total' => $paginated->total(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 점검표 생성 (카테고리+항목 일괄)
|
||||
*/
|
||||
public function store(array $data): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
// 중복 체크
|
||||
$exists = AuditChecklist::where('year', $data['year'])
|
||||
->where('quarter', $data['quarter'])
|
||||
->where('type', $data['type'] ?? AuditChecklist::TYPE_STANDARD_MANUAL)
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
throw new BadRequestHttpException(__('error.duplicate', ['attribute' => '해당 분기 점검표']));
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($data, $tenantId, $userId) {
|
||||
$checklist = AuditChecklist::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'year' => $data['year'],
|
||||
'quarter' => $data['quarter'],
|
||||
'type' => $data['type'] ?? AuditChecklist::TYPE_STANDARD_MANUAL,
|
||||
'status' => AuditChecklist::STATUS_DRAFT,
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
|
||||
$this->syncCategories($checklist, $data['categories'], $tenantId);
|
||||
|
||||
return $this->show($checklist->id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 점검표 상세 (카테고리→항목→문서 중첩)
|
||||
*/
|
||||
public function show(int $id): array
|
||||
{
|
||||
$checklist = AuditChecklist::with([
|
||||
'categories.items.standardDocuments.document',
|
||||
])->findOrFail($id);
|
||||
|
||||
return $this->transformDetail($checklist);
|
||||
}
|
||||
|
||||
/**
|
||||
* 점검표 수정
|
||||
*/
|
||||
public function update(int $id, array $data): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$checklist = AuditChecklist::findOrFail($id);
|
||||
|
||||
if ($checklist->isCompleted()) {
|
||||
throw new BadRequestHttpException('완료된 점검표는 수정할 수 없습니다.');
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($checklist, $data, $tenantId) {
|
||||
$checklist->update([
|
||||
'updated_by' => $this->apiUserId(),
|
||||
]);
|
||||
|
||||
if (isset($data['categories'])) {
|
||||
$this->syncCategories($checklist, $data['categories'], $tenantId);
|
||||
}
|
||||
|
||||
return $this->show($checklist->id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 점검표 완료 처리
|
||||
*/
|
||||
public function complete(int $id): array
|
||||
{
|
||||
$checklist = AuditChecklist::with('categories.items')->findOrFail($id);
|
||||
|
||||
// 미완료 항목 확인
|
||||
$totalItems = 0;
|
||||
$completedItems = 0;
|
||||
foreach ($checklist->categories as $category) {
|
||||
foreach ($category->items as $item) {
|
||||
$totalItems++;
|
||||
if ($item->is_completed) {
|
||||
$completedItems++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($completedItems < $totalItems) {
|
||||
throw new BadRequestHttpException("미완료 항목이 있습니다. ({$completedItems}/{$totalItems})");
|
||||
}
|
||||
|
||||
$checklist->update([
|
||||
'status' => AuditChecklist::STATUS_COMPLETED,
|
||||
'updated_by' => $this->apiUserId(),
|
||||
]);
|
||||
|
||||
return $this->show($checklist->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 항목 완료/미완료 토글
|
||||
*/
|
||||
public function toggleItem(int $itemId): array
|
||||
{
|
||||
$item = AuditChecklistItem::findOrFail($itemId);
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
DB::transaction(function () use ($item, $userId) {
|
||||
$item->lockForUpdate();
|
||||
|
||||
$newCompleted = ! $item->is_completed;
|
||||
$item->update([
|
||||
'is_completed' => $newCompleted,
|
||||
'completed_at' => $newCompleted ? now() : null,
|
||||
'completed_by' => $newCompleted ? $userId : null,
|
||||
]);
|
||||
|
||||
// 점검표 상태 자동 업데이트: draft → in_progress
|
||||
$category = $item->category;
|
||||
$checklist = $category->checklist;
|
||||
if ($checklist->isDraft()) {
|
||||
$checklist->update([
|
||||
'status' => AuditChecklist::STATUS_IN_PROGRESS,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
$item->refresh();
|
||||
|
||||
return [
|
||||
'id' => (string) $item->id,
|
||||
'name' => $item->name,
|
||||
'is_completed' => $item->is_completed,
|
||||
'completed_at' => $item->completed_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 항목별 기준 문서 조회
|
||||
*/
|
||||
public function itemDocuments(int $itemId): array
|
||||
{
|
||||
$item = AuditChecklistItem::findOrFail($itemId);
|
||||
|
||||
return $item->standardDocuments()->with('document')->get()
|
||||
->map(fn ($doc) => $this->transformStandardDocument($doc))
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 기준 문서 연결
|
||||
*/
|
||||
public function attachDocument(int $itemId, array $data): array
|
||||
{
|
||||
$item = AuditChecklistItem::findOrFail($itemId);
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$doc = AuditStandardDocument::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'checklist_item_id' => $item->id,
|
||||
'title' => $data['title'],
|
||||
'version' => $data['version'] ?? null,
|
||||
'date' => $data['date'] ?? null,
|
||||
'document_id' => $data['document_id'] ?? null,
|
||||
]);
|
||||
|
||||
$doc->load('document');
|
||||
|
||||
return $this->transformStandardDocument($doc);
|
||||
}
|
||||
|
||||
/**
|
||||
* 기준 문서 연결 해제
|
||||
*/
|
||||
public function detachDocument(int $itemId, int $docId): void
|
||||
{
|
||||
$doc = AuditStandardDocument::where('checklist_item_id', $itemId)
|
||||
->where('id', $docId)
|
||||
->firstOrFail();
|
||||
|
||||
$doc->delete();
|
||||
}
|
||||
|
||||
// ===== Private: Sync & Transform =====
|
||||
|
||||
private function syncCategories(AuditChecklist $checklist, array $categoriesData, int $tenantId): void
|
||||
{
|
||||
// 기존 카테고리 ID 추적 (삭제 감지용)
|
||||
$existingCategoryIds = $checklist->categories()->pluck('id')->all();
|
||||
$keptCategoryIds = [];
|
||||
|
||||
foreach ($categoriesData as $catIdx => $catData) {
|
||||
if (! empty($catData['id'])) {
|
||||
// 기존 카테고리 업데이트
|
||||
$category = AuditChecklistCategory::findOrFail($catData['id']);
|
||||
$category->update([
|
||||
'title' => $catData['title'],
|
||||
'sort_order' => $catData['sort_order'] ?? $catIdx,
|
||||
]);
|
||||
$keptCategoryIds[] = $category->id;
|
||||
} else {
|
||||
// 새 카테고리 생성
|
||||
$category = AuditChecklistCategory::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'checklist_id' => $checklist->id,
|
||||
'title' => $catData['title'],
|
||||
'sort_order' => $catData['sort_order'] ?? $catIdx,
|
||||
]);
|
||||
$keptCategoryIds[] = $category->id;
|
||||
}
|
||||
|
||||
// 하위 항목 동기화
|
||||
$this->syncItems($category, $catData['items'] ?? [], $tenantId);
|
||||
}
|
||||
|
||||
// 삭제된 카테고리 제거 (cascade로 items도 삭제)
|
||||
$deletedIds = array_diff($existingCategoryIds, $keptCategoryIds);
|
||||
if (! empty($deletedIds)) {
|
||||
AuditChecklistCategory::whereIn('id', $deletedIds)->delete();
|
||||
}
|
||||
}
|
||||
|
||||
private function syncItems(AuditChecklistCategory $category, array $itemsData, int $tenantId): void
|
||||
{
|
||||
$existingItemIds = $category->items()->pluck('id')->all();
|
||||
$keptItemIds = [];
|
||||
|
||||
foreach ($itemsData as $itemIdx => $itemData) {
|
||||
if (! empty($itemData['id'])) {
|
||||
$item = AuditChecklistItem::findOrFail($itemData['id']);
|
||||
$item->update([
|
||||
'name' => $itemData['name'],
|
||||
'description' => $itemData['description'] ?? null,
|
||||
'sort_order' => $itemData['sort_order'] ?? $itemIdx,
|
||||
]);
|
||||
$keptItemIds[] = $item->id;
|
||||
} else {
|
||||
$item = AuditChecklistItem::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'category_id' => $category->id,
|
||||
'name' => $itemData['name'],
|
||||
'description' => $itemData['description'] ?? null,
|
||||
'sort_order' => $itemData['sort_order'] ?? $itemIdx,
|
||||
]);
|
||||
$keptItemIds[] = $item->id;
|
||||
}
|
||||
}
|
||||
|
||||
$deletedIds = array_diff($existingItemIds, $keptItemIds);
|
||||
if (! empty($deletedIds)) {
|
||||
AuditChecklistItem::whereIn('id', $deletedIds)->delete();
|
||||
}
|
||||
}
|
||||
|
||||
private function transformListItem(AuditChecklist $checklist): array
|
||||
{
|
||||
$total = 0;
|
||||
$completed = 0;
|
||||
foreach ($checklist->categories as $category) {
|
||||
foreach ($category->items as $item) {
|
||||
$total++;
|
||||
if ($item->is_completed) {
|
||||
$completed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (string) $checklist->id,
|
||||
'year' => $checklist->year,
|
||||
'quarter' => $checklist->quarter,
|
||||
'type' => $checklist->type,
|
||||
'status' => $checklist->status,
|
||||
'progress' => [
|
||||
'completed' => $completed,
|
||||
'total' => $total,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function transformDetail(AuditChecklist $checklist): array
|
||||
{
|
||||
$total = 0;
|
||||
$completed = 0;
|
||||
|
||||
$categories = $checklist->categories->map(function ($category) use (&$total, &$completed) {
|
||||
$subItems = $category->items->map(function ($item) use (&$total, &$completed) {
|
||||
$total++;
|
||||
if ($item->is_completed) {
|
||||
$completed++;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (string) $item->id,
|
||||
'name' => $item->name,
|
||||
'description' => $item->description,
|
||||
'is_completed' => $item->is_completed,
|
||||
'completed_at' => $item->completed_at?->toIso8601String(),
|
||||
'sort_order' => $item->sort_order,
|
||||
'standard_documents' => $item->standardDocuments->map(
|
||||
fn ($doc) => $this->transformStandardDocument($doc)
|
||||
)->all(),
|
||||
];
|
||||
})->all();
|
||||
|
||||
return [
|
||||
'id' => (string) $category->id,
|
||||
'title' => $category->title,
|
||||
'sort_order' => $category->sort_order,
|
||||
'sub_items' => $subItems,
|
||||
];
|
||||
})->all();
|
||||
|
||||
return [
|
||||
'id' => (string) $checklist->id,
|
||||
'year' => $checklist->year,
|
||||
'quarter' => $checklist->quarter,
|
||||
'type' => $checklist->type,
|
||||
'status' => $checklist->status,
|
||||
'progress' => [
|
||||
'completed' => $completed,
|
||||
'total' => $total,
|
||||
],
|
||||
'categories' => $categories,
|
||||
];
|
||||
}
|
||||
|
||||
private function transformStandardDocument(AuditStandardDocument $doc): array
|
||||
{
|
||||
$file = $doc->document;
|
||||
|
||||
return [
|
||||
'id' => (string) $doc->id,
|
||||
'title' => $doc->title,
|
||||
'version' => $doc->version ?? '-',
|
||||
'date' => $doc->date?->toDateString() ?? '',
|
||||
'file_name' => $file?->original_name ?? null,
|
||||
'file_url' => $file ? "/api/v1/documents/{$file->id}/download" : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// 1) 심사 점검표 마스터
|
||||
Schema::create('audit_checklists', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('tenant_id')->comment('테넌트ID');
|
||||
$table->smallInteger('year')->unsigned()->comment('연도');
|
||||
$table->tinyInteger('quarter')->unsigned()->comment('분기 1~4');
|
||||
$table->string('type', 30)->default('standard_manual')->comment('심사유형');
|
||||
$table->string('status', 20)->default('draft')->comment('draft/in_progress/completed');
|
||||
$table->json('options')->nullable()->comment('추가 설정');
|
||||
$table->unsignedBigInteger('created_by')->nullable()->comment('생성자');
|
||||
$table->unsignedBigInteger('updated_by')->nullable()->comment('수정자');
|
||||
$table->unsignedBigInteger('deleted_by')->nullable()->comment('삭제자');
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->unique(['tenant_id', 'year', 'quarter', 'type'], 'uq_audit_checklists_tenant_period');
|
||||
$table->index(['tenant_id', 'status'], 'idx_audit_checklists_status');
|
||||
$table->foreign('tenant_id')->references('id')->on('tenants');
|
||||
});
|
||||
|
||||
// 2) 점검표 카테고리
|
||||
Schema::create('audit_checklist_categories', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('tenant_id')->comment('테넌트ID');
|
||||
$table->unsignedBigInteger('checklist_id')->comment('점검표ID');
|
||||
$table->string('title', 200)->comment('카테고리명');
|
||||
$table->unsignedInteger('sort_order')->default(0)->comment('정렬순서');
|
||||
$table->json('options')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['checklist_id', 'sort_order'], 'idx_audit_categories_sort');
|
||||
$table->foreign('checklist_id')->references('id')->on('audit_checklists')->onDelete('cascade');
|
||||
});
|
||||
|
||||
// 3) 점검표 세부 항목
|
||||
Schema::create('audit_checklist_items', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('tenant_id')->comment('테넌트ID');
|
||||
$table->unsignedBigInteger('category_id')->comment('카테고리ID');
|
||||
$table->string('name', 200)->comment('항목명');
|
||||
$table->text('description')->nullable()->comment('항목 설명');
|
||||
$table->boolean('is_completed')->default(false)->comment('완료여부');
|
||||
$table->timestamp('completed_at')->nullable()->comment('완료일시');
|
||||
$table->unsignedBigInteger('completed_by')->nullable()->comment('완료처리자');
|
||||
$table->unsignedInteger('sort_order')->default(0)->comment('정렬순서');
|
||||
$table->json('options')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['category_id', 'sort_order'], 'idx_audit_items_sort');
|
||||
$table->index(['category_id', 'is_completed'], 'idx_audit_items_completed');
|
||||
$table->foreign('category_id')->references('id')->on('audit_checklist_categories')->onDelete('cascade');
|
||||
$table->foreign('completed_by')->references('id')->on('users')->onDelete('set null');
|
||||
});
|
||||
|
||||
// 4) 기준 문서 연결
|
||||
Schema::create('audit_standard_documents', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('tenant_id')->comment('테넌트ID');
|
||||
$table->unsignedBigInteger('checklist_item_id')->comment('점검항목ID');
|
||||
$table->string('title', 200)->comment('문서명');
|
||||
$table->string('version', 20)->nullable()->comment('버전');
|
||||
$table->date('date')->nullable()->comment('시행일');
|
||||
$table->unsignedBigInteger('document_id')->nullable()->comment('EAV 파일 FK');
|
||||
$table->json('options')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('checklist_item_id', 'idx_audit_std_docs_item');
|
||||
$table->foreign('checklist_item_id')->references('id')->on('audit_checklist_items')->onDelete('cascade');
|
||||
$table->foreign('document_id')->references('id')->on('documents')->onDelete('set null');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('audit_standard_documents');
|
||||
Schema::dropIfExists('audit_checklist_items');
|
||||
Schema::dropIfExists('audit_checklist_categories');
|
||||
Schema::dropIfExists('audit_checklists');
|
||||
}
|
||||
};
|
||||
@@ -7,6 +7,7 @@
|
||||
* - 실적신고
|
||||
*/
|
||||
|
||||
use App\Http\Controllers\Api\V1\AuditChecklistController;
|
||||
use App\Http\Controllers\Api\V1\PerformanceReportController;
|
||||
use App\Http\Controllers\Api\V1\QmsLotAuditController;
|
||||
use App\Http\Controllers\Api\V1\QualityDocumentController;
|
||||
@@ -48,3 +49,16 @@
|
||||
Route::get('/documents/{type}/{id}', [QmsLotAuditController::class, 'documentDetail'])->whereNumber('id')->name('v1.qms.lot-audit.documents.detail');
|
||||
Route::patch('/units/{id}/confirm', [QmsLotAuditController::class, 'confirm'])->whereNumber('id')->name('v1.qms.lot-audit.units.confirm');
|
||||
});
|
||||
|
||||
// QMS 기준/매뉴얼 심사 (1일차)
|
||||
Route::prefix('qms')->group(function () {
|
||||
Route::get('/checklists', [AuditChecklistController::class, 'index'])->name('v1.qms.checklists.index');
|
||||
Route::post('/checklists', [AuditChecklistController::class, 'store'])->name('v1.qms.checklists.store');
|
||||
Route::get('/checklists/{id}', [AuditChecklistController::class, 'show'])->whereNumber('id')->name('v1.qms.checklists.show');
|
||||
Route::put('/checklists/{id}', [AuditChecklistController::class, 'update'])->whereNumber('id')->name('v1.qms.checklists.update');
|
||||
Route::patch('/checklists/{id}/complete', [AuditChecklistController::class, 'complete'])->whereNumber('id')->name('v1.qms.checklists.complete');
|
||||
Route::patch('/checklist-items/{id}/toggle', [AuditChecklistController::class, 'toggleItem'])->whereNumber('id')->name('v1.qms.checklist-items.toggle');
|
||||
Route::get('/checklist-items/{id}/documents', [AuditChecklistController::class, 'itemDocuments'])->whereNumber('id')->name('v1.qms.checklist-items.documents');
|
||||
Route::post('/checklist-items/{id}/documents', [AuditChecklistController::class, 'attachDocument'])->whereNumber('id')->name('v1.qms.checklist-items.documents.attach');
|
||||
Route::delete('/checklist-items/{id}/documents/{docId}', [AuditChecklistController::class, 'detachDocument'])->whereNumber('id')->whereNumber('docId')->name('v1.qms.checklist-items.documents.detach');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user