feat: [QMS] 점검표 템플릿 관리 백엔드 구현
- checklist_templates 테이블 마이그레이션 + 기본 시딩 - ChecklistTemplate 모델 (BelongsToTenant, Auditable, SoftDeletes) - ChecklistTemplateService: 조회/저장/파일 업로드/삭제 - SaveChecklistTemplateRequest: 중첩 JSON 검증 - ChecklistTemplateController: 5개 엔드포인트 - 라우트 등록 (quality/checklist-templates, quality/qms-documents)
This commit is contained in:
82
app/Http/Controllers/Api/V1/ChecklistTemplateController.php
Normal file
82
app/Http/Controllers/Api/V1/ChecklistTemplateController.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Quality\SaveChecklistTemplateRequest;
|
||||
use App\Services\ChecklistTemplateService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ChecklistTemplateController extends Controller
|
||||
{
|
||||
public function __construct(private ChecklistTemplateService $service) {}
|
||||
|
||||
/**
|
||||
* 템플릿 조회 (type별)
|
||||
*/
|
||||
public function show(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$type = $request->query('type', 'day1_audit');
|
||||
|
||||
return $this->service->getByType($type);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 저장 (전체 덮어쓰기)
|
||||
*/
|
||||
public function update(SaveChecklistTemplateRequest $request, int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request, $id) {
|
||||
return $this->service->save($id, $request->validated());
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 항목별 파일 목록 조회
|
||||
*/
|
||||
public function documents(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$templateId = (int) $request->query('template_id');
|
||||
$subItemId = $request->query('sub_item_id');
|
||||
|
||||
return $this->service->getDocuments($templateId, $subItemId);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 업로드
|
||||
*/
|
||||
public function uploadDocument(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'template_id' => ['required', 'integer'],
|
||||
'sub_item_id' => ['required', 'string', 'max:50'],
|
||||
'file' => ['required', 'file', 'max:10240'], // 10MB
|
||||
]);
|
||||
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->uploadDocument(
|
||||
(int) $request->input('template_id'),
|
||||
$request->input('sub_item_id'),
|
||||
$request->file('file')
|
||||
);
|
||||
}, __('message.created'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 삭제
|
||||
*/
|
||||
public function deleteDocument(int $id, Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $request) {
|
||||
$replace = filter_var($request->query('replace', false), FILTER_VALIDATE_BOOLEAN);
|
||||
$this->service->deleteDocument($id, $replace);
|
||||
|
||||
return 'success';
|
||||
}, __('message.deleted'));
|
||||
}
|
||||
}
|
||||
40
app/Http/Requests/Quality/SaveChecklistTemplateRequest.php
Normal file
40
app/Http/Requests/Quality/SaveChecklistTemplateRequest.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Quality;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SaveChecklistTemplateRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['nullable', 'string', 'max:255'],
|
||||
'categories' => ['required', 'array', 'min:1'],
|
||||
'categories.*.id' => ['required', 'string', 'max:50'],
|
||||
'categories.*.title' => ['required', 'string', 'max:255'],
|
||||
'categories.*.subItems' => ['required', 'array'],
|
||||
'categories.*.subItems.*.id' => ['required', 'string', 'max:50'],
|
||||
'categories.*.subItems.*.name' => ['required', 'string', 'max:255'],
|
||||
'options' => ['nullable', 'array'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'categories.required' => __('validation.required', ['attribute' => '카테고리']),
|
||||
'categories.min' => __('validation.min.array', ['attribute' => '카테고리', 'min' => 1]),
|
||||
'categories.*.id.required' => __('validation.required', ['attribute' => '카테고리 ID']),
|
||||
'categories.*.title.required' => __('validation.required', ['attribute' => '카테고리 제목']),
|
||||
'categories.*.subItems.required' => __('validation.required', ['attribute' => '점검항목']),
|
||||
'categories.*.subItems.*.id.required' => __('validation.required', ['attribute' => '항목 ID']),
|
||||
'categories.*.subItems.*.name.required' => __('validation.required', ['attribute' => '항목명']),
|
||||
];
|
||||
}
|
||||
}
|
||||
76
app/Models/Qualitys/ChecklistTemplate.php
Normal file
76
app/Models/Qualitys/ChecklistTemplate.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Qualitys;
|
||||
|
||||
use App\Models\Commons\File;
|
||||
use App\Traits\Auditable;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class ChecklistTemplate extends Model
|
||||
{
|
||||
use Auditable, BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $table = 'checklist_templates';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'name',
|
||||
'type',
|
||||
'categories',
|
||||
'options',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'categories' => 'array',
|
||||
'options' => 'array',
|
||||
];
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function updater(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class, 'updated_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* 점검항목별 연결 파일 (files 테이블 polymorphic)
|
||||
* document_type = 'checklist_template', document_id = this.id
|
||||
* field_key = sub_item_id (e.g. 'cat-1-1')
|
||||
*/
|
||||
public function documents(): MorphMany
|
||||
{
|
||||
return $this->morphMany(File::class, 'document', 'document_type', 'document_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 항목의 파일 조회
|
||||
*/
|
||||
public function documentsForItem(string $subItemId)
|
||||
{
|
||||
return $this->documents()->where('field_key', $subItemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* categories JSON에서 모든 sub_item_id 추출
|
||||
*/
|
||||
public function getAllSubItemIds(): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach ($this->categories ?? [] as $category) {
|
||||
foreach ($category['subItems'] ?? [] as $subItem) {
|
||||
$ids[] = $subItem['id'];
|
||||
}
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
}
|
||||
214
app/Services/ChecklistTemplateService.php
Normal file
214
app/Services/ChecklistTemplateService.php
Normal file
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Commons\File;
|
||||
use App\Models\Qualitys\ChecklistTemplate;
|
||||
use App\Services\Audit\AuditLogger;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class ChecklistTemplateService extends Service
|
||||
{
|
||||
private const AUDIT_TARGET = 'checklist_template';
|
||||
|
||||
private const DOCUMENT_TYPE = 'checklist_template';
|
||||
|
||||
public function __construct(
|
||||
private readonly AuditLogger $auditLogger
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 템플릿 조회 (type별)
|
||||
*/
|
||||
public function getByType(string $type): array
|
||||
{
|
||||
$template = ChecklistTemplate::query()
|
||||
->where('type', $type)
|
||||
->first();
|
||||
|
||||
if (! $template) {
|
||||
throw new NotFoundHttpException(__('error.not_found'));
|
||||
}
|
||||
|
||||
// 각 항목별 파일 수 포함
|
||||
$fileCounts = File::query()
|
||||
->where('document_type', self::DOCUMENT_TYPE)
|
||||
->where('document_id', $template->id)
|
||||
->whereNull('deleted_at')
|
||||
->selectRaw('field_key, COUNT(*) as count')
|
||||
->groupBy('field_key')
|
||||
->pluck('count', 'field_key')
|
||||
->toArray();
|
||||
|
||||
return [
|
||||
'id' => $template->id,
|
||||
'name' => $template->name,
|
||||
'type' => $template->type,
|
||||
'categories' => $template->categories,
|
||||
'options' => $template->options,
|
||||
'file_counts' => $fileCounts,
|
||||
'updated_at' => $template->updated_at?->toIso8601String(),
|
||||
'updated_by' => $template->updater?->name,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 저장 (전체 덮어쓰기)
|
||||
*/
|
||||
public function save(int $id, array $data): array
|
||||
{
|
||||
$template = ChecklistTemplate::findOrFail($id);
|
||||
$before = $template->toArray();
|
||||
|
||||
// 삭제된 항목의 파일 처리
|
||||
$oldSubItemIds = $template->getAllSubItemIds();
|
||||
$newSubItemIds = $this->extractSubItemIds($data['categories']);
|
||||
$removedIds = array_diff($oldSubItemIds, $newSubItemIds);
|
||||
|
||||
DB::transaction(function () use ($template, $data, $removedIds) {
|
||||
// 템플릿 업데이트
|
||||
$template->update([
|
||||
'name' => $data['name'] ?? $template->name,
|
||||
'categories' => $data['categories'],
|
||||
'options' => $data['options'] ?? $template->options,
|
||||
'updated_by' => $this->apiUserId(),
|
||||
]);
|
||||
|
||||
// 삭제된 항목의 파일 → soft delete
|
||||
if (! empty($removedIds)) {
|
||||
$orphanFiles = File::query()
|
||||
->where('document_type', self::DOCUMENT_TYPE)
|
||||
->where('document_id', $template->id)
|
||||
->whereIn('field_key', $removedIds)
|
||||
->get();
|
||||
|
||||
foreach ($orphanFiles as $file) {
|
||||
$file->softDeleteFile($this->apiUserId());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$template->refresh();
|
||||
|
||||
$this->auditLogger->log(
|
||||
self::AUDIT_TARGET,
|
||||
$template->id,
|
||||
'updated',
|
||||
$before,
|
||||
$template->toArray(),
|
||||
$this->apiUserId()
|
||||
);
|
||||
|
||||
return $this->getByType($template->type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 항목별 파일 목록 조회
|
||||
*/
|
||||
public function getDocuments(int $templateId, ?string $subItemId = null): array
|
||||
{
|
||||
$query = File::query()
|
||||
->where('document_type', self::DOCUMENT_TYPE)
|
||||
->where('document_id', $templateId)
|
||||
->with('uploader:id,name');
|
||||
|
||||
if ($subItemId) {
|
||||
$query->where('field_key', $subItemId);
|
||||
}
|
||||
|
||||
$files = $query->orderBy('field_key')->orderByDesc('id')->get();
|
||||
|
||||
return $files->map(fn (File $file) => [
|
||||
'id' => $file->id,
|
||||
'field_key' => $file->field_key,
|
||||
'display_name' => $file->display_name ?? $file->original_name,
|
||||
'file_size' => $file->file_size,
|
||||
'mime_type' => $file->mime_type,
|
||||
'uploaded_by' => $file->uploader?->name,
|
||||
'created_at' => $file->created_at?->toIso8601String(),
|
||||
])->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 업로드 (polymorphic)
|
||||
*/
|
||||
public function uploadDocument(int $templateId, string $subItemId, $uploadedFile): array
|
||||
{
|
||||
$template = ChecklistTemplate::findOrFail($templateId);
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
// 저장 경로: {tenant_id}/checklist-templates/{year}/{month}/{stored_name}
|
||||
$date = now();
|
||||
$storedName = bin2hex(random_bytes(16)).'.'.$uploadedFile->getClientOriginalExtension();
|
||||
$filePath = sprintf(
|
||||
'%d/checklist-templates/%s/%s/%s',
|
||||
$tenantId,
|
||||
$date->format('Y'),
|
||||
$date->format('m'),
|
||||
$storedName
|
||||
);
|
||||
|
||||
// 파일 저장
|
||||
Storage::disk('tenant')->put($filePath, file_get_contents($uploadedFile->getPathname()));
|
||||
|
||||
// DB 레코드 생성
|
||||
$file = File::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'document_type' => self::DOCUMENT_TYPE,
|
||||
'document_id' => $template->id,
|
||||
'field_key' => $subItemId,
|
||||
'display_name' => $uploadedFile->getClientOriginalName(),
|
||||
'stored_name' => $storedName,
|
||||
'file_path' => $filePath,
|
||||
'file_size' => $uploadedFile->getSize(),
|
||||
'mime_type' => $uploadedFile->getClientMimeType(),
|
||||
'uploaded_by' => $userId,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
|
||||
return [
|
||||
'id' => $file->id,
|
||||
'field_key' => $file->field_key,
|
||||
'display_name' => $file->display_name,
|
||||
'file_size' => $file->file_size,
|
||||
'mime_type' => $file->mime_type,
|
||||
'created_at' => $file->created_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 삭제
|
||||
* - 교체(replace=true): hard delete (물리 파일 + DB)
|
||||
* - 일반 삭제: soft delete (휴지통)
|
||||
*/
|
||||
public function deleteDocument(int $fileId, bool $replace = false): void
|
||||
{
|
||||
$file = File::query()
|
||||
->where('document_type', self::DOCUMENT_TYPE)
|
||||
->findOrFail($fileId);
|
||||
|
||||
if ($replace) {
|
||||
$file->permanentDelete();
|
||||
} else {
|
||||
$file->softDeleteFile($this->apiUserId());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* categories JSON에서 sub_item_id 목록 추출
|
||||
*/
|
||||
private function extractSubItemIds(array $categories): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach ($categories as $category) {
|
||||
foreach ($category['subItems'] ?? [] as $subItem) {
|
||||
$ids[] = $subItem['id'];
|
||||
}
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('checklist_templates', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('tenant_id')->comment('테넌트ID');
|
||||
$table->string('name', 255)->default('품질인정심사 점검표')->comment('템플릿명');
|
||||
$table->string('type', 50)->default('day1_audit')->comment('심사유형: day1_audit, day2_lot 등');
|
||||
$table->json('categories')->comment('카테고리/항목 JSON');
|
||||
$table->json('options')->nullable()->comment('확장 속성');
|
||||
$table->unsignedBigInteger('created_by')->nullable()->comment('생성자');
|
||||
$table->unsignedBigInteger('updated_by')->nullable()->comment('수정자');
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->unique(['tenant_id', 'type'], 'uq_checklist_templates_tenant_type');
|
||||
$table->index(['tenant_id', 'type'], 'idx_checklist_templates_tenant_type');
|
||||
$table->foreign('tenant_id')->references('id')->on('tenants');
|
||||
$table->foreign('created_by')->references('id')->on('users')->onDelete('set null');
|
||||
$table->foreign('updated_by')->references('id')->on('users')->onDelete('set null');
|
||||
});
|
||||
|
||||
// 기존 테넌트에 기본 템플릿 시딩
|
||||
$this->seedDefaultTemplates();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('checklist_templates');
|
||||
}
|
||||
|
||||
private function seedDefaultTemplates(): void
|
||||
{
|
||||
$defaultCategories = json_encode([
|
||||
[
|
||||
'id' => 'cat-1',
|
||||
'title' => '원재료 품질관리 기준',
|
||||
'subItems' => [
|
||||
['id' => 'cat-1-1', 'name' => '수입검사 기준 확인'],
|
||||
['id' => 'cat-1-2', 'name' => '불합격품 처리 기준 확인'],
|
||||
['id' => 'cat-1-3', 'name' => '자재 보관 기준 확인'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'cat-2',
|
||||
'title' => '제조공정 관리 기준',
|
||||
'subItems' => [
|
||||
['id' => 'cat-2-1', 'name' => '작업표준서 확인'],
|
||||
['id' => 'cat-2-2', 'name' => '공정검사 기준 확인'],
|
||||
['id' => 'cat-2-3', 'name' => '부적합품 처리 기준 확인'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'cat-3',
|
||||
'title' => '제품 품질관리 기준',
|
||||
'subItems' => [
|
||||
['id' => 'cat-3-1', 'name' => '제품검사 기준 확인'],
|
||||
['id' => 'cat-3-2', 'name' => '출하검사 기준 확인'],
|
||||
['id' => 'cat-3-3', 'name' => '클레임 처리 기준 확인'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'cat-4',
|
||||
'title' => '제조설비 관리',
|
||||
'subItems' => [
|
||||
['id' => 'cat-4-1', 'name' => '설비관리 기준 확인'],
|
||||
['id' => 'cat-4-2', 'name' => '설비점검 이력 확인'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'cat-5',
|
||||
'title' => '검사설비 관리',
|
||||
'subItems' => [
|
||||
['id' => 'cat-5-1', 'name' => '검사설비 관리 기준 확인'],
|
||||
['id' => 'cat-5-2', 'name' => '교정 이력 확인'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'cat-6',
|
||||
'title' => '문서 및 인증 관리',
|
||||
'subItems' => [
|
||||
['id' => 'cat-6-1', 'name' => '문서관리 기준 확인'],
|
||||
['id' => 'cat-6-2', 'name' => 'KS/인증 관리 현황 확인'],
|
||||
],
|
||||
],
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$tenantIds = DB::table('tenants')->pluck('id');
|
||||
$now = now();
|
||||
|
||||
foreach ($tenantIds as $tenantId) {
|
||||
DB::table('checklist_templates')->insert([
|
||||
'tenant_id' => $tenantId,
|
||||
'name' => '품질인정심사 점검표',
|
||||
'type' => 'day1_audit',
|
||||
'categories' => $defaultCategories,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
|
||||
use App\Http\Controllers\Api\V1\AuditChecklistController;
|
||||
use App\Http\Controllers\Api\V1\ChecklistTemplateController;
|
||||
use App\Http\Controllers\Api\V1\PerformanceReportController;
|
||||
use App\Http\Controllers\Api\V1\QmsLotAuditController;
|
||||
use App\Http\Controllers\Api\V1\QualityDocumentController;
|
||||
@@ -50,6 +51,19 @@
|
||||
Route::patch('/units/{id}/confirm', [QmsLotAuditController::class, 'confirm'])->whereNumber('id')->name('v1.qms.lot-audit.units.confirm');
|
||||
});
|
||||
|
||||
// QMS 점검표 템플릿 관리
|
||||
Route::prefix('quality/checklist-templates')->group(function () {
|
||||
Route::get('', [ChecklistTemplateController::class, 'show'])->name('v1.quality.checklist-templates.show');
|
||||
Route::put('/{id}', [ChecklistTemplateController::class, 'update'])->whereNumber('id')->name('v1.quality.checklist-templates.update');
|
||||
});
|
||||
|
||||
// QMS 점검표 문서 (파일) 관리
|
||||
Route::prefix('quality/qms-documents')->group(function () {
|
||||
Route::get('', [ChecklistTemplateController::class, 'documents'])->name('v1.quality.qms-documents.index');
|
||||
Route::post('', [ChecklistTemplateController::class, 'uploadDocument'])->name('v1.quality.qms-documents.store');
|
||||
Route::delete('/{id}', [ChecklistTemplateController::class, 'deleteDocument'])->whereNumber('id')->name('v1.quality.qms-documents.destroy');
|
||||
});
|
||||
|
||||
// QMS 기준/매뉴얼 심사 (1일차)
|
||||
Route::prefix('qms')->group(function () {
|
||||
Route::get('/checklists', [AuditChecklistController::class, 'index'])->name('v1.qms.checklists.index');
|
||||
|
||||
Reference in New Issue
Block a user