From 5eaa5f036b1b0858c11ef0c377585f03dca87ea2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Mon, 9 Feb 2026 16:47:50 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix(API):=20=EC=9E=90=EC=9E=AC=ED=88=AC?= =?UTF-8?q?=EC=9E=85=20=EB=AA=A8=EB=8B=AC=20=EC=A4=91=EB=B3=B5=20=EB=A1=9C?= =?UTF-8?q?=ED=8A=B8=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 동일 자재가 여러 작업지시 품목에 걸쳐 있을 때 StockLot이 중복 표시되던 문제 수정. Phase 1(유니크 자재 수집) → Phase 2(로트 조회) 구조로 변경하여 중복 제거 및 필요수량 합산. Co-Authored-By: Claude Opus 4.6 --- app/Services/WorkOrderService.php | 117 ++++++++++++++++-------------- 1 file changed, 63 insertions(+), 54 deletions(-) diff --git a/app/Services/WorkOrderService.php b/app/Services/WorkOrderService.php index 62d44aa..d0ee318 100644 --- a/app/Services/WorkOrderService.php +++ b/app/Services/WorkOrderService.php @@ -1092,7 +1092,7 @@ public function updateItemStatus(int $workOrderId, int $itemId, string $status) * 작업지시에 필요한 자재 목록 조회 (BOM 기반 + 로트별 재고) * * 작업지시 품목의 BOM 자재별로 StockLot(입고 로트)를 FIFO 순서로 반환합니다. - * 로트번호는 입고관리(Receiving)에서 생성된 실제 로트번호입니다. + * 동일 자재가 여러 작업지시 품목에 걸쳐 있으면 필요수량을 합산하고 로트는 중복 없이 반환합니다. * * @param int $workOrderId 작업지시 ID * @return array 자재 목록 (로트 단위) @@ -1109,8 +1109,8 @@ public function getMaterials(int $workOrderId): array throw new NotFoundHttpException(__('error.not_found')); } - $materials = []; - $rank = 1; + // Phase 1: 작업지시 품목들에서 유니크 자재 목록 수집 (item_id 기준 합산) + $uniqueMaterials = []; foreach ($workOrder->items as $woItem) { $materialItems = []; @@ -1140,7 +1140,6 @@ public function getMaterials(int $workOrderId): array 'item' => $childItem, 'bom_qty' => $bomQty, 'required_qty' => $bomQty * ($woItem->quantity ?? 1), - 'work_order_item_id' => $woItem->id, ]; } } @@ -1152,73 +1151,83 @@ public function getMaterials(int $workOrderId): array 'item' => $woItem->item, 'bom_qty' => 1, 'required_qty' => $woItem->quantity ?? 1, - 'work_order_item_id' => $woItem->id, ]; } - // 각 자재별로 StockLot(입고 로트) 조회 + // 유니크 자재 수집 (같은 item_id면 required_qty 합산) foreach ($materialItems as $matInfo) { - $materialItem = $matInfo['item']; - - // Stock 조회 - $stock = \App\Models\Tenants\Stock::where('tenant_id', $tenantId) - ->where('item_id', $materialItem->id) - ->first(); - - if ($stock) { - // 가용 로트를 FIFO 순서로 조회 - $lots = \App\Models\Tenants\StockLot::where('tenant_id', $tenantId) - ->where('stock_id', $stock->id) - ->where('status', 'available') - ->where('available_qty', '>', 0) - ->orderBy('fifo_order', 'asc') - ->get(); - - foreach ($lots as $lot) { - $materials[] = [ - 'stock_lot_id' => $lot->id, - 'item_id' => $materialItem->id, - 'work_order_item_id' => $matInfo['work_order_item_id'], - 'lot_no' => $lot->lot_no, - 'material_code' => $materialItem->code, - 'material_name' => $materialItem->name, - 'specification' => $materialItem->specification, - 'unit' => $lot->unit ?? $materialItem->unit ?? 'EA', - 'bom_qty' => $matInfo['bom_qty'], - 'required_qty' => $matInfo['required_qty'], - 'lot_qty' => (float) $lot->qty, - 'lot_available_qty' => (float) $lot->available_qty, - 'lot_reserved_qty' => (float) $lot->reserved_qty, - 'receipt_date' => $lot->receipt_date, - 'supplier' => $lot->supplier, - 'fifo_rank' => $rank++, - ]; - } + $itemId = $matInfo['item']->id; + if (isset($uniqueMaterials[$itemId])) { + $uniqueMaterials[$itemId]['required_qty'] += $matInfo['required_qty']; + } else { + $uniqueMaterials[$itemId] = $matInfo; } + } + } - // 가용 로트가 없는 경우 자재 정보만 반환 (재고 없음 표시) - $hasLots = collect($materials)->where('item_id', $materialItem->id)->isNotEmpty(); - if (! $hasLots) { + // Phase 2: 유니크 자재별로 StockLot 조회 + $materials = []; + $rank = 1; + + foreach ($uniqueMaterials as $matInfo) { + $materialItem = $matInfo['item']; + + $stock = \App\Models\Tenants\Stock::where('tenant_id', $tenantId) + ->where('item_id', $materialItem->id) + ->first(); + + $lotsFound = false; + + if ($stock) { + $lots = \App\Models\Tenants\StockLot::where('tenant_id', $tenantId) + ->where('stock_id', $stock->id) + ->where('status', 'available') + ->where('available_qty', '>', 0) + ->orderBy('fifo_order', 'asc') + ->get(); + + foreach ($lots as $lot) { + $lotsFound = true; $materials[] = [ - 'stock_lot_id' => null, + 'stock_lot_id' => $lot->id, 'item_id' => $materialItem->id, - 'work_order_item_id' => $matInfo['work_order_item_id'], - 'lot_no' => null, + 'lot_no' => $lot->lot_no, 'material_code' => $materialItem->code, 'material_name' => $materialItem->name, 'specification' => $materialItem->specification, - 'unit' => $materialItem->unit ?? 'EA', + 'unit' => $lot->unit ?? $materialItem->unit ?? 'EA', 'bom_qty' => $matInfo['bom_qty'], 'required_qty' => $matInfo['required_qty'], - 'lot_qty' => 0, - 'lot_available_qty' => 0, - 'lot_reserved_qty' => 0, - 'receipt_date' => null, - 'supplier' => null, + 'lot_qty' => (float) $lot->qty, + 'lot_available_qty' => (float) $lot->available_qty, + 'lot_reserved_qty' => (float) $lot->reserved_qty, + 'receipt_date' => $lot->receipt_date, + 'supplier' => $lot->supplier, 'fifo_rank' => $rank++, ]; } } + + // 가용 로트가 없는 경우 자재 정보만 반환 (재고 없음 표시) + if (! $lotsFound) { + $materials[] = [ + 'stock_lot_id' => null, + 'item_id' => $materialItem->id, + 'lot_no' => null, + 'material_code' => $materialItem->code, + 'material_name' => $materialItem->name, + 'specification' => $materialItem->specification, + 'unit' => $materialItem->unit ?? 'EA', + 'bom_qty' => $matInfo['bom_qty'], + 'required_qty' => $matInfo['required_qty'], + 'lot_qty' => 0, + 'lot_available_qty' => 0, + 'lot_reserved_qty' => 0, + 'receipt_date' => null, + 'supplier' => null, + 'fifo_rank' => $rank++, + ]; + } } return $materials; From 9bae7fccae83ec5192d354c1138306ccf20a7019 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Mon, 9 Feb 2026 16:55:34 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=ED=8A=B8=EB=A6=AC=EA=B1=B0=20?= =?UTF-8?q?=EA=B0=90=EC=82=AC=20=EB=A1=9C=EA=B7=B8=20Swagger=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20api=5Frequest=5Flo?= =?UTF-8?q?gs=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TriggerAuditLogApi.php Swagger 파일 생성 (6개 엔드포인트 문서화) - 목록 조회, 통계, 상세, 레코드 이력, 롤백 미리보기, 롤백 실행 - api_request_logs를 트리거 제외 테이블 목록에 추가 - Pint 포매팅 적용 Co-Authored-By: Claude Opus 4.6 --- app/Swagger/v1/TriggerAuditLogApi.php | 234 +++ ...1_create_audit_triggers_for_all_tables.php | 10 +- storage/api-docs/api-docs-v1.json | 1761 +++++++++++++++++ 3 files changed, 2001 insertions(+), 4 deletions(-) create mode 100644 app/Swagger/v1/TriggerAuditLogApi.php diff --git a/app/Swagger/v1/TriggerAuditLogApi.php b/app/Swagger/v1/TriggerAuditLogApi.php new file mode 100644 index 0000000..34bfa3d --- /dev/null +++ b/app/Swagger/v1/TriggerAuditLogApi.php @@ -0,0 +1,234 @@ +excludeTables, true)) { $skipped++; + continue; } @@ -106,12 +108,12 @@ private function createTriggersForTable(string $dbName, string $tableName): void $pk = $pkRow->COLUMN_NAME; // 컬럼 목록 (제외 컬럼 필터링) - $columns = DB::select(" + $columns = DB::select(' SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION - ", [$dbName, $tableName]); + ', [$dbName, $tableName]); $cols = []; $hasTenantId = false; @@ -144,8 +146,8 @@ private function createTriggersForTable(string $dbName, string $tableName): void $cols )); - $tenantNew = $hasTenantId ? "NEW.`tenant_id`" : 'NULL'; - $tenantOld = $hasTenantId ? "OLD.`tenant_id`" : 'NULL'; + $tenantNew = $hasTenantId ? 'NEW.`tenant_id`' : 'NULL'; + $tenantOld = $hasTenantId ? 'OLD.`tenant_id`' : 'NULL'; // 기존 트리거 삭제 DB::unprepared("DROP TRIGGER IF EXISTS `trg_{$tableName}_ai`"); diff --git a/storage/api-docs/api-docs-v1.json b/storage/api-docs/api-docs-v1.json index 3750ac9..1b9fb4d 100755 --- a/storage/api-docs/api-docs-v1.json +++ b/storage/api-docs/api-docs-v1.json @@ -3123,6 +3123,141 @@ ] } }, + "/api/v1/app/version": { + "get": { + "tags": [ + "App Version" + ], + "summary": "최신 버전 확인", + "description": "현재 앱 버전과 서버의 최신 버전을 비교하여 업데이트 필요 여부를 반환합니다. Bearer 토큰 불필요, API Key만 필요합니다.", + "operationId": "9509c32f1c66e8817112c81571a761b2", + "parameters": [ + { + "name": "platform", + "in": "query", + "description": "플랫폼 (기본값: android)", + "required": false, + "schema": { + "type": "string", + "enum": [ + "android", + "ios" + ], + "example": "android" + } + }, + { + "name": "current_version_code", + "in": "query", + "description": "현재 앱의 버전 코드 (정수)", + "required": true, + "schema": { + "type": "integer", + "example": 1 + } + } + ], + "responses": { + "200": { + "description": "버전 확인 성공", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResponse" + }, + { + "properties": { + "data": { + "$ref": "#/components/schemas/AppVersionCheckResponse" + } + }, + "type": "object" + } + ] + } + } + } + }, + "401": { + "description": "API Key 인증 실패", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "ApiKeyAuth": [] + } + ] + } + }, + "/api/v1/app/download/{id}": { + "get": { + "tags": [ + "App Version" + ], + "summary": "APK 다운로드", + "description": "지정된 버전의 APK 파일을 다운로드합니다. 다운로드 카운트가 자동으로 증가합니다. Bearer 토큰 불필요, API Key만 필요합니다.", + "operationId": "6cb801087b59e4dbf5dc1ffdcf9d9068", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "앱 버전 ID", + "required": true, + "schema": { + "type": "integer", + "example": 3 + } + } + ], + "responses": { + "200": { + "description": "APK 파일 다운로드", + "content": { + "application/vnd.android.package-archive": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "APK 파일을 찾을 수 없음", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "API Key 인증 실패", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "ApiKeyAuth": [] + } + ] + } + }, "/api/v1/approvals/drafts": { "get": { "tags": [ @@ -20493,6 +20628,652 @@ ] } }, + "/api/v1/documents/{id}/submit": { + "post": { + "tags": [ + "Documents" + ], + "summary": "결재 제출", + "description": "DRAFT 또는 REJECTED 상태의 문서를 결재 요청합니다 (PENDING 상태로 변경).", + "operationId": "b4037b49b968c05841434356b0c9218e", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "문서 ID", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "결재 제출 성공", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResponse" + }, + { + "properties": { + "data": { + "$ref": "#/components/schemas/Document" + } + }, + "type": "object" + } + ] + } + } + } + }, + "400": { + "description": "잘못된 요청 (제출 불가 상태 또는 결재선 미설정)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "인증 실패", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "문서를 찾을 수 없음", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "서버 에러", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ] + } + }, + "/api/v1/documents/{id}/approve": { + "post": { + "tags": [ + "Documents" + ], + "summary": "결재 승인", + "description": "현재 사용자의 결재 단계를 승인합니다. 모든 단계 완료 시 문서가 APPROVED 상태로 변경됩니다.", + "operationId": "5ab96b982b0953b93461c06e74c54a70", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "문서 ID", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "properties": { + "comment": { + "description": "결재 의견", + "type": "string", + "example": "승인합니다.", + "nullable": true + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "승인 성공", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResponse" + }, + { + "properties": { + "data": { + "$ref": "#/components/schemas/Document" + } + }, + "type": "object" + } + ] + } + } + } + }, + "400": { + "description": "잘못된 요청 (승인 불가 상태 또는 차례 아님)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "인증 실패", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "문서를 찾을 수 없음", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "서버 에러", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ] + } + }, + "/api/v1/documents/{id}/reject": { + "post": { + "tags": [ + "Documents" + ], + "summary": "결재 반려", + "description": "현재 사용자의 결재 단계를 반려합니다. 문서가 REJECTED 상태로 변경됩니다.", + "operationId": "15213c9112db6e018e045dfbd645ca05", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "문서 ID", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "required": [ + "comment" + ], + "properties": { + "comment": { + "description": "반려 사유 (필수)", + "type": "string", + "example": "검사 기준 미달로 반려합니다." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "반려 성공", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResponse" + }, + { + "properties": { + "data": { + "$ref": "#/components/schemas/Document" + } + }, + "type": "object" + } + ] + } + } + } + }, + "400": { + "description": "잘못된 요청 (반려 불가 상태 또는 차례 아님)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "인증 실패", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "문서를 찾을 수 없음", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "서버 에러", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ] + } + }, + "/api/v1/documents/{id}/cancel": { + "post": { + "tags": [ + "Documents" + ], + "summary": "결재 취소/회수", + "description": "작성자만 DRAFT 또는 PENDING 상태의 문서를 취소할 수 있습니다. CANCELLED 상태로 변경됩니다.", + "operationId": "f95eb227a23d428d67a6022ebdac2d81", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "문서 ID", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "취소 성공", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResponse" + }, + { + "properties": { + "data": { + "$ref": "#/components/schemas/Document" + } + }, + "type": "object" + } + ] + } + } + } + }, + "400": { + "description": "잘못된 요청 (취소 불가 상태 또는 작성자 아님)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "인증 실패", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "문서를 찾을 수 없음", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "서버 에러", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ] + } + }, + "/api/v1/document-templates": { + "get": { + "tags": [ + "DocumentTemplates" + ], + "summary": "양식 목록 조회", + "description": "문서 양식(템플릿) 목록을 조회합니다. 결재라인과 기본필드를 포함합니다.", + "operationId": "f6d1903439436f2911e32c00f7fb17f5", + "parameters": [ + { + "name": "is_active", + "in": "query", + "description": "활성 상태 필터", + "schema": { + "type": "boolean" + } + }, + { + "name": "category", + "in": "query", + "description": "카테고리 필터", + "schema": { + "type": "string", + "example": "품질" + } + }, + { + "name": "search", + "in": "query", + "description": "검색어 (양식명, 제목)", + "schema": { + "type": "string" + } + }, + { + "name": "sort_by", + "in": "query", + "description": "정렬 기준", + "schema": { + "type": "string", + "default": "created_at", + "enum": [ + "created_at", + "name", + "category" + ] + } + }, + { + "name": "sort_dir", + "in": "query", + "description": "정렬 방향", + "schema": { + "type": "string", + "default": "desc", + "enum": [ + "asc", + "desc" + ] + } + }, + { + "$ref": "#/components/parameters/Page" + }, + { + "$ref": "#/components/parameters/Size" + } + ], + "responses": { + "200": { + "description": "조회 성공", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResponse" + }, + { + "properties": { + "data": { + "properties": { + "current_page": { + "type": "integer", + "example": 1 + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DocumentTemplate" + } + }, + "per_page": { + "type": "integer", + "example": 20 + }, + "total": { + "type": "integer", + "example": 10 + } + }, + "type": "object" + } + }, + "type": "object" + } + ] + } + } + } + }, + "400": { + "description": "잘못된 요청", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "인증 실패", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "서버 에러", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ] + } + }, + "/api/v1/document-templates/{id}": { + "get": { + "tags": [ + "DocumentTemplates" + ], + "summary": "양식 상세 조회", + "description": "ID 기준 양식 상세 정보를 조회합니다. 결재라인, 기본필드, 섹션(검사항목 포함), 컬럼 전체를 반환합니다.", + "operationId": "1729eaeb3a2525c0ae5205933a78c9cb", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "양식 ID", + "required": true, + "schema": { + "type": "integer", + "example": 7 + } + } + ], + "responses": { + "200": { + "description": "조회 성공", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResponse" + }, + { + "properties": { + "data": { + "$ref": "#/components/schemas/DocumentTemplate" + } + }, + "type": "object" + } + ] + } + } + } + }, + "401": { + "description": "인증 실패", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "양식을 찾을 수 없음", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "서버 에러", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ] + } + }, "/api/v1/employees": { "get": { "tags": [ @@ -54693,6 +55474,506 @@ ] } }, + "/api/v1/trigger-audit-logs": { + "get": { + "tags": [ + "Trigger Audit" + ], + "summary": "감사 로그 목록 조회", + "description": "DB 트리거 기반 변경 로그를 페이지네이션으로 조회합니다.", + "operationId": "f8811286b399207c792047a90da1bfb1", + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1 + } + }, + { + "name": "size", + "in": "query", + "schema": { + "type": "integer", + "maximum": 200, + "minimum": 1 + } + }, + { + "name": "table_name", + "in": "query", + "description": "테이블명 필터", + "schema": { + "type": "string" + } + }, + { + "name": "row_id", + "in": "query", + "description": "레코드 PK 필터", + "schema": { + "type": "string" + } + }, + { + "name": "dml_type", + "in": "query", + "description": "DML 유형 필터", + "schema": { + "type": "string", + "enum": [ + "INSERT", + "UPDATE", + "DELETE" + ] + } + }, + { + "name": "tenant_id", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "actor_id", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "db_user", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "from", + "in": "query", + "description": "시작일", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "to", + "in": "query", + "description": "종료일", + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "sort", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "created_at", + "id" + ] + } + }, + { + "name": "order", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + } + } + ], + "responses": { + "200": { + "description": "목록 조회 성공", + "content": { + "application/json": { + "schema": { + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + }, + "data": { + "properties": { + "current_page": { + "type": "integer" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TriggerAuditLog" + } + }, + "last_page": { + "type": "integer" + }, + "per_page": { + "type": "integer" + }, + "total": { + "type": "integer" + } + }, + "type": "object" + } + }, + "type": "object" + } + } + } + } + }, + "security": [ + { + "ApiKeyAuth": [], + "BearerAuth": [] + } + ] + } + }, + "/api/v1/trigger-audit-logs/stats": { + "get": { + "tags": [ + "Trigger Audit" + ], + "summary": "감사 로그 통계", + "description": "전체/오늘/DML별 건수, 상위 테이블, 저장소 크기 통계를 반환합니다.", + "operationId": "6c9634c59472ce800617df1424865616", + "parameters": [ + { + "name": "tenant_id", + "in": "query", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "통계 조회 성공", + "content": { + "application/json": { + "schema": { + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + }, + "data": { + "properties": { + "total": { + "type": "integer" + }, + "today": { + "type": "integer" + }, + "by_dml_type": { + "properties": { + "INSERT": { + "type": "integer" + }, + "UPDATE": { + "type": "integer" + }, + "DELETE": { + "type": "integer" + } + }, + "type": "object" + }, + "top_tables": { + "type": "object" + }, + "storage_mb": { + "type": "number" + } + }, + "type": "object" + } + }, + "type": "object" + } + } + } + } + }, + "security": [ + { + "ApiKeyAuth": [], + "BearerAuth": [] + } + ] + } + }, + "/api/v1/trigger-audit-logs/{id}": { + "get": { + "tags": [ + "Trigger Audit" + ], + "summary": "감사 로그 상세 조회", + "operationId": "5b09fda6b7594f2ab85d3544b1d0850f", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "상세 조회 성공", + "content": { + "application/json": { + "schema": { + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/TriggerAuditLog" + } + }, + "type": "object" + } + } + } + }, + "404": { + "description": "Not Found" + } + }, + "security": [ + { + "ApiKeyAuth": [], + "BearerAuth": [] + } + ] + } + }, + "/api/v1/trigger-audit-logs/{tableName}/{rowId}/history": { + "get": { + "tags": [ + "Trigger Audit" + ], + "summary": "레코드 변경 이력 조회", + "description": "특정 테이블의 특정 레코드에 대한 전체 변경 이력을 조회합니다.", + "operationId": "96643eef44f80bfb03dfae370a3e89cd", + "parameters": [ + { + "name": "tableName", + "in": "path", + "description": "테이블명", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "rowId", + "in": "path", + "description": "레코드 PK", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "이력 조회 성공", + "content": { + "application/json": { + "schema": { + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TriggerAuditLog" + } + } + }, + "type": "object" + } + } + } + } + }, + "security": [ + { + "ApiKeyAuth": [], + "BearerAuth": [] + } + ] + } + }, + "/api/v1/trigger-audit-logs/{id}/rollback-preview": { + "get": { + "tags": [ + "Trigger Audit" + ], + "summary": "롤백 SQL 미리보기", + "description": "해당 변경을 되돌리기 위한 SQL문을 미리 확인합니다.", + "operationId": "b7c422185815b176bdabf91455cdfdd5", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "롤백 SQL 반환", + "content": { + "application/json": { + "schema": { + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + }, + "data": { + "properties": { + "audit_id": { + "type": "integer" + }, + "rollback_sql": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + } + } + } + }, + "404": { + "description": "Not Found" + } + }, + "security": [ + { + "ApiKeyAuth": [], + "BearerAuth": [] + } + ] + } + }, + "/api/v1/trigger-audit-logs/{id}/rollback": { + "post": { + "tags": [ + "Trigger Audit" + ], + "summary": "롤백 실행", + "description": "해당 변경을 실제로 되돌립니다. confirm=true 필수.", + "operationId": "d32645b2f3f5ae258fc16575b2d301eb", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "required": [ + "confirm" + ], + "properties": { + "confirm": { + "description": "롤백 확인 (true 필수)", + "type": "boolean", + "example": true + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "롤백 성공", + "content": { + "application/json": { + "schema": { + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + }, + "data": { + "properties": { + "rolled_back": { + "type": "boolean" + }, + "sql_executed": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + } + } + } + }, + "404": { + "description": "Not Found" + }, + "422": { + "description": "Validation Error" + } + }, + "security": [ + { + "ApiKeyAuth": [], + "BearerAuth": [] + } + ] + } + }, "/api/v1/users/me": { "get": { "tags": [ @@ -60373,6 +61654,75 @@ }, "type": "object" }, + "AppVersionLatest": { + "description": "최신 앱 버전 정보", + "properties": { + "id": { + "type": "integer", + "example": 3 + }, + "version_code": { + "description": "정수 비교용 버전 코드", + "type": "integer", + "example": 2 + }, + "version_name": { + "description": "표시용 버전명", + "type": "string", + "example": "0.2" + }, + "release_notes": { + "description": "변경사항", + "type": "string", + "example": "- 알림음 채널 정리\\n- 인앱 업데이트 추가", + "nullable": true + }, + "force_update": { + "description": "강제 업데이트 여부", + "type": "boolean", + "example": false + }, + "apk_size": { + "description": "APK 파일 크기(bytes)", + "type": "integer", + "example": 15728640, + "nullable": true + }, + "download_url": { + "description": "APK 다운로드 URL", + "type": "string", + "example": "https://api.codebridge-x.com/api/v1/app/download/3" + }, + "published_at": { + "description": "배포일", + "type": "string", + "format": "date", + "example": "2026-01-30", + "nullable": true + } + }, + "type": "object" + }, + "AppVersionCheckResponse": { + "description": "버전 확인 응답", + "properties": { + "has_update": { + "description": "업데이트 존재 여부", + "type": "boolean", + "example": true + }, + "latest_version": { + "oneOf": [ + { + "$ref": "#/components/schemas/AppVersionLatest" + } + ], + "nullable": true, + "description": "최신 버전 정보 (has_update=false면 null)" + } + }, + "type": "object" + }, "Approval": { "description": "결재 문서 정보", "properties": { @@ -69896,6 +71246,333 @@ }, "type": "object" }, + "DocumentTemplate": { + "description": "문서 양식 정보", + "properties": { + "id": { + "description": "양식 ID", + "type": "integer", + "example": 1 + }, + "tenant_id": { + "description": "테넌트 ID", + "type": "integer", + "example": 1 + }, + "name": { + "description": "양식명", + "type": "string", + "example": "수입검사 성적서 (EGI)" + }, + "category": { + "description": "분류", + "type": "string", + "example": "품질" + }, + "title": { + "description": "문서 제목", + "type": "string", + "example": "수입검사 성적서", + "nullable": true + }, + "company_name": { + "description": "회사명", + "type": "string", + "example": "(주)SAM", + "nullable": true + }, + "company_address": { + "description": "회사 주소", + "type": "string", + "nullable": true + }, + "company_contact": { + "description": "연락처", + "type": "string", + "nullable": true + }, + "footer_remark_label": { + "description": "하단 비고 라벨", + "type": "string", + "example": "부적합 내용" + }, + "footer_judgement_label": { + "description": "하단 판정 라벨", + "type": "string", + "example": "종합판정" + }, + "footer_judgement_options": { + "description": "판정 옵션", + "type": "array", + "items": { + "type": "string", + "example": "적합" + }, + "nullable": true + }, + "is_active": { + "description": "활성 여부", + "type": "boolean", + "example": true + }, + "approval_lines": { + "description": "결재라인", + "type": "array", + "items": { + "$ref": "#/components/schemas/DocumentTemplateApprovalLine" + } + }, + "basic_fields": { + "description": "기본 필드", + "type": "array", + "items": { + "$ref": "#/components/schemas/DocumentTemplateBasicField" + } + }, + "sections": { + "description": "검사 기준서 섹션", + "type": "array", + "items": { + "$ref": "#/components/schemas/DocumentTemplateSection" + } + }, + "columns": { + "description": "테이블 컬럼", + "type": "array", + "items": { + "$ref": "#/components/schemas/DocumentTemplateColumn" + } + }, + "created_at": { + "type": "string", + "format": "date-time", + "example": "2026-01-28T09:00:00Z" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "example": "2026-01-28T09:00:00Z" + } + }, + "type": "object" + }, + "DocumentTemplateApprovalLine": { + "description": "양식 결재라인", + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "template_id": { + "type": "integer", + "example": 1 + }, + "name": { + "description": "결재자 이름/직책", + "type": "string", + "example": "작성" + }, + "dept": { + "description": "부서", + "type": "string", + "example": "품질", + "nullable": true + }, + "role": { + "description": "역할", + "type": "string", + "example": "작성" + }, + "sort_order": { + "description": "정렬 순서", + "type": "integer", + "example": 1 + } + }, + "type": "object" + }, + "DocumentTemplateBasicField": { + "description": "양식 기본 필드", + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "template_id": { + "type": "integer", + "example": 1 + }, + "label": { + "description": "필드 라벨", + "type": "string", + "example": "품명" + }, + "field_type": { + "description": "필드 타입", + "type": "string", + "example": "text" + }, + "default_value": { + "description": "기본값", + "type": "string", + "example": "", + "nullable": true + }, + "sort_order": { + "description": "정렬 순서", + "type": "integer", + "example": 1 + } + }, + "type": "object" + }, + "DocumentTemplateSection": { + "description": "양식 검사 기준서 섹션", + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "template_id": { + "type": "integer", + "example": 1 + }, + "title": { + "description": "섹션 제목", + "type": "string", + "example": "검사항목" + }, + "image_path": { + "description": "검사 기준 이미지", + "type": "string", + "example": "/img/inspection/screen_inspection.jpg", + "nullable": true + }, + "sort_order": { + "description": "정렬 순서", + "type": "integer", + "example": 1 + }, + "items": { + "description": "검사항목", + "type": "array", + "items": { + "$ref": "#/components/schemas/DocumentTemplateSectionItem" + } + } + }, + "type": "object" + }, + "DocumentTemplateSectionItem": { + "description": "양식 검사항목", + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "section_id": { + "type": "integer", + "example": 1 + }, + "category": { + "description": "항목 분류", + "type": "string", + "example": "외관", + "nullable": true + }, + "item": { + "description": "검사항목", + "type": "string", + "example": "표면 스크래치" + }, + "standard": { + "description": "검사기준", + "type": "string", + "example": "스크래치 없을 것", + "nullable": true + }, + "method": { + "description": "검사방식", + "type": "string", + "example": "육안검사", + "nullable": true + }, + "frequency": { + "description": "검사주기", + "type": "string", + "example": "전수", + "nullable": true + }, + "regulation": { + "description": "관련 규격", + "type": "string", + "nullable": true + }, + "sort_order": { + "description": "정렬 순서", + "type": "integer", + "example": 1 + } + }, + "type": "object" + }, + "DocumentTemplateColumn": { + "description": "양식 테이블 컬럼", + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "template_id": { + "type": "integer", + "example": 1 + }, + "label": { + "description": "컬럼 라벨", + "type": "string", + "example": "1차" + }, + "width": { + "description": "컬럼 너비", + "type": "string", + "example": "80px", + "nullable": true + }, + "column_type": { + "description": "컬럼 타입", + "type": "string", + "enum": [ + "text", + "check", + "complex", + "select", + "measurement" + ], + "example": "check" + }, + "group_name": { + "description": "그룹명", + "type": "string", + "example": "측정치", + "nullable": true + }, + "sub_labels": { + "description": "하위 라벨 (complex 타입)", + "type": "array", + "items": { + "type": "string", + "example": "n1" + }, + "nullable": true + }, + "sort_order": { + "description": "정렬 순서", + "type": "integer", + "example": 1 + } + }, + "type": "object" + }, "Employee": { "description": "사원 정보", "properties": { @@ -88380,6 +90057,78 @@ }, "type": "object" }, + "TriggerAuditLog": { + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "table_name": { + "type": "string", + "example": "products" + }, + "row_id": { + "type": "string", + "example": "42" + }, + "dml_type": { + "type": "string", + "enum": [ + "INSERT", + "UPDATE", + "DELETE" + ] + }, + "old_values": { + "type": "object", + "nullable": true + }, + "new_values": { + "type": "object", + "nullable": true + }, + "changed_columns": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "tenant_id": { + "type": "integer", + "nullable": true + }, + "actor_id": { + "type": "integer", + "nullable": true + }, + "session_info": { + "properties": { + "ip": { + "type": "string" + }, + "ua": { + "type": "string" + }, + "route": { + "type": "string" + } + }, + "type": "object", + "nullable": true + }, + "db_user": { + "type": "string", + "example": "samuser@%", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "type": "object" + }, "Member": { "description": "회원 기본 정보", "required": [ @@ -91427,6 +93176,10 @@ "name": "Documents", "description": "문서 관리" }, + { + "name": "DocumentTemplates", + "description": "문서 양식(템플릿) 관리" + }, { "name": "Employees", "description": "사원 관리 (HR)" @@ -91603,6 +93356,10 @@ "name": "TodayIssue", "description": "CEO 대시보드 - 오늘의 이슈 리스트 API" }, + { + "name": "Trigger Audit", + "description": "DB 트리거 기반 데이터 변경 추적 로그" + }, { "name": "UserInvitation", "description": "사용자 초대 관리" @@ -91631,6 +93388,10 @@ "name": "Admin.GlobalMenu", "description": "Admin.GlobalMenu" }, + { + "name": "App Version", + "description": "App Version" + }, { "name": "BankTransaction", "description": "BankTransaction" From be572678db9e8da590360cef627502021d20e61e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Mon, 9 Feb 2026 21:31:19 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=EC=88=98=EC=A3=BC=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EB=A1=9C=EC=A7=81=20=EA=B0=95=ED=99=94=20-=20?= =?UTF-8?q?=EC=97=B0=EA=B4=80=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20cascade=20so?= =?UTF-8?q?ft=20delete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 삭제 불가 상태 추가 (생산중/생산완료/출하중/출하완료) - 작업지시/출하 존재 시 삭제 차단 + 에러 메시지 - order_item_components → order_items → order_nodes → order 순차 soft delete - DB 트랜잭션으로 원자성 보장 Co-Authored-By: Claude Opus 4.6 --- app/Services/OrderService.php | 42 +++++++++++++++++++++++++++++++---- lang/ko/error.php | 2 ++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index 565a1b5..151c2f2 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -254,6 +254,7 @@ public function update(int $id, array $data) public function destroy(int $id) { $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); $order = Order::where('tenant_id', $tenantId)->find($id); if (! $order) { @@ -263,16 +264,49 @@ public function destroy(int $id) // 진행 중이거나 완료된 수주는 삭제 불가 if (in_array($order->status_code, [ Order::STATUS_IN_PROGRESS, + Order::STATUS_IN_PRODUCTION, + Order::STATUS_PRODUCED, + Order::STATUS_SHIPPING, + Order::STATUS_SHIPPED, Order::STATUS_COMPLETED, ])) { throw new BadRequestHttpException(__('error.order.cannot_delete_in_progress')); } - $order->deleted_by = $this->apiUserId(); - $order->save(); - $order->delete(); + // 작업지시가 존재하면 삭제 불가 + if ($order->workOrders()->exists()) { + throw new BadRequestHttpException(__('error.order.cannot_delete_has_work_orders')); + } - return 'success'; + // 출하 정보가 존재하면 삭제 불가 + if ($order->shipments()->exists()) { + throw new BadRequestHttpException(__('error.order.cannot_delete_has_shipments')); + } + + return DB::transaction(function () use ($order, $userId) { + // 1. order_item_components soft delete + foreach ($order->items as $item) { + $item->components()->update(['deleted_by' => $userId]); + $item->components()->delete(); + } + + // 2. order_items soft delete + $order->items()->update(['deleted_by' => $userId]); + $order->items()->delete(); + + // 3. order_nodes soft delete + $order->nodes()->update(['deleted_by' => $userId]); + $order->nodes()->delete(); + + // 4. order 마스터 soft delete + $order->deleted_by = $userId; + $order->save(); + $order->delete(); + + // order_histories, order_versions는 감사 기록이므로 보존 + + return 'success'; + }); } /** diff --git a/lang/ko/error.php b/lang/ko/error.php index 7219270..ab2dd98 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -400,6 +400,8 @@ 'order' => [ 'cannot_update_completed' => '완료 또는 취소된 수주는 수정할 수 없습니다.', 'cannot_delete_in_progress' => '진행 중이거나 완료된 수주는 삭제할 수 없습니다.', + 'cannot_delete_has_work_orders' => '작업지시가 존재하는 수주는 삭제할 수 없습니다. 작업지시를 먼저 삭제해주세요.', + 'cannot_delete_has_shipments' => '출하 정보가 존재하는 수주는 삭제할 수 없습니다. 출하를 먼저 삭제해주세요.', 'invalid_status_transition' => '유효하지 않은 상태 전환입니다.', 'already_created_from_quote' => '이미 해당 견적에서 수주가 생성되었습니다.', 'must_be_confirmed_for_production' => '확정 상태의 수주만 생산지시를 생성할 수 있습니다.', From 072d0c0ae184b65986a7f8995e0f76e93368eaec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Mon, 9 Feb 2026 21:31:25 +0900 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20LOGICAL=5FRELATIONSHIPS.md=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20=EA=B4=80=EA=B3=84=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OrderNode 모델 관계 추가 (parent, order, children, items) - Order.nodes/rootNodes, OrderItem.node 관계 추가 - WorkOrder.stepProgress, WorkOrderStepProgress 관계 추가 Co-Authored-By: Claude Opus 4.6 --- LOGICAL_RELATIONSHIPS.md | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/LOGICAL_RELATIONSHIPS.md b/LOGICAL_RELATIONSHIPS.md index a81b37c..bf16312 100644 --- a/LOGICAL_RELATIONSHIPS.md +++ b/LOGICAL_RELATIONSHIPS.md @@ -1,6 +1,6 @@ # 논리적 데이터베이스 관계 문서 -> **자동 생성**: 2026-02-07 09:56:46 +> **자동 생성**: 2026-02-07 01:10:55 > **소스**: Eloquent 모델 관계 분석 ## 📊 모델별 관계 현황 @@ -499,6 +499,8 @@ ### orders - **item()**: belongsTo → `items` - **sale()**: belongsTo → `sales` - **items()**: hasMany → `order_items` +- **nodes()**: hasMany → `order_nodes` +- **rootNodes()**: hasMany → `order_nodes` - **histories()**: hasMany → `order_histories` - **versions()**: hasMany → `order_versions` - **workOrders()**: hasMany → `work_orders` @@ -514,6 +516,7 @@ ### order_items **모델**: `App\Models\Orders\OrderItem` - **order()**: belongsTo → `orders` +- **node()**: belongsTo → `order_nodes` - **item()**: belongsTo → `items` - **quote()**: belongsTo → `quotes` - **quoteItem()**: belongsTo → `quote_items` @@ -524,6 +527,14 @@ ### order_item_components - **orderItem()**: belongsTo → `order_items` +### order_nodes +**모델**: `App\Models\Orders\OrderNode` + +- **parent()**: belongsTo → `order_nodes` +- **order()**: belongsTo → `orders` +- **children()**: hasMany → `order_nodes` +- **items()**: hasMany → `order_items` + ### order_versions **모델**: `App\Models\Orders\OrderVersion` @@ -597,6 +608,7 @@ ### work_orders - **primaryAssignee()**: hasMany → `work_order_assignees` - **items()**: hasMany → `work_order_items` - **issues()**: hasMany → `work_order_issues` +- **stepProgress()**: hasMany → `work_order_step_progress` - **shipments()**: hasMany → `shipments` - **bendingDetail()**: hasOne → `work_order_bending_details` @@ -624,6 +636,14 @@ ### work_order_items - **workOrder()**: belongsTo → `work_orders` - **item()**: belongsTo → `items` +### work_order_step_progress +**모델**: `App\Models\Production\WorkOrderStepProgress` + +- **workOrder()**: belongsTo → `work_orders` +- **processStep()**: belongsTo → `process_steps` +- **workOrderItem()**: belongsTo → `work_order_items` +- **completedByUser()**: belongsTo → `users` + ### work_results **모델**: `App\Models\Production\WorkResult`