deploy: 2026-03-11 배포

- feat: QMS 감사 체크리스트 (AuditChecklist CRUD, 카테고리/항목/표준문서 모델, 마이그레이션)
- feat: QMS LOT 감사 (QmsLotAudit 컨트롤러/서비스, 확인/문서상세 FormRequest)
- fix: CalendarService, MemberService 수정
- chore: QualityDocumentLocation options 컬럼 추가, tenant_id 마이그레이션, 품질 더미데이터 시더
This commit is contained in:
2026-03-11 02:04:29 +09:00
parent bd500a87bd
commit 047524c19f
21 changed files with 2356 additions and 1 deletions

View 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'));
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Qms\QmsLotAuditConfirmRequest;
use App\Http\Requests\Qms\QmsLotAuditDocumentDetailRequest;
use App\Http\Requests\Qms\QmsLotAuditIndexRequest;
use App\Services\QmsLotAuditService;
class QmsLotAuditController extends Controller
{
public function __construct(private QmsLotAuditService $service) {}
/**
* 품질관리서 목록 (로트 추적 심사용)
*/
public function index(QmsLotAuditIndexRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->index($request->validated());
}, __('message.fetched'));
}
/**
* 품질관리서 상세 — 수주/개소 목록
*/
public function show(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->show($id);
}, __('message.fetched'));
}
/**
* 수주 루트별 8종 서류 목록
*/
public function routeDocuments(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->routeDocuments($id);
}, __('message.fetched'));
}
/**
* 서류 상세 조회 (2단계 로딩)
*/
public function documentDetail(QmsLotAuditDocumentDetailRequest $request, string $type, int $id)
{
return ApiResponse::handle(function () use ($type, $id) {
return $this->service->documentDetail($type, $id);
}, __('message.fetched'));
}
/**
* 개소별 로트 심사 확인 토글
*/
public function confirm(QmsLotAuditConfirmRequest $request, int $id)
{
return ApiResponse::handle(function () use ($request, $id) {
return $this->service->confirm($id, $request->validated());
}, __('message.updated'));
}
}

View 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' => '항목명']),
];
}
}

View 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',
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests\Qms;
use Illuminate\Foundation\Http\FormRequest;
class QmsLotAuditConfirmRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'confirmed' => 'required|boolean',
];
}
public function messages(): array
{
return [
'confirmed.required' => __('validation.required', ['attribute' => '확인 상태']),
'confirmed.boolean' => __('validation.boolean', ['attribute' => '확인 상태']),
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests\Qms;
use Illuminate\Foundation\Http\FormRequest;
class QmsLotAuditDocumentDetailRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
$this->merge([
'type' => $this->route('type'),
]);
}
public function rules(): array
{
return [
'type' => 'required|string|in:import,order,log,report,confirmation,shipping,product,quality',
];
}
public function messages(): array
{
return [
'type.in' => __('validation.in', ['attribute' => '서류 타입']),
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests\Qms;
use Illuminate\Foundation\Http\FormRequest;
class QmsLotAuditIndexRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'year' => 'nullable|integer|min:2020|max:2100',
'quarter' => 'nullable|integer|in:1,2,3,4',
'q' => 'nullable|string|max:100',
'per_page' => 'nullable|integer|min:1|max:100',
];
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Models\Qualitys;
use App\Traits\Auditable;
use App\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;
}
}

View 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');
}
}

View 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');
}
}

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

View File

@@ -26,10 +26,12 @@ class QualityDocumentLocation extends Model
'inspection_data',
'document_id',
'inspection_status',
'options',
];
protected $casts = [
'inspection_data' => 'array',
'options' => 'array',
];
public function qualityDocument()

View 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,
];
}
}

View File

@@ -4,9 +4,12 @@
use App\Models\Construction\Contract;
use App\Models\Production\WorkOrder;
use App\Models\Orders\Order;
use App\Models\Tenants\Bill;
use App\Models\Tenants\ExpectedExpense;
use App\Models\Tenants\Leave;
use App\Models\Tenants\Schedule;
use App\Models\Tenants\Shipment;
use Illuminate\Support\Collection;
/**
@@ -26,7 +29,7 @@ class CalendarService extends Service
*
* @param string $startDate 조회 시작일 (Y-m-d)
* @param string $endDate 조회 종료일 (Y-m-d)
* @param string|null $type 일정 타입 필터 (schedule|order|construction|other|bill|null=전체)
* @param string|null $type 일정 타입 필터 (schedule|order|construction|other|bill|expected_expense|delivery|shipment|null=전체)
* @param string|null $departmentFilter 부서 필터 (all|department|personal)
*/
public function getSchedules(
@@ -73,6 +76,27 @@ public function getSchedules(
);
}
// 매입 결제 예정일
if ($type === null || $type === 'expected_expense') {
$schedules = $schedules->merge(
$this->getExpectedExpenseSchedules($tenantId, $startDate, $endDate)
);
}
// 수주 납기일
if ($type === null || $type === 'delivery') {
$schedules = $schedules->merge(
$this->getDeliverySchedules($tenantId, $startDate, $endDate)
);
}
// 출고 예정일
if ($type === null || $type === 'shipment') {
$schedules = $schedules->merge(
$this->getShipmentSchedules($tenantId, $startDate, $endDate)
);
}
// startDate 기준 정렬
$sortedSchedules = $schedules
->sortBy('startDate')
@@ -382,4 +406,128 @@ private function getBillSchedules(
];
});
}
/**
* 매입 결제 예정일 조회
*/
private function getExpectedExpenseSchedules(
int $tenantId,
string $startDate,
string $endDate
): Collection {
$expenses = ExpectedExpense::query()
->where('tenant_id', $tenantId)
->whereNotNull('expected_payment_date')
->where('expected_payment_date', '>=', $startDate)
->where('expected_payment_date', '<=', $endDate)
->where('payment_status', '!=', 'paid')
->with(['client:id,name'])
->orderBy('expected_payment_date')
->limit(100)
->get();
return $expenses->map(function ($expense) {
$clientName = $expense->client?->name ?? $expense->client_name ?? '';
return [
'id' => 'expense_'.$expense->id,
'title' => '[결제] '.$clientName.' '.number_format($expense->amount).'원',
'startDate' => $expense->expected_payment_date->format('Y-m-d'),
'endDate' => $expense->expected_payment_date->format('Y-m-d'),
'startTime' => null,
'endTime' => null,
'isAllDay' => true,
'type' => 'expected_expense',
'department' => null,
'personName' => null,
'color' => null,
];
});
}
/**
* 수주 납기일 조회
*/
private function getDeliverySchedules(
int $tenantId,
string $startDate,
string $endDate
): Collection {
$activeStatuses = [
'CONFIRMED',
'IN_PROGRESS',
'IN_PRODUCTION',
'PRODUCED',
'SHIPPING',
];
$orders = Order::query()
->where('tenant_id', $tenantId)
->whereNotNull('delivery_date')
->where('delivery_date', '>=', $startDate)
->where('delivery_date', '<=', $endDate)
->whereIn('status_code', $activeStatuses)
->with(['client:id,name'])
->orderBy('delivery_date')
->limit(100)
->get();
return $orders->map(function ($order) {
$clientName = $order->client?->name ?? $order->client_name ?? '';
$siteName = $order->site_name ?? $order->order_no;
return [
'id' => 'delivery_'.$order->id,
'title' => '[납기] '.$clientName.' '.$siteName,
'startDate' => $order->delivery_date->format('Y-m-d'),
'endDate' => $order->delivery_date->format('Y-m-d'),
'startTime' => null,
'endTime' => null,
'isAllDay' => true,
'type' => 'delivery',
'department' => null,
'personName' => null,
'color' => null,
];
});
}
/**
* 출고 예정일 조회
*/
private function getShipmentSchedules(
int $tenantId,
string $startDate,
string $endDate
): Collection {
$shipments = Shipment::query()
->where('tenant_id', $tenantId)
->whereNotNull('scheduled_date')
->where('scheduled_date', '>=', $startDate)
->where('scheduled_date', '<=', $endDate)
->whereIn('status', ['scheduled', 'ready'])
->with(['client:id,name', 'order:id,site_name'])
->orderBy('scheduled_date')
->limit(100)
->get();
return $shipments->map(function ($shipment) {
$clientName = $shipment->client?->name ?? $shipment->customer_name ?? '';
$siteName = $shipment->site_name ?? $shipment->order?->site_name ?? $shipment->shipment_no;
return [
'id' => 'shipment_'.$shipment->id,
'title' => '[출고] '.$clientName.' '.$siteName,
'startDate' => $shipment->scheduled_date->format('Y-m-d'),
'endDate' => $shipment->scheduled_date->format('Y-m-d'),
'startTime' => null,
'endTime' => null,
'isAllDay' => true,
'type' => 'shipment',
'department' => null,
'personName' => null,
'color' => null,
];
});
}
}

