diff --git a/LOGICAL_RELATIONSHIPS.md b/LOGICAL_RELATIONSHIPS.md index 6174ca53..34aca152 100644 --- a/LOGICAL_RELATIONSHIPS.md +++ b/LOGICAL_RELATIONSHIPS.md @@ -1,6 +1,6 @@ # 논리적 데이터베이스 관계 문서 -> **자동 생성**: 2026-03-19 16:09:46 +> **자동 생성**: 2026-03-20 16:30:28 > **소스**: Eloquent 모델 관계 분석 ## 📊 모델별 관계 현황 @@ -88,6 +88,16 @@ ### hometax_invoice_journals - **tenant()**: belongsTo → `tenants` - **invoice()**: belongsTo → `hometax_invoices` +### bending_items +**모델**: `App\Models\BendingItem` + +- **files()**: hasMany → `files` + +### bending_models +**모델**: `App\Models\BendingModel` + +- **files()**: hasMany → `files` + ### biddings **모델**: `App\Models\Bidding\Bidding` @@ -563,6 +573,25 @@ ### material_receipts - **material()**: belongsTo → `materials` - **inspections()**: hasMany → `material_inspections` +### nonconforming_reports +**모델**: `App\Models\Materials\NonconformingReport` + +- **approval()**: belongsTo → `approvals` +- **order()**: belongsTo → `orders` +- **item()**: belongsTo → `items` +- **department()**: belongsTo → `departments` +- **creator()**: belongsTo → `users` +- **actionManager()**: belongsTo → `users` +- **relatedEmployee()**: belongsTo → `users` +- **items()**: hasMany → `nonconforming_report_items` +- **files()**: morphMany → `files` + +### nonconforming_report_items +**모델**: `App\Models\Materials\NonconformingReportItem` + +- **report()**: belongsTo → `nonconforming_reports` +- **item()**: belongsTo → `items` + ### users **모델**: `App\Models\Members\User` @@ -723,11 +752,6 @@ ### process_steps - **process()**: belongsTo → `processes` -### bending_item_mappings -**모델**: `App\Models\Production\BendingItemMapping` - -- **item()**: belongsTo → `items` - ### work_orders **모델**: `App\Models\Production\WorkOrder` @@ -815,6 +839,7 @@ ### parts ### prices **모델**: `App\Models\Products\Price` +- **item()**: belongsTo → `items` - **clientGroup()**: belongsTo → `client_groups` - **revisions()**: hasMany → `price_revisions` @@ -1087,6 +1112,10 @@ ### cards - **creator()**: belongsTo → `users` - **updater()**: belongsTo → `users` +### condolence_expenses +**모델**: `App\Models\Tenants\CondolenceExpense` + + ### data_exports **모델**: `App\Models\Tenants\DataExport` diff --git a/app/Http/Controllers/Api/V1/BendingItemController.php b/app/Http/Controllers/Api/V1/BendingItemController.php index ec948b37..56f9ad89 100644 --- a/app/Http/Controllers/Api/V1/BendingItemController.php +++ b/app/Http/Controllers/Api/V1/BendingItemController.php @@ -53,6 +53,19 @@ public function filters(Request $request): JsonResponse ); } + public function prefixes(Request $request): JsonResponse + { + $this->ensureContext($request); + + return ApiResponse::handle( + fn () => [ + 'prefixes' => $this->service->prefixes(), + 'labels' => BendingItemService::PREFIX_LABELS, + ], + __('message.fetched') + ); + } + public function show(Request $request, int $id): JsonResponse { $this->ensureContext($request); @@ -83,6 +96,16 @@ public function update(BendingItemUpdateRequest $request, int $id): JsonResponse ); } + public function duplicate(Request $request, int $id): JsonResponse + { + $this->ensureContext($request); + + return ApiResponse::handle( + fn () => new BendingItemResource($this->service->duplicate($id)), + __('message.created') + ); + } + public function destroy(Request $request, int $id): JsonResponse { $this->ensureContext($request); diff --git a/app/Http/Controllers/Api/V1/StockController.php b/app/Http/Controllers/Api/V1/StockController.php index c19e6eb8..4d2eca28 100644 --- a/app/Http/Controllers/Api/V1/StockController.php +++ b/app/Http/Controllers/Api/V1/StockController.php @@ -73,6 +73,32 @@ public function statsByItemType(): JsonResponse return ApiResponse::success($stats, __('message.fetched')); } + /** + * 재고 수정 (안전재고, 최대재고, 사용상태) + */ + public function update(int $id, Request $request): JsonResponse + { + try { + $data = $request->validate([ + 'safety_stock' => 'nullable|numeric|min:0', + 'max_stock' => 'nullable|numeric|min:0', + 'is_active' => 'nullable|boolean', + ]); + + // 최대재고가 설정된 경우 안전재고 이상이어야 함 + if (isset($data['max_stock']) && $data['max_stock'] > 0 + && isset($data['safety_stock']) && $data['safety_stock'] > $data['max_stock']) { + return ApiResponse::error('최대재고는 안전재고 이상이어야 합니다.', 422); + } + + $stock = $this->service->updateStock($id, $data); + + return ApiResponse::success($stock, __('message.updated')); + } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + return ApiResponse::error(__('error.stock.not_found'), 404); + } + } + /** * 재고 조정 이력 조회 */ diff --git a/app/Http/Controllers/V1/ProcessController.php b/app/Http/Controllers/V1/ProcessController.php index dec46878..039ab1c7 100644 --- a/app/Http/Controllers/V1/ProcessController.php +++ b/app/Http/Controllers/V1/ProcessController.php @@ -95,6 +95,17 @@ public function toggleActive(int $id): JsonResponse ); } + /** + * 공정 복제 + */ + public function duplicate(int $id): JsonResponse + { + return ApiResponse::handle( + fn () => $this->processService->duplicate($id), + 'message.created' + ); + } + /** * 공정 옵션 목록 (드롭다운용) */ diff --git a/app/Http/Middleware/ApiKeyMiddleware.php b/app/Http/Middleware/ApiKeyMiddleware.php index 707ce3ce..fa82e861 100644 --- a/app/Http/Middleware/ApiKeyMiddleware.php +++ b/app/Http/Middleware/ApiKeyMiddleware.php @@ -143,6 +143,13 @@ public function handle(Request $request, Closure $next) 'api/v1/internal/exchange-token', // 내부 서버간 토큰 교환 (HMAC 인증 사용) 'api/v1/admin/fcm/*', // Admin FCM API (MNG에서 API Key만으로 접근) 'api/v1/app/*', // 앱 버전 확인/다운로드 (API Key만 필요) + 'api/v1/bending-items', // 절곡 기초관리 (MNG에서 API Key + X-TENANT-ID로 접근) + 'api/v1/bending-items/*', // 절곡 기초관리 상세/수정/삭제 + 'api/v1/guiderail-models', // 절곡품 가이드레일 (MNG에서 API Key + X-TENANT-ID로 접근) + 'api/v1/guiderail-models/*', // 절곡품 가이드레일 상세 + 'api/v1/items/*/files', // 품목 파일 (절곡품 이미지 업로드/조회) + 'api/v1/files/*/presigned-url', // 파일 presigned URL (이미지 표시) + 'api/v1/files/presigned-url-by-path', // 파일 경로 기반 presigned URL (문서양식 섹션 이미지) ]; // 현재 라우트 확인 (경로 또는 이름) diff --git a/app/Http/Requests/Order/StoreOrderRequest.php b/app/Http/Requests/Order/StoreOrderRequest.php index e4a4a5c4..312acf2c 100644 --- a/app/Http/Requests/Order/StoreOrderRequest.php +++ b/app/Http/Requests/Order/StoreOrderRequest.php @@ -57,6 +57,7 @@ public function rules(): array 'options.manager_name' => 'nullable|string|max:100', 'options.production_reason' => 'nullable|string|max:500', 'options.target_stock_qty' => 'nullable|numeric|min:0', + 'options.reg_date' => 'nullable|date', // 절곡품 LOT 정보 (STOCK 전용) 'options.bending_lot' => 'nullable|array', @@ -70,7 +71,7 @@ public function rules(): array // 품목 배열 'items' => 'nullable|array', - 'items.*.item_id' => 'nullable|integer|exists:items,id', + 'items.*.item_id' => 'nullable|integer', 'items.*.item_code' => 'nullable|string|max:50', 'items.*.item_name' => 'required|string|max:200', 'items.*.specification' => 'nullable|string|max:500', diff --git a/app/Http/Requests/Order/UpdateOrderRequest.php b/app/Http/Requests/Order/UpdateOrderRequest.php index 2a2b17be..5fb611e6 100644 --- a/app/Http/Requests/Order/UpdateOrderRequest.php +++ b/app/Http/Requests/Order/UpdateOrderRequest.php @@ -51,6 +51,7 @@ public function rules(): array 'options.manager_name' => 'nullable|string|max:100', 'options.production_reason' => 'nullable|string|max:500', 'options.target_stock_qty' => 'nullable|numeric|min:0', + 'options.reg_date' => 'nullable|date', // 절곡품 LOT 정보 (STOCK 전용) 'options.bending_lot' => 'nullable|array', @@ -64,7 +65,7 @@ public function rules(): array // 품목 배열 (전체 교체) 'items' => 'nullable|array', - 'items.*.item_id' => 'nullable|integer|exists:items,id', + 'items.*.item_id' => 'nullable|integer', 'items.*.item_code' => 'nullable|string|max:50', 'items.*.item_name' => 'sometimes|required|string|max:200', 'items.*.specification' => 'nullable|string|max:500', diff --git a/app/Http/Requests/V1/Process/StoreProcessRequest.php b/app/Http/Requests/V1/Process/StoreProcessRequest.php index 68eda989..93e173d7 100644 --- a/app/Http/Requests/V1/Process/StoreProcessRequest.php +++ b/app/Http/Requests/V1/Process/StoreProcessRequest.php @@ -14,6 +14,7 @@ public function authorize(): bool public function rules(): array { return [ + 'parent_id' => ['nullable', 'integer', 'exists:processes,id'], 'process_name' => ['required', 'string', 'max:100'], 'description' => ['nullable', 'string'], 'process_type' => ['required', 'string', 'in:생산,검사,포장,조립'], diff --git a/app/Http/Requests/V1/Process/UpdateProcessRequest.php b/app/Http/Requests/V1/Process/UpdateProcessRequest.php index 36579148..041ae778 100644 --- a/app/Http/Requests/V1/Process/UpdateProcessRequest.php +++ b/app/Http/Requests/V1/Process/UpdateProcessRequest.php @@ -13,7 +13,10 @@ public function authorize(): bool public function rules(): array { + $processId = $this->route('id'); + return [ + 'parent_id' => ['nullable', 'integer', 'exists:processes,id', "not_in:{$processId}"], 'process_name' => ['sometimes', 'required', 'string', 'max:100'], 'description' => ['nullable', 'string'], 'process_type' => ['sometimes', 'required', 'string', 'in:생산,검사,포장,조립'], diff --git a/app/Http/Requests/V1/Receiving/StoreReceivingRequest.php b/app/Http/Requests/V1/Receiving/StoreReceivingRequest.php index 8b94a5fc..777c31cf 100644 --- a/app/Http/Requests/V1/Receiving/StoreReceivingRequest.php +++ b/app/Http/Requests/V1/Receiving/StoreReceivingRequest.php @@ -21,13 +21,13 @@ public function rules(): array 'item_name' => ['required', 'string', 'max:200'], 'specification' => ['nullable', 'string', 'max:200'], 'supplier' => ['required', 'string', 'max:100'], - 'order_qty' => ['nullable', 'numeric', 'min:0'], + 'order_qty' => ['required', 'numeric', 'min:0.01'], 'order_unit' => ['nullable', 'string', 'max:20'], 'due_date' => ['nullable', 'date'], 'receiving_qty' => ['nullable', 'numeric', 'min:0'], 'receiving_date' => ['nullable', 'date'], 'lot_no' => ['nullable', 'string', 'max:50'], - 'status' => ['nullable', 'string', 'in:order_completed,shipping,inspection_pending,receiving_pending'], + 'status' => ['nullable', 'string', 'in:order_completed,shipping,inspection_pending,receiving_pending,inspection_completed,completed'], 'remark' => ['nullable', 'string', 'max:1000'], 'manufacturer' => ['nullable', 'string', 'max:100'], 'material_no' => ['nullable', 'string', 'max:50'], diff --git a/app/Http/Resources/Api/V1/BendingItemResource.php b/app/Http/Resources/Api/V1/BendingItemResource.php index 01552477..1cea112b 100644 --- a/app/Http/Resources/Api/V1/BendingItemResource.php +++ b/app/Http/Resources/Api/V1/BendingItemResource.php @@ -77,6 +77,10 @@ private function getImageFileId(): ?int private function getImageUrl(): ?string { - return $this->getImageFile()?->presignedUrl(); + try { + return $this->getImageFile()?->presignedUrl(); + } catch (\Throwable) { + return null; + } } } diff --git a/app/Http/Resources/Api/V1/GuiderailModelResource.php b/app/Http/Resources/Api/V1/GuiderailModelResource.php index ba94ab04..415fe099 100644 --- a/app/Http/Resources/Api/V1/GuiderailModelResource.php +++ b/app/Http/Resources/Api/V1/GuiderailModelResource.php @@ -50,7 +50,7 @@ public function toArray(Request $request): array 'image_file_id' => $this->getImageFileId(), 'image_url' => $this->getImageUrl(), // 부품 조합 - 'components' => $this->enrichComponentsWithImageUrls($components), + 'components' => $this->enrichComponents($components), 'material_summary' => $materialSummary, 'component_count' => count($components), // 메타 @@ -88,23 +88,32 @@ private function getImageUrl(): ?string return $this->getImageFile()?->presignedUrl(); } - private function enrichComponentsWithImageUrls(array $components): array + private function enrichComponents(array $components): array { - $fileIds = array_filter(array_column($components, 'image_file_id')); - if (empty($fileIds)) { - return $components; - } + // sam_item_id → 기초자료 품목코드 매핑 + $itemIds = array_filter(array_column($components, 'sam_item_id')); + $itemCodes = ! empty($itemIds) + ? \App\Models\BendingItem::withoutGlobalScopes()->whereIn('id', $itemIds)->pluck('code', 'id')->toArray() + : []; - $files = \App\Models\Commons\File::whereIn('id', $fileIds) - ->whereNull('deleted_at') - ->get() - ->keyBy('id'); + // image_file_id → presigned URL 매핑 + $fileIds = array_filter(array_column($components, 'image_file_id')); + $files = ! empty($fileIds) + ? \App\Models\Commons\File::whereIn('id', $fileIds)->whereNull('deleted_at')->get()->keyBy('id') + : collect(); foreach ($components as &$comp) { + $samId = $comp['sam_item_id'] ?? null; + $comp['item_code'] = $samId ? ($itemCodes[$samId] ?? null) : null; + $fileId = $comp['image_file_id'] ?? null; - $comp['image_url'] = $fileId && isset($files[$fileId]) - ? $files[$fileId]->presignedUrl() - : null; + try { + $comp['image_url'] = $fileId && $files->has($fileId) + ? $files[$fileId]->presignedUrl() + : null; + } catch (\Throwable) { + $comp['image_url'] = null; + } } unset($comp); @@ -122,6 +131,7 @@ private function calcMaterialSummary(array $components): array $summary[$material] = ($summary[$material] ?? 0) + ($widthSum * $qty); } } + return $summary; } } diff --git a/app/Models/Process.php b/app/Models/Process.php index 6dc1636f..070c69c0 100644 --- a/app/Models/Process.php +++ b/app/Models/Process.php @@ -21,6 +21,7 @@ class Process extends Model protected $fillable = [ 'tenant_id', + 'parent_id', 'process_code', 'process_name', 'description', @@ -47,6 +48,24 @@ class Process extends Model 'required_workers' => 'integer', ]; + /** 부모 공정 */ + public function parent(): BelongsTo + { + return $this->belongsTo(self::class, 'parent_id'); + } + + /** 자식 공정 */ + public function children(): HasMany + { + return $this->hasMany(self::class, 'parent_id')->orderBy('process_code'); + } + + /** 루트 공정만 조회 */ + public function scopeRoots($query) + { + return $query->whereNull('parent_id'); + } + /** * 중간검사 양식 */ diff --git a/app/Models/Tenants/Receiving.php b/app/Models/Tenants/Receiving.php index 68cedf5b..b58984c0 100644 --- a/app/Models/Tenants/Receiving.php +++ b/app/Models/Tenants/Receiving.php @@ -83,6 +83,7 @@ class Receiving extends Model 'shipping' => '배송중', 'inspection_pending' => '검사대기', 'receiving_pending' => '입고대기', + 'inspection_completed' => '검사완료', 'completed' => '입고완료', ]; @@ -191,7 +192,7 @@ public function canEdit(): bool */ public function canDelete(): bool { - return $this->status !== 'completed'; + return ! in_array($this->status, ['completed', 'inspection_completed']); } /** diff --git a/app/Models/Tenants/Stock.php b/app/Models/Tenants/Stock.php index 0b734079..8ff2720c 100644 --- a/app/Models/Tenants/Stock.php +++ b/app/Models/Tenants/Stock.php @@ -23,6 +23,7 @@ class Stock extends Model 'unit', 'stock_qty', 'safety_stock', + 'max_stock', 'reserved_qty', 'available_qty', 'lot_count', @@ -39,6 +40,7 @@ class Stock extends Model protected $casts = [ 'stock_qty' => 'decimal:3', 'safety_stock' => 'decimal:3', + 'max_stock' => 'decimal:3', 'reserved_qty' => 'decimal:3', 'available_qty' => 'decimal:3', 'lot_count' => 'integer', @@ -65,6 +67,7 @@ class Stock extends Model 'normal' => '정상', 'low' => '부족', 'out' => '없음', + 'over' => '초과', ]; /** @@ -140,6 +143,10 @@ public function calculateStatus(): string return 'low'; } + if ($this->max_stock > 0 && $this->stock_qty > $this->max_stock) { + return 'over'; + } + return 'normal'; } diff --git a/app/Services/BendingCodeService.php b/app/Services/BendingCodeService.php index da8f7d4f..d9ca7978 100644 --- a/app/Services/BendingCodeService.php +++ b/app/Services/BendingCodeService.php @@ -3,7 +3,6 @@ namespace App\Services; use App\Models\BendingItem; -use App\Models\Orders\Order; class BendingCodeService extends Service { @@ -128,9 +127,9 @@ public function getCodeMap(): array } /** - * 드롭다운 선택 조합 → bending_items 품목 매핑 조회 + * 드롭다운 선택 조합 → 품목(items) 매핑 조회 * - * legacy_code 패턴: BD-{prod}{spec}-{length} (예: BD-CP-30) + * 품목코드 패턴: BD-{prod}{spec}-{length} (예: BD-RC-24) */ public function resolveItem(string $prodCode, string $specCode, string $lengthCode): ?array { @@ -202,4 +201,87 @@ public static function getMaterial(string $prodCode, string $specCode): ?string { return self::MATERIAL_MAP["{$prodCode}:{$specCode}"] ?? null; } + + /** + * 품목 코드(BD-XX-YY) → 매칭되는 bending_item의 전개 폭(width_sum) 반환 + * + * 매칭 로직: + * BD-{prod}{spec}-{length} 파싱 + * → PRODUCTS/SPECS에서 item_bending, item_sep, 키워드 추출 + * → bending_items 검색 → bending_data 마지막 sum = 전개 폭 + */ + public function getBendingWidthByItemCode(string $itemCode): ?float + { + if (! preg_match('/^BD-([A-Z])([A-Z])-(\d+)$/', $itemCode, $m)) { + return null; + } + $prodCode = $m[1]; + $specCode = $m[2]; + + // 제품명 → item_bending 추출 (가이드레일(벽면형) → 가이드레일) + $productName = null; + foreach (self::PRODUCTS as $p) { + if ($p['code'] === $prodCode) { + $productName = $p['name']; + break; + } + } + if (! $productName) { + return null; + } + + // 종류명 추출 + $specName = null; + foreach (self::SPECS as $s) { + if ($s['code'] === $specCode && in_array($prodCode, $s['products'])) { + $specName = $s['name']; + break; + } + } + if (! $specName) { + return null; + } + + // item_bending: 괄호 제거 (가이드레일(벽면형) → 가이드레일) + $itemBending = preg_replace('/\(.*\)/', '', $productName); + + // item_sep 판단: 종류명 또는 제품명에 '철재' → 철재, 아니면 스크린 + $itemSep = (str_contains($specName, '철재') || str_contains($productName, '철재')) + ? '철재' : '스크린'; + + // bending_items 검색 + $query = \App\Models\BendingItem::query() + ->where('tenant_id', $this->tenantId()) + ->where('item_bending', $itemBending) + ->where('item_sep', $itemSep) + ->whereNotNull('bending_data'); + + // 가이드레일: 벽면형/측면형 구분 (item_name 키워드 매칭) + if (str_contains($productName, '벽면형')) { + $query->where('item_name', 'LIKE', '%벽면형%'); + } elseif (str_contains($productName, '측면형')) { + $query->where('item_name', 'LIKE', '%측면형%'); + } + + // 종류 키워드 매칭 (본체, C형, D형, 전면, 점검구, 린텔 등) + $specKeyword = preg_replace('/\(.*\)/', '', $specName); // 본체(철재) → 본체 + $query->where('item_name', 'LIKE', "%{$specKeyword}%"); + + // 최신 코드 우선 + $bendingItem = $query->orderByDesc('code')->first(); + + if (! $bendingItem) { + return null; + } + + // bending_data 마지막 항목의 sum = 전개 폭 + $data = $bendingItem->bending_data; + if (empty($data)) { + return null; + } + + $last = end($data); + + return isset($last['sum']) ? (float) $last['sum'] : null; + } } diff --git a/app/Services/BendingItemService.php b/app/Services/BendingItemService.php index 5d9e8228..157187f6 100644 --- a/app/Services/BendingItemService.php +++ b/app/Services/BendingItemService.php @@ -46,9 +46,16 @@ public function find(int $id): BendingItem public function create(array $data): BendingItem { + $code = $data['code'] ?? ''; + + // BD-XX 접두사 → 자동 채번 (BD-XX 또는 BD-XX.nn) + if (preg_match('/^BD-([A-Z]{2})$/i', $code, $m)) { + $code = $this->generateCode($m[1]); + } + return BendingItem::create([ 'tenant_id' => $this->tenantId(), - 'code' => $data['code'], + 'code' => $code, 'lot_no' => $data['lot_no'] ?? null, 'legacy_code' => $data['legacy_code'] ?? null, 'legacy_bending_id' => $data['legacy_bending_id'] ?? null, @@ -78,8 +85,27 @@ public function update(int $id, array $data): BendingItem { $item = BendingItem::findOrFail($id); + // code 변경 시 중복 검사 + if (array_key_exists('code', $data) && $data['code'] && $data['code'] !== $item->code) { + $exists = BendingItem::withoutGlobalScopes() + ->where('code', $data['code']) + ->where('id', '!=', $id) + ->exists(); + if ($exists) { + throw new \Illuminate\Validation\ValidationException( + validator([], []), + response()->json([ + 'success' => false, + 'message' => "코드 '{$data['code']}'가 이미 존재합니다.", + 'errors' => ['code' => ["코드 '{$data['code']}'는 이미 사용 중입니다. 다른 코드를 입력하세요."]], + ], 422) + ); + } + $item->code = $data['code']; + } + $columns = [ - 'code', 'lot_no', 'item_name', 'item_sep', 'item_bending', + 'lot_no', 'item_name', 'item_sep', 'item_bending', 'material', 'item_spec', 'model_name', 'model_UA', 'rail_width', 'exit_direction', 'box_width', 'box_height', 'front_bottom', 'inspection_door', 'length_code', 'length_mm', @@ -111,6 +137,95 @@ public function update(int $id, array $data): BendingItem return $item; } + /** + * 기초자료 복사 — 같은 분류코드의 다음 번호 자동 채번 + 이미지 복사 + */ + public function duplicate(int $id): BendingItem + { + $source = BendingItem::findOrFail($id); + + // 분류코드 추출 (BD-CL.001 → CL) + preg_match('/^BD-([A-Z]{2})/', $source->code, $m); + $prefix = $m[1] ?? 'XX'; + $newCode = $this->generateCode($prefix); + + $newItem = BendingItem::create([ + 'tenant_id' => $source->tenant_id, + 'code' => $newCode, + 'item_name' => $source->item_name, + 'item_sep' => $source->item_sep, + 'item_bending' => $source->item_bending, + 'material' => $source->material, + 'item_spec' => $source->item_spec, + 'model_name' => $source->model_name, + 'model_UA' => $source->model_UA, + 'rail_width' => $source->rail_width, + 'exit_direction' => $source->exit_direction, + 'box_width' => $source->box_width, + 'box_height' => $source->box_height, + 'front_bottom' => $source->front_bottom, + 'inspection_door' => $source->inspection_door, + 'length_code' => $source->length_code, + 'length_mm' => $source->length_mm, + 'bending_data' => $source->bending_data, + 'options' => $source->options, + 'is_active' => true, + 'created_by' => $this->apiUserId(), + ]); + + // 이미지 파일 복사 (R2) + $this->duplicateFiles($source, $newItem); + + return $newItem; + } + + /** + * 원본 아이템의 파일을 R2에서 복사하여 새 아이템에 연결 + */ + private function duplicateFiles(BendingItem $source, BendingItem $target): void + { + $files = \App\Models\Commons\File::where('document_id', $source->id) + ->where('document_type', 'bending_item') + ->whereNull('deleted_at') + ->get(); + + foreach ($files as $file) { + try { + $disk = \Illuminate\Support\Facades\Storage::disk('r2'); + $newStoredName = bin2hex(random_bytes(8)).'.'.pathinfo($file->stored_name, PATHINFO_EXTENSION); + $dir = dirname($file->file_path); + $newPath = $dir.'/'.$newStoredName; + + // R2 내 파일 복사 + if ($file->file_path && $disk->exists($file->file_path)) { + $disk->copy($file->file_path, $newPath); + } + + // 새 File 레코드 생성 + \App\Models\Commons\File::create([ + 'tenant_id' => $target->tenant_id, + 'document_id' => $target->id, + 'document_type' => 'bending_item', + 'field_key' => $file->field_key, + 'file_path' => $newPath, + 'stored_name' => $newStoredName, + 'original_name' => $file->original_name, + 'display_name' => $file->display_name, + 'file_size' => $file->file_size, + 'mime_type' => $file->mime_type, + 'file_type' => $file->file_type, + 'created_by' => $this->apiUserId(), + ]); + } catch (\Throwable $e) { + \Illuminate\Support\Facades\Log::warning('BendingItem file duplicate failed', [ + 'source_id' => $source->id, + 'file_id' => $file->id, + 'error' => $e->getMessage(), + ]); + } + } + } + public function delete(int $id): bool { $item = BendingItem::findOrFail($id); @@ -136,4 +251,56 @@ private function buildOptions(array $data): ?array 'search_keyword', 'registration_date', 'author', 'memo', 'parent_num', 'modified_by', ]; + + /** + * 기초자료 코드 자동 채번 + * + * BD-XX.001 : 대표(표준) 형상 — 재공품 코드(BD-XX-길이)의 기준 + * BD-XX.002~: 표준 대비 변형 (주문 수정 형상, 최대 999종) + * + * 항상 .001부터 시작, .001 = 대표 번호 + */ + private function generateCode(string $prefix): string + { + $prefix = strtoupper($prefix); + + $lastCode = BendingItem::withoutGlobalScopes() + ->where('code', 'like', "BD-{$prefix}.%") + ->orderByRaw('CAST(SUBSTRING(code, ?) AS UNSIGNED) DESC', [strlen("BD-{$prefix}.") + 1]) + ->value('code'); + + $nextSeq = 1; + if ($lastCode && preg_match('/\.(\d+)$/', $lastCode, $m)) { + $nextSeq = (int) $m[1] + 1; + } + + return "BD-{$prefix}.".str_pad($nextSeq, 3, '0', STR_PAD_LEFT); + } + + /** + * 사용 가능한 분류코드 접두사 목록 + */ + public function prefixes(): array + { + return BendingItem::withoutGlobalScopes() + ->where('code', 'like', 'BD-%') + ->selectRaw("CASE WHEN code LIKE 'BD-__.%' THEN SUBSTRING(code, 4, 2) ELSE SUBSTRING(code, 4, 2) END as prefix, COUNT(*) as cnt") + ->groupBy('prefix') + ->orderBy('prefix') + ->pluck('cnt', 'prefix') + ->toArray(); + } + + /** 분류코드 접두사 정의 */ + public const PREFIX_LABELS = [ + 'RS' => '가이드레일 SUS마감재', 'RM' => '가이드레일 본체/보강', 'RC' => '가이드레일 C형', + 'RD' => '가이드레일 D형', 'RE' => '가이드레일 측면마감', 'RT' => '가이드레일 절단판', + 'RH' => '가이드레일 뒷보강', 'RN' => '가이드레일 비인정', + 'CP' => '케이스 밑면판/점검구', 'CF' => '케이스 전면판', 'CB' => '케이스 후면코너/후면부', + 'CL' => '케이스 린텔', 'CX' => '케이스 상부덮개', + 'BS' => '하단마감재 SUS', 'BE' => '하단마감재 EGI', 'BH' => '하단마감재 보강평철', + 'TS' => '철재 하단마감재 SUS', 'TE' => '철재 하단마감재 EGI', + 'XE' => '마구리', 'LE' => 'L-BAR', + 'ZP' => '특수 밑면/점검구', 'ZF' => '특수 전면판', 'ZB' => '특수 후면', + ]; } diff --git a/app/Services/DocumentService.php b/app/Services/DocumentService.php index d70b47f2..7d64ee1e 100644 --- a/app/Services/DocumentService.php +++ b/app/Services/DocumentService.php @@ -977,7 +977,7 @@ public function formatTemplateForReact(DocumentTemplate $template): array 'is_required' => $field->is_required, 'sort_order' => $field->sort_order, ])->toArray(), - 'sections' => $template->sections->map(function ($section) { + 'sections' => $template->sections->map(function ($section) use ($methodCodes) { $imageUrl = null; if ($section->file_id) { $file = \App\Models\Commons\File::withoutGlobalScopes()->find($section->file_id); @@ -987,37 +987,37 @@ public function formatTemplateForReact(DocumentTemplate $template): array } return [ - 'id' => $section->id, - 'name' => $section->title, - 'title' => $section->title, - 'image_path' => $section->image_path, - 'file_id' => $section->file_id, - 'image_url' => $imageUrl, - 'sort_order' => $section->sort_order, - 'items' => $section->items->map(function ($item) use ($methodCodes) { - // method 코드를 한글 이름으로 변환 - $methodName = $item->method ? ($methodCodes[$item->method] ?? $item->method) : null; + 'id' => $section->id, + 'name' => $section->title, + 'title' => $section->title, + 'image_path' => $section->image_path, + 'file_id' => $section->file_id, + 'image_url' => $imageUrl, + 'sort_order' => $section->sort_order, + 'items' => $section->items->map(function ($item) use ($methodCodes) { + // method 코드를 한글 이름으로 변환 + $methodName = $item->method ? ($methodCodes[$item->method] ?? $item->method) : null; - return [ - 'id' => $item->id, - 'field_values' => $item->field_values ?? [], - // 레거시 필드도 포함 (하위 호환) - 'category' => $item->category, - 'item' => $item->item, - 'standard' => $item->standard, - 'standard_criteria' => $item->standard_criteria, - 'tolerance' => $item->tolerance, - 'method' => $item->method, - 'method_name' => $methodName, // 검사방식 한글 이름 추가 - 'measurement_type' => $item->measurement_type, - 'frequency' => $item->frequency, - 'frequency_n' => $item->frequency_n, - 'frequency_c' => $item->frequency_c, - 'regulation' => $item->regulation, - 'sort_order' => $item->sort_order, - ]; - })->toArray(), - ]; + return [ + 'id' => $item->id, + 'field_values' => $item->field_values ?? [], + // 레거시 필드도 포함 (하위 호환) + 'category' => $item->category, + 'item' => $item->item, + 'standard' => $item->standard, + 'standard_criteria' => $item->standard_criteria, + 'tolerance' => $item->tolerance, + 'method' => $item->method, + 'method_name' => $methodName, // 검사방식 한글 이름 추가 + 'measurement_type' => $item->measurement_type, + 'frequency' => $item->frequency, + 'frequency_n' => $item->frequency_n, + 'frequency_c' => $item->frequency_c, + 'regulation' => $item->regulation, + 'sort_order' => $item->sort_order, + ]; + })->toArray(), + ]; })->toArray(), 'columns' => $template->columns->map(fn ($col) => [ 'id' => $col->id, diff --git a/app/Services/InspectionService.php b/app/Services/InspectionService.php index 3f142c77..45c9e2af 100644 --- a/app/Services/InspectionService.php +++ b/app/Services/InspectionService.php @@ -218,6 +218,10 @@ public function update(int $id, array $data) throw new NotFoundHttpException(__('error.not_found')); } + if ($inspection->status === Inspection::STATUS_COMPLETED) { + throw new BadRequestHttpException(__('error.inspection.cannot_modify_completed')); + } + $beforeData = $inspection->toArray(); return DB::transaction(function () use ($inspection, $data, $userId, $beforeData) { diff --git a/app/Services/ProcessService.php b/app/Services/ProcessService.php index 7eb64995..77ae1e9d 100644 --- a/app/Services/ProcessService.php +++ b/app/Services/ProcessService.php @@ -23,9 +23,11 @@ public function index(array $params) $status = $params['status'] ?? null; $processType = $params['process_type'] ?? null; + $eagerLoad = ['classificationRules', 'processItems.item:id,code,name', 'steps', 'documentTemplate:id,name,category', 'workLogTemplateRelation:id,name,category', 'parent:id,process_code,process_name', 'children:id,parent_id,process_code,process_name,is_active']; + $query = Process::query() ->where('tenant_id', $tenantId) - ->with(['classificationRules', 'processItems.item:id,code,name', 'steps', 'documentTemplate:id,name,category', 'workLogTemplateRelation:id,name,category']); + ->with($eagerLoad); // 검색어 if ($q !== '') { @@ -62,7 +64,7 @@ public function show(int $id) $tenantId = $this->tenantId(); $process = Process::where('tenant_id', $tenantId) - ->with(['classificationRules', 'processItems.item:id,code,name', 'steps', 'documentTemplate:id,name,category', 'workLogTemplateRelation:id,name,category']) + ->with(['classificationRules', 'processItems.item:id,code,name', 'steps', 'documentTemplate:id,name,category', 'workLogTemplateRelation:id,name,category', 'parent:id,process_code,process_name', 'children:id,parent_id,process_code,process_name,is_active']) ->find($id); if (! $process) { @@ -81,6 +83,16 @@ public function store(array $data) $userId = $this->apiUserId(); return DB::transaction(function () use ($data, $tenantId, $userId) { + // 2depth 제한: 부모가 이미 자식이면 거부 + if (! empty($data['parent_id'])) { + $parent = Process::find($data['parent_id']); + if ($parent && $parent->parent_id) { + throw \Illuminate\Validation\ValidationException::withMessages([ + 'parent_id' => ['2단계까지만 허용됩니다. 선택한 부모 공정이 이미 하위 공정입니다.'], + ]); + } + } + // 공정코드 자동 생성 $data['process_code'] = $this->generateProcessCode($tenantId); $data['tenant_id'] = $tenantId; @@ -122,6 +134,22 @@ public function update(int $id, array $data) } return DB::transaction(function () use ($process, $data, $userId) { + // parent_id 변경 시 2depth + 순환 참조 검증 + if (array_key_exists('parent_id', $data) && $data['parent_id']) { + $parent = Process::find($data['parent_id']); + if ($parent && $parent->parent_id) { + throw \Illuminate\Validation\ValidationException::withMessages([ + 'parent_id' => ['2단계까지만 허용됩니다.'], + ]); + } + // 자기 자식을 부모로 설정하는 것 방지 + if ($process->children()->where('id', $data['parent_id'])->exists()) { + throw \Illuminate\Validation\ValidationException::withMessages([ + 'parent_id' => ['하위 공정을 부모로 설정할 수 없습니다.'], + ]); + } + } + $data['updated_by'] = $userId; // work_steps가 문자열이면 배열로 변환 @@ -267,6 +295,93 @@ private function syncProcessItems(Process $process, array $itemIds): void } } + /** + * 공정 복제 + */ + public function duplicate(int $id) + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $source = Process::where('tenant_id', $tenantId) + ->with(['classificationRules', 'processItems', 'steps']) + ->find($id); + + if (! $source) { + throw new NotFoundHttpException(__('error.not_found')); + } + + return DB::transaction(function () use ($source, $tenantId, $userId) { + $newCode = $this->generateProcessCode($tenantId); + + $newProcess = Process::create([ + 'tenant_id' => $tenantId, + 'parent_id' => $source->parent_id, + 'process_code' => $newCode, + 'process_name' => $source->process_name.' (복사)', + 'description' => $source->description, + 'process_type' => $source->process_type, + 'department' => $source->department, + 'manager' => $source->manager, + 'process_category' => $source->process_category, + 'use_production_date' => $source->use_production_date, + 'work_log_template' => $source->work_log_template, + 'document_template_id' => $source->document_template_id, + 'work_log_template_id' => $source->work_log_template_id, + 'options' => $source->options, + 'required_workers' => $source->required_workers, + 'equipment_info' => $source->equipment_info, + 'work_steps' => $source->work_steps, + 'note' => $source->note, + 'is_active' => $source->is_active, + 'created_by' => $userId, + ]); + + // 분류 규칙 복제 + foreach ($source->classificationRules as $rule) { + ProcessClassificationRule::create([ + 'process_id' => $newProcess->id, + 'registration_type' => $rule->registration_type, + 'rule_type' => $rule->rule_type, + 'matching_type' => $rule->matching_type, + 'condition_value' => $rule->condition_value, + 'priority' => $rule->priority, + 'description' => $rule->description, + 'is_active' => $rule->is_active, + ]); + } + + // 품목 연결 복제 + foreach ($source->processItems as $item) { + ProcessItem::create([ + 'process_id' => $newProcess->id, + 'item_id' => $item->item_id, + 'priority' => $item->priority, + 'is_active' => $item->is_active, + ]); + } + + // 공정 단계 복제 + foreach ($source->steps as $step) { + $newProcess->steps()->create([ + 'step_code' => $step->step_code, + 'step_name' => $step->step_name, + 'is_required' => $step->is_required, + 'needs_approval' => $step->needs_approval, + 'needs_inspection' => $step->needs_inspection, + 'is_active' => $step->is_active, + 'sort_order' => $step->sort_order, + 'connection_type' => $step->connection_type, + 'connection_target' => $step->connection_target, + 'completion_type' => $step->completion_type, + 'options' => $step->options, + ]); + } + + return $newProcess->load(['classificationRules', 'processItems.item:id,code,name', 'steps', 'documentTemplate:id,name,category', 'workLogTemplateRelation:id,name,category']); + }); + } + /** * 드롭다운용 공정 옵션 목록 */ diff --git a/app/Services/QualityDocumentService.php b/app/Services/QualityDocumentService.php index 253ae335..10ecc02e 100644 --- a/app/Services/QualityDocumentService.php +++ b/app/Services/QualityDocumentService.php @@ -100,7 +100,7 @@ public function stats(array $params = []): array ->toArray(); return [ - 'reception_count' => $counts[QualityDocument::STATUS_RECEIVED] ?? 0, + 'reception_count' => ($counts[QualityDocument::STATUS_RECEIVED] ?? 0) + ($counts['draft'] ?? 0), 'in_progress_count' => $counts[QualityDocument::STATUS_IN_PROGRESS] ?? 0, 'completed_count' => $counts[QualityDocument::STATUS_COMPLETED] ?? 0, ]; diff --git a/app/Services/ReceivingService.php b/app/Services/ReceivingService.php index 8571051a..ffb04329 100644 --- a/app/Services/ReceivingService.php +++ b/app/Services/ReceivingService.php @@ -140,6 +140,10 @@ public function stats(): array ->where('status', 'inspection_pending') ->count(); + $inspectionCompletedCount = Receiving::where('tenant_id', $tenantId) + ->where('status', 'inspection_completed') + ->count(); + $todayReceivingCount = Receiving::where('tenant_id', $tenantId) ->where('status', 'completed') ->whereDate('receiving_date', $today) @@ -149,6 +153,7 @@ public function stats(): array 'receiving_pending_count' => $receivingPendingCount, 'shipping_count' => $shippingCount, 'inspection_pending_count' => $inspectionPendingCount, + 'inspection_completed_count' => $inspectionCompletedCount, 'today_receiving_count' => $todayReceivingCount, ]; } @@ -277,17 +282,18 @@ public function update(int $id, array $data): Receiving // 상태 변경 감지 $oldStatus = $receiving->status; $newStatus = $data['status'] ?? $oldStatus; - $wasCompleted = $oldStatus === 'completed'; + $stockStatuses = ['completed', 'inspection_completed']; + $wasCompleted = in_array($oldStatus, $stockStatuses); - // 입고완료(completed) 상태로 신규 전환 - $isCompletingReceiving = $newStatus === 'completed' && ! $wasCompleted; + // 재고 반영 대상 상태(입고완료/검사완료)로 신규 전환 + $isCompletingReceiving = in_array($newStatus, $stockStatuses) && ! $wasCompleted; if ($isCompletingReceiving) { // 입고수량 설정 (없으면 발주수량 사용) $receiving->receiving_qty = $data['receiving_qty'] ?? $receiving->order_qty; $receiving->receiving_date = $data['receiving_date'] ?? now()->toDateString(); $receiving->lot_no = $data['lot_no'] ?? $this->generateLotNo(); - $receiving->status = 'completed'; + $receiving->status = $newStatus; } else { // 일반 필드 업데이트 if (isset($data['receiving_qty'])) { @@ -326,7 +332,7 @@ public function update(int $id, array $data): Receiving // 기존 완료 상태에서 수정: 차이만큼 조정 // 완료→완료(수량변경): newQty = 변경된 수량 // 완료→대기: newQty = 0 (전량 차감) - $newQty = $newStatus === 'completed' + $newQty = in_array($newStatus, $stockStatuses) ? (float) $receiving->receiving_qty : 0; $stockService->adjustFromReceiving($receiving, $newQty); @@ -350,8 +356,21 @@ public function destroy(int $id): bool ->where('tenant_id', $tenantId) ->findOrFail($id); + // 관리자/슈퍼관리자/개발자는 모든 상태 삭제 가능, 일반 사용자는 완료 상태 삭제 불가 if (! $receiving->canDelete()) { - throw new \Exception(__('error.receiving.cannot_delete')); + $user = \App\Models\Members\User::find($userId); + app(\Spatie\Permission\PermissionRegistrar::class)->setPermissionsTeamId($tenantId); + $isAdmin = $user && $user->hasRole(['admin', 'super_admin', '개발자']); + if (! $isAdmin) { + throw new \Exception(__('error.receiving.cannot_delete')); + } + } + + // 완료/검사완료 상태 삭제 시 재고 차감 + $stockStatuses = ['completed', 'inspection_completed']; + if (in_array($receiving->status, $stockStatuses) && $receiving->item_id) { + $stockService = app(StockService::class); + $stockService->adjustFromReceiving($receiving, 0); } $receiving->deleted_by = $userId; diff --git a/app/Services/StockService.php b/app/Services/StockService.php index 65820ed6..d3ff99e6 100644 --- a/app/Services/StockService.php +++ b/app/Services/StockService.php @@ -191,6 +191,36 @@ public function show(int $id): Item ->findOrFail($id); } + /** + * 재고 수정 (안전재고, 최대재고, 사용상태) + */ + public function updateStock(int $id, array $data): Item + { + $tenantId = $this->tenantId(); + + $item = Item::where('tenant_id', $tenantId)->findOrFail($id); + $stock = $item->stock; + + if ($stock) { + if (isset($data['safety_stock'])) { + $stock->safety_stock = $data['safety_stock']; + } + if (isset($data['max_stock'])) { + $stock->max_stock = $data['max_stock']; + } + $stock->status = $stock->calculateStatus(); + $stock->updated_by = $this->apiUserId(); + $stock->save(); + } + + if (isset($data['is_active'])) { + $item->is_active = $data['is_active']; + $item->save(); + } + + return $item->load(['stock.lots' => fn ($q) => $q->orderBy('fifo_order')]); + } + /** * 재고 조정 이력 조회 */ diff --git a/app/Services/WorkOrderService.php b/app/Services/WorkOrderService.php index 5e08398d..99d3b65d 100644 --- a/app/Services/WorkOrderService.php +++ b/app/Services/WorkOrderService.php @@ -17,6 +17,7 @@ use App\Models\Tenants\StockTransaction; use App\Services\Audit\AuditLogger; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Storage; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -61,6 +62,7 @@ public function index(array $params) 'salesOrder.client:id,name', 'process:id,process_name,process_code,department,options', 'items:id,work_order_id,item_id,item_name,specification,quantity,unit,status,options,sort_order,source_order_item_id', + 'items.item:id,code', 'items.sourceOrderItem:id,order_node_id,floor_code,symbol_code', 'items.sourceOrderItem.node:id,name,code', 'items.materialInputs:id,work_order_id,work_order_item_id,stock_lot_id,item_id,qty,input_by,input_at', @@ -122,14 +124,21 @@ public function index(array $params) ->orWhereHas('assignees', fn ($aq) => $aq->where('user_id', $userId)); }); } else { - // 2차: 사용자 소속 부서의 작업지시 필터 + // 2차: 사용자 소속 부서 + 상위 부서의 작업지시 필터 $departmentIds = DB::table('department_user') ->where('user_id', $userId) ->where('tenant_id', $tenantId) ->pluck('department_id'); if ($departmentIds->isNotEmpty()) { - $query->whereIn('team_id', $departmentIds); + // 소속 부서의 상위 부서도 포함 (부서 계층 지원) + $parentIds = DB::table('departments') + ->whereIn('id', $departmentIds) + ->whereNotNull('parent_id') + ->pluck('parent_id'); + + $allDeptIds = $departmentIds->merge($parentIds)->unique(); + $query->whereIn('team_id', $allDeptIds); } // 3차: 부서도 없으면 필터 없이 전체 노출 } @@ -150,7 +159,37 @@ public function index(array $params) $query->orderByDesc('created_at'); - return $query->paginate($size, ['*'], 'page', $page); + $result = $query->paginate($size, ['*'], 'page', $page); + + // 작업자 화면: BENDING 카테고리 품목에 전개도 폭(bending_width) 추가 + if ($workerScreen) { + $this->appendBendingWidths($result); + } + + return $result; + } + + /** + * BENDING 카테고리 품목에 전개도 폭 추가 + */ + private function appendBendingWidths($paginator): void + { + $bendingService = app(BendingCodeService::class); + + foreach ($paginator->items() as $workOrder) { + foreach ($workOrder->items as $item) { + $itemCode = $item->item?->code; + if (! $itemCode || ! str_starts_with($itemCode, 'BD-')) { + continue; + } + $width = $bendingService->getBendingWidthByItemCode($itemCode); + if ($width !== null) { + $options = $item->options ?? []; + $options['bending_width'] = $width; + $item->setAttribute('options', $options); + } + } + } } /** @@ -214,6 +253,7 @@ public function show(int $id) 'salesOrder.writer:id,name', 'process:id,process_name,process_code,work_steps,department,options', 'process.steps' => fn ($q) => $q->where('is_active', true)->orderBy('sort_order'), + 'items.item:id,code', 'items.sourceOrderItem:id,order_node_id,floor_code,symbol_code', 'items.sourceOrderItem.node:id,name,code', 'items.materialInputs:id,work_order_id,work_order_item_id,stock_lot_id,item_id,qty,input_by,input_at', @@ -3445,7 +3485,7 @@ public function getWorkLog(int $workOrderId): array 'comment' => $a->comment, 'acted_at' => $a->acted_at, ])->toArray(), - 'data' => $document->data->map(fn ($d) => [ + 'data' => ($document->data ?? collect())->map(fn ($d) => [ 'field_key' => $d->field_key, 'field_value' => $d->field_value, 'section_id' => $d->section_id, @@ -3456,9 +3496,41 @@ public function getWorkLog(int $workOrderId): array 'auto_values' => $this->buildWorkLogAutoValues($workOrder), 'work_order_info' => $this->buildWorkOrderInfo($workOrder), 'work_stats' => $this->calculateWorkStats($workOrder), + 'bending_images' => $this->buildBendingImageUrls(), ]; } + /** + * 절곡 정적 이미지 R2 presigned URL 맵 생성 + */ + private function buildBendingImageUrls(): array + { + $images = []; + $paths = [ + 'guiderail_KSS01_wall_120x70', 'guiderail_KSS01_side_120x120', + 'guiderail_KSS02_wall_120x70', 'guiderail_KSS02_side_120x120', + 'guiderail_KSE01_wall_120x70', 'guiderail_KSE01_side_120x120', + 'guiderail_KWE01_wall_120x70', 'guiderail_KWE01_side_120x120', + 'guiderail_KTE01_wall_130x75', 'guiderail_KTE01_side_130x125', + 'guiderail_KQTS01_wall_130x75', 'guiderail_KQTS01_side_130x125', + 'bottombar_KSS01', 'bottombar_KSS02', 'bottombar_KSE01', + 'bottombar_KWE01', 'bottombar_KTE01', 'bottombar_KQTS01', + 'box_both', 'box_bottom', 'box_rear', + 'smokeban', + ]; + + foreach ($paths as $name) { + $category = str_contains($name, 'guiderail') ? 'guiderail' + : (str_contains($name, 'bottombar') ? 'bottombar' + : (str_contains($name, 'box') ? 'box' : 'part')); + $r2Path = "images/bending/{$category}/{$name}.jpg"; + + $images[$name] = Storage::disk('r2')->temporaryUrl($r2Path, now()->addMinutes(30)); + } + + return $images; + } + /** * 작업일지 생성/수정 (Document 기반) * @@ -3773,13 +3845,59 @@ public function getMaterialsForItem(int $workOrderId, int $itemId): array } } - // BOM이 없으면 품목 자체를 자재로 사용 + // BOM이 없으면 BD 품목의 재질 정보로 원자재 자동 매칭 if (empty($materialItems) && $woItem->item_id && $woItem->item) { - $materialItems[] = [ - 'item' => $woItem->item, - 'bom_qty' => 1, - 'required_qty' => $woItem->quantity ?? 1, - ]; + $itemOptions = $woItem->item->options ?? []; + $material = $itemOptions['material'] ?? null; + + $matchedRawItems = []; + if ($material && preg_match('/^(\w+)\s*(\d+\.?\d*)/i', $material, $matMatch)) { + $matType = $matMatch[1]; + $matThickness = (float) $matMatch[2]; + + // 품목명에서 제품 길이 추출 (예: 1750mm) + $productLength = 0; + if (preg_match('/(\d{3,5})mm/', $woItem->item->name, $lenMatch)) { + $productLength = (int) $lenMatch[1]; + } + + // 원자재 검색: material_type + thickness 매칭, length >= 제품길이 + $rawItems = \App\Models\Items\Item::where('tenant_id', $tenantId) + ->where('item_type', 'RM') + ->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(options, '$.attributes.material_type')) = ?", [$matType]) + ->whereRaw('CAST(JSON_EXTRACT(options, \'$.attributes.thickness\') AS DECIMAL(10,2)) = ?', [$matThickness]) + ->get(); + + foreach ($rawItems as $rawItem) { + $rawAttrs = $rawItem->options['attributes'] ?? []; + $rawLength = $rawAttrs['length'] ?? null; + + // 길이 조건: 원자재 길이 >= 제품 길이 (길이 미정이면 통과) + if ($rawLength !== null && $productLength > 0 && $rawLength < $productLength) { + continue; + } + + $matchedRawItems[] = $rawItem; + } + } + + if (! empty($matchedRawItems)) { + // 매칭된 원자재를 자재 목록으로 추가 + foreach ($matchedRawItems as $rawItem) { + $materialItems[] = [ + 'item' => $rawItem, + 'bom_qty' => 1, + 'required_qty' => $woItem->quantity ?? 1, + ]; + } + } else { + // 매칭 실패 시 기존 동작 유지 (품목 자체를 자재로 표시) + $materialItems[] = [ + 'item' => $woItem->item, + 'bom_qty' => 1, + 'required_qty' => $woItem->quantity ?? 1, + ]; + } } // 이미 투입된 수량 조회 (item_id별 SUM) diff --git a/database/migrations/2026_03_20_120000_migrate_daily_work_logs_to_codebridge.php b/database/migrations/2026_03_20_120000_migrate_daily_work_logs_to_codebridge.php new file mode 100644 index 00000000..067972ad --- /dev/null +++ b/database/migrations/2026_03_20_120000_migrate_daily_work_logs_to_codebridge.php @@ -0,0 +1,84 @@ +copyOrder as $table) { + if (! Schema::hasTable($table)) { + continue; + } + + $samCount = DB::table($table)->count(); + if ($samCount === 0) { + continue; + } + + if (! Schema::connection($this->cb)->hasTable($table)) { + throw new \RuntimeException( + "[MIGRATION ABORT] codebridge.{$table} 테이블이 없습니다. " + .'2026_03_19_200000 마이그레이션을 먼저 실행하세요.' + ); + } + + $cbExisting = DB::connection($this->cb)->table($table)->count(); + if ($cbExisting > 0) { + continue; + } + + DB::connection($this->cb)->statement( + "INSERT INTO `{$cbDb}`.`{$table}` SELECT * FROM `{$samDb}`.`{$table}`" + ); + + $cbCount = DB::connection($this->cb)->table($table)->count(); + if ($samCount !== $cbCount) { + throw new \RuntimeException( + "[MIGRATION ABORT] 데이터 불일치! {$table}: sam={$samCount}, codebridge={$cbCount}" + ); + } + } + + // ─── Phase 2: sam 테이블 삭제 (자식 → 부모 순서) ─── + DB::statement('SET FOREIGN_KEY_CHECKS=0'); + foreach ($this->dropOrder as $table) { + Schema::dropIfExists($table); + } + DB::statement('SET FOREIGN_KEY_CHECKS=1'); + } + + public function down(): void + { + // 롤백은 수동 처리 — 데이터가 이미 codebridge에 있으므로 자동 롤백 불가 + } +}; diff --git a/database/migrations/2026_03_20_162822_add_max_stock_to_stocks_table.php b/database/migrations/2026_03_20_162822_add_max_stock_to_stocks_table.php new file mode 100644 index 00000000..8ca99d7c --- /dev/null +++ b/database/migrations/2026_03_20_162822_add_max_stock_to_stocks_table.php @@ -0,0 +1,30 @@ +decimal('max_stock', 15, 3)->default(0) + ->comment('최대 재고 (적정재고 상한)') + ->after('safety_stock'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('stocks', function (Blueprint $table) { + $table->dropColumn('max_stock'); + }); + } +}; diff --git a/database/migrations/2026_03_21_152057_add_parent_id_to_processes_table.php b/database/migrations/2026_03_21_152057_add_parent_id_to_processes_table.php new file mode 100644 index 00000000..d45c97f4 --- /dev/null +++ b/database/migrations/2026_03_21_152057_add_parent_id_to_processes_table.php @@ -0,0 +1,34 @@ +unsignedBigInteger('parent_id') + ->nullable() + ->after('tenant_id') + ->comment('부모 공정 ID (NULL이면 루트)'); + + $table->foreign('parent_id') + ->references('id') + ->on('processes') + ->onDelete('set null'); + + $table->index(['tenant_id', 'parent_id']); + }); + } + + public function down(): void + { + Schema::table('processes', function (Blueprint $table) { + $table->dropForeign(['parent_id']); + $table->dropIndex(['tenant_id', 'parent_id']); + $table->dropColumn('parent_id'); + }); + } +}; diff --git a/lang/ko/error.php b/lang/ko/error.php index c914ef12..e5ef71c2 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -456,6 +456,7 @@ 'inspection' => [ 'not_found' => '검사를 찾을 수 없습니다.', 'cannot_delete_completed' => '완료된 검사는 삭제할 수 없습니다.', + 'cannot_modify_completed' => '완료된 검사는 수정할 수 없습니다.', 'already_completed' => '이미 완료된 검사입니다.', ], diff --git a/routes/api/v1/inventory.php b/routes/api/v1/inventory.php index 9dd8e624..e689107c 100644 --- a/routes/api/v1/inventory.php +++ b/routes/api/v1/inventory.php @@ -109,6 +109,7 @@ Route::get('/stats', [StockController::class, 'stats'])->name('v1.stocks.stats'); Route::get('/stats-by-type', [StockController::class, 'statsByItemType'])->name('v1.stocks.stats-by-type'); Route::get('/{id}', [StockController::class, 'show'])->whereNumber('id')->name('v1.stocks.show'); + Route::put('/{id}', [StockController::class, 'update'])->whereNumber('id')->name('v1.stocks.update'); Route::get('/{id}/adjustments', [StockController::class, 'adjustments'])->whereNumber('id')->name('v1.stocks.adjustments'); Route::post('/{id}/adjustments', [StockController::class, 'storeAdjustment'])->whereNumber('id')->name('v1.stocks.adjustments.store'); }); diff --git a/routes/api/v1/production.php b/routes/api/v1/production.php index 18c33f16..c177fc62 100644 --- a/routes/api/v1/production.php +++ b/routes/api/v1/production.php @@ -31,6 +31,7 @@ Route::put('/{id}', [ProcessController::class, 'update'])->whereNumber('id')->name('v1.processes.update'); Route::delete('/{id}', [ProcessController::class, 'destroy'])->whereNumber('id')->name('v1.processes.destroy'); Route::patch('/{id}/toggle', [ProcessController::class, 'toggleActive'])->whereNumber('id')->name('v1.processes.toggle'); + Route::post('/{id}/duplicate', [ProcessController::class, 'duplicate'])->whereNumber('id')->name('v1.processes.duplicate'); // Process Steps (공정 단계) Route::prefix('{processId}/steps')->whereNumber('processId')->group(function () { @@ -138,9 +139,11 @@ Route::prefix('bending-items')->group(function () { Route::get('', [BendingItemController::class, 'index'])->name('v1.bending-items.index'); Route::get('/filters', [BendingItemController::class, 'filters'])->name('v1.bending-items.filters'); + Route::get('/prefixes', [BendingItemController::class, 'prefixes'])->name('v1.bending-items.prefixes'); Route::post('', [BendingItemController::class, 'store'])->name('v1.bending-items.store'); Route::get('/{id}', [BendingItemController::class, 'show'])->whereNumber('id')->name('v1.bending-items.show'); Route::put('/{id}', [BendingItemController::class, 'update'])->whereNumber('id')->name('v1.bending-items.update'); + Route::post('/{id}/duplicate', [BendingItemController::class, 'duplicate'])->whereNumber('id')->name('v1.bending-items.duplicate'); Route::delete('/{id}', [BendingItemController::class, 'destroy'])->whereNumber('id')->name('v1.bending-items.destroy'); });