diff --git a/.serena/memories/labor-api-implementation.md b/.serena/memories/labor-api-implementation.md new file mode 100644 index 0000000..a993ee8 --- /dev/null +++ b/.serena/memories/labor-api-implementation.md @@ -0,0 +1,48 @@ +# Labor (노임관리) API Implementation + +## Overview +시공관리 > 노임관리 API 구현 완료 (2026-01-11) + +## Database Schema +```sql +CREATE TABLE labors ( + id BIGINT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + labor_number VARCHAR(50) NOT NULL, -- 노임번호 + category ENUM('가로', '세로할증'), -- 구분 + min_m DECIMAL(10,2) DEFAULT 0, -- 최소(m) + max_m DECIMAL(10,2) DEFAULT 0, -- 최대(m) + labor_price INT NULL, -- 노임단가 + status ENUM('사용', '중지') DEFAULT '사용', -- 상태 + is_active BOOLEAN DEFAULT TRUE, + created_by, updated_by, deleted_by, + timestamps, soft_deletes +); +``` + +## Files Structure +``` +app/ +├── Models/Labor.php # 모델 + Scopes +├── Http/ +│ ├── Controllers/Api/V1/LaborController.php +│ └── Requests/Labor/ +│ ├── LaborIndexRequest.php # 목록 파라미터 +│ ├── LaborStoreRequest.php # 등록 검증 +│ ├── LaborUpdateRequest.php # 수정 검증 +│ └── LaborBulkDeleteRequest.php # 일괄삭제 검증 +└── Services/LaborService.php # 비즈니스 로직 +``` + +## API Endpoints +- GET /api/v1/labor - 목록 (search, category, status, pagination) +- GET /api/v1/labor/stats - 통계 (total, active) +- POST /api/v1/labor - 등록 +- GET /api/v1/labor/{id} - 상세 +- PUT /api/v1/labor/{id} - 수정 +- DELETE /api/v1/labor/{id} - 삭제 +- DELETE /api/v1/labor/bulk - 일괄삭제 + +## Frontend Integration +- react/src/components/business/construction/labor-management/actions.ts +- Mock → API 호출 변환 완료 diff --git a/CURRENT_WORKS.md b/CURRENT_WORKS.md index 0aa816e..d65c40a 100644 --- a/CURRENT_WORKS.md +++ b/CURRENT_WORKS.md @@ -1,5 +1,92 @@ +## 2026-01-11 (토) - Labor(노임관리) API 구현 + +### 작업 목표 +- 시공관리 > 노임관리 API 백엔드 구현 +- Frontend actions.ts API 연동 + +### 생성된 파일 +| 파일명 | 설명 | +|--------|------| +| `app/Models/Labor.php` | 노임 모델 (BelongsToTenant, SoftDeletes) | +| `app/Http/Controllers/Api/V1/LaborController.php` | 노임 컨트롤러 (7개 메서드) | +| `app/Services/LaborService.php` | 노임 서비스 (비즈니스 로직) | +| `app/Http/Requests/Labor/LaborIndexRequest.php` | 목록 조회 검증 | +| `app/Http/Requests/Labor/LaborStoreRequest.php` | 등록 요청 검증 | +| `app/Http/Requests/Labor/LaborUpdateRequest.php` | 수정 요청 검증 | +| `app/Http/Requests/Labor/LaborBulkDeleteRequest.php` | 일괄 삭제 검증 | +| `database/migrations/2026_01_11_000000_create_labors_table.php` | 마이그레이션 | + +### 수정된 파일 +| 파일명 | 설명 | +|--------|------| +| `routes/api.php` | Labor 라우트 7개 추가 (line 1005-1014) | + +### API 엔드포인트 +| Method | Endpoint | 설명 | +|--------|----------|------| +| GET | `/api/v1/labor` | 목록 조회 | +| GET | `/api/v1/labor/stats` | 통계 조회 | +| POST | `/api/v1/labor` | 등록 | +| GET | `/api/v1/labor/{id}` | 상세 조회 | +| PUT | `/api/v1/labor/{id}` | 수정 | +| DELETE | `/api/v1/labor/{id}` | 삭제 | +| DELETE | `/api/v1/labor/bulk` | 일괄 삭제 | + +### 검증 완료 +- [x] 마이그레이션 실행 완료 +- [x] Pint 코드 스타일 통과 +- [x] Service-First 아키텍처 준수 +- [x] FormRequest 검증 사용 +- [x] Multi-tenancy (BelongsToTenant) 적용 + +--- + # SAM API 작업 현황 +## 2025-01-09 (목) - 작업지시 코드 리뷰 기반 전면 개선 + +### 작업 목표 +- 작업지시(Work Orders) 기능 코드 리뷰 결과 기반 전면 개선 +- Critical, High, Medium 우선순위 항목 전체 수정 + +### 수정된 파일 +| 파일명 | 설명 | +|--------|------| +| `app/Models/Production/WorkOrderItem.php` | BelongsToTenant 트레이트 적용 | +| `app/Models/Production/WorkOrderBendingDetail.php` | BelongsToTenant 트레이트 적용 | +| `app/Models/Production/WorkOrderIssue.php` | BelongsToTenant 트레이트 적용 | +| `app/Models/Production/WorkOrder.php` | 상태 전이 규칙 (STATUS_TRANSITIONS, canTransitionTo, transitionTo) | +| `app/Services/WorkOrderService.php` | 감사 로그, 다중 담당자, 부분 수정 지원 | + +### 생성된 파일 +| 파일명 | 설명 | +|--------|------| +| `app/Models/Production/WorkOrderAssignee.php` | 다중 담당자 피벗 모델 | +| `database/migrations/*_create_work_order_assignees_table.php` | 다중 담당자 테이블 마이그레이션 | + +### 주요 변경 내용 +1. **Multi-tenancy 적용**: 하위 모델 4개에 BelongsToTenant 트레이트 적용 +2. **감사 로그 적용**: 품목 삭제, 상태 변경, 이슈 등록/해결, 담당자 배정 시 기록 +3. **상태 전이 규칙**: STATUS_TRANSITIONS 상수 + canTransitionTo(), transitionTo() 메서드 +4. **다중 담당자 지원**: + - WorkOrderAssignee 피벗 모델 생성 + - assign() 메서드에서 배열 담당자 지원 + - is_primary 플래그로 주 담당자 구분 +5. **부분 수정 지원**: 품목 업데이트 시 ID 기반 upsert/delete (기존 삭제 후 재생성 → ID 기반 부분 수정) + +### 검증 완료 +- [x] Pint 코드 스타일 (3개 파일) +- [x] Service-First 아키텍처 준수 +- [x] Eager loading에 assignees.user:id,name 추가 + +### Git 커밋 +- `349917f refactor(work-orders): 코드 리뷰 기반 전면 개선` + +### 관련 문서 +- 계획: `~/.claude/plans/purring-sparking-pinwheel.md` + +--- + ## 2026-01-08 (수) - Order Management API Phase 1.1 구현 ### 작업 목표 diff --git a/app/Http/Controllers/Api/V1/LaborController.php b/app/Http/Controllers/Api/V1/LaborController.php new file mode 100644 index 0000000..e0ea750 --- /dev/null +++ b/app/Http/Controllers/Api/V1/LaborController.php @@ -0,0 +1,103 @@ +service->index($request->validated()); + + return ['data' => $data, 'message' => __('message.fetched')]; + }); + } + + /** + * 노임 통계 조회 + */ + public function stats() + { + return ApiResponse::handle(function () { + $data = $this->service->stats(); + + return ['data' => $data, 'message' => __('message.fetched')]; + }); + } + + /** + * 노임 상세 조회 + */ + public function show(int $id) + { + return ApiResponse::handle(function () use ($id) { + $data = $this->service->show($id); + + return ['data' => $data, 'message' => __('message.fetched')]; + }); + } + + /** + * 노임 등록 + */ + public function store(LaborStoreRequest $request) + { + return ApiResponse::handle(function () use ($request) { + $data = $this->service->store($request->validated()); + + return ['data' => $data, 'message' => __('message.created'), 'statusCode' => 201]; + }); + } + + /** + * 노임 수정 + */ + public function update(LaborUpdateRequest $request, int $id) + { + return ApiResponse::handle(function () use ($request, $id) { + $data = $this->service->update($id, $request->validated()); + + return ['data' => $data, 'message' => __('message.updated')]; + }); + } + + /** + * 노임 삭제 + */ + public function destroy(int $id) + { + return ApiResponse::handle(function () use ($id) { + $this->service->destroy($id); + + return ['data' => null, 'message' => __('message.deleted')]; + }); + } + + /** + * 노임 일괄 삭제 + */ + public function bulkDestroy(LaborBulkDeleteRequest $request) + { + return ApiResponse::handle(function () use ($request) { + $validated = $request->validated(); + $deletedCount = $this->service->bulkDestroy($validated['ids']); + + return ['data' => ['deleted_count' => $deletedCount], 'message' => __('message.deleted')]; + }); + } +} diff --git a/app/Http/Requests/Labor/LaborBulkDeleteRequest.php b/app/Http/Requests/Labor/LaborBulkDeleteRequest.php new file mode 100644 index 0000000..e16cf4f --- /dev/null +++ b/app/Http/Requests/Labor/LaborBulkDeleteRequest.php @@ -0,0 +1,31 @@ + ['required', 'array', 'min:1'], + 'ids.*' => ['required', 'integer', 'exists:labors,id'], + ]; + } + + public function messages(): array + { + return [ + 'ids.required' => __('validation.required', ['attribute' => '삭제 대상']), + 'ids.array' => __('validation.array', ['attribute' => '삭제 대상']), + 'ids.min' => __('validation.min.array', ['attribute' => '삭제 대상', 'min' => 1]), + 'ids.*.exists' => __('validation.exists', ['attribute' => '노임 ID']), + ]; + } +} diff --git a/app/Http/Requests/Labor/LaborIndexRequest.php b/app/Http/Requests/Labor/LaborIndexRequest.php new file mode 100644 index 0000000..1be6f72 --- /dev/null +++ b/app/Http/Requests/Labor/LaborIndexRequest.php @@ -0,0 +1,37 @@ + ['nullable', 'string', 'max:100'], + 'category' => ['nullable', 'string', 'in:가로,세로할증'], + 'status' => ['nullable', 'string', 'in:사용,중지'], + 'start_date' => ['nullable', 'date'], + 'end_date' => ['nullable', 'date', 'after_or_equal:start_date'], + 'page' => ['nullable', 'integer', 'min:1'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + 'sort_by' => ['nullable', 'string', 'in:created_at,labor_number,category,min_m,max_m,labor_price'], + 'sort_dir' => ['nullable', 'string', 'in:asc,desc'], + ]; + } + + public function messages(): array + { + return [ + 'category.in' => __('validation.in', ['attribute' => '구분']), + 'status.in' => __('validation.in', ['attribute' => '상태']), + 'end_date.after_or_equal' => __('validation.after_or_equal', ['attribute' => '종료일', 'date' => '시작일']), + ]; + } +} diff --git a/app/Http/Requests/Labor/LaborStoreRequest.php b/app/Http/Requests/Labor/LaborStoreRequest.php new file mode 100644 index 0000000..e9c0d7d --- /dev/null +++ b/app/Http/Requests/Labor/LaborStoreRequest.php @@ -0,0 +1,39 @@ + ['required', 'string', 'max:50'], + 'category' => ['required', 'string', 'in:가로,세로할증'], + 'min_m' => ['required', 'numeric', 'min:0', 'max:9999.99'], + 'max_m' => ['required', 'numeric', 'min:0', 'max:9999.99', 'gte:min_m'], + 'labor_price' => ['nullable', 'integer', 'min:0'], + 'status' => ['required', 'string', 'in:사용,중지'], + ]; + } + + public function messages(): array + { + return [ + 'labor_number.required' => __('validation.required', ['attribute' => '노임번호']), + 'category.required' => __('validation.required', ['attribute' => '구분']), + 'category.in' => __('validation.in', ['attribute' => '구분']), + 'min_m.required' => __('validation.required', ['attribute' => '최소(m)']), + 'max_m.required' => __('validation.required', ['attribute' => '최대(m)']), + 'max_m.gte' => __('validation.gte.numeric', ['attribute' => '최대(m)', 'value' => '최소(m)']), + 'status.required' => __('validation.required', ['attribute' => '상태']), + 'status.in' => __('validation.in', ['attribute' => '상태']), + ]; + } +} diff --git a/app/Http/Requests/Labor/LaborUpdateRequest.php b/app/Http/Requests/Labor/LaborUpdateRequest.php new file mode 100644 index 0000000..80ddded --- /dev/null +++ b/app/Http/Requests/Labor/LaborUpdateRequest.php @@ -0,0 +1,39 @@ + ['sometimes', 'required', 'string', 'max:50'], + 'category' => ['sometimes', 'required', 'string', 'in:가로,세로할증'], + 'min_m' => ['sometimes', 'required', 'numeric', 'min:0', 'max:9999.99'], + 'max_m' => ['sometimes', 'required', 'numeric', 'min:0', 'max:9999.99', 'gte:min_m'], + 'labor_price' => ['nullable', 'integer', 'min:0'], + 'status' => ['sometimes', 'required', 'string', 'in:사용,중지'], + ]; + } + + public function messages(): array + { + return [ + 'labor_number.required' => __('validation.required', ['attribute' => '노임번호']), + 'category.required' => __('validation.required', ['attribute' => '구분']), + 'category.in' => __('validation.in', ['attribute' => '구분']), + 'min_m.required' => __('validation.required', ['attribute' => '최소(m)']), + 'max_m.required' => __('validation.required', ['attribute' => '최대(m)']), + 'max_m.gte' => __('validation.gte.numeric', ['attribute' => '최대(m)', 'value' => '최소(m)']), + 'status.required' => __('validation.required', ['attribute' => '상태']), + 'status.in' => __('validation.in', ['attribute' => '상태']), + ]; + } +} diff --git a/app/Models/Labor.php b/app/Models/Labor.php new file mode 100644 index 0000000..cb08cf9 --- /dev/null +++ b/app/Models/Labor.php @@ -0,0 +1,67 @@ + 'decimal:2', + 'max_m' => 'decimal:2', + 'labor_price' => 'integer', + 'is_active' => 'boolean', + ]; + + /** + * 상태별 필터 Scope + */ + public function scopeByStatus($query, string $status) + { + return $query->where('status', $status); + } + + /** + * 구분별 필터 Scope + */ + public function scopeByCategory($query, string $category) + { + return $query->where('category', $category); + } + + /** + * 검색 Scope + */ + public function scopeSearch($query, ?string $search) + { + if (empty($search)) { + return $query; + } + + return $query->where(function ($q) use ($search) { + $q->where('labor_number', 'like', "%{$search}%") + ->orWhere('category', 'like', "%{$search}%"); + }); + } +} diff --git a/app/Services/LaborService.php b/app/Services/LaborService.php new file mode 100644 index 0000000..f0a107f --- /dev/null +++ b/app/Services/LaborService.php @@ -0,0 +1,196 @@ +tenantId(); + + $size = (int) ($params['size'] ?? 20); + $search = trim((string) ($params['search'] ?? '')); + $category = $params['category'] ?? null; + $status = $params['status'] ?? null; + $startDate = $params['start_date'] ?? null; + $endDate = $params['end_date'] ?? null; + $sortOrder = $params['sort_order'] ?? 'latest'; + + $query = Labor::query() + ->where('tenant_id', $tenantId); + + // 검색어 필터 + if ($search !== '') { + $query->search($search); + } + + // 구분 필터 + if ($category && $category !== 'all') { + $query->byCategory($category); + } + + // 상태 필터 + if ($status && $status !== 'all') { + $query->byStatus($status); + } + + // 날짜 필터 + if ($startDate) { + $query->whereDate('created_at', '>=', $startDate); + } + if ($endDate) { + $query->whereDate('created_at', '<=', $endDate); + } + + // 정렬 + if ($sortOrder === 'oldest') { + $query->orderBy('created_at', 'asc'); + } else { + $query->orderByDesc('created_at'); + } + + return $query->paginate($size); + } + + /** + * 노임 통계 조회 + */ + public function stats(): array + { + $tenantId = $this->tenantId(); + + $baseQuery = Labor::where('tenant_id', $tenantId); + + $total = (clone $baseQuery)->count(); + $active = (clone $baseQuery)->byStatus('사용')->count(); + + return [ + 'total' => $total, + 'active' => $active, + ]; + } + + /** + * 노임 상세 조회 + */ + public function show(int $id): Labor + { + $tenantId = $this->tenantId(); + + $labor = Labor::query() + ->where('tenant_id', $tenantId) + ->find($id); + + if (! $labor) { + throw new NotFoundHttpException(__('error.not_found')); + } + + return $labor; + } + + /** + * 노임 등록 + */ + public function store(array $data): Labor + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($data, $tenantId, $userId) { + $payload = array_merge($data, [ + 'tenant_id' => $tenantId, + 'status' => $data['status'] ?? '사용', + 'is_active' => true, + 'created_by' => $userId, + ]); + + return Labor::create($payload); + }); + } + + /** + * 노임 수정 + */ + public function update(int $id, array $data): Labor + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $data, $tenantId, $userId) { + $labor = Labor::query() + ->where('tenant_id', $tenantId) + ->find($id); + + if (! $labor) { + throw new NotFoundHttpException(__('error.not_found')); + } + + $data['updated_by'] = $userId; + $labor->update($data); + $labor->refresh(); + + return $labor; + }); + } + + /** + * 노임 삭제 (soft delete) + */ + public function destroy(int $id): void + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + DB::transaction(function () use ($id, $tenantId, $userId) { + $labor = Labor::query() + ->where('tenant_id', $tenantId) + ->find($id); + + if (! $labor) { + throw new NotFoundHttpException(__('error.not_found')); + } + + $labor->deleted_by = $userId; + $labor->save(); + $labor->delete(); + }); + } + + /** + * 노임 일괄 삭제 (soft delete) + */ + public function bulkDestroy(array $ids): int + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($ids, $tenantId, $userId) { + $labors = Labor::query() + ->where('tenant_id', $tenantId) + ->whereIn('id', $ids) + ->get(); + + if ($labors->isEmpty()) { + throw new NotFoundHttpException(__('error.not_found')); + } + + $deletedCount = 0; + foreach ($labors as $labor) { + $labor->deleted_by = $userId; + $labor->save(); + $labor->delete(); + $deletedCount++; + } + + return $deletedCount; + }); + } +} diff --git a/database/migrations/2026_01_11_000000_create_labors_table.php b/database/migrations/2026_01_11_000000_create_labors_table.php new file mode 100644 index 0000000..a39e7f9 --- /dev/null +++ b/database/migrations/2026_01_11_000000_create_labors_table.php @@ -0,0 +1,44 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->string('labor_number', 50)->comment('노임번호'); + $table->enum('category', ['가로', '세로할증'])->comment('구분'); + $table->decimal('min_m', 10, 2)->default(0)->comment('최소 m'); + $table->decimal('max_m', 10, 2)->default(0)->comment('최대 m'); + $table->unsignedInteger('labor_price')->nullable()->comment('노임단가'); + $table->enum('status', ['사용', '중지'])->default('사용')->comment('상태'); + $table->boolean('is_active')->default(true)->comment('활성화 여부 (ModelTrait::scopeActive() 사용)'); + $table->unsignedBigInteger('created_by')->nullable()->comment('생성자 ID'); + $table->unsignedBigInteger('updated_by')->nullable()->comment('수정자 ID'); + $table->unsignedBigInteger('deleted_by')->nullable()->comment('삭제자 ID'); + $table->timestamps(); + $table->softDeletes(); + + // 인덱스 + $table->index(['tenant_id', 'category'], 'idx_tenant_category'); + $table->index(['tenant_id', 'status'], 'idx_tenant_status'); + $table->index(['tenant_id', 'labor_number'], 'idx_tenant_labor_number'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('labors'); + } +}; diff --git a/routes/api.php b/routes/api.php index ba455bc..9ee02e0 100644 --- a/routes/api.php +++ b/routes/api.php @@ -72,6 +72,7 @@ use App\Http\Controllers\Api\V1\PopupController; // use App\Http\Controllers\Api\V1\ProductBomItemController; // REMOVED: products 테이블 삭제됨 // use App\Http\Controllers\Api\V1\ProductController; // REMOVED: products 테이블 삭제됨 +use App\Http\Controllers\Api\V1\LaborController; use App\Http\Controllers\Api\V1\PositionController; use App\Http\Controllers\Api\V1\PostController; use App\Http\Controllers\Api\V1\PricingController; @@ -1001,6 +1002,17 @@ Route::get('/{id}/revisions', [PricingController::class, 'revisions'])->whereNumber('id')->name('v1.pricing.revisions'); // 변경이력 }); + // Labor (노임관리) + Route::prefix('labor')->group(function () { + Route::get('', [LaborController::class, 'index'])->name('v1.labor.index'); // 목록 + Route::get('/stats', [LaborController::class, 'stats'])->name('v1.labor.stats'); // 통계 + Route::delete('/bulk', [LaborController::class, 'bulkDestroy'])->name('v1.labor.bulk-destroy'); // 일괄 삭제 + Route::post('', [LaborController::class, 'store'])->name('v1.labor.store'); // 등록 + Route::get('/{id}', [LaborController::class, 'show'])->whereNumber('id')->name('v1.labor.show'); // 상세 + Route::put('/{id}', [LaborController::class, 'update'])->whereNumber('id')->name('v1.labor.update'); // 수정 + Route::delete('/{id}', [LaborController::class, 'destroy'])->whereNumber('id')->name('v1.labor.destroy'); // 삭제 + }); + // REMOVED: Products & Materials 라우트 삭제됨 (products/materials 테이블 삭제) // 모든 품목 관리는 /items 엔드포인트 사용