View File

@@ -231,6 +231,7 @@ public static function getUserInfoForLogin(int $userId): array
$dept = DB::table('departments')->where('id', $profile->department_id)->first();
if ($dept) {
$userInfo['department'] = $dept->name;
$userInfo['department_id'] = $dept->id;
}
}

View File

@@ -0,0 +1,517 @@
<?php
namespace App\Services;
use App\Models\Orders\Order;
use App\Models\Production\WorkOrder;
use App\Models\Production\WorkOrderMaterialInput;
use App\Models\Qualitys\Inspection;
use App\Models\Qualitys\QualityDocument;
use App\Models\Qualitys\QualityDocumentLocation;
use App\Models\Qualitys\QualityDocumentOrder;
use App\Models\Tenants\Shipment;
use App\Models\Tenants\StockLot;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class QmsLotAuditService extends Service
{
/**
* 품질관리서 목록 (로트 추적 심사용)
* completed 상태의 품질관리서를 PerformanceReport 기반으로 필터링
*/
public function index(array $params): array
{
$query = QualityDocument::with([
'documentOrders.order.nodes' => fn ($q) => $q->whereNull('parent_id'),
'documentOrders.order.nodes.items.item',
'locations',
'performanceReport',
])
->where('status', QualityDocument::STATUS_COMPLETED);
// 연도 필터
if (! empty($params['year'])) {
$year = (int) $params['year'];
$query->where(function ($q) use ($year) {
$q->whereHas('performanceReport', fn ($pr) => $pr->where('year', $year))
->orWhereDoesntHave('performanceReport');
});
}
// 분기 필터
if (! empty($params['quarter'])) {
$quarter = (int) $params['quarter'];
$query->whereHas('performanceReport', fn ($pr) => $pr->where('quarter', $quarter));
}
// 검색어 필터
if (! empty($params['q'])) {
$term = trim($params['q']);
$query->where(function ($q) use ($term) {
$q->where('quality_doc_number', 'like', "%{$term}%")
->orWhere('site_name', 'like', "%{$term}%");
});
}
$query->orderByDesc('id');
$perPage = (int) ($params['per_page'] ?? 20);
$paginated = $query->paginate($perPage);
$items = $paginated->getCollection()->map(fn ($doc) => $this->transformReportToFrontend($doc));
return [
'items' => $items,
'current_page' => $paginated->currentPage(),
'last_page' => $paginated->lastPage(),
'per_page' => $paginated->perPage(),
'total' => $paginated->total(),
];
}
/**
* 품질관리서 상세 — 수주/개소 목록 (RouteItem[])
*/
public function show(int $id): array
{
$doc = QualityDocument::with([
'documentOrders.order',
'documentOrders.locations.orderItem',
])->findOrFail($id);
return $doc->documentOrders->map(fn ($docOrder) => $this->transformRouteToFrontend($docOrder, $doc))->values()->all();
}
/**
* 수주 루트별 8종 서류 목록 (Document[])
*/
public function routeDocuments(int $qualityDocumentOrderId): array
{
$docOrder = QualityDocumentOrder::with([
'order.workOrders.process',
'locations',
'qualityDocument',
])->findOrFail($qualityDocumentOrderId);
$order = $docOrder->order;
$qualityDoc = $docOrder->qualityDocument;
$workOrders = $order->workOrders;
$documents = [];
// 1. 수입검사 성적서 (IQC): WorkOrderMaterialInput → StockLot → lot_no → Inspection(IQC)
$investedLotIds = WorkOrderMaterialInput::whereIn('work_order_id', $workOrders->pluck('id'))
->pluck('stock_lot_id')
->unique();
$investedLotNos = StockLot::whereIn('id', $investedLotIds)
->whereNotNull('lot_no')
->pluck('lot_no')
->unique();
$iqcInspections = Inspection::where('inspection_type', 'IQC')
->whereIn('lot_no', $investedLotNos)
->where('status', 'completed')
->get();
$documents[] = $this->formatDocument('import', '수입검사 성적서', $iqcInspections);
// 2. 수주서
$documents[] = $this->formatDocument('order', '수주서', collect([$order]));
// 3. 작업일지 (subType: process.process_name 기반)
$documents[] = $this->formatDocumentWithSubType('log', '작업일지', $workOrders);
// 4. 중간검사 성적서 (PQC)
$pqcInspections = Inspection::where('inspection_type', 'PQC')
->whereIn('work_order_id', $workOrders->pluck('id'))
->where('status', 'completed')
->with('workOrder.process')
->get();
$documents[] = $this->formatDocumentWithSubType('report', '중간검사 성적서', $pqcInspections, 'workOrder');
// 5. 납품확인서
$shipments = $order->shipments()->get();
$documents[] = $this->formatDocument('confirmation', '납품확인서', $shipments);
// 6. 출고증
$documents[] = $this->formatDocument('shipping', '출고증', $shipments);
// 7. 제품검사 성적서
$locationsWithDoc = $docOrder->locations->filter(fn ($loc) => $loc->document_id);
$documents[] = $this->formatDocument('product', '제품검사 성적서', $locationsWithDoc);
// 8. 품질관리서
$documents[] = $this->formatDocument('quality', '품질관리서', collect([$qualityDoc]));
return $documents;
}
/**
* 서류 상세 조회 (2단계 로딩 — 모달 렌더링용)
*/
public function documentDetail(string $type, int $id): array
{
return match ($type) {
'import' => $this->getInspectionDetail($id, 'IQC'),
'order' => $this->getOrderDetail($id),
'log' => $this->getWorkOrderLogDetail($id),
'report' => $this->getInspectionDetail($id, 'PQC'),
'confirmation', 'shipping' => $this->getShipmentDetail($id),
'product' => $this->getLocationDetail($id),
'quality' => $this->getQualityDocDetail($id),
default => throw new NotFoundHttpException(__('error.not_found')),
};
}
/**
* 개소별 로트 심사 확인 토글
*/
public function confirm(int $locationId, array $data): array
{
$location = QualityDocumentLocation::findOrFail($locationId);
$confirmed = (bool) $data['confirmed'];
$userId = $this->apiUserId();
DB::transaction(function () use ($location, $confirmed, $userId) {
$location->lockForUpdate();
$options = $location->options ?? [];
$options['lot_audit_confirmed'] = $confirmed;
$options['lot_audit_confirmed_at'] = $confirmed ? now()->toIso8601String() : null;
$options['lot_audit_confirmed_by'] = $confirmed ? $userId : null;
$location->options = $options;
$location->save();
});
$location->refresh();
return [
'id' => (string) $location->id,
'name' => $this->buildLocationName($location),
'location' => $this->buildLocationCode($location),
'is_completed' => (bool) data_get($location->options, 'lot_audit_confirmed', false),
];
}
// ===== Private: Transform Methods =====
private function transformReportToFrontend(QualityDocument $doc): array
{
$performanceReport = $doc->performanceReport;
$confirmedCount = $doc->locations->filter(function ($loc) {
return data_get($loc->options, 'lot_audit_confirmed', false);
})->count();
return [
'id' => (string) $doc->id,
'code' => $doc->quality_doc_number,
'site_name' => $doc->site_name,
'item' => $this->getFgProductName($doc),
'route_count' => $confirmedCount,
'total_routes' => $doc->locations->count(),
'quarter' => $performanceReport
? $performanceReport->year.'년 '.$performanceReport->quarter.'분기'
: '',
'year' => $performanceReport?->year ?? now()->year,
'quarter_num' => $performanceReport?->quarter ?? 0,
];
}
/**
* BOM 최상위(FG) 제품명 추출
* Order → root OrderNode(parent_id=null) → 대표 OrderItem → Item(FG).name
*/
private function getFgProductName(QualityDocument $doc): string
{
$firstDocOrder = $doc->documentOrders->first();
if (! $firstDocOrder) {
return '';
}
$order = $firstDocOrder->order;
if (! $order) {
return '';
}
// eager loaded with whereNull('parent_id') filter
$rootNode = $order->nodes->first();
if (! $rootNode) {
return '';
}
$representativeItem = $rootNode->items->first();
return $representativeItem?->item?->name ?? '';
}
private function transformRouteToFrontend(QualityDocumentOrder $docOrder, QualityDocument $qualityDoc): array
{
return [
'id' => (string) $docOrder->id,
'code' => $docOrder->order->order_no,
'date' => $docOrder->order->received_at?->toDateString(),
'site' => $docOrder->order->site_name ?? '',
'location_count' => $docOrder->locations->count(),
'sub_items' => $docOrder->locations->values()->map(fn ($loc, $idx) => [
'id' => (string) $loc->id,
'name' => $qualityDoc->quality_doc_number.'-'.str_pad($idx + 1, 2, '0', STR_PAD_LEFT),
'location' => $this->buildLocationCode($loc),
'is_completed' => (bool) data_get($loc->options, 'lot_audit_confirmed', false),
])->all(),
];
}
private function buildLocationName(QualityDocumentLocation $location): string
{
$qualityDoc = $location->qualityDocument;
if (! $qualityDoc) {
return '';
}
// location의 순번을 구하기 위해 같은 문서의 location 목록 조회
$locations = QualityDocumentLocation::where('quality_document_id', $qualityDoc->id)
->orderBy('id')
->pluck('id');
$index = $locations->search($location->id);
return $qualityDoc->quality_doc_number.'-'.str_pad(($index !== false ? $index + 1 : 1), 2, '0', STR_PAD_LEFT);
}
private function buildLocationCode(QualityDocumentLocation $location): string
{
$orderItem = $location->orderItem;
if (! $orderItem) {
return '';
}
return trim(($orderItem->floor_code ?? '').' '.($orderItem->symbol_code ?? ''));
}
// ===== Private: Document Format Helpers =====
private function formatDocument(string $type, string $title, $collection): array
{
return [
'id' => $type,
'type' => $type,
'title' => $title,
'count' => $collection->count(),
'items' => $collection->values()->map(fn ($item) => $this->formatDocumentItem($type, $item))->all(),
];
}
private function formatDocumentWithSubType(string $type, string $title, $collection, ?string $workOrderRelation = null): array
{
return [
'id' => $type,
'type' => $type,
'title' => $title,
'count' => $collection->count(),
'items' => $collection->values()->map(function ($item) use ($type, $workOrderRelation) {
$formatted = $this->formatDocumentItem($type, $item);
// subType: process.process_name 기반
$workOrder = $workOrderRelation ? $item->{$workOrderRelation} : $item;
if ($workOrder instanceof WorkOrder) {
$processName = $workOrder->process?->process_name;
$formatted['sub_type'] = $this->mapProcessToSubType($processName);
}
return $formatted;
})->all(),
];
}
private function formatDocumentItem(string $type, $item): array
{
return match ($type) {
'import', 'report' => [
'id' => (string) $item->id,
'title' => $item->inspection_no ?? '',
'date' => $item->inspection_date?->toDateString() ?? $item->request_date?->toDateString() ?? '',
'code' => $item->inspection_no ?? '',
],
'order' => [
'id' => (string) $item->id,
'title' => $item->order_no,
'date' => $item->received_at?->toDateString() ?? '',
'code' => $item->order_no,
],
'log' => [
'id' => (string) $item->id,
'title' => $item->project_name ?? '작업일지',
'date' => $item->created_at?->toDateString() ?? '',
'code' => $item->id,
],
'confirmation', 'shipping' => [
'id' => (string) $item->id,
'title' => $item->shipment_no ?? '',
'date' => $item->scheduled_date?->toDateString() ?? '',
'code' => $item->shipment_no ?? '',
],
'product' => [
'id' => (string) $item->id,
'title' => '제품검사 성적서',
'date' => $item->updated_at?->toDateString() ?? '',
'code' => '',
],
'quality' => [
'id' => (string) $item->id,
'title' => $item->quality_doc_number ?? '',
'date' => $item->received_date?->toDateString() ?? '',
'code' => $item->quality_doc_number ?? '',
],
default => [
'id' => (string) $item->id,
'title' => '',
'date' => '',
],
};
}
/**
* process_name → subType 매핑
*/
private function mapProcessToSubType(?string $processName): ?string
{
if (! $processName) {
return null;
}
$name = mb_strtolower($processName);
return match (true) {
str_contains($name, 'screen') || str_contains($name, '스크린') => 'screen',
str_contains($name, 'bending') || str_contains($name, '절곡') => 'bending',
str_contains($name, 'slat') || str_contains($name, '슬랫') => 'slat',
str_contains($name, 'jointbar') || str_contains($name, '조인트바') || str_contains($name, 'joint') => 'jointbar',
default => null,
};
}
// ===== Private: Document Detail Methods (2단계 로딩) =====
private function getInspectionDetail(int $id, string $type): array
{
$inspection = Inspection::where('inspection_type', $type)
->with(['item', 'workOrder.process'])
->findOrFail($id);
return [
'type' => $type === 'IQC' ? 'import' : 'report',
'data' => [
'id' => $inspection->id,
'inspection_no' => $inspection->inspection_no,
'inspection_type' => $inspection->inspection_type,
'status' => $inspection->status,
'result' => $inspection->result,
'request_date' => $inspection->request_date?->toDateString(),
'inspection_date' => $inspection->inspection_date?->toDateString(),
'lot_no' => $inspection->lot_no,
'item_name' => $inspection->item?->name,
'process_name' => $inspection->workOrder?->process?->process_name,
'meta' => $inspection->meta,
'items' => $inspection->items,
'attachments' => $inspection->attachments,
'extra' => $inspection->extra,
],
];
}
private function getOrderDetail(int $id): array
{
$order = Order::with(['nodes' => fn ($q) => $q->whereNull('parent_id')])->findOrFail($id);
return [
'type' => 'order',
'data' => [
'id' => $order->id,
'order_no' => $order->order_no,
'status' => $order->status,
'received_at' => $order->received_at?->toDateString(),
'site_name' => $order->site_name,
'nodes_count' => $order->nodes->count(),
],
];
}
private function getWorkOrderLogDetail(int $id): array
{
$workOrder = WorkOrder::with('process')->findOrFail($id);
return [
'type' => 'log',
'data' => [
'id' => $workOrder->id,
'project_name' => $workOrder->project_name,
'status' => $workOrder->status,
'process_name' => $workOrder->process?->process_name,
'options' => $workOrder->options,
'created_at' => $workOrder->created_at?->toDateString(),
],
];
}
private function getShipmentDetail(int $id): array
{
$shipment = Shipment::findOrFail($id);
return [
'type' => 'shipping',
'data' => [
'id' => $shipment->id,
'shipment_no' => $shipment->shipment_no,
'status' => $shipment->status,
'scheduled_date' => $shipment->scheduled_date?->toDateString(),
'customer_name' => $shipment->customer_name,
'site_name' => $shipment->site_name,
'delivery_address' => $shipment->delivery_address,
'delivery_method' => $shipment->delivery_method,
'vehicle_no' => $shipment->vehicle_no,
'driver_name' => $shipment->driver_name,
'remarks' => $shipment->remarks,
],
];
}
private function getLocationDetail(int $id): array
{
$location = QualityDocumentLocation::with(['orderItem', 'document'])->findOrFail($id);
return [
'type' => 'product',
'data' => [
'id' => $location->id,
'inspection_status' => $location->inspection_status,
'inspection_data' => $location->inspection_data,
'post_width' => $location->post_width,
'post_height' => $location->post_height,
'floor_code' => $location->orderItem?->floor_code,
'symbol_code' => $location->orderItem?->symbol_code,
'document_id' => $location->document_id,
],
];
}
private function getQualityDocDetail(int $id): array
{
$doc = QualityDocument::with(['client', 'inspector:id,name'])->findOrFail($id);
return [
'type' => 'quality',
'data' => [
'id' => $doc->id,
'quality_doc_number' => $doc->quality_doc_number,
'site_name' => $doc->site_name,
'status' => $doc->status,
'received_date' => $doc->received_date?->toDateString(),
'client_name' => $doc->client?->name,
'inspector_name' => $doc->inspector?->name,
'options' => $doc->options,
],
];
}
}

