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:
112
app/Http/Controllers/Api/V1/AuditChecklistController.php
Normal file
112
app/Http/Controllers/Api/V1/AuditChecklistController.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Qms\AuditChecklistStoreRequest;
|
||||
use App\Http\Requests\Qms\AuditChecklistUpdateRequest;
|
||||
use App\Services\AuditChecklistService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AuditChecklistController extends Controller
|
||||
{
|
||||
public function __construct(private AuditChecklistService $service) {}
|
||||
|
||||
/**
|
||||
* 점검표 목록
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->index($request->all());
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 점검표 생성 (카테고리+항목 일괄)
|
||||
*/
|
||||
public function store(AuditChecklistStoreRequest $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->store($request->validated());
|
||||
}, __('message.created'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 점검표 상세
|
||||
*/
|
||||
public function show(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->show($id);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 점검표 수정
|
||||
*/
|
||||
public function update(AuditChecklistUpdateRequest $request, int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request, $id) {
|
||||
return $this->service->update($id, $request->validated());
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 점검표 완료 처리
|
||||
*/
|
||||
public function complete(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->complete($id);
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 항목 완료/미완료 토글
|
||||
*/
|
||||
public function toggleItem(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->toggleItem($id);
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 항목별 기준 문서 조회
|
||||
*/
|
||||
public function itemDocuments(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->itemDocuments($id);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 기준 문서 연결
|
||||
*/
|
||||
public function attachDocument(Request $request, int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request, $id) {
|
||||
return $this->service->attachDocument($id, $request->validate([
|
||||
'title' => 'required|string|max:200',
|
||||
'version' => 'nullable|string|max:20',
|
||||
'date' => 'nullable|date',
|
||||
'document_id' => 'nullable|integer|exists:documents,id',
|
||||
]));
|
||||
}, __('message.created'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 기준 문서 연결 해제
|
||||
*/
|
||||
public function detachDocument(int $id, int $docId)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $docId) {
|
||||
$this->service->detachDocument($id, $docId);
|
||||
|
||||
return null;
|
||||
}, __('message.deleted'));
|
||||
}
|
||||
}
|
||||
65
app/Http/Controllers/Api/V1/QmsLotAuditController.php
Normal file
65
app/Http/Controllers/Api/V1/QmsLotAuditController.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
39
app/Http/Requests/Qms/AuditChecklistStoreRequest.php
Normal file
39
app/Http/Requests/Qms/AuditChecklistStoreRequest.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Qms;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class AuditChecklistStoreRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'year' => 'required|integer|min:2020|max:2100',
|
||||
'quarter' => 'required|integer|in:1,2,3,4',
|
||||
'type' => 'nullable|string|max:30',
|
||||
'categories' => 'required|array|min:1',
|
||||
'categories.*.title' => 'required|string|max:200',
|
||||
'categories.*.sort_order' => 'nullable|integer|min:0',
|
||||
'categories.*.items' => 'required|array|min:1',
|
||||
'categories.*.items.*.name' => 'required|string|max:200',
|
||||
'categories.*.items.*.description' => 'nullable|string',
|
||||
'categories.*.items.*.sort_order' => 'nullable|integer|min:0',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'categories.required' => __('validation.required', ['attribute' => '카테고리']),
|
||||
'categories.*.title.required' => __('validation.required', ['attribute' => '카테고리 제목']),
|
||||
'categories.*.items.required' => __('validation.required', ['attribute' => '점검 항목']),
|
||||
'categories.*.items.*.name.required' => __('validation.required', ['attribute' => '항목명']),
|
||||
];
|
||||
}
|
||||
}
|
||||
28
app/Http/Requests/Qms/AuditChecklistUpdateRequest.php
Normal file
28
app/Http/Requests/Qms/AuditChecklistUpdateRequest.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Qms;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class AuditChecklistUpdateRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'categories' => 'sometimes|array|min:1',
|
||||
'categories.*.id' => 'nullable|integer|exists:audit_checklist_categories,id',
|
||||
'categories.*.title' => 'required|string|max:200',
|
||||
'categories.*.sort_order' => 'nullable|integer|min:0',
|
||||
'categories.*.items' => 'required|array|min:1',
|
||||
'categories.*.items.*.id' => 'nullable|integer|exists:audit_checklist_items,id',
|
||||
'categories.*.items.*.name' => 'required|string|max:200',
|
||||
'categories.*.items.*.description' => 'nullable|string',
|
||||
'categories.*.items.*.sort_order' => 'nullable|integer|min:0',
|
||||
];
|
||||
}
|
||||
}
|
||||
28
app/Http/Requests/Qms/QmsLotAuditConfirmRequest.php
Normal file
28
app/Http/Requests/Qms/QmsLotAuditConfirmRequest.php
Normal 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' => '확인 상태']),
|
||||
];
|
||||
}
|
||||
}
|
||||
34
app/Http/Requests/Qms/QmsLotAuditDocumentDetailRequest.php
Normal file
34
app/Http/Requests/Qms/QmsLotAuditDocumentDetailRequest.php
Normal 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' => '서류 타입']),
|
||||
];
|
||||
}
|
||||
}
|
||||
23
app/Http/Requests/Qms/QmsLotAuditIndexRequest.php
Normal file
23
app/Http/Requests/Qms/QmsLotAuditIndexRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
57
app/Models/Qualitys/AuditChecklist.php
Normal file
57
app/Models/Qualitys/AuditChecklist.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Qualitys;
|
||||
|
||||
use App\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;
|
||||
}
|
||||
}
|
||||
35
app/Models/Qualitys/AuditChecklistCategory.php
Normal file
35
app/Models/Qualitys/AuditChecklistCategory.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Qualitys;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class AuditChecklistCategory extends Model
|
||||
{
|
||||
protected $table = 'audit_checklist_categories';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'checklist_id',
|
||||
'title',
|
||||
'sort_order',
|
||||
'options',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sort_order' => 'integer',
|
||||
'options' => 'array',
|
||||
];
|
||||
|
||||
public function checklist(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AuditChecklist::class, 'checklist_id');
|
||||
}
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(AuditChecklistItem::class, 'category_id')->orderBy('sort_order');
|
||||
}
|
||||
}
|
||||
47
app/Models/Qualitys/AuditChecklistItem.php
Normal file
47
app/Models/Qualitys/AuditChecklistItem.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Qualitys;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class AuditChecklistItem extends Model
|
||||
{
|
||||
protected $table = 'audit_checklist_items';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'category_id',
|
||||
'name',
|
||||
'description',
|
||||
'is_completed',
|
||||
'completed_at',
|
||||
'completed_by',
|
||||
'sort_order',
|
||||
'options',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_completed' => 'boolean',
|
||||
'completed_at' => 'datetime',
|
||||
'sort_order' => 'integer',
|
||||
'options' => 'array',
|
||||
];
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AuditChecklistCategory::class, 'category_id');
|
||||
}
|
||||
|
||||
public function completedByUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'completed_by');
|
||||
}
|
||||
|
||||
public function standardDocuments(): HasMany
|
||||
{
|
||||
return $this->hasMany(AuditStandardDocument::class, 'checklist_item_id');
|
||||
}
|
||||
}
|
||||
37
app/Models/Qualitys/AuditStandardDocument.php
Normal file
37
app/Models/Qualitys/AuditStandardDocument.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Qualitys;
|
||||
|
||||
use App\Models\Documents\Document;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AuditStandardDocument extends Model
|
||||
{
|
||||
protected $table = 'audit_standard_documents';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'checklist_item_id',
|
||||
'title',
|
||||
'version',
|
||||
'date',
|
||||
'document_id',
|
||||
'options',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'date' => 'date',
|
||||
'options' => 'array',
|
||||
];
|
||||
|
||||
public function checklistItem(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AuditChecklistItem::class, 'checklist_item_id');
|
||||
}
|
||||
|
||||
public function document(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Document::class);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
392
app/Services/AuditChecklistService.php
Normal file
392
app/Services/AuditChecklistService.php
Normal file
@@ -0,0 +1,392 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Qualitys\AuditChecklist;
|
||||
use App\Models\Qualitys\AuditChecklistCategory;
|
||||
use App\Models\Qualitys\AuditChecklistItem;
|
||||
use App\Models\Qualitys\AuditStandardDocument;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
class AuditChecklistService extends Service
|
||||
{
|
||||
/**
|
||||
* 점검표 목록 (year, quarter 필터)
|
||||
*/
|
||||
public function index(array $params): array
|
||||
{
|
||||
$query = AuditChecklist::with(['categories.items']);
|
||||
|
||||
if (! empty($params['year'])) {
|
||||
$query->where('year', (int) $params['year']);
|
||||
}
|
||||
if (! empty($params['quarter'])) {
|
||||
$query->where('quarter', (int) $params['quarter']);
|
||||
}
|
||||
if (! empty($params['type'])) {
|
||||
$query->where('type', $params['type']);
|
||||
}
|
||||
|
||||
$query->orderByDesc('year')->orderByDesc('quarter');
|
||||
$perPage = (int) ($params['per_page'] ?? 20);
|
||||
$paginated = $query->paginate($perPage);
|
||||
|
||||
$items = $paginated->getCollection()->map(fn ($checklist) => $this->transformListItem($checklist));
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'current_page' => $paginated->currentPage(),
|
||||
'last_page' => $paginated->lastPage(),
|
||||
'per_page' => $paginated->perPage(),
|
||||
'total' => $paginated->total(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 점검표 생성 (카테고리+항목 일괄)
|
||||
*/
|
||||
public function store(array $data): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
// 중복 체크
|
||||
$exists = AuditChecklist::where('year', $data['year'])
|
||||
->where('quarter', $data['quarter'])
|
||||
->where('type', $data['type'] ?? AuditChecklist::TYPE_STANDARD_MANUAL)
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
throw new BadRequestHttpException(__('error.duplicate', ['attribute' => '해당 분기 점검표']));
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($data, $tenantId, $userId) {
|
||||
$checklist = AuditChecklist::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'year' => $data['year'],
|
||||
'quarter' => $data['quarter'],
|
||||
'type' => $data['type'] ?? AuditChecklist::TYPE_STANDARD_MANUAL,
|
||||
'status' => AuditChecklist::STATUS_DRAFT,
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
|
||||
$this->syncCategories($checklist, $data['categories'], $tenantId);
|
||||
|
||||
return $this->show($checklist->id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 점검표 상세 (카테고리→항목→문서 중첩)
|
||||
*/
|
||||
public function show(int $id): array
|
||||
{
|
||||
$checklist = AuditChecklist::with([
|
||||
'categories.items.standardDocuments.document',
|
||||
])->findOrFail($id);
|
||||
|
||||
return $this->transformDetail($checklist);
|
||||
}
|
||||
|
||||
/**
|
||||
* 점검표 수정
|
||||
*/
|
||||
public function update(int $id, array $data): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$checklist = AuditChecklist::findOrFail($id);
|
||||
|
||||
if ($checklist->isCompleted()) {
|
||||
throw new BadRequestHttpException('완료된 점검표는 수정할 수 없습니다.');
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($checklist, $data, $tenantId) {
|
||||
$checklist->update([
|
||||
'updated_by' => $this->apiUserId(),
|
||||
]);
|
||||
|
||||
if (isset($data['categories'])) {
|
||||
$this->syncCategories($checklist, $data['categories'], $tenantId);
|
||||
}
|
||||
|
||||
return $this->show($checklist->id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 점검표 완료 처리
|
||||
*/
|
||||
public function complete(int $id): array
|
||||
{
|
||||
$checklist = AuditChecklist::with('categories.items')->findOrFail($id);
|
||||
|
||||
// 미완료 항목 확인
|
||||
$totalItems = 0;
|
||||
$completedItems = 0;
|
||||
foreach ($checklist->categories as $category) {
|
||||
foreach ($category->items as $item) {
|
||||
$totalItems++;
|
||||
if ($item->is_completed) {
|
||||
$completedItems++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($completedItems < $totalItems) {
|
||||
throw new BadRequestHttpException("미완료 항목이 있습니다. ({$completedItems}/{$totalItems})");
|
||||
}
|
||||
|
||||
$checklist->update([
|
||||
'status' => AuditChecklist::STATUS_COMPLETED,
|
||||
'updated_by' => $this->apiUserId(),
|
||||
]);
|
||||
|
||||
return $this->show($checklist->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 항목 완료/미완료 토글
|
||||
*/
|
||||
public function toggleItem(int $itemId): array
|
||||
{
|
||||
$item = AuditChecklistItem::findOrFail($itemId);
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
DB::transaction(function () use ($item, $userId) {
|
||||
$item->lockForUpdate();
|
||||
|
||||
$newCompleted = ! $item->is_completed;
|
||||
$item->update([
|
||||
'is_completed' => $newCompleted,
|
||||
'completed_at' => $newCompleted ? now() : null,
|
||||
'completed_by' => $newCompleted ? $userId : null,
|
||||
]);
|
||||
|
||||
// 점검표 상태 자동 업데이트: draft → in_progress
|
||||
$category = $item->category;
|
||||
$checklist = $category->checklist;
|
||||
if ($checklist->isDraft()) {
|
||||
$checklist->update([
|
||||
'status' => AuditChecklist::STATUS_IN_PROGRESS,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
$item->refresh();
|
||||
|
||||
return [
|
||||
'id' => (string) $item->id,
|
||||
'name' => $item->name,
|
||||
'is_completed' => $item->is_completed,
|
||||
'completed_at' => $item->completed_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 항목별 기준 문서 조회
|
||||
*/
|
||||
public function itemDocuments(int $itemId): array
|
||||
{
|
||||
$item = AuditChecklistItem::findOrFail($itemId);
|
||||
|
||||
return $item->standardDocuments()->with('document')->get()
|
||||
->map(fn ($doc) => $this->transformStandardDocument($doc))
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 기준 문서 연결
|
||||
*/
|
||||
public function attachDocument(int $itemId, array $data): array
|
||||
{
|
||||
$item = AuditChecklistItem::findOrFail($itemId);
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$doc = AuditStandardDocument::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'checklist_item_id' => $item->id,
|
||||
'title' => $data['title'],
|
||||
'version' => $data['version'] ?? null,
|
||||
'date' => $data['date'] ?? null,
|
||||
'document_id' => $data['document_id'] ?? null,
|
||||
]);
|
||||
|
||||
$doc->load('document');
|
||||
|
||||
return $this->transformStandardDocument($doc);
|
||||
}
|
||||
|
||||
/**
|
||||
* 기준 문서 연결 해제
|
||||
*/
|
||||
public function detachDocument(int $itemId, int $docId): void
|
||||
{
|
||||
$doc = AuditStandardDocument::where('checklist_item_id', $itemId)
|
||||
->where('id', $docId)
|
||||
->firstOrFail();
|
||||
|
||||
$doc->delete();
|
||||
}
|
||||
|
||||
// ===== Private: Sync & Transform =====
|
||||
|
||||
private function syncCategories(AuditChecklist $checklist, array $categoriesData, int $tenantId): void
|
||||
{
|
||||
// 기존 카테고리 ID 추적 (삭제 감지용)
|
||||
$existingCategoryIds = $checklist->categories()->pluck('id')->all();
|
||||
$keptCategoryIds = [];
|
||||
|
||||
foreach ($categoriesData as $catIdx => $catData) {
|
||||
if (! empty($catData['id'])) {
|
||||
// 기존 카테고리 업데이트
|
||||
$category = AuditChecklistCategory::findOrFail($catData['id']);
|
||||
$category->update([
|
||||
'title' => $catData['title'],
|
||||
'sort_order' => $catData['sort_order'] ?? $catIdx,
|
||||
]);
|
||||
$keptCategoryIds[] = $category->id;
|
||||
} else {
|
||||
// 새 카테고리 생성
|
||||
$category = AuditChecklistCategory::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'checklist_id' => $checklist->id,
|
||||
'title' => $catData['title'],
|
||||
'sort_order' => $catData['sort_order'] ?? $catIdx,
|
||||
]);
|
||||
$keptCategoryIds[] = $category->id;
|
||||
}
|
||||
|
||||
// 하위 항목 동기화
|
||||
$this->syncItems($category, $catData['items'] ?? [], $tenantId);
|
||||
}
|
||||
|
||||
// 삭제된 카테고리 제거 (cascade로 items도 삭제)
|
||||
$deletedIds = array_diff($existingCategoryIds, $keptCategoryIds);
|
||||
if (! empty($deletedIds)) {
|
||||
AuditChecklistCategory::whereIn('id', $deletedIds)->delete();
|
||||
}
|
||||
}
|
||||
|
||||
private function syncItems(AuditChecklistCategory $category, array $itemsData, int $tenantId): void
|
||||
{
|
||||
$existingItemIds = $category->items()->pluck('id')->all();
|
||||
$keptItemIds = [];
|
||||
|
||||
foreach ($itemsData as $itemIdx => $itemData) {
|
||||
if (! empty($itemData['id'])) {
|
||||
$item = AuditChecklistItem::findOrFail($itemData['id']);
|
||||
$item->update([
|
||||
'name' => $itemData['name'],
|
||||
'description' => $itemData['description'] ?? null,
|
||||
'sort_order' => $itemData['sort_order'] ?? $itemIdx,
|
||||
]);
|
||||
$keptItemIds[] = $item->id;
|
||||
} else {
|
||||
$item = AuditChecklistItem::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'category_id' => $category->id,
|
||||
'name' => $itemData['name'],
|
||||
'description' => $itemData['description'] ?? null,
|
||||
'sort_order' => $itemData['sort_order'] ?? $itemIdx,
|
||||
]);
|
||||
$keptItemIds[] = $item->id;
|
||||
}
|
||||
}
|
||||
|
||||
$deletedIds = array_diff($existingItemIds, $keptItemIds);
|
||||
if (! empty($deletedIds)) {
|
||||
AuditChecklistItem::whereIn('id', $deletedIds)->delete();
|
||||
}
|
||||
}
|
||||
|
||||
private function transformListItem(AuditChecklist $checklist): array
|
||||
{
|
||||
$total = 0;
|
||||
$completed = 0;
|
||||
foreach ($checklist->categories as $category) {
|
||||
foreach ($category->items as $item) {
|
||||
$total++;
|
||||
if ($item->is_completed) {
|
||||
$completed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (string) $checklist->id,
|
||||
'year' => $checklist->year,
|
||||
'quarter' => $checklist->quarter,
|
||||
'type' => $checklist->type,
|
||||
'status' => $checklist->status,
|
||||
'progress' => [
|
||||
'completed' => $completed,
|
||||
'total' => $total,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function transformDetail(AuditChecklist $checklist): array
|
||||
{
|
||||
$total = 0;
|
||||
$completed = 0;
|
||||
|
||||
$categories = $checklist->categories->map(function ($category) use (&$total, &$completed) {
|
||||
$subItems = $category->items->map(function ($item) use (&$total, &$completed) {
|
||||
$total++;
|
||||
if ($item->is_completed) {
|
||||
$completed++;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (string) $item->id,
|
||||
'name' => $item->name,
|
||||
'description' => $item->description,
|
||||
'is_completed' => $item->is_completed,
|
||||
'completed_at' => $item->completed_at?->toIso8601String(),
|
||||
'sort_order' => $item->sort_order,
|
||||
'standard_documents' => $item->standardDocuments->map(
|
||||
fn ($doc) => $this->transformStandardDocument($doc)
|
||||
)->all(),
|
||||
];
|
||||
})->all();
|
||||
|
||||
return [
|
||||
'id' => (string) $category->id,
|
||||
'title' => $category->title,
|
||||
'sort_order' => $category->sort_order,
|
||||
'sub_items' => $subItems,
|
||||
];
|
||||
})->all();
|
||||
|
||||
return [
|
||||
'id' => (string) $checklist->id,
|
||||
'year' => $checklist->year,
|
||||
'quarter' => $checklist->quarter,
|
||||
'type' => $checklist->type,
|
||||
'status' => $checklist->status,
|
||||
'progress' => [
|
||||
'completed' => $completed,
|
||||
'total' => $total,
|
||||
],
|
||||
'categories' => $categories,
|
||||
];
|
||||
}
|
||||
|
||||
private function transformStandardDocument(AuditStandardDocument $doc): array
|
||||
{
|
||||
$file = $doc->document;
|
||||
|
||||
return [
|
||||
'id' => (string) $doc->id,
|
||||
'title' => $doc->title,
|
||||
'version' => $doc->version ?? '-',
|
||||
'date' => $doc->date?->toDateString() ?? '',
|
||||
'file_name' => $file?->original_name ?? null,
|
||||
'file_url' => $file ? "/api/v1/documents/{$file->id}/download" : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
517
app/Services/QmsLotAuditService.php
Normal file
517
app/Services/QmsLotAuditService.php
Normal 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,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user