- 마이그레이션: 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>
393 lines
13 KiB
PHP
393 lines
13 KiB
PHP
<?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,
|
|
];
|
|
}
|
|
}
|