View File

@@ -0,0 +1,22 @@
<?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
{
Schema::table('quality_document_locations', function (Blueprint $table) {
$table->json('options')->nullable()->after('inspection_status')->comment('QMS 심사 확인 등 추가 데이터');
});
}
public function down(): void
{
Schema::table('quality_document_locations', function (Blueprint $table) {
$table->dropColumn('options');
});
}
};

View File

@@ -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');
}
};

View File

@@ -0,0 +1,46 @@
<?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
{
/**
* Run the migrations.
*/
public function up(): void
{
$tables = ['document_data', 'document_approvals', 'document_attachments'];
foreach ($tables as $table) {
Schema::table($table, function (Blueprint $table) {
$table->unsignedBigInteger('tenant_id')->nullable()->after('id')->comment('테넌트 ID');
$table->index('tenant_id');
});
// 부모 테이블(documents)에서 tenant_id 채우기
DB::statement("
UPDATE {$table} t
JOIN documents d ON t.document_id = d.id
SET t.tenant_id = d.tenant_id
");
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$tables = ['document_data', 'document_approvals', 'document_attachments'];
foreach ($tables as $table) {
Schema::table($table, function (Blueprint $table) {
$table->dropIndex(['tenant_id']);
$table->dropColumn('tenant_id');
});
}
}
};

View File

@@ -0,0 +1,608 @@
<?php
namespace Database\Seeders;
use Carbon\Carbon;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
class QualityDummyDataSeeder extends Seeder
{
private const TENANT_ID = 287;
private const USER_ID = 33;
private const CLIENT_IDS = [9, 10, 11, 12, 13];
private const ORDER_IDS = [11, 17, 18, 28, 29, 41, 42, 43, 57, 59];
public function run(): void
{
$tenantId = self::TENANT_ID;
$userId = self::USER_ID;
$now = Carbon::now();
// 멱등성: 이미 데이터가 있으면 스킵
$existing = DB::table('quality_documents')
->where('tenant_id', $tenantId)
->where('quality_doc_number', 'like', 'KD-QD-202604-%')
->count();
if ($existing > 0) {
$this->command->info(' ⚠ quality_documents: 이미 '.$existing.'개 존재 (스킵)');
return;
}
DB::transaction(function () use ($tenantId, $userId, $now) {
// ============================================================
// Page 1: 제품검사/품질관리서 (quality_documents + orders + locations)
// ============================================================
$this->command->info('📋 Page 1: 제품검사/품질관리서 더미 데이터 생성...');
$qualityDocs = [
[
'quality_doc_number' => 'KD-QD-202604-0001',
'site_name' => '강남 르네상스 오피스텔 신축공사',
'status' => 'completed',
'client_id' => self::CLIENT_IDS[0],
'inspector_id' => $userId,
'received_date' => '2026-02-15',
'options' => json_encode([
'manager' => ['name' => '김현수', 'phone' => '010-1111-2222'],
'contractor' => ['name' => '이건설', 'phone' => '02-3333-4444', 'address' => '서울시 강남구 역삼동', 'company' => '대한건설(주)'],
'inspection' => ['end_date' => '2026-03-05', 'start_date' => '2026-03-03', 'request_date' => '2026-02-28'],
'supervisor' => ['name' => '박감리', 'phone' => '02-5555-6666', 'office' => '한국감리사무소', 'address' => '서울시 서초구'],
'site_address' => ['detail' => '강남 르네상스 오피스텔 B1~15F', 'address' => '서울시 강남구 역삼동 123-45', 'postal_code' => '06241'],
'construction_site' => ['name' => '강남 르네상스 오피스텔 신축공사', 'lot_number' => '123-45', 'land_location' => '서울시 강남구 역삼동'],
'material_distributor' => ['ceo' => '최대표', 'phone' => '02-7777-8888', 'address' => '서울시 송파구', 'company' => '경동자재(주)'],
]),
],
[
'quality_doc_number' => 'KD-QD-202604-0002',
'site_name' => '판교 테크노밸리 물류센터',
'status' => 'completed',
'client_id' => self::CLIENT_IDS[1],
'inspector_id' => $userId,
'received_date' => '2026-02-20',
'options' => json_encode([
'manager' => ['name' => '정우성', 'phone' => '010-2222-3333'],
'contractor' => ['name' => '박시공', 'phone' => '031-4444-5555', 'address' => '경기도 성남시 분당구', 'company' => '판교건설(주)'],
'inspection' => ['end_date' => '2026-03-08', 'start_date' => '2026-03-06', 'request_date' => '2026-03-01'],
'supervisor' => ['name' => '이감리', 'phone' => '031-6666-7777', 'office' => '성남감리사무소', 'address' => '경기도 성남시 분당구'],
'site_address' => ['detail' => '판교 테크노밸리 3단지 물류동', 'address' => '경기도 성남시 분당구 판교동 678-9', 'postal_code' => '13487'],
'construction_site' => ['name' => '판교 테크노밸리 물류센터 신축', 'lot_number' => '678-9', 'land_location' => '경기도 성남시 분당구 판교동'],
'material_distributor' => ['ceo' => '김대표', 'phone' => '031-8888-9999', 'address' => '경기도 용인시', 'company' => '한국자재유통(주)'],
]),
],
[
'quality_doc_number' => 'KD-QD-202604-0003',
'site_name' => '잠실 롯데월드타워 리모델링',
'status' => 'completed',
'client_id' => self::CLIENT_IDS[2],
'inspector_id' => $userId,
'received_date' => '2026-02-25',
'options' => json_encode([
'manager' => ['name' => '송민호', 'phone' => '010-3333-4444'],
'contractor' => ['name' => '최시공', 'phone' => '02-5555-6666', 'address' => '서울시 송파구 잠실동', 'company' => '잠실건설(주)'],
'inspection' => ['end_date' => '2026-03-12', 'start_date' => '2026-03-10', 'request_date' => '2026-03-05'],
'supervisor' => ['name' => '강감리', 'phone' => '02-7777-8888', 'office' => '송파감리사무소', 'address' => '서울시 송파구'],
'site_address' => ['detail' => '잠실 롯데월드타워 15~20F', 'address' => '서울시 송파구 잠실동 29', 'postal_code' => '05551'],
'construction_site' => ['name' => '잠실 롯데월드타워 리모델링 공사', 'lot_number' => '29', 'land_location' => '서울시 송파구 잠실동'],
'material_distributor' => ['ceo' => '한대표', 'phone' => '02-9999-0000', 'address' => '서울시 강동구', 'company' => '동부자재(주)'],
]),
],
[
'quality_doc_number' => 'KD-QD-202604-0004',
'site_name' => '마곡 LG사이언스파크 증축',
'status' => 'completed',
'client_id' => self::CLIENT_IDS[3],
'inspector_id' => $userId,
'received_date' => '2026-03-01',
'options' => json_encode([
'manager' => ['name' => '윤서연', 'phone' => '010-4444-5555'],
'contractor' => ['name' => '임시공', 'phone' => '02-1234-5678', 'address' => '서울시 강서구 마곡동', 'company' => '마곡종합건설(주)'],
'inspection' => ['end_date' => '2026-03-15', 'start_date' => '2026-03-13', 'request_date' => '2026-03-08'],
'supervisor' => ['name' => '오감리', 'phone' => '02-2345-6789', 'office' => '강서감리사무소', 'address' => '서울시 강서구'],
'site_address' => ['detail' => 'LG사이언스파크 E동 증축', 'address' => '서울시 강서구 마곡동 757', 'postal_code' => '07796'],
'construction_site' => ['name' => '마곡 LG사이언스파크 증축공사', 'lot_number' => '757', 'land_location' => '서울시 강서구 마곡동'],
'material_distributor' => ['ceo' => '장대표', 'phone' => '02-3456-7890', 'address' => '서울시 영등포구', 'company' => '서부자재(주)'],
]),
],
[
'quality_doc_number' => 'KD-QD-202604-0005',
'site_name' => '인천 송도 스마트시티 아파트',
'status' => 'in_progress',
'client_id' => self::CLIENT_IDS[3],
'inspector_id' => $userId,
'received_date' => '2026-03-05',
'options' => json_encode([
'manager' => ['name' => '안재현', 'phone' => '010-5555-6666'],
'contractor' => ['name' => '배시공', 'phone' => '032-1111-2222', 'address' => '인천시 연수구 송도동', 'company' => '송도종합건설(주)'],
'inspection' => ['end_date' => null, 'start_date' => '2026-03-18', 'request_date' => '2026-03-10'],
'supervisor' => ['name' => '황감리', 'phone' => '032-3333-4444', 'office' => '인천감리사무소', 'address' => '인천시 연수구'],
'site_address' => ['detail' => '송도 스마트시티 A블록 101~105동', 'address' => '인천시 연수구 송도동 100-1', 'postal_code' => '21990'],
'construction_site' => ['name' => '인천 송도 스마트시티 아파트 신축', 'lot_number' => '100-1', 'land_location' => '인천시 연수구 송도동'],
'material_distributor' => ['ceo' => '서대표', 'phone' => '032-5555-6666', 'address' => '인천시 남동구', 'company' => '인천자재(주)'],
]),
],
[
'quality_doc_number' => 'KD-QD-202604-0006',
'site_name' => '화성 동탄2 행복주택 단지',
'status' => 'in_progress',
'client_id' => self::CLIENT_IDS[4],
'inspector_id' => $userId,
'received_date' => '2026-03-06',
'options' => json_encode([
'manager' => ['name' => '류준열', 'phone' => '010-6666-7777'],
'contractor' => ['name' => '조시공', 'phone' => '031-2222-3333', 'address' => '경기도 화성시 동탄', 'company' => '동탄건설(주)'],
'inspection' => ['end_date' => null, 'start_date' => null, 'request_date' => '2026-03-12'],
'supervisor' => ['name' => '문감리', 'phone' => '031-4444-5555', 'office' => '화성감리사무소', 'address' => '경기도 화성시'],
'site_address' => ['detail' => '동탄2 행복주택 A1~A5동', 'address' => '경기도 화성시 동탄면 200-3', 'postal_code' => '18450'],
'construction_site' => ['name' => '화성 동탄2 행복주택 단지 신축공사', 'lot_number' => '200-3', 'land_location' => '경기도 화성시 동탄면'],
'material_distributor' => ['ceo' => '남대표', 'phone' => '031-6666-7777', 'address' => '경기도 오산시', 'company' => '경기자재(주)'],
]),
],
[
'quality_doc_number' => 'KD-QD-202604-0007',
'site_name' => '세종시 정부청사 별관',
'status' => 'in_progress',
'client_id' => self::CLIENT_IDS[0],
'inspector_id' => null,
'received_date' => '2026-03-07',
'options' => json_encode([
'manager' => ['name' => '김세종', 'phone' => '010-7777-8888'],
'contractor' => ['name' => '정시공', 'phone' => '044-1111-2222', 'address' => '세종시 어진동', 'company' => '세종건설(주)'],
'inspection' => ['end_date' => null, 'start_date' => null, 'request_date' => null],
'supervisor' => ['name' => '고감리', 'phone' => '044-3333-4444', 'office' => '세종감리사무소', 'address' => '세종시 나성동'],
'site_address' => ['detail' => '정부세종청사 별관동', 'address' => '세종시 어진동 850', 'postal_code' => '30113'],
'construction_site' => ['name' => '세종시 정부청사 별관 신축공사', 'lot_number' => '850', 'land_location' => '세종시 어진동'],
'material_distributor' => ['ceo' => '윤대표', 'phone' => '044-5555-6666', 'address' => '대전시 유성구', 'company' => '중부자재(주)'],
]),
],
[
'quality_doc_number' => 'KD-QD-202604-0008',
'site_name' => '부산 해운대 엘시티 주상복합',
'status' => 'in_progress',
'client_id' => self::CLIENT_IDS[1],
'inspector_id' => null,
'received_date' => '2026-03-08',
'options' => json_encode([
'manager' => ['name' => '이부산', 'phone' => '010-8888-9999'],
'contractor' => ['name' => '노시공', 'phone' => '051-1111-2222', 'address' => '부산시 해운대구', 'company' => '해운대건설(주)'],
'inspection' => ['end_date' => null, 'start_date' => null, 'request_date' => null],
'supervisor' => ['name' => '차감리', 'phone' => '051-3333-4444', 'office' => '부산감리사무소', 'address' => '부산시 해운대구'],
'site_address' => ['detail' => '해운대 엘시티 B동 전층', 'address' => '부산시 해운대구 우동 1478', 'postal_code' => '48060'],
'construction_site' => ['name' => '부산 해운대 엘시티 주상복합 리모델링', 'lot_number' => '1478', 'land_location' => '부산시 해운대구 우동'],
'material_distributor' => ['ceo' => '백대표', 'phone' => '051-5555-6666', 'address' => '부산시 사하구', 'company' => '남부자재(주)'],
]),
],
[
'quality_doc_number' => 'KD-QD-202604-0009',
'site_name' => '수원 광교 복합문화센터',
'status' => 'draft',
'client_id' => self::CLIENT_IDS[4],
'inspector_id' => null,
'received_date' => '2026-03-09',
'options' => json_encode([
'manager' => ['name' => '한지민', 'phone' => '010-5555-6666'],
'contractor' => ['name' => '', 'phone' => '', 'address' => '', 'company' => ''],
'inspection' => ['end_date' => null, 'start_date' => null, 'request_date' => null],
'supervisor' => ['name' => '', 'phone' => '', 'office' => '', 'address' => ''],
'site_address' => ['detail' => '광교 복합문화센터 전관', 'address' => '경기도 수원시 영통구 광교동 200', 'postal_code' => '16508'],
'construction_site' => ['name' => '수원 광교 복합문화센터 신축공사', 'lot_number' => '200', 'land_location' => '경기도 수원시 영통구 광교동'],
'material_distributor' => ['ceo' => '', 'phone' => '', 'address' => '', 'company' => ''],
]),
],
[
'quality_doc_number' => 'KD-QD-202604-0010',
'site_name' => '대구 수성 의료복합단지',
'status' => 'draft',
'client_id' => self::CLIENT_IDS[2],
'inspector_id' => null,
'received_date' => '2026-03-10',
'options' => json_encode([
'manager' => ['name' => '박대구', 'phone' => '010-9999-0000'],
'contractor' => ['name' => '', 'phone' => '', 'address' => '', 'company' => ''],
'inspection' => ['end_date' => null, 'start_date' => null, 'request_date' => null],
'supervisor' => ['name' => '', 'phone' => '', 'office' => '', 'address' => ''],
'site_address' => ['detail' => '수성 의료복합단지 본관', 'address' => '대구시 수성구 범어동 350', 'postal_code' => '42020'],
'construction_site' => ['name' => '대구 수성 의료복합단지 신축공사', 'lot_number' => '350', 'land_location' => '대구시 수성구 범어동'],
'material_distributor' => ['ceo' => '', 'phone' => '', 'address' => '', 'company' => ''],
]),
],
];
$qualityDocIds = [];
foreach ($qualityDocs as $doc) {
$qualityDocIds[] = DB::table('quality_documents')->insertGetId(array_merge($doc, [
'tenant_id' => $tenantId,
'created_by' => $userId,
'updated_by' => $userId,
'created_at' => $now,
'updated_at' => $now,
]));
}
$this->command->info(' ✅ quality_documents: '.count($qualityDocIds).'개 생성');
// completed/in_progress 문서에 수주 연결 (draft 제외)
$docOrderMapping = [
$qualityDocIds[0] => [self::ORDER_IDS[0], self::ORDER_IDS[1]], // completed
$qualityDocIds[1] => [self::ORDER_IDS[2], self::ORDER_IDS[3], self::ORDER_IDS[4]], // completed
$qualityDocIds[2] => [self::ORDER_IDS[5], self::ORDER_IDS[6]], // completed
$qualityDocIds[3] => [self::ORDER_IDS[7], self::ORDER_IDS[8]], // completed
$qualityDocIds[4] => [self::ORDER_IDS[9], self::ORDER_IDS[0]], // in_progress
$qualityDocIds[5] => [self::ORDER_IDS[1], self::ORDER_IDS[2]], // in_progress
$qualityDocIds[6] => [self::ORDER_IDS[3]], // in_progress
$qualityDocIds[7] => [self::ORDER_IDS[4], self::ORDER_IDS[5]], // in_progress
];
$qdoMap = [];
$qdoCount = 0;
foreach ($docOrderMapping as $docId => $orderIds) {
foreach ($orderIds as $orderId) {
$qdoId = DB::table('quality_document_orders')->insertGetId([
'quality_document_id' => $docId,
'order_id' => $orderId,
'created_at' => $now,
'updated_at' => $now,
]);
$qdoMap[$docId][$orderId] = $qdoId;
$qdoCount++;
}
}
$this->command->info(' ✅ quality_document_orders: '.$qdoCount.'개 생성');
// 각 수주별 order_items 조회 후 locations 생성
$inspectionDataSets = [
json_encode(['motor' => 'pass', 'material' => 'pass', 'impactTest' => 'pass', 'productName' => '방화셔터', 'guideRailGap' => 'pass', 'openCloseTest' => 'pass']),
json_encode(['motor' => 'pass', 'material' => 'pass', 'impactTest' => 'fail', 'productName' => '방화셔터', 'guideRailGap' => 'pass', 'openCloseTest' => 'pass']),
json_encode(['motor' => 'pass', 'material' => 'pass', 'impactTest' => 'pass', 'productName' => '방화문', 'guideRailGap' => 'N/A', 'openCloseTest' => 'pass']),
json_encode(['motor' => 'N/A', 'material' => 'pass', 'impactTest' => 'pass', 'productName' => '스크린셔터', 'guideRailGap' => 'pass', 'openCloseTest' => 'pass']),
json_encode(['motor' => 'pass', 'material' => 'fail', 'impactTest' => 'pass', 'productName' => '방화셔터', 'guideRailGap' => 'pass', 'openCloseTest' => 'fail']),
json_encode(['motor' => 'pass', 'material' => 'pass', 'impactTest' => 'pass', 'productName' => '절곡셔터', 'guideRailGap' => 'pass', 'openCloseTest' => 'pass']),
];
$completedDocIds = array_slice($qualityDocIds, 0, 4); // 처음 4개가 completed
$locationCount = 0;
foreach ($qdoMap as $docId => $orderMap) {
foreach ($orderMap as $orderId => $qdoId) {
$orderItemIds = DB::table('order_items')
->where('order_id', $orderId)
->pluck('id')
->take(4)
->toArray();
if (empty($orderItemIds)) {
// order_items가 없으면 order_nodes → order_items 경로 시도
$nodeIds = DB::table('order_nodes')
->where('order_id', $orderId)
->pluck('id');
$orderItemIds = DB::table('order_items')
->whereIn('order_node_id', $nodeIds)
->pluck('id')
->take(4)
->toArray();
}
if (empty($orderItemIds)) {
$this->command->warn(' ⚠ order_id='.$orderId.'에 order_items 없음 (스킵)');
continue;
}
foreach ($orderItemIds as $idx => $orderItemId) {
$isCompleted = in_array($docId, $completedDocIds);
$inspectionStatus = $isCompleted
? ($idx === 1 ? 'fail' : 'pass')
: 'pending';
$inspectionData = $isCompleted
? $inspectionDataSets[$idx % count($inspectionDataSets)]
: null;
$postWidth = ($isCompleted || $idx < 2) ? rand(1800, 3200) : null;
$postHeight = ($isCompleted || $idx < 2) ? rand(2100, 3800) : null;
$changeReason = ($isCompleted && $idx === 0) ? '현장 사정으로 규격 변경' : null;
$locOptions = null;
if ($isCompleted) {
$locOptions = json_encode([
'lot_audit_confirmed' => $idx !== 1,
'lot_audit_confirmed_at' => $idx !== 1 ? $now->toDateTimeString() : null,
'lot_audit_confirmed_by' => $idx !== 1 ? $userId : null,
]);
}
DB::table('quality_document_locations')->insert([
'quality_document_id' => $docId,
'quality_document_order_id' => $qdoId,
'order_item_id' => $orderItemId,
'post_width' => $postWidth,
'post_height' => $postHeight,
'change_reason' => $changeReason,
'inspection_data' => $inspectionData,
'document_id' => null,
'inspection_status' => $inspectionStatus,
'options' => $locOptions,
'created_at' => $now,
'updated_at' => $now,
]);
$locationCount++;
}
}
}
$this->command->info(' ✅ quality_document_locations: '.$locationCount.'개 생성');
// ============================================================
// Page 2: 실적신고 (performance_reports)
// ============================================================
$this->command->info('📊 Page 2: 실적신고 더미 데이터 생성...');
$existingReports = DB::table('performance_reports')
->where('tenant_id', $tenantId)
->whereIn('quality_document_id', $qualityDocIds)
->count();
if ($existingReports > 0) {
$this->command->info(' ⚠ performance_reports: 이미 '.$existingReports.'개 존재 (스킵)');
} else {
$performanceReports = [
[
'quality_document_id' => $qualityDocIds[0],
'year' => 2026, 'quarter' => 1,
'confirmation_status' => 'confirmed',
'confirmed_date' => '2026-03-06',
'confirmed_by' => $userId,
'memo' => '1분기 검사 완료 - 강남 르네상스 건',
],
[
'quality_document_id' => $qualityDocIds[1],
'year' => 2026, 'quarter' => 1,
'confirmation_status' => 'confirmed',
'confirmed_date' => '2026-03-09',
'confirmed_by' => $userId,
'memo' => '판교 물류센터 건 - 확인 완료',
],
[
'quality_document_id' => $qualityDocIds[2],
'year' => 2026, 'quarter' => 1,
'confirmation_status' => 'unconfirmed',
'confirmed_date' => null,
'confirmed_by' => null,
'memo' => '잠실 리모델링 건 - 확인 대기중',
],
[
'quality_document_id' => $qualityDocIds[3],
'year' => 2026, 'quarter' => 1,
'confirmation_status' => 'confirmed',
'confirmed_date' => '2026-03-16',
'confirmed_by' => $userId,
'memo' => '마곡 LG사이언스파크 건 - 확인 완료',
],
[
'quality_document_id' => $qualityDocIds[4],
'year' => 2026, 'quarter' => 1,
'confirmation_status' => 'pending',
'confirmed_date' => null,
'confirmed_by' => null,
'memo' => '송도 아파트 건 - 검사 진행중',
],
[
'quality_document_id' => $qualityDocIds[5],
'year' => 2026, 'quarter' => 1,
'confirmation_status' => 'pending',
'confirmed_date' => null,
'confirmed_by' => null,
'memo' => '동탄 행복주택 건 - 검사 진행중',
],
];
foreach ($performanceReports as $report) {
DB::table('performance_reports')->insert(array_merge($report, [
'tenant_id' => $tenantId,
'created_by' => $userId,
'updated_by' => $userId,
'created_at' => $now,
'updated_at' => $now,
]));
}
$this->command->info(' ✅ performance_reports: '.count($performanceReports).'개 생성');
}
// ============================================================
// Page 3: 품질인정심사 (audit_checklists + categories + items + standard_documents)
// ============================================================
$this->command->info('🏅 Page 3: 품질인정심사 더미 데이터 생성...');
$existingChecklist = DB::table('audit_checklists')
->where('tenant_id', $tenantId)
->where('year', 2026)
->where('type', 'standard_manual')
->first();
if ($existingChecklist) {
$this->command->info(' ⚠ audit_checklists: 이미 존재 (스킵)');
} else {
// Q1 점검표 (in_progress)
$this->seedChecklist($tenantId, $userId, $now, 2026, 1, 'in_progress', '정기심사');
// Q2 점검표 (draft)
$this->seedChecklist($tenantId, $userId, $now, 2026, 2, 'draft', '중간심사');
}
$this->command->info('');
$this->command->info('🎉 품질 더미 데이터 생성 완료!');
});
}
private function seedChecklist(int $tenantId, int $userId, Carbon $now, int $year, int $quarter, string $status, string $auditType): void
{
$checklistId = DB::table('audit_checklists')->insertGetId([
'tenant_id' => $tenantId,
'year' => $year,
'quarter' => $quarter,
'type' => 'standard_manual',
'status' => $status,
'options' => json_encode([
'audit_date' => $year.'-'.str_pad($quarter * 3, 2, '0', STR_PAD_LEFT).'-15',
'auditor' => '한국품질인증원',
'audit_type' => $auditType,
]),
'created_by' => $userId,
'updated_by' => $userId,
'created_at' => $now,
'updated_at' => $now,
]);
$this->command->info(' ✅ audit_checklists: '.$year.' Q'.$quarter.' ('.$status.') 생성');
$isActive = $status !== 'draft';
$categoriesData = [
[
'title' => '품질경영 시스템',
'sort_order' => 1,
'items' => [
['name' => '품질방침 수립 및 공표', 'description' => '최고경영자의 품질방침 수립, 문서화 및 전직원 공표 여부', 'is_completed' => $isActive,
'docs' => [['title' => '품질매뉴얼 Rev.5', 'version' => '5.0', 'date' => '2025-12-01'], ['title' => '품질방침 선언서', 'version' => '3.0', 'date' => '2025-06-15']]],
['name' => '품질목표 설정 및 관리', 'description' => '연간 품질목표 설정, 실행계획 수립 및 주기적 모니터링', 'is_completed' => $isActive,
'docs' => [['title' => $year.'년 품질목표 관리대장', 'version' => '1.0', 'date' => $year.'-01-05'], ['title' => '품질목표 관리절차서', 'version' => '2.1', 'date' => '2025-09-10']]],
['name' => '내부심사 계획 및 실시', 'description' => '연간 내부심사 계획 수립 및 실시 기록', 'is_completed' => false,
'docs' => [['title' => '내부심사 절차서', 'version' => '4.0', 'date' => '2025-03-20'], ['title' => $year.'년 내부심사 계획서', 'version' => '1.0', 'date' => $year.'-01-15'], ['title' => '내부심사 보고서 양식', 'version' => '2.0', 'date' => '2025-07-01']]],
['name' => '경영검토 실시', 'description' => '최고경영자 주관 경영검토 실시 및 기록 유지', 'is_completed' => false,
'docs' => [['title' => '경영검토 절차서', 'version' => '3.2', 'date' => '2025-08-01'], ['title' => '2025년 하반기 경영검토 회의록', 'version' => '1.0', 'date' => '2025-12-20']]],
['name' => '문서 및 기록 관리 체계', 'description' => '품질문서 체계(매뉴얼, 절차서, 지침서) 수립 및 관리', 'is_completed' => $isActive,
'docs' => [['title' => '문서관리 절차서', 'version' => '4.5', 'date' => '2025-10-01'], ['title' => '기록관리 절차서', 'version' => '4.0', 'date' => '2025-03-10']]],
],
],
[
'title' => '설계 및 개발',
'sort_order' => 2,
'items' => [
['name' => '설계 입력 관리', 'description' => '설계 입력 요구사항 식별, 문서화 및 검토', 'is_completed' => $isActive,
'docs' => [['title' => '설계관리 절차서', 'version' => '4.1', 'date' => '2025-10-15'], ['title' => '설계입력 검토서 양식', 'version' => '2.0', 'date' => '2025-05-20']]],
['name' => '설계 출력 관리', 'description' => '설계 출력물 문서화 및 입력 요구사항 충족 확인', 'is_completed' => $isActive,
'docs' => [['title' => '설계출력 검증서 양식', 'version' => '2.0', 'date' => '2025-06-15'], ['title' => '도면 관리기준서', 'version' => '3.0', 'date' => '2025-08-20']]],
['name' => '설계 검증 및 유효성 확인', 'description' => '설계 출력물에 대한 검증/유효성 확인 절차 운영', 'is_completed' => false,
'docs' => [['title' => '설계검증 절차서', 'version' => '3.0', 'date' => '2025-04-10'], ['title' => '설계유효성확인 체크리스트', 'version' => '1.5', 'date' => '2025-11-01'], ['title' => '시제품 시험성적서 양식', 'version' => '2.0', 'date' => '2025-06-30']]],
['name' => '설계 변경 관리', 'description' => '설계 변경 요청, 승인 및 이력 관리', 'is_completed' => false,
'docs' => [['title' => '설계변경 관리절차서', 'version' => '2.3', 'date' => '2025-07-15'], ['title' => '설계변경 요청서(ECR) 양식', 'version' => '1.0', 'date' => '2025-01-10']]],
['name' => 'FMEA 및 위험 분석', 'description' => '설계 고장모드 영향분석(FMEA) 실시 및 관리', 'is_completed' => false,
'docs' => [['title' => 'FMEA 절차서', 'version' => '2.0', 'date' => '2025-09-01'], ['title' => 'DFMEA 양식', 'version' => '1.5', 'date' => '2025-11-20']]],
],
],
[
'title' => '구매 관리',
'sort_order' => 3,
'items' => [
['name' => '협력업체 평가 및 선정', 'description' => '협력업체 초기평가, 정기평가 기준 및 실시 기록', 'is_completed' => $isActive,
'docs' => [['title' => '협력업체 관리절차서', 'version' => '5.0', 'date' => '2025-09-01'], ['title' => '2025년 협력업체 평가결과', 'version' => '1.0', 'date' => '2025-12-15']]],
['name' => '수입검사 절차', 'description' => '구매 자재 수입검사 기준 및 합/불합격 처리 절차', 'is_completed' => $isActive,
'docs' => [['title' => '수입검사 절차서', 'version' => '3.1', 'date' => '2025-08-20'], ['title' => '수입검사 기준서', 'version' => '4.0', 'date' => '2025-11-10'], ['title' => '자재별 검사항목 목록', 'version' => '2.0', 'date' => '2025-10-01']]],
['name' => '부적합 자재 처리', 'description' => '수입검사 불합격 자재의 격리, 반품, 특채 처리', 'is_completed' => false,
'docs' => [['title' => '부적합품 관리절차서', 'version' => '3.0', 'date' => '2025-05-15'], ['title' => '특채 요청서 양식', 'version' => '1.2', 'date' => '2025-09-20']]],
['name' => '구매문서 관리', 'description' => '구매 사양서, 발주서 등 구매문서 관리 체계', 'is_completed' => false,
'docs' => [['title' => '구매관리 절차서', 'version' => '4.2', 'date' => '2025-07-01']]],
['name' => '입고 및 자재 보관 관리', 'description' => '입고 검수, 자재 보관 조건 및 선입선출 관리', 'is_completed' => $isActive,
'docs' => [['title' => '자재관리 절차서', 'version' => '3.5', 'date' => '2025-08-01'], ['title' => '창고관리 기준서', 'version' => '2.0', 'date' => '2025-10-15']]],
],
],
[
'title' => '제조 공정 관리',
'sort_order' => 4,
'items' => [
['name' => '공정 관리 계획', 'description' => '제조 공정별 관리항목, 관리기준, 검사방법 수립', 'is_completed' => $isActive,
'docs' => [['title' => '공정관리 절차서', 'version' => '4.0', 'date' => '2025-07-10'], ['title' => 'QC공정도', 'version' => '3.0', 'date' => '2025-09-15']]],
['name' => '작업표준서 관리', 'description' => '공정별 작업표준서 작성, 배포 및 최신본 관리', 'is_completed' => $isActive,
'docs' => [['title' => '작업표준서 관리절차', 'version' => '2.5', 'date' => '2025-06-20'], ['title' => '방화셔터 조립 작업표준서', 'version' => '5.0', 'date' => '2025-11-01']]],
['name' => '공정검사 실시', 'description' => '제조 공정 중 품질검사 기준 및 기록 관리', 'is_completed' => false,
'docs' => [['title' => '공정검사 절차서', 'version' => '3.5', 'date' => '2025-10-20'], ['title' => '공정검사 체크시트', 'version' => '2.0', 'date' => '2025-11-15']]],
['name' => '부적합품 관리', 'description' => '공정 중 발생한 부적합품 식별, 격리, 처리', 'is_completed' => false,
'docs' => [['title' => '부적합품 관리절차서', 'version' => '3.0', 'date' => '2025-05-15'], ['title' => '부적합 처리대장 양식', 'version' => '2.0', 'date' => '2025-08-01']]],
],
],
[
'title' => '검사 및 시험',
'sort_order' => 5,
'items' => [
['name' => '최종검사 및 시험', 'description' => '완제품 출하 전 최종검사 기준 및 기록', 'is_completed' => false,
'docs' => [['title' => '최종검사 절차서', 'version' => '4.0', 'date' => '2025-06-01'], ['title' => '방화셔터 시험성적서 양식', 'version' => '3.0', 'date' => '2025-08-10'], ['title' => '제품검사 기준서', 'version' => '5.1', 'date' => '2025-12-05']]],
['name' => '검사·측정 장비 관리', 'description' => '검사장비 교정, 유지보수 계획 및 이력 관리', 'is_completed' => $isActive,
'docs' => [['title' => '계측기 관리절차서', 'version' => '2.8', 'date' => '2025-04-15'], ['title' => $year.'년 교정계획표', 'version' => '1.0', 'date' => $year.'-01-10']]],
['name' => '시정 및 예방조치', 'description' => '부적합 발생 시 시정조치, 재발방지 및 예방조치 관리', 'is_completed' => false,
'docs' => [['title' => '시정예방조치 절차서', 'version' => '3.3', 'date' => '2025-09-25'], ['title' => '시정조치 보고서 양식', 'version' => '2.0', 'date' => '2025-05-01']]],
['name' => '품질기록 관리', 'description' => '품질기록 식별, 보관, 보호, 검색, 폐기 절차', 'is_completed' => $isActive,
'docs' => [['title' => '기록관리 절차서', 'version' => '4.0', 'date' => '2025-03-10'], ['title' => '기록보존 기간표', 'version' => '2.5', 'date' => '2025-07-20']]],
['name' => '출하 및 인도 관리', 'description' => '완제품 출하검사, 포장, 운송 및 인도 절차', 'is_completed' => false,
'docs' => [['title' => '출하관리 절차서', 'version' => '3.0', 'date' => '2025-05-20'], ['title' => '포장 및 운송 기준서', 'version' => '2.5', 'date' => '2025-09-10']]],
],
],
[
'title' => '고객만족 및 지속적 개선',
'sort_order' => 6,
'items' => [
['name' => '고객 불만 처리', 'description' => '고객 불만 접수, 처리, 회신 및 재발방지 체계', 'is_completed' => $isActive,
'docs' => [['title' => '고객불만 처리절차서', 'version' => '3.0', 'date' => '2025-04-01'], ['title' => '고객불만 처리대장 양식', 'version' => '2.0', 'date' => '2025-07-15']]],
['name' => '고객만족도 조사', 'description' => '정기적 고객만족도 조사 실시 및 결과 분석', 'is_completed' => false,
'docs' => [['title' => '고객만족도 조사절차서', 'version' => '2.0', 'date' => '2025-06-01'], ['title' => '2025년 고객만족도 조사결과', 'version' => '1.0', 'date' => '2025-12-30']]],
['name' => '지속적 개선 활동', 'description' => '품질개선 과제 발굴, 실행 및 효과 확인', 'is_completed' => false,
'docs' => [['title' => '지속적개선 절차서', 'version' => '2.5', 'date' => '2025-08-15'], ['title' => '개선활동 보고서 양식', 'version' => '1.5', 'date' => '2025-10-20']]],
],
],
];
$categoryCount = 0;
$itemCount = 0;
$docCount = 0;
foreach ($categoriesData as $catData) {
$categoryId = DB::table('audit_checklist_categories')->insertGetId([
'tenant_id' => $tenantId,
'checklist_id' => $checklistId,
'title' => $catData['title'],
'sort_order' => $catData['sort_order'],
'options' => null,
'created_at' => $now,
'updated_at' => $now,
]);
$categoryCount++;
foreach ($catData['items'] as $itemIdx => $itemData) {
$completedAt = $itemData['is_completed'] ? $now->copy()->subDays(rand(1, 15)) : null;
$itemId = DB::table('audit_checklist_items')->insertGetId([
'tenant_id' => $tenantId,
'category_id' => $categoryId,
'name' => $itemData['name'],
'description' => $itemData['description'],
'is_completed' => $itemData['is_completed'],
'completed_at' => $completedAt,
'completed_by' => $itemData['is_completed'] ? $userId : null,
'sort_order' => $itemIdx + 1,
'options' => null,
'created_at' => $now,
'updated_at' => $now,
]);
$itemCount++;
foreach ($itemData['docs'] as $docData) {
DB::table('audit_standard_documents')->insert([
'tenant_id' => $tenantId,
'checklist_item_id' => $itemId,
'title' => $docData['title'],
'version' => $docData['version'],
'date' => $docData['date'],
'document_id' => null,
'options' => null,
'created_at' => $now,
'updated_at' => $now,
]);
$docCount++;
}
}
}
$this->command->info(' ✅ audit_checklist_categories: '.$categoryCount.'개 생성');
$this->command->info(' ✅ audit_checklist_items: '.$itemCount.'개 생성');
$this->command->info(' ✅ audit_standard_documents: '.$docCount.'개 생성');
}
}

View File

@@ -7,7 +7,9 @@
* - 실적신고
*/
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;
use Illuminate\Support\Facades\Route;
@@ -38,3 +40,25 @@
Route::patch('/unconfirm', [PerformanceReportController::class, 'unconfirm'])->name('v1.quality.performance-reports.unconfirm');
Route::patch('/memo', [PerformanceReportController::class, 'updateMemo'])->name('v1.quality.performance-reports.memo');
});
// QMS 로트 추적 심사
Route::prefix('qms/lot-audit')->group(function () {
Route::get('/reports', [QmsLotAuditController::class, 'index'])->name('v1.qms.lot-audit.reports');
Route::get('/reports/{id}', [QmsLotAuditController::class, 'show'])->whereNumber('id')->name('v1.qms.lot-audit.reports.show');
Route::get('/routes/{id}/documents', [QmsLotAuditController::class, 'routeDocuments'])->whereNumber('id')->name('v1.qms.lot-audit.routes.documents');
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');
});