feat(labor): 노임관리 API 구현

- Labor 모델 (BelongsToTenant, SoftDeletes)
- LaborController 7개 엔드포인트
- LaborService 비즈니스 로직
- FormRequest 4개 (Index/Store/Update/BulkDelete)
- 마이그레이션 및 라우트 등록

API: GET/POST /labor, GET/PUT/DELETE /labor/{id}, DELETE /labor/bulk, GET /labor/stats

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-01-11 23:29:32 +09:00
parent ceb7798c28
commit f59dd1b9fb
11 changed files with 703 additions and 0 deletions

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Labor\LaborBulkDeleteRequest;
use App\Http\Requests\Labor\LaborIndexRequest;
use App\Http\Requests\Labor\LaborStoreRequest;
use App\Http\Requests\Labor\LaborUpdateRequest;
use App\Services\LaborService;
class LaborController extends Controller
{
public function __construct(
protected LaborService $service
) {}
/**
* 노임 목록 조회
*/
public function index(LaborIndexRequest $request)
{
return ApiResponse::handle(function () use ($request) {
$data = $this->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')];
});
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests\Labor;
use Illuminate\Foundation\Http\FormRequest;
class LaborBulkDeleteRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'ids' => ['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']),
];
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Http\Requests\Labor;
use Illuminate\Foundation\Http\FormRequest;
class LaborIndexRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'search' => ['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' => '시작일']),
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Requests\Labor;
use Illuminate\Foundation\Http\FormRequest;
class LaborStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'labor_number' => ['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' => '상태']),
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Requests\Labor;
use Illuminate\Foundation\Http\FormRequest;
class LaborUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'labor_number' => ['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' => '상태']),
];
}
}

67
app/Models/Labor.php Normal file
View File

@@ -0,0 +1,67 @@
<?php
namespace App\Models;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Labor extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
protected $table = 'labors';
protected $fillable = [
'tenant_id',
'labor_number',
'category',
'min_m',
'max_m',
'labor_price',
'status',
'is_active',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'min_m' => '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}%");
});
}
}

View File

@@ -0,0 +1,196 @@
<?php
namespace App\Services;
use App\Models\Labor;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class LaborService extends Service
{
/**
* 노임 목록 조회
*/
public function index(array $params): LengthAwarePaginator
{
$tenantId = $this->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;
});
}
}