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:
103
app/Http/Controllers/Api/V1/LaborController.php
Normal file
103
app/Http/Controllers/Api/V1/LaborController.php
Normal 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')];
|
||||
});
|
||||
}
|
||||
}
|
||||
31
app/Http/Requests/Labor/LaborBulkDeleteRequest.php
Normal file
31
app/Http/Requests/Labor/LaborBulkDeleteRequest.php
Normal 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']),
|
||||
];
|
||||
}
|
||||
}
|
||||
37
app/Http/Requests/Labor/LaborIndexRequest.php
Normal file
37
app/Http/Requests/Labor/LaborIndexRequest.php
Normal 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' => '시작일']),
|
||||
];
|
||||
}
|
||||
}
|
||||
39
app/Http/Requests/Labor/LaborStoreRequest.php
Normal file
39
app/Http/Requests/Labor/LaborStoreRequest.php
Normal 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' => '상태']),
|
||||
];
|
||||
}
|
||||
}
|
||||
39
app/Http/Requests/Labor/LaborUpdateRequest.php
Normal file
39
app/Http/Requests/Labor/LaborUpdateRequest.php
Normal 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
67
app/Models/Labor.php
Normal 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}%");
|
||||
});
|
||||
}
|
||||
}
|
||||
196
app/Services/LaborService.php
Normal file
196
app/Services/LaborService.php
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user