diff --git a/app/Enums/InspectionCycle.php b/app/Enums/InspectionCycle.php new file mode 100644 index 00000000..d1e42fb2 --- /dev/null +++ b/app/Enums/InspectionCycle.php @@ -0,0 +1,232 @@ + '일일', + self::WEEKLY => '주간', + self::MONTHLY => '월간', + self::BIMONTHLY => '2개월', + self::QUARTERLY => '분기', + self::SEMIANNUAL => '반년', + ]; + } + + public static function label(string $cycle): string + { + return self::all()[$cycle] ?? $cycle; + } + + public static function periodType(string $cycle): string + { + return $cycle === self::DAILY ? 'month' : 'year'; + } + + public static function columnLabels(string $cycle, ?string $period = null): array + { + return match ($cycle) { + self::DAILY => self::dailyLabels($period), + self::WEEKLY => self::weeklyLabels(), + self::MONTHLY => self::monthlyLabels(), + self::BIMONTHLY => self::bimonthlyLabels(), + self::QUARTERLY => self::quarterlyLabels(), + self::SEMIANNUAL => self::semiannualLabels(), + default => self::dailyLabels($period), + }; + } + + public static function resolveCheckDate(string $cycle, string $period, int $colIndex): string + { + return match ($cycle) { + self::DAILY => self::dailyCheckDate($period, $colIndex), + self::WEEKLY => self::weeklyCheckDate($period, $colIndex), + self::MONTHLY => self::monthlyCheckDate($period, $colIndex), + self::BIMONTHLY => self::bimonthlyCheckDate($period, $colIndex), + self::QUARTERLY => self::quarterlyCheckDate($period, $colIndex), + self::SEMIANNUAL => self::semiannualCheckDate($period, $colIndex), + default => self::dailyCheckDate($period, $colIndex), + }; + } + + public static function resolvePeriod(string $cycle, string $checkDate): string + { + $date = Carbon::parse($checkDate); + + return match ($cycle) { + self::DAILY => $date->format('Y-m'), + self::WEEKLY => (string) $date->isoWeekYear, + default => $date->format('Y'), + }; + } + + public static function columnCount(string $cycle, ?string $period = null): int + { + return count(self::columnLabels($cycle, $period)); + } + + public static function isWeekend(string $period, int $colIndex): bool + { + $date = Carbon::createFromFormat('Y-m', $period)->startOfMonth()->addDays($colIndex - 1); + + return in_array($date->dayOfWeek, [0, 6]); + } + + public static function getHolidayDates(string $cycle, string $period, int $tenantId): array + { + if ($cycle === self::DAILY) { + $start = Carbon::createFromFormat('Y-m', $period)->startOfMonth(); + $end = $start->copy()->endOfMonth(); + } else { + $start = Carbon::create((int) $period, 1, 1); + $end = Carbon::create((int) $period, 12, 31); + } + + $holidays = Holiday::where('tenant_id', $tenantId) + ->where('start_date', '<=', $end->toDateString()) + ->where('end_date', '>=', $start->toDateString()) + ->get(); + + $dates = []; + foreach ($holidays as $holiday) { + $hStart = $holiday->start_date->copy()->max($start); + $hEnd = $holiday->end_date->copy()->min($end); + $current = $hStart->copy(); + while ($current->lte($hEnd)) { + $dates[$current->format('Y-m-d')] = true; + $current->addDay(); + } + } + + return $dates; + } + + public static function isNonWorkingDay(string $checkDate, array $holidayDates = []): bool + { + $date = Carbon::parse($checkDate); + + return $date->isWeekend() || isset($holidayDates[$checkDate]); + } + + // --- Daily --- + private static function dailyLabels(?string $period): array + { + $date = Carbon::createFromFormat('Y-m', $period ?? now()->format('Y-m')); + $days = $date->daysInMonth; + $labels = []; + for ($d = 1; $d <= $days; $d++) { + $labels[$d] = (string) $d; + } + + return $labels; + } + + private static function dailyCheckDate(string $period, int $colIndex): string + { + return Carbon::createFromFormat('Y-m', $period)->startOfMonth()->addDays($colIndex - 1)->format('Y-m-d'); + } + + // --- Weekly --- + private static function weeklyLabels(): array + { + $labels = []; + for ($w = 1; $w <= 52; $w++) { + $labels[$w] = $w.'주'; + } + + return $labels; + } + + private static function weeklyCheckDate(string $year, int $colIndex): string + { + return Carbon::create((int) $year)->setISODate((int) $year, $colIndex, 1)->format('Y-m-d'); + } + + // --- Monthly --- + private static function monthlyLabels(): array + { + $labels = []; + for ($m = 1; $m <= 12; $m++) { + $labels[$m] = $m.'월'; + } + + return $labels; + } + + private static function monthlyCheckDate(string $year, int $colIndex): string + { + return Carbon::create((int) $year, $colIndex, 1)->format('Y-m-d'); + } + + // --- Bimonthly --- + private static function bimonthlyLabels(): array + { + return [ + 1 => '1~2월', + 2 => '3~4월', + 3 => '5~6월', + 4 => '7~8월', + 5 => '9~10월', + 6 => '11~12월', + ]; + } + + private static function bimonthlyCheckDate(string $year, int $colIndex): string + { + $month = ($colIndex - 1) * 2 + 1; + + return Carbon::create((int) $year, $month, 1)->format('Y-m-d'); + } + + // --- Quarterly --- + private static function quarterlyLabels(): array + { + return [ + 1 => '1분기', + 2 => '2분기', + 3 => '3분기', + 4 => '4분기', + ]; + } + + private static function quarterlyCheckDate(string $year, int $colIndex): string + { + $month = ($colIndex - 1) * 3 + 1; + + return Carbon::create((int) $year, $month, 1)->format('Y-m-d'); + } + + // --- Semiannual --- + private static function semiannualLabels(): array + { + return [ + 1 => '상반기', + 2 => '하반기', + ]; + } + + private static function semiannualCheckDate(string $year, int $colIndex): string + { + $month = $colIndex === 1 ? 1 : 7; + + return Carbon::create((int) $year, $month, 1)->format('Y-m-d'); + } +} diff --git a/app/Http/Controllers/V1/Equipment/EquipmentController.php b/app/Http/Controllers/V1/Equipment/EquipmentController.php new file mode 100644 index 00000000..41c64b7a --- /dev/null +++ b/app/Http/Controllers/V1/Equipment/EquipmentController.php @@ -0,0 +1,91 @@ + $this->service->index($request->only([ + 'search', 'status', 'production_line', 'equipment_type', + 'sort_by', 'sort_direction', 'per_page', + ])), + __('message.fetched') + ); + } + + public function show(int $id): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->show($id), + __('message.fetched') + ); + } + + public function store(StoreEquipmentRequest $request): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->store($request->validated()), + __('message.equipment.created') + ); + } + + public function update(UpdateEquipmentRequest $request, int $id): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->update($id, $request->validated()), + __('message.equipment.updated') + ); + } + + public function destroy(int $id): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->destroy($id), + __('message.equipment.deleted') + ); + } + + public function restore(int $id): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->restore($id), + __('message.equipment.restored') + ); + } + + public function toggleActive(int $id): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->toggleActive($id), + __('message.toggled') + ); + } + + public function stats(): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->stats(), + __('message.fetched') + ); + } + + public function options(): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->options(), + __('message.fetched') + ); + } +} diff --git a/app/Http/Controllers/V1/Equipment/EquipmentInspectionController.php b/app/Http/Controllers/V1/Equipment/EquipmentInspectionController.php new file mode 100644 index 00000000..7433efec --- /dev/null +++ b/app/Http/Controllers/V1/Equipment/EquipmentInspectionController.php @@ -0,0 +1,130 @@ + $this->service->getInspections( + $request->input('cycle', 'daily'), + $request->input('period', now()->format('Y-m')), + $request->input('production_line'), + $request->input('equipment_id') ? (int) $request->input('equipment_id') : null + ), + __('message.fetched') + ); + } + + public function toggleDetail(ToggleInspectionDetailRequest $request): JsonResponse + { + $data = $request->validated(); + + return ApiResponse::handle( + fn () => $this->service->toggleDetail( + $data['equipment_id'], + $data['template_item_id'], + $data['check_date'], + $data['cycle'] ?? 'daily' + ), + __('message.equipment.inspection_saved') + ); + } + + public function setResult(Request $request): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->setResult( + (int) $request->input('equipment_id'), + (int) $request->input('template_item_id'), + $request->input('check_date'), + $request->input('cycle', 'daily'), + $request->input('result') + ), + __('message.equipment.inspection_saved') + ); + } + + public function updateNotes(UpdateInspectionNotesRequest $request): JsonResponse + { + $data = $request->validated(); + + return ApiResponse::handle( + fn () => $this->service->updateNotes( + $data['equipment_id'], + $data['year_month'], + collect($data)->only(['overall_judgment', 'inspector_id', 'repair_note', 'issue_note'])->toArray(), + $data['cycle'] ?? 'daily' + ), + __('message.equipment.inspection_saved') + ); + } + + public function resetInspection(Request $request): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->resetInspection( + (int) $request->input('equipment_id'), + $request->input('cycle', 'daily'), + $request->input('period') + ), + __('message.equipment.inspection_reset') + ); + } + + public function templates(int $id): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->getActiveCycles($id), + __('message.fetched') + ); + } + + public function storeTemplate(StoreInspectionTemplateRequest $request, int $id): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->saveTemplate($id, $request->validated()), + __('message.equipment.template_created') + ); + } + + public function updateTemplate(Request $request, int $templateId): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->updateTemplate($templateId, $request->all()), + __('message.updated') + ); + } + + public function deleteTemplate(int $templateId): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->deleteTemplate($templateId), + __('message.deleted') + ); + } + + public function copyTemplates(Request $request, int $id): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->copyTemplates( + $id, + $request->input('source_cycle'), + $request->input('target_cycles', []) + ), + __('message.equipment.template_copied') + ); + } +} diff --git a/app/Http/Controllers/V1/Equipment/EquipmentPhotoController.php b/app/Http/Controllers/V1/Equipment/EquipmentPhotoController.php new file mode 100644 index 00000000..71ca832c --- /dev/null +++ b/app/Http/Controllers/V1/Equipment/EquipmentPhotoController.php @@ -0,0 +1,38 @@ + $this->service->index($id), + __('message.fetched') + ); + } + + public function store(Request $request, int $id): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->store($id, $request->all()), + __('message.equipment.photo_uploaded') + ); + } + + public function destroy(int $id, int $fileId): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->destroy($id, $fileId), + __('message.deleted') + ); + } +} diff --git a/app/Http/Controllers/V1/Equipment/EquipmentRepairController.php b/app/Http/Controllers/V1/Equipment/EquipmentRepairController.php new file mode 100644 index 00000000..c31399a2 --- /dev/null +++ b/app/Http/Controllers/V1/Equipment/EquipmentRepairController.php @@ -0,0 +1,49 @@ + $this->service->index($request->only([ + 'equipment_id', 'repair_type', 'date_from', 'date_to', 'search', 'per_page', + ])), + __('message.fetched') + ); + } + + public function store(StoreEquipmentRepairRequest $request): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->store($request->validated()), + __('message.equipment.repair_created') + ); + } + + public function update(StoreEquipmentRepairRequest $request, int $id): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->update($id, $request->validated()), + __('message.updated') + ); + } + + public function destroy(int $id): JsonResponse + { + return ApiResponse::handle( + fn () => $this->service->destroy($id), + __('message.deleted') + ); + } +} diff --git a/app/Http/Requests/V1/Equipment/StoreEquipmentRepairRequest.php b/app/Http/Requests/V1/Equipment/StoreEquipmentRepairRequest.php new file mode 100644 index 00000000..440e78a1 --- /dev/null +++ b/app/Http/Requests/V1/Equipment/StoreEquipmentRepairRequest.php @@ -0,0 +1,29 @@ + 'required|integer|exists:equipments,id', + 'repair_date' => 'required|date', + 'repair_type' => 'nullable|string|in:internal,external', + 'repair_hours' => 'nullable|numeric|min:0', + 'description' => 'nullable|string', + 'cost' => 'nullable|numeric|min:0', + 'vendor' => 'nullable|string|max:100', + 'repaired_by' => 'nullable|integer|exists:users,id', + 'memo' => 'nullable|string', + 'options' => 'nullable|array', + ]; + } +} diff --git a/app/Http/Requests/V1/Equipment/StoreEquipmentRequest.php b/app/Http/Requests/V1/Equipment/StoreEquipmentRequest.php new file mode 100644 index 00000000..1990daa8 --- /dev/null +++ b/app/Http/Requests/V1/Equipment/StoreEquipmentRequest.php @@ -0,0 +1,41 @@ + 'required|string|max:50', + 'name' => 'required|string|max:100', + 'equipment_type' => 'nullable|string|max:50', + 'specification' => 'nullable|string|max:200', + 'manufacturer' => 'nullable|string|max:100', + 'model_name' => 'nullable|string|max:100', + 'serial_no' => 'nullable|string|max:100', + 'location' => 'nullable|string|max:100', + 'production_line' => 'nullable|string|max:50', + 'purchase_date' => 'nullable|date', + 'install_date' => 'nullable|date', + 'purchase_price' => 'nullable|numeric|min:0', + 'useful_life' => 'nullable|integer|min:0', + 'status' => 'nullable|in:active,idle,disposed', + 'disposed_date' => 'nullable|date', + 'manager_id' => 'nullable|integer|exists:users,id', + 'sub_manager_id' => 'nullable|integer|exists:users,id', + 'photo_path' => 'nullable|string|max:500', + 'memo' => 'nullable|string', + 'options' => 'nullable|array', + 'is_active' => 'nullable|boolean', + 'sort_order' => 'nullable|integer|min:0', + ]; + } +} diff --git a/app/Http/Requests/V1/Equipment/StoreInspectionTemplateRequest.php b/app/Http/Requests/V1/Equipment/StoreInspectionTemplateRequest.php new file mode 100644 index 00000000..43d3ce0a --- /dev/null +++ b/app/Http/Requests/V1/Equipment/StoreInspectionTemplateRequest.php @@ -0,0 +1,28 @@ + 'required|string|in:daily,weekly,monthly,bimonthly,quarterly,semiannual', + 'item_no' => 'required|string|max:20', + 'check_point' => 'required|string|max:100', + 'check_item' => 'required|string|max:200', + 'check_timing' => 'nullable|string|max:50', + 'check_frequency' => 'nullable|string|max:50', + 'check_method' => 'nullable|string|max:200', + 'sort_order' => 'nullable|integer|min:0', + 'is_active' => 'nullable|boolean', + ]; + } +} diff --git a/app/Http/Requests/V1/Equipment/ToggleInspectionDetailRequest.php b/app/Http/Requests/V1/Equipment/ToggleInspectionDetailRequest.php new file mode 100644 index 00000000..56b2a98f --- /dev/null +++ b/app/Http/Requests/V1/Equipment/ToggleInspectionDetailRequest.php @@ -0,0 +1,23 @@ + 'required|integer|exists:equipments,id', + 'template_item_id' => 'required|integer|exists:equipment_inspection_templates,id', + 'check_date' => 'required|date', + 'cycle' => 'nullable|string|in:daily,weekly,monthly,bimonthly,quarterly,semiannual', + ]; + } +} diff --git a/app/Http/Requests/V1/Equipment/UpdateEquipmentRequest.php b/app/Http/Requests/V1/Equipment/UpdateEquipmentRequest.php new file mode 100644 index 00000000..922b4848 --- /dev/null +++ b/app/Http/Requests/V1/Equipment/UpdateEquipmentRequest.php @@ -0,0 +1,41 @@ + 'sometimes|string|max:50', + 'name' => 'sometimes|string|max:100', + 'equipment_type' => 'nullable|string|max:50', + 'specification' => 'nullable|string|max:200', + 'manufacturer' => 'nullable|string|max:100', + 'model_name' => 'nullable|string|max:100', + 'serial_no' => 'nullable|string|max:100', + 'location' => 'nullable|string|max:100', + 'production_line' => 'nullable|string|max:50', + 'purchase_date' => 'nullable|date', + 'install_date' => 'nullable|date', + 'purchase_price' => 'nullable|numeric|min:0', + 'useful_life' => 'nullable|integer|min:0', + 'status' => 'nullable|in:active,idle,disposed', + 'disposed_date' => 'nullable|date', + 'manager_id' => 'nullable|integer|exists:users,id', + 'sub_manager_id' => 'nullable|integer|exists:users,id', + 'photo_path' => 'nullable|string|max:500', + 'memo' => 'nullable|string', + 'options' => 'nullable|array', + 'is_active' => 'nullable|boolean', + 'sort_order' => 'nullable|integer|min:0', + ]; + } +} diff --git a/app/Http/Requests/V1/Equipment/UpdateInspectionNotesRequest.php b/app/Http/Requests/V1/Equipment/UpdateInspectionNotesRequest.php new file mode 100644 index 00000000..d757053e --- /dev/null +++ b/app/Http/Requests/V1/Equipment/UpdateInspectionNotesRequest.php @@ -0,0 +1,26 @@ + 'required|integer|exists:equipments,id', + 'year_month' => 'required|string', + 'cycle' => 'nullable|string|in:daily,weekly,monthly,bimonthly,quarterly,semiannual', + 'overall_judgment' => 'nullable|string|in:OK,NG', + 'inspector_id' => 'nullable|integer|exists:users,id', + 'repair_note' => 'nullable|string', + 'issue_note' => 'nullable|string', + ]; + } +} diff --git a/app/Models/Equipment/Equipment.php b/app/Models/Equipment/Equipment.php new file mode 100644 index 00000000..3c7d996d --- /dev/null +++ b/app/Models/Equipment/Equipment.php @@ -0,0 +1,154 @@ + 'date', + 'install_date' => 'date', + 'disposed_date' => 'date', + 'purchase_price' => 'decimal:2', + 'is_active' => 'boolean', + 'options' => 'array', + ]; + + public function getOption(string $key, mixed $default = null): mixed + { + return data_get($this->options, $key, $default); + } + + public function setOption(string $key, mixed $value): self + { + $options = $this->options ?? []; + data_set($options, $key, $value); + $this->options = $options; + + return $this; + } + + public function manager(): BelongsTo + { + return $this->belongsTo(\App\Models\User::class, 'manager_id'); + } + + public function subManager(): BelongsTo + { + return $this->belongsTo(\App\Models\User::class, 'sub_manager_id'); + } + + public function canInspect(?int $userId = null): bool + { + if (! $userId) { + return false; + } + + return $this->manager_id === $userId || $this->sub_manager_id === $userId; + } + + public function inspectionTemplates(): HasMany + { + return $this->hasMany(EquipmentInspectionTemplate::class, 'equipment_id')->orderBy('sort_order'); + } + + public function inspections(): HasMany + { + return $this->hasMany(EquipmentInspection::class, 'equipment_id'); + } + + public function repairs(): HasMany + { + return $this->hasMany(EquipmentRepair::class, 'equipment_id'); + } + + public function photos(): HasMany + { + return $this->hasMany(File::class, 'document_id') + ->where('document_type', 'equipment') + ->orderBy('id'); + } + + public function processes(): BelongsToMany + { + return $this->belongsToMany(\App\Models\Process::class, 'equipment_process') + ->withPivot('is_primary') + ->withTimestamps(); + } + + public function scopeByLine($query, string $line) + { + return $query->where('production_line', $line); + } + + public function scopeByType($query, string $type) + { + return $query->where('equipment_type', $type); + } + + public function scopeByStatus($query, string $status) + { + return $query->where('status', $status); + } + + public static function getEquipmentTypes(): array + { + return ['포밍기', '미싱기', '샤링기', 'V컷팅기', '절곡기', '프레스', '드릴', '기타']; + } + + public static function getProductionLines(): array + { + return ['스라트', '스크린', '절곡', '기타']; + } + + public static function getStatuses(): array + { + return [ + 'active' => '가동', + 'idle' => '유휴', + 'disposed' => '폐기', + ]; + } +} diff --git a/app/Models/Equipment/EquipmentInspection.php b/app/Models/Equipment/EquipmentInspection.php new file mode 100644 index 00000000..b8c7e338 --- /dev/null +++ b/app/Models/Equipment/EquipmentInspection.php @@ -0,0 +1,43 @@ +belongsTo(Equipment::class, 'equipment_id'); + } + + public function inspector(): BelongsTo + { + return $this->belongsTo(\App\Models\User::class, 'inspector_id'); + } + + public function details(): HasMany + { + return $this->hasMany(EquipmentInspectionDetail::class, 'inspection_id'); + } +} diff --git a/app/Models/Equipment/EquipmentInspectionDetail.php b/app/Models/Equipment/EquipmentInspectionDetail.php new file mode 100644 index 00000000..382b23fa --- /dev/null +++ b/app/Models/Equipment/EquipmentInspectionDetail.php @@ -0,0 +1,55 @@ + 'date', + ]; + + public function inspection(): BelongsTo + { + return $this->belongsTo(EquipmentInspection::class, 'inspection_id'); + } + + public function templateItem(): BelongsTo + { + return $this->belongsTo(EquipmentInspectionTemplate::class, 'template_item_id'); + } + + public static function getNextResult(?string $current): ?string + { + return match ($current) { + null, '' => 'good', + 'good' => 'bad', + 'bad' => 'repaired', + 'repaired' => null, + default => 'good', + }; + } + + public static function getResultSymbol(?string $result): string + { + return match ($result) { + 'good' => '○', + 'bad' => 'X', + 'repaired' => '△', + default => '', + }; + } +} diff --git a/app/Models/Equipment/EquipmentInspectionTemplate.php b/app/Models/Equipment/EquipmentInspectionTemplate.php new file mode 100644 index 00000000..539c6a62 --- /dev/null +++ b/app/Models/Equipment/EquipmentInspectionTemplate.php @@ -0,0 +1,42 @@ + 'boolean', + ]; + + public function equipment(): BelongsTo + { + return $this->belongsTo(Equipment::class, 'equipment_id'); + } + + public function scopeByCycle($query, string $cycle) + { + return $query->where('inspection_cycle', $cycle); + } +} diff --git a/app/Models/Equipment/EquipmentProcess.php b/app/Models/Equipment/EquipmentProcess.php new file mode 100644 index 00000000..ca8260f6 --- /dev/null +++ b/app/Models/Equipment/EquipmentProcess.php @@ -0,0 +1,31 @@ + 'boolean', + ]; + + public function equipment(): BelongsTo + { + return $this->belongsTo(Equipment::class, 'equipment_id'); + } + + public function process(): BelongsTo + { + return $this->belongsTo(\App\Models\Process::class, 'process_id'); + } +} diff --git a/app/Models/Equipment/EquipmentRepair.php b/app/Models/Equipment/EquipmentRepair.php new file mode 100644 index 00000000..60733ed6 --- /dev/null +++ b/app/Models/Equipment/EquipmentRepair.php @@ -0,0 +1,62 @@ + 'date', + 'repair_hours' => 'decimal:1', + 'cost' => 'decimal:2', + 'options' => 'array', + ]; + + public function getOption(string $key, mixed $default = null): mixed + { + return data_get($this->options, $key, $default); + } + + public function setOption(string $key, mixed $value): self + { + $options = $this->options ?? []; + data_set($options, $key, $value); + $this->options = $options; + + return $this; + } + + public function equipment(): BelongsTo + { + return $this->belongsTo(Equipment::class, 'equipment_id'); + } + + public function repairer(): BelongsTo + { + return $this->belongsTo(\App\Models\User::class, 'repaired_by'); + } +} diff --git a/app/Services/Equipment/EquipmentInspectionService.php b/app/Services/Equipment/EquipmentInspectionService.php new file mode 100644 index 00000000..ebc5972d --- /dev/null +++ b/app/Services/Equipment/EquipmentInspectionService.php @@ -0,0 +1,376 @@ +where('status', '!=', 'disposed') + ->with(['manager', 'subManager']); + + if ($productionLine) { + $equipmentQuery->byLine($productionLine); + } + + if ($equipmentId) { + $equipmentQuery->where('id', $equipmentId); + } + + $equipments = $equipmentQuery->orderBy('sort_order')->orderBy('name')->get(); + + $labels = InspectionCycle::columnLabels($cycle, $period); + $userId = $this->apiUserId(); + + $result = []; + + foreach ($equipments as $equipment) { + $templates = EquipmentInspectionTemplate::where('equipment_id', $equipment->id) + ->byCycle($cycle) + ->active() + ->orderBy('sort_order') + ->get(); + + if ($templates->isEmpty()) { + continue; + } + + $inspection = EquipmentInspection::where('equipment_id', $equipment->id) + ->where('inspection_cycle', $cycle) + ->where('year_month', $period) + ->first(); + + $details = []; + if ($inspection) { + $details = EquipmentInspectionDetail::where('inspection_id', $inspection->id) + ->get() + ->groupBy(function ($d) { + return $d->template_item_id.'_'.$d->check_date->format('Y-m-d'); + }); + } + + $result[] = [ + 'equipment' => $equipment, + 'templates' => $templates, + 'inspection' => $inspection, + 'details' => $details, + 'labels' => $labels, + 'can_inspect' => $equipment->canInspect($userId), + ]; + } + + return $result; + } + + public function toggleDetail(int $equipmentId, int $templateItemId, string $checkDate, string $cycle = 'daily'): array + { + return DB::transaction(function () use ($equipmentId, $templateItemId, $checkDate, $cycle) { + $equipment = Equipment::find($equipmentId); + if (! $equipment) { + throw new NotFoundHttpException(__('error.equipment.not_found')); + } + + $userId = $this->apiUserId(); + if (! $equipment->canInspect($userId)) { + throw new AccessDeniedHttpException(__('error.equipment.no_inspect_permission')); + } + + $period = InspectionCycle::resolvePeriod($cycle, $checkDate); + if ($cycle === InspectionCycle::DAILY) { + $tenantId = $this->tenantId(); + $holidayDates = InspectionCycle::getHolidayDates($cycle, $period, $tenantId); + if (InspectionCycle::isNonWorkingDay($checkDate, $holidayDates)) { + throw new BadRequestHttpException(__('error.equipment.non_working_day')); + } + } + + $tenantId = $this->tenantId(); + + $inspection = EquipmentInspection::firstOrCreate( + [ + 'tenant_id' => $tenantId, + 'equipment_id' => $equipmentId, + 'inspection_cycle' => $cycle, + 'year_month' => $period, + ], + [ + 'created_by' => $userId, + ] + ); + + $detail = EquipmentInspectionDetail::where('inspection_id', $inspection->id) + ->where('template_item_id', $templateItemId) + ->where('check_date', $checkDate) + ->first(); + + if ($detail) { + $nextResult = EquipmentInspectionDetail::getNextResult($detail->result); + if ($nextResult === null) { + $detail->delete(); + + return ['result' => null, 'symbol' => '']; + } + $detail->update(['result' => $nextResult]); + } else { + $detail = EquipmentInspectionDetail::create([ + 'inspection_id' => $inspection->id, + 'template_item_id' => $templateItemId, + 'check_date' => $checkDate, + 'result' => 'good', + ]); + $nextResult = 'good'; + } + + return [ + 'result' => $nextResult, + 'symbol' => EquipmentInspectionDetail::getResultSymbol($nextResult), + ]; + }); + } + + public function setResult(int $equipmentId, int $templateItemId, string $checkDate, string $cycle, ?string $result): array + { + return DB::transaction(function () use ($equipmentId, $templateItemId, $checkDate, $cycle, $result) { + $equipment = Equipment::find($equipmentId); + if (! $equipment) { + throw new NotFoundHttpException(__('error.equipment.not_found')); + } + + $userId = $this->apiUserId(); + if (! $equipment->canInspect($userId)) { + throw new AccessDeniedHttpException(__('error.equipment.no_inspect_permission')); + } + + $period = InspectionCycle::resolvePeriod($cycle, $checkDate); + if ($cycle === InspectionCycle::DAILY) { + $tenantId = $this->tenantId(); + $holidayDates = InspectionCycle::getHolidayDates($cycle, $period, $tenantId); + if (InspectionCycle::isNonWorkingDay($checkDate, $holidayDates)) { + throw new BadRequestHttpException(__('error.equipment.non_working_day')); + } + } + + $tenantId = $this->tenantId(); + + $inspection = EquipmentInspection::firstOrCreate( + [ + 'tenant_id' => $tenantId, + 'equipment_id' => $equipmentId, + 'inspection_cycle' => $cycle, + 'year_month' => $period, + ], + [ + 'created_by' => $userId, + ] + ); + + $detail = EquipmentInspectionDetail::where('inspection_id', $inspection->id) + ->where('template_item_id', $templateItemId) + ->where('check_date', $checkDate) + ->first(); + + if ($result === null) { + if ($detail) { + $detail->delete(); + } + + return ['result' => null, 'symbol' => '']; + } + + if ($detail) { + $detail->update(['result' => $result]); + } else { + $detail = EquipmentInspectionDetail::create([ + 'inspection_id' => $inspection->id, + 'template_item_id' => $templateItemId, + 'check_date' => $checkDate, + 'result' => $result, + ]); + } + + return [ + 'result' => $result, + 'symbol' => EquipmentInspectionDetail::getResultSymbol($result), + ]; + }); + } + + public function updateNotes(int $equipmentId, string $yearMonth, array $data, string $cycle = 'daily'): EquipmentInspection + { + return DB::transaction(function () use ($equipmentId, $yearMonth, $data, $cycle) { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $inspection = EquipmentInspection::firstOrCreate( + [ + 'tenant_id' => $tenantId, + 'equipment_id' => $equipmentId, + 'inspection_cycle' => $cycle, + 'year_month' => $yearMonth, + ], + [ + 'created_by' => $userId, + ] + ); + + $inspection->update($data); + + return $inspection->fresh(); + }); + } + + public function resetInspection(int $equipmentId, string $cycle, string $period): int + { + return DB::transaction(function () use ($equipmentId, $cycle, $period) { + $equipment = Equipment::find($equipmentId); + if (! $equipment) { + throw new NotFoundHttpException(__('error.equipment.not_found')); + } + + $userId = $this->apiUserId(); + if (! $equipment->canInspect($userId)) { + throw new AccessDeniedHttpException(__('error.equipment.no_inspect_permission')); + } + + $tenantId = $this->tenantId(); + + $inspection = EquipmentInspection::where('tenant_id', $tenantId) + ->where('equipment_id', $equipmentId) + ->where('inspection_cycle', $cycle) + ->where('year_month', $period) + ->first(); + + if (! $inspection) { + return 0; + } + + $deleted = EquipmentInspectionDetail::where('inspection_id', $inspection->id)->delete(); + $inspection->update([ + 'overall_judgment' => null, + 'repair_note' => null, + 'issue_note' => null, + 'inspector_id' => null, + ]); + + return $deleted; + }); + } + + public function saveTemplate(int $equipmentId, array $data): EquipmentInspectionTemplate + { + return DB::transaction(function () use ($equipmentId, $data) { + $tenantId = $this->tenantId(); + + return EquipmentInspectionTemplate::create(array_merge($data, [ + 'tenant_id' => $tenantId, + 'equipment_id' => $equipmentId, + ])); + }); + } + + public function updateTemplate(int $id, array $data): EquipmentInspectionTemplate + { + return DB::transaction(function () use ($id, $data) { + $template = EquipmentInspectionTemplate::find($id); + + if (! $template) { + throw new NotFoundHttpException(__('error.equipment.template_not_found')); + } + + $template->update($data); + + return $template->fresh(); + }); + } + + public function deleteTemplate(int $id): bool + { + return DB::transaction(function () use ($id) { + $template = EquipmentInspectionTemplate::find($id); + + if (! $template) { + throw new NotFoundHttpException(__('error.equipment.template_not_found')); + } + + return $template->delete(); + }); + } + + public function copyTemplates(int $equipmentId, string $sourceCycle, array $targetCycles): array + { + return DB::transaction(function () use ($equipmentId, $sourceCycle, $targetCycles) { + $tenantId = $this->tenantId(); + + $sourceTemplates = EquipmentInspectionTemplate::where('equipment_id', $equipmentId) + ->byCycle($sourceCycle) + ->active() + ->orderBy('sort_order') + ->get(); + + if ($sourceTemplates->isEmpty()) { + throw new BadRequestHttpException(__('error.equipment.no_source_templates')); + } + + $copiedCount = 0; + $skippedCount = 0; + + foreach ($targetCycles as $targetCycle) { + foreach ($sourceTemplates as $template) { + $exists = EquipmentInspectionTemplate::where('equipment_id', $equipmentId) + ->where('inspection_cycle', $targetCycle) + ->where('item_no', $template->item_no) + ->exists(); + + if ($exists) { + $skippedCount++; + + continue; + } + + EquipmentInspectionTemplate::create([ + 'tenant_id' => $tenantId, + 'equipment_id' => $equipmentId, + 'inspection_cycle' => $targetCycle, + 'item_no' => $template->item_no, + 'check_point' => $template->check_point, + 'check_item' => $template->check_item, + 'check_timing' => $template->check_timing, + 'check_frequency' => $template->check_frequency, + 'check_method' => $template->check_method, + 'sort_order' => $template->sort_order, + 'is_active' => true, + ]); + $copiedCount++; + } + } + + return [ + 'copied' => $copiedCount, + 'skipped' => $skippedCount, + 'source_count' => $sourceTemplates->count(), + 'target_cycles' => $targetCycles, + ]; + }); + } + + public function getActiveCycles(int $equipmentId): array + { + return EquipmentInspectionTemplate::where('equipment_id', $equipmentId) + ->active() + ->distinct() + ->pluck('inspection_cycle') + ->toArray(); + } +} diff --git a/app/Services/Equipment/EquipmentPhotoService.php b/app/Services/Equipment/EquipmentPhotoService.php new file mode 100644 index 00000000..e06cccb8 --- /dev/null +++ b/app/Services/Equipment/EquipmentPhotoService.php @@ -0,0 +1,50 @@ +photos; + } + + public function store(int $equipmentId, array $fileData): File + { + $equipment = Equipment::find($equipmentId); + + if (! $equipment) { + throw new NotFoundHttpException(__('error.equipment.not_found')); + } + + return File::create(array_merge($fileData, [ + 'document_id' => $equipmentId, + 'document_type' => 'equipment', + ])); + } + + public function destroy(int $equipmentId, int $fileId): bool + { + $file = File::where('document_id', $equipmentId) + ->where('document_type', 'equipment') + ->where('id', $fileId) + ->first(); + + if (! $file) { + throw new NotFoundHttpException(__('error.file.not_found')); + } + + return $file->delete(); + } +} diff --git a/app/Services/Equipment/EquipmentRepairService.php b/app/Services/Equipment/EquipmentRepairService.php new file mode 100644 index 00000000..47826b01 --- /dev/null +++ b/app/Services/Equipment/EquipmentRepairService.php @@ -0,0 +1,102 @@ +with('equipment', 'repairer'); + + if (! empty($filters['equipment_id'])) { + $query->where('equipment_id', $filters['equipment_id']); + } + + if (! empty($filters['repair_type'])) { + $query->where('repair_type', $filters['repair_type']); + } + + if (! empty($filters['date_from'])) { + $query->where('repair_date', '>=', $filters['date_from']); + } + + if (! empty($filters['date_to'])) { + $query->where('repair_date', '<=', $filters['date_to']); + } + + if (! empty($filters['search'])) { + $search = $filters['search']; + $query->where(function ($q) use ($search) { + $q->where('description', 'like', "%{$search}%") + ->orWhereHas('equipment', function ($eq) use ($search) { + $eq->where('name', 'like', "%{$search}%") + ->orWhere('equipment_code', 'like', "%{$search}%"); + }); + }); + } + + return $query->orderBy('repair_date', 'desc')->paginate($filters['per_page'] ?? 20); + } + + public function show(int $id): EquipmentRepair + { + $repair = EquipmentRepair::with('equipment', 'repairer')->find($id); + + if (! $repair) { + throw new NotFoundHttpException(__('error.equipment.repair_not_found')); + } + + return $repair; + } + + public function store(array $data): EquipmentRepair + { + return DB::transaction(function () use ($data) { + $data['tenant_id'] = $this->tenantId(); + + return EquipmentRepair::create($data); + }); + } + + public function update(int $id, array $data): EquipmentRepair + { + return DB::transaction(function () use ($id, $data) { + $repair = EquipmentRepair::find($id); + + if (! $repair) { + throw new NotFoundHttpException(__('error.equipment.repair_not_found')); + } + + $repair->update($data); + + return $repair->fresh(); + }); + } + + public function destroy(int $id): bool + { + return DB::transaction(function () use ($id) { + $repair = EquipmentRepair::find($id); + + if (! $repair) { + throw new NotFoundHttpException(__('error.equipment.repair_not_found')); + } + + return $repair->delete(); + }); + } + + public function recentRepairs(int $limit = 5): \Illuminate\Database\Eloquent\Collection + { + return EquipmentRepair::with('equipment') + ->orderBy('repair_date', 'desc') + ->limit($limit) + ->get(); + } +} diff --git a/app/Services/Equipment/EquipmentService.php b/app/Services/Equipment/EquipmentService.php new file mode 100644 index 00000000..7d736d54 --- /dev/null +++ b/app/Services/Equipment/EquipmentService.php @@ -0,0 +1,153 @@ +with(['manager', 'subManager']); + + if (! empty($filters['search'])) { + $search = $filters['search']; + $query->where(function ($q) use ($search) { + $q->where('equipment_code', 'like', "%{$search}%") + ->orWhere('name', 'like', "%{$search}%"); + }); + } + + if (! empty($filters['status'])) { + $query->byStatus($filters['status']); + } + + if (! empty($filters['production_line'])) { + $query->byLine($filters['production_line']); + } + + if (! empty($filters['equipment_type'])) { + $query->byType($filters['equipment_type']); + } + + $sortBy = $filters['sort_by'] ?? 'sort_order'; + $sortDir = $filters['sort_direction'] ?? 'asc'; + $query->orderBy($sortBy, $sortDir); + + return $query->paginate($filters['per_page'] ?? 20); + } + + public function show(int $id): Equipment + { + $equipment = Equipment::with(['manager', 'subManager', 'inspectionTemplates', 'repairs', 'processes', 'photos'])->find($id); + + if (! $equipment) { + throw new NotFoundHttpException(__('error.equipment.not_found')); + } + + return $equipment; + } + + public function store(array $data): Equipment + { + return DB::transaction(function () use ($data) { + $data['tenant_id'] = $this->tenantId(); + + return Equipment::create($data); + }); + } + + public function update(int $id, array $data): Equipment + { + return DB::transaction(function () use ($id, $data) { + $equipment = Equipment::find($id); + + if (! $equipment) { + throw new NotFoundHttpException(__('error.equipment.not_found')); + } + + $equipment->update($data); + + return $equipment->fresh(); + }); + } + + public function destroy(int $id): bool + { + return DB::transaction(function () use ($id) { + $equipment = Equipment::find($id); + + if (! $equipment) { + throw new NotFoundHttpException(__('error.equipment.not_found')); + } + + return $equipment->delete(); + }); + } + + public function restore(int $id): Equipment + { + return DB::transaction(function () use ($id) { + $equipment = Equipment::onlyTrashed()->find($id); + + if (! $equipment) { + throw new NotFoundHttpException(__('error.equipment.not_found')); + } + + $equipment->restore(); + + return $equipment->fresh(); + }); + } + + public function toggleActive(int $id): Equipment + { + return DB::transaction(function () use ($id) { + $equipment = Equipment::find($id); + + if (! $equipment) { + throw new NotFoundHttpException(__('error.equipment.not_found')); + } + + $equipment->update(['is_active' => ! $equipment->is_active]); + + return $equipment->fresh(); + }); + } + + public function stats(): array + { + $total = Equipment::count(); + $active = Equipment::where('status', 'active')->count(); + $idle = Equipment::where('status', 'idle')->count(); + $disposed = Equipment::where('status', 'disposed')->count(); + + return compact('total', 'active', 'idle', 'disposed'); + } + + public function options(): array + { + return [ + 'equipment_types' => Equipment::getEquipmentTypes(), + 'production_lines' => Equipment::getProductionLines(), + 'statuses' => Equipment::getStatuses(), + 'equipment_list' => Equipment::active() + ->orderBy('sort_order') + ->orderBy('name') + ->get(['id', 'equipment_code', 'name', 'equipment_type', 'production_line']), + ]; + } + + public function typeStats(): array + { + return Equipment::where('status', '!=', 'disposed') + ->selectRaw('equipment_type, count(*) as count') + ->groupBy('equipment_type') + ->pluck('count', 'equipment_type') + ->toArray(); + } +} diff --git a/database/migrations/2026_03_12_100000_add_options_to_equipment_tables.php b/database/migrations/2026_03_12_100000_add_options_to_equipment_tables.php new file mode 100644 index 00000000..290a0a0b --- /dev/null +++ b/database/migrations/2026_03_12_100000_add_options_to_equipment_tables.php @@ -0,0 +1,38 @@ +json('options')->nullable()->after('memo')->comment('확장 속성 JSON'); + }); + } + + if (Schema::hasTable('equipment_repairs') && ! Schema::hasColumn('equipment_repairs', 'options')) { + Schema::table('equipment_repairs', function (Blueprint $table) { + $table->json('options')->nullable()->after('memo')->comment('확장 속성 JSON'); + }); + } + } + + public function down(): void + { + if (Schema::hasTable('equipments') && Schema::hasColumn('equipments', 'options')) { + Schema::table('equipments', function (Blueprint $table) { + $table->dropColumn('options'); + }); + } + + if (Schema::hasTable('equipment_repairs') && Schema::hasColumn('equipment_repairs', 'options')) { + Schema::table('equipment_repairs', function (Blueprint $table) { + $table->dropColumn('options'); + }); + } + } +}; diff --git a/lang/ko/error.php b/lang/ko/error.php index 6418ac09..4b426e08 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -472,6 +472,17 @@ 'invalid_status' => '유효하지 않은 입찰 상태입니다.', ], + // 설비 관리 + 'equipment' => [ + 'not_found' => '설비 정보를 찾을 수 없습니다.', + 'template_not_found' => '점검항목을 찾을 수 없습니다.', + 'inspection_not_found' => '점검 데이터를 찾을 수 없습니다.', + 'repair_not_found' => '수리이력을 찾을 수 없습니다.', + 'photo_not_found' => '사진을 찾을 수 없습니다.', + 'invalid_cycle' => '유효하지 않은 점검주기입니다.', + 'no_source_templates' => '복사할 점검항목이 없습니다.', + ], + // 전자계약 (E-Sign) 'esign' => [ 'invalid_token' => '유효하지 않은 서명 링크입니다.', diff --git a/lang/ko/message.php b/lang/ko/message.php index a727c996..46159a0e 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -575,6 +575,20 @@ 'downloaded' => '문서가 다운로드되었습니다.', ], + // 설비 관리 + 'equipment' => [ + 'created' => '설비가 등록되었습니다.', + 'updated' => '설비 정보가 수정되었습니다.', + 'deleted' => '설비가 삭제되었습니다.', + 'restored' => '설비가 복원되었습니다.', + 'inspection_saved' => '점검 정보가 저장되었습니다.', + 'inspection_reset' => '점검 데이터가 초기화되었습니다.', + 'template_created' => '점검항목이 추가되었습니다.', + 'template_copied' => '점검항목이 복사되었습니다.', + 'repair_created' => '수리이력이 등록되었습니다.', + 'photo_uploaded' => '사진이 업로드되었습니다.', + ], + // 일반전표입력 'journal_entry' => [ 'fetched' => '전표 조회 성공', diff --git a/routes/api.php b/routes/api.php index 0d329de2..e82f5e5b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -42,6 +42,7 @@ require __DIR__.'/api/v1/audit.php'; require __DIR__.'/api/v1/esign.php'; require __DIR__.'/api/v1/quality.php'; + require __DIR__.'/api/v1/equipment.php'; // 공유 링크 다운로드 (인증 불필요 - auth.apikey 그룹 밖) Route::get('/files/share/{token}', [FileStorageController::class, 'downloadShared'])->name('v1.files.share.download'); diff --git a/routes/api/v1/equipment.php b/routes/api/v1/equipment.php new file mode 100644 index 00000000..619f1b4c --- /dev/null +++ b/routes/api/v1/equipment.php @@ -0,0 +1,45 @@ +group(function () { + // 설비 CRUD + Route::get('', [EquipmentController::class, 'index'])->name('v1.equipment.index'); + Route::get('/options', [EquipmentController::class, 'options'])->name('v1.equipment.options'); + Route::get('/stats', [EquipmentController::class, 'stats'])->name('v1.equipment.stats'); + Route::post('', [EquipmentController::class, 'store'])->name('v1.equipment.store'); + Route::get('/{id}', [EquipmentController::class, 'show'])->whereNumber('id')->name('v1.equipment.show'); + Route::put('/{id}', [EquipmentController::class, 'update'])->whereNumber('id')->name('v1.equipment.update'); + Route::delete('/{id}', [EquipmentController::class, 'destroy'])->whereNumber('id')->name('v1.equipment.destroy'); + Route::post('/{id}/restore', [EquipmentController::class, 'restore'])->whereNumber('id')->name('v1.equipment.restore'); + Route::patch('/{id}/toggle', [EquipmentController::class, 'toggleActive'])->whereNumber('id')->name('v1.equipment.toggle'); + + // 점검 템플릿 + Route::get('/{id}/templates', [EquipmentInspectionController::class, 'templates'])->whereNumber('id')->name('v1.equipment.templates'); + Route::post('/{id}/templates', [EquipmentInspectionController::class, 'storeTemplate'])->whereNumber('id')->name('v1.equipment.templates.store'); + Route::put('/templates/{templateId}', [EquipmentInspectionController::class, 'updateTemplate'])->whereNumber('templateId')->name('v1.equipment.templates.update'); + Route::delete('/templates/{templateId}', [EquipmentInspectionController::class, 'deleteTemplate'])->whereNumber('templateId')->name('v1.equipment.templates.destroy'); + Route::post('/{id}/templates/copy', [EquipmentInspectionController::class, 'copyTemplates'])->whereNumber('id')->name('v1.equipment.templates.copy'); + + // 점검 + Route::get('/inspections', [EquipmentInspectionController::class, 'index'])->name('v1.equipment.inspections.index'); + Route::patch('/inspections/toggle', [EquipmentInspectionController::class, 'toggleDetail'])->name('v1.equipment.inspections.toggle'); + Route::patch('/inspections/set-result', [EquipmentInspectionController::class, 'setResult'])->name('v1.equipment.inspections.set-result'); + Route::patch('/inspections/notes', [EquipmentInspectionController::class, 'updateNotes'])->name('v1.equipment.inspections.notes'); + Route::delete('/inspections/reset', [EquipmentInspectionController::class, 'resetInspection'])->name('v1.equipment.inspections.reset'); + + // 수리이력 + Route::get('/repairs', [EquipmentRepairController::class, 'index'])->name('v1.equipment.repairs.index'); + Route::post('/repairs', [EquipmentRepairController::class, 'store'])->name('v1.equipment.repairs.store'); + Route::put('/repairs/{id}', [EquipmentRepairController::class, 'update'])->whereNumber('id')->name('v1.equipment.repairs.update'); + Route::delete('/repairs/{id}', [EquipmentRepairController::class, 'destroy'])->whereNumber('id')->name('v1.equipment.repairs.destroy'); + + // 사진 + Route::get('/{id}/photos', [EquipmentPhotoController::class, 'index'])->whereNumber('id')->name('v1.equipment.photos.index'); + Route::post('/{id}/photos', [EquipmentPhotoController::class, 'store'])->whereNumber('id')->name('v1.equipment.photos.store'); + Route::delete('/{id}/photos/{fileId}', [EquipmentPhotoController::class, 'destroy'])->whereNumber('id')->name('v1.equipment.photos.destroy'); +});