diff --git a/.serena/memories/quote-bom-bulk-api-phase-1.2.md b/.serena/memories/quote-bom-bulk-api-phase-1.2.md new file mode 100644 index 0000000..c212391 --- /dev/null +++ b/.serena/memories/quote-bom-bulk-api-phase-1.2.md @@ -0,0 +1,103 @@ +# Phase 1.2: 다건 BOM 기반 자동산출 API + +## 개요 +- **작업일**: 2026-01-02 +- **커밋**: `4e59bbf` feat: Phase 1.2 - 다건 BOM 기반 자동산출 API 구현 +- **목적**: React 견적등록 화면에서 여러 품목의 자동산출 일괄 요청 + +## API 엔드포인트 +``` +POST /api/v1/quotes/calculate/bom/bulk +``` + +## 파일 목록 + +### 생성된 파일 +| 파일 | 설명 | +|------|------| +| `app/Http/Requests/Quote/QuoteBomBulkCalculateRequest.php` | 다건 BOM 산출 FormRequest | +| `api/docs/changes/20260102_1300_quote_bom_bulk_calculation.md` | 변경 내용 문서 | + +### 수정된 파일 +| 파일 | 설명 | +|------|------| +| `app/Services/Quote/QuoteCalculationService.php` | calculateBomBulk() 메서드 추가 | +| `app/Http/Controllers/Api/V1/QuoteController.php` | calculateBomBulk 액션 추가 | +| `routes/api.php` | /calculate/bom/bulk 라우트 추가 | +| `app/Swagger/v1/QuoteApi.php` | 스키마 3개 + 엔드포인트 추가 | + +## React → API 필드 매핑 + +| React 필드 (camelCase) | API 변수 (약어) | 설명 | +|----------------------|----------------|------| +| openWidth | W0 | 개구부 폭 (mm) | +| openHeight | H0 | 개구부 높이 (mm) | +| quantity | QTY | 수량 | +| productCategory | PC | 제품 카테고리 | +| guideRailType | GT | 가이드레일 타입 | +| motorPower | MP | 모터 출력 | +| controller | CT | 제어반 | +| wingSize | WS | 날개 크기 | +| inspectionFee | INSP | 검사비 | + +## 요청/응답 예시 + +### 요청 +```json +{ + "items": [ + { + "finished_goods_code": "SC-1000", + "openWidth": 3000, + "openHeight": 2500, + "quantity": 2 + }, + { + "finished_goods_code": "SC-2000", + "W0": 4000, + "H0": 3000, + "QTY": 1 + } + ], + "debug": false +} +``` + +### 응답 +```json +{ + "success": true, + "message": "견적 일괄 산출이 완료되었습니다.", + "data": { + "success": true, + "summary": { + "total_count": 2, + "success_count": 2, + "fail_count": 0, + "grand_total": 2500000 + }, + "items": [...] + } +} +``` + +## 핵심 로직 + +### QuoteBomBulkCalculateRequest::getInputItems() +- React camelCase 필드명을 API 약어로 자동 변환 +- 양쪽 필드명 모두 지원 (하위 호환성) + +### QuoteCalculationService::calculateBomBulk() +- 각 품목에 대해 calculateBom() 순회 호출 +- 성공/실패 카운트 집계 +- 개별 품목 실패가 전체에 영향 없음 (예외 처리) + +## 관련 문서 +- 계획 문서: `docs/plans/quote-calculation-api-plan.md` +- Phase 1.1 문서: `docs/changes/20260102_quote_bom_calculation_api.md` +- Phase 1.2 문서: `docs/changes/20260102_1300_quote_bom_bulk_calculation.md` + +## 다음 단계 +- React 프론트엔드에서 `/calculate/bom/bulk` API 연동 +- 실제 품목 데이터로 테스트 +- 저장 플로우 연결 (`POST /quotes` store API) diff --git a/LOGICAL_RELATIONSHIPS.md b/LOGICAL_RELATIONSHIPS.md index a0641ba..4ea9940 100644 --- a/LOGICAL_RELATIONSHIPS.md +++ b/LOGICAL_RELATIONSHIPS.md @@ -1,6 +1,6 @@ # 논리적 데이터베이스 관계 문서 -> **자동 생성**: 2026-01-13 09:55:38 +> **자동 생성**: 2026-01-14 19:19:09 > **소스**: Eloquent 모델 관계 분석 ## 📊 모델별 관계 현황 diff --git a/app/Models/Items/ItemReceipt.php b/app/Models/Items/ItemReceipt.php index 87d46d2..ae2a225 100644 --- a/app/Models/Items/ItemReceipt.php +++ b/app/Models/Items/ItemReceipt.php @@ -2,8 +2,8 @@ namespace App\Models\Items; -use App\Models\Scopes\BelongsToTenant; -use App\Models\Tenants\User; +use App\Models\Members\User; +use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; @@ -30,7 +30,7 @@ */ class ItemReceipt extends Model { - use SoftDeletes; + use BelongsToTenant, SoftDeletes; protected $table = 'item_receipts'; @@ -61,11 +61,6 @@ class ItemReceipt extends Model 'weight_kg' => 'decimal:2', ]; - protected static function booted(): void - { - static::addGlobalScope(new BelongsToTenant); - } - // ===== Relationships ===== /** diff --git a/app/Models/Production/WorkOrderItem.php b/app/Models/Production/WorkOrderItem.php index 626c5b4..c11930f 100644 --- a/app/Models/Production/WorkOrderItem.php +++ b/app/Models/Production/WorkOrderItem.php @@ -26,6 +26,7 @@ class WorkOrderItem extends Model 'unit', 'sort_order', 'status', + 'options', ]; /** @@ -44,6 +45,7 @@ class WorkOrderItem extends Model protected $casts = [ 'quantity' => 'decimal:2', 'sort_order' => 'integer', + 'options' => 'array', ]; // ────────────────────────────────────────────────────────────── @@ -77,4 +79,70 @@ public function scopeOrdered($query) { return $query->orderBy('sort_order'); } + + /** + * 완료된 품목만 필터 + */ + public function scopeCompleted($query) + { + return $query->where('status', self::STATUS_COMPLETED); + } + + /** + * 작업 결과가 있는 품목만 필터 + */ + public function scopeHasResult($query) + { + return $query->whereNotNull('options->result'); + } + + // ────────────────────────────────────────────────────────────── + // 헬퍼 메서드 + // ────────────────────────────────────────────────────────────── + + /** + * 작업 결과 데이터 가져오기 + */ + public function getResult(): ?array + { + return $this->options['result'] ?? null; + } + + /** + * 작업 결과 데이터 설정 + */ + public function setResult(array $result): void + { + $options = $this->options ?? []; + $options['result'] = array_merge($options['result'] ?? [], $result); + $this->options = $options; + } + + /** + * 작업 완료 처리 (결과 데이터 저장) + */ + public function completeWithResult(array $resultData = []): void + { + $this->status = self::STATUS_COMPLETED; + + $result = [ + 'completed_at' => now()->toDateTimeString(), + 'good_qty' => $resultData['good_qty'] ?? $this->quantity, + 'defect_qty' => $resultData['defect_qty'] ?? 0, + 'lot_no' => $resultData['lot_no'] ?? null, + 'is_inspected' => $resultData['is_inspected'] ?? false, + 'is_packaged' => $resultData['is_packaged'] ?? false, + 'worker_id' => $resultData['worker_id'] ?? null, + 'memo' => $resultData['memo'] ?? null, + ]; + + // 불량률 자동 계산 + $totalQty = $result['good_qty'] + $result['defect_qty']; + $result['defect_rate'] = $totalQty > 0 + ? round(($result['defect_qty'] / $totalQty) * 100, 2) + : 0; + + $this->setResult($result); + $this->save(); + } } diff --git a/app/Models/Qualitys/Inspection.php b/app/Models/Qualitys/Inspection.php index dda9494..7ac6545 100644 --- a/app/Models/Qualitys/Inspection.php +++ b/app/Models/Qualitys/Inspection.php @@ -3,8 +3,8 @@ namespace App\Models\Qualitys; use App\Models\Items\Item; -use App\Models\Scopes\BelongsToTenant; -use App\Models\Tenants\User; +use App\Models\Members\User; +use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; @@ -32,7 +32,7 @@ */ class Inspection extends Model { - use SoftDeletes; + use BelongsToTenant, SoftDeletes; protected $table = 'inspections'; @@ -89,11 +89,6 @@ class Inspection extends Model public const RESULT_FAIL = 'fail'; - protected static function booted(): void - { - static::addGlobalScope(new BelongsToTenant); - } - // ===== Relationships ===== /** diff --git a/app/Services/InspectionService.php b/app/Services/InspectionService.php index af38973..dcc5e38 100644 --- a/app/Services/InspectionService.php +++ b/app/Services/InspectionService.php @@ -69,7 +69,7 @@ public function index(array $params) $transformedData = $paginated->getCollection()->map(fn ($item) => $this->transformToFrontend($item)); return [ - 'data' => $transformedData, + 'items' => $transformedData, 'current_page' => $paginated->currentPage(), 'last_page' => $paginated->lastPage(), 'per_page' => $paginated->perPage(), diff --git a/app/Services/WorkResultService.php b/app/Services/WorkResultService.php index 7ba34fc..e0e5527 100644 --- a/app/Services/WorkResultService.php +++ b/app/Services/WorkResultService.php @@ -2,15 +2,21 @@ namespace App\Services; -use App\Models\Production\WorkOrder; -use App\Models\Production\WorkResult; +use App\Models\Production\WorkOrderItem; use Illuminate\Support\Facades\DB; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +/** + * 작업실적 서비스 + * + * 완료된 WorkOrderItem의 options->result 데이터를 기반으로 작업실적 조회/수정 + */ class WorkResultService extends Service { /** * 목록 조회 (검색/필터링/페이징) + * + * 완료된 WorkOrderItem에서 result 데이터가 있는 항목만 조회 */ public function index(array $params) { @@ -19,7 +25,6 @@ public function index(array $params) $page = (int) ($params['page'] ?? 1); $size = (int) ($params['size'] ?? 20); $q = trim((string) ($params['q'] ?? '')); - $processType = $params['process_type'] ?? null; $workOrderId = $params['work_order_id'] ?? null; $workerId = $params['worker_id'] ?? null; $workDateFrom = $params['work_date_from'] ?? null; @@ -27,27 +32,24 @@ public function index(array $params) $isInspected = isset($params['is_inspected']) ? filter_var($params['is_inspected'], FILTER_VALIDATE_BOOLEAN) : null; $isPackaged = isset($params['is_packaged']) ? filter_var($params['is_packaged'], FILTER_VALIDATE_BOOLEAN) : null; - $query = WorkResult::query() + $query = WorkOrderItem::query() ->where('tenant_id', $tenantId) + ->where('status', WorkOrderItem::STATUS_COMPLETED) + ->whereNotNull('options->result') ->with([ - 'workOrder:id,work_order_no', - 'worker:id,name', + 'workOrder:id,work_order_no,project_name,process_id,completed_at', + 'workOrder.process:id,process_name,process_code', ]); - // 검색어 + // 검색어 (로트번호, 품목명, 작업지시번호) if ($q !== '') { $query->where(function ($qq) use ($q) { - $qq->where('lot_no', 'like', "%{$q}%") - ->orWhere('product_name', 'like', "%{$q}%") + $qq->where('options->result->lot_no', 'like', "%{$q}%") + ->orWhere('item_name', 'like', "%{$q}%") ->orWhereHas('workOrder', fn ($wo) => $wo->where('work_order_no', 'like', "%{$q}%")); }); } - // 공정유형 필터 - if ($processType !== null) { - $query->where('process_type', $processType); - } - // 작업지시 필터 if ($workOrderId !== null) { $query->where('work_order_id', $workOrderId); @@ -55,28 +57,29 @@ public function index(array $params) // 작업자 필터 if ($workerId !== null) { - $query->where('worker_id', $workerId); + $query->where('options->result->worker_id', $workerId); } - // 작업일 범위 + // 작업일 범위 (completed_at 기준) if ($workDateFrom !== null) { - $query->where('work_date', '>=', $workDateFrom); + $query->where('options->result->completed_at', '>=', $workDateFrom); } if ($workDateTo !== null) { - $query->where('work_date', '<=', $workDateTo); + $query->where('options->result->completed_at', '<=', $workDateTo . ' 23:59:59'); } // 검사 완료 필터 if ($isInspected !== null) { - $query->where('is_inspected', $isInspected); + $query->where('options->result->is_inspected', $isInspected); } // 포장 완료 필터 if ($isPackaged !== null) { - $query->where('is_packaged', $isPackaged); + $query->where('options->result->is_packaged', $isPackaged); } - $query->orderByDesc('work_date')->orderByDesc('created_at'); + // 최신 완료순 정렬 + $query->orderByDesc('options->result->completed_at')->orderByDesc('id'); return $query->paginate($size, ['*'], 'page', $page); } @@ -90,41 +93,44 @@ public function stats(array $params = []): array $workDateFrom = $params['work_date_from'] ?? null; $workDateTo = $params['work_date_to'] ?? null; - $processType = $params['process_type'] ?? null; - $query = WorkResult::where('tenant_id', $tenantId); + $query = WorkOrderItem::where('tenant_id', $tenantId) + ->where('status', WorkOrderItem::STATUS_COMPLETED) + ->whereNotNull('options->result'); // 작업일 범위 if ($workDateFrom !== null) { - $query->where('work_date', '>=', $workDateFrom); + $query->where('options->result->completed_at', '>=', $workDateFrom); } if ($workDateTo !== null) { - $query->where('work_date', '<=', $workDateTo); + $query->where('options->result->completed_at', '<=', $workDateTo . ' 23:59:59'); } - // 공정유형 필터 - if ($processType !== null) { - $query->where('process_type', $processType); + // JSON에서 집계 (MySQL/MariaDB 기준) + $items = $query->get(); + + $totalProduction = 0; + $totalGood = 0; + $totalDefect = 0; + + foreach ($items as $item) { + $result = $item->options['result'] ?? []; + $goodQty = (float) ($result['good_qty'] ?? 0); + $defectQty = (float) ($result['defect_qty'] ?? 0); + + $totalGood += $goodQty; + $totalDefect += $defectQty; + $totalProduction += ($goodQty + $defectQty); } - $totals = $query->select([ - DB::raw('SUM(production_qty) as total_production'), - DB::raw('SUM(good_qty) as total_good'), - DB::raw('SUM(defect_qty) as total_defect'), - ])->first(); - - $totalProduction = (int) ($totals->total_production ?? 0); - $totalGood = (int) ($totals->total_good ?? 0); - $totalDefect = (int) ($totals->total_defect ?? 0); - $defectRate = $totalProduction > 0 ? round(($totalDefect / $totalProduction) * 100, 1) : 0; return [ - 'total_production' => $totalProduction, - 'total_good' => $totalGood, - 'total_defect' => $totalDefect, + 'total_production' => (int) $totalProduction, + 'total_good' => (int) $totalGood, + 'total_defect' => (int) $totalDefect, 'defect_rate' => $defectRate, ]; } @@ -136,123 +142,92 @@ public function show(int $id) { $tenantId = $this->tenantId(); - $workResult = WorkResult::where('tenant_id', $tenantId) + $item = WorkOrderItem::where('tenant_id', $tenantId) + ->where('status', WorkOrderItem::STATUS_COMPLETED) + ->whereNotNull('options->result') ->with([ - 'workOrder:id,work_order_no,project_name,status', - 'workOrderItem:id,item_name,specification,quantity', - 'worker:id,name', + 'workOrder:id,work_order_no,project_name,process_id,completed_at', + 'workOrder.process:id,process_name,process_code', ]) ->find($id); - if (! $workResult) { + if (! $item) { throw new NotFoundHttpException(__('error.not_found')); } - return $workResult; + return $item; } /** - * 생성 - */ - public function store(array $data) - { - $tenantId = $this->tenantId(); - $userId = $this->apiUserId(); - - return DB::transaction(function () use ($data, $tenantId, $userId) { - $data['tenant_id'] = $tenantId; - $data['created_by'] = $userId; - $data['updated_by'] = $userId; - - // 양품수량 자동 계산 (입력 안 된 경우) - if (! isset($data['good_qty'])) { - $data['good_qty'] = max(0, ($data['production_qty'] ?? 0) - ($data['defect_qty'] ?? 0)); - } - - // 작업지시 정보로 자동 채움 - if (! empty($data['work_order_id'])) { - $workOrder = WorkOrder::find($data['work_order_id']); - if ($workOrder) { - $data['process_type'] = $data['process_type'] ?? $workOrder->process_type; - } - } - - return WorkResult::create($data); - }); - } - - /** - * 수정 + * 작업실적 수정 (양품/불량 수량 등) */ public function update(int $id, array $data) { $tenantId = $this->tenantId(); - $userId = $this->apiUserId(); - $workResult = WorkResult::where('tenant_id', $tenantId)->find($id); + $item = WorkOrderItem::where('tenant_id', $tenantId) + ->where('status', WorkOrderItem::STATUS_COMPLETED) + ->whereNotNull('options->result') + ->find($id); - if (! $workResult) { + if (! $item) { throw new NotFoundHttpException(__('error.not_found')); } - return DB::transaction(function () use ($workResult, $data, $userId) { - $data['updated_by'] = $userId; + return DB::transaction(function () use ($item, $data) { + $options = $item->options ?? []; + $result = $options['result'] ?? []; - // 양품수량 재계산 (생산수량 또는 불량수량 변경 시) - if (isset($data['production_qty']) || isset($data['defect_qty'])) { - $productionQty = $data['production_qty'] ?? $workResult->production_qty; - $defectQty = $data['defect_qty'] ?? $workResult->defect_qty; + // 수정 가능한 필드만 업데이트 + $allowedFields = ['good_qty', 'defect_qty', 'lot_no', 'is_inspected', 'is_packaged', 'memo']; - if (! isset($data['good_qty'])) { - $data['good_qty'] = max(0, $productionQty - $defectQty); + foreach ($allowedFields as $field) { + if (array_key_exists($field, $data)) { + $result[$field] = $data[$field]; } } - $workResult->update($data); + // 불량률 재계산 + $totalQty = ($result['good_qty'] ?? 0) + ($result['defect_qty'] ?? 0); + $result['defect_rate'] = $totalQty > 0 + ? round(($result['defect_qty'] / $totalQty) * 100, 2) + : 0; - return $workResult->fresh([ - 'workOrder:id,work_order_no', - 'worker:id,name', + $options['result'] = $result; + $item->options = $options; + $item->save(); + + return $item->fresh([ + 'workOrder:id,work_order_no,project_name,process_id', + 'workOrder.process:id,process_name,process_code', ]); }); } - /** - * 삭제 - */ - public function destroy(int $id): void - { - $tenantId = $this->tenantId(); - - $workResult = WorkResult::where('tenant_id', $tenantId)->find($id); - - if (! $workResult) { - throw new NotFoundHttpException(__('error.not_found')); - } - - $workResult->delete(); - } - /** * 검사 상태 토글 */ public function toggleInspection(int $id) { $tenantId = $this->tenantId(); - $userId = $this->apiUserId(); - $workResult = WorkResult::where('tenant_id', $tenantId)->find($id); + $item = WorkOrderItem::where('tenant_id', $tenantId) + ->where('status', WorkOrderItem::STATUS_COMPLETED) + ->whereNotNull('options->result') + ->find($id); - if (! $workResult) { + if (! $item) { throw new NotFoundHttpException(__('error.not_found')); } - $workResult->update([ - 'is_inspected' => ! $workResult->is_inspected, - 'updated_by' => $userId, - ]); + $options = $item->options ?? []; + $result = $options['result'] ?? []; + $result['is_inspected'] = ! ($result['is_inspected'] ?? false); + $options['result'] = $result; + $item->options = $options; + $item->save(); - return $workResult->fresh(); + return $item->fresh(); } /** @@ -261,19 +236,23 @@ public function toggleInspection(int $id) public function togglePackaging(int $id) { $tenantId = $this->tenantId(); - $userId = $this->apiUserId(); - $workResult = WorkResult::where('tenant_id', $tenantId)->find($id); + $item = WorkOrderItem::where('tenant_id', $tenantId) + ->where('status', WorkOrderItem::STATUS_COMPLETED) + ->whereNotNull('options->result') + ->find($id); - if (! $workResult) { + if (! $item) { throw new NotFoundHttpException(__('error.not_found')); } - $workResult->update([ - 'is_packaged' => ! $workResult->is_packaged, - 'updated_by' => $userId, - ]); + $options = $item->options ?? []; + $result = $options['result'] ?? []; + $result['is_packaged'] = ! ($result['is_packaged'] ?? false); + $options['result'] = $result; + $item->options = $options; + $item->save(); - return $workResult->fresh(); + return $item->fresh(); } -} +} \ No newline at end of file diff --git a/database/migrations/2026_01_14_155354_add_options_to_work_order_items_table.php b/database/migrations/2026_01_14_155354_add_options_to_work_order_items_table.php new file mode 100644 index 0000000..7fc0b53 --- /dev/null +++ b/database/migrations/2026_01_14_155354_add_options_to_work_order_items_table.php @@ -0,0 +1,28 @@ +json('options')->nullable()->after('status')->comment('작업 결과 등 추가 정보 (JSON)'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('work_order_items', function (Blueprint $table) { + $table->dropColumn('options'); + }); + } +};