feat: [생산지시] 전용 API 엔드포인트 신규 생성

- ProductionOrderService: 목록(index), 통계(stats), 상세(show) 구현
  - Order 기반 생산지시 대상 필터링 (IN_PROGRESS~SHIPPED)
  - workOrderProgress 가공 필드 (total/completed/inProgress)
  - production_ordered_at (첫 WorkOrder created_at 기반)
  - BOM 공정 분류 추출 (order_nodes.options.bom_result)
- ProductionOrderController: FormRequest + ApiResponse 패턴
- ProductionOrderIndexRequest: search, production_status, sort, pagination 검증
- ProductionOrderApi.php: Swagger 문서 (목록/통계/상세)
- production.php: GET /production-orders, /stats, /{orderId} 라우트 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 16:41:51 +09:00
parent ad93743bdc
commit 2df8ecf765
5 changed files with 500 additions and 0 deletions

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\ProductionOrder\ProductionOrderIndexRequest;
use App\Services\ProductionOrderService;
use Illuminate\Http\JsonResponse;
class ProductionOrderController extends Controller
{
public function __construct(
private readonly ProductionOrderService $service
) {}
/**
* 생산지시 목록 조회
*/
public function index(ProductionOrderIndexRequest $request): JsonResponse
{
$result = $this->service->index($request->validated());
return ApiResponse::success($result, __('message.fetched'));
}
/**
* 생산지시 상태별 통계
*/
public function stats(): JsonResponse
{
$stats = $this->service->stats();
return ApiResponse::success($stats, __('message.fetched'));
}
/**
* 생산지시 상세 조회
*/
public function show(int $orderId): JsonResponse
{
try {
$detail = $this->service->show($orderId);
return ApiResponse::success($detail, __('message.fetched'));
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return ApiResponse::error(__('error.order.not_found'), 404);
}
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests\ProductionOrder;
use Illuminate\Foundation\Http\FormRequest;
class ProductionOrderIndexRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'search' => 'nullable|string|max:100',
'production_status' => 'nullable|in:waiting,in_production,completed',
'sort_by' => 'nullable|in:created_at,delivery_date,order_no',
'sort_dir' => 'nullable|in:asc,desc',
'page' => 'nullable|integer|min:1',
'per_page' => 'nullable|integer|min:1|max:100',
];
}
}

View File

@@ -0,0 +1,231 @@
<?php
namespace App\Services;
use App\Models\Orders\Order;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
class ProductionOrderService extends Service
{
/**
* 생산지시 대상 상태 코드
*/
private const PRODUCTION_STATUSES = [
Order::STATUS_IN_PROGRESS,
Order::STATUS_IN_PRODUCTION,
Order::STATUS_PRODUCED,
Order::STATUS_SHIPPING,
Order::STATUS_SHIPPED,
];
/**
* 생산지시 목록 조회
*/
public function index(array $params): LengthAwarePaginator
{
$tenantId = $this->tenantId();
$query = Order::query()
->where('tenant_id', $tenantId)
->whereIn('status_code', self::PRODUCTION_STATUSES)
->with(['client', 'workOrders.process', 'workOrders.assignees.user'])
->withCount('workOrders');
// 검색어 필터
if (! empty($params['search'])) {
$search = $params['search'];
$query->where(function ($q) use ($search) {
$q->where('order_no', 'like', "%{$search}%")
->orWhere('client_name', 'like', "%{$search}%")
->orWhere('site_name', 'like', "%{$search}%");
});
}
// 생산 상태 필터
if (! empty($params['production_status'])) {
switch ($params['production_status']) {
case 'waiting':
$query->where('status_code', Order::STATUS_IN_PROGRESS);
break;
case 'in_production':
$query->where('status_code', Order::STATUS_IN_PRODUCTION);
break;
case 'completed':
$query->whereIn('status_code', [
Order::STATUS_PRODUCED,
Order::STATUS_SHIPPING,
Order::STATUS_SHIPPED,
]);
break;
}
}
// 정렬
$sortBy = $params['sort_by'] ?? 'created_at';
$sortDir = $params['sort_dir'] ?? 'desc';
$query->orderBy($sortBy, $sortDir);
// 페이지네이션
$perPage = $params['per_page'] ?? 20;
$result = $query->paginate($perPage);
// 가공 필드 추가
$result->getCollection()->transform(function (Order $order) {
$order->production_ordered_at = $order->workOrders->min('created_at');
$workOrders = $order->workOrders;
$order->work_order_progress = [
'total' => $workOrders->count(),
'completed' => $workOrders->where('status', 'completed')->count()
+ $workOrders->where('status', 'shipped')->count(),
'in_progress' => $workOrders->where('status', 'in_progress')->count(),
];
// 프론트 탭용 production_status 매핑
$order->production_status = $this->mapProductionStatus($order->status_code);
return $order;
});
return $result;
}
/**
* 상태별 통계
*/
public function stats(): array
{
$tenantId = $this->tenantId();
$waiting = Order::where('tenant_id', $tenantId)
->where('status_code', Order::STATUS_IN_PROGRESS)
->count();
$inProduction = Order::where('tenant_id', $tenantId)
->where('status_code', Order::STATUS_IN_PRODUCTION)
->count();
$completed = Order::where('tenant_id', $tenantId)
->whereIn('status_code', [
Order::STATUS_PRODUCED,
Order::STATUS_SHIPPING,
Order::STATUS_SHIPPED,
])
->count();
return [
'total' => $waiting + $inProduction + $completed,
'waiting' => $waiting,
'in_production' => $inProduction,
'completed' => $completed,
];
}
/**
* 생산지시 상세 조회
*/
public function show(int $orderId): array
{
$tenantId = $this->tenantId();
$order = Order::query()
->where('tenant_id', $tenantId)
->whereIn('status_code', self::PRODUCTION_STATUSES)
->with([
'client',
'workOrders.process',
'workOrders.items',
'workOrders.assignees.user',
'nodes',
])
->findOrFail($orderId);
// 생산지시일
$order->production_ordered_at = $order->workOrders->min('created_at');
$order->production_status = $this->mapProductionStatus($order->status_code);
// WorkOrder 진행 현황
$workOrderProgress = [
'total' => $order->workOrders->count(),
'completed' => $order->workOrders->where('status', 'completed')->count()
+ $order->workOrders->where('status', 'shipped')->count(),
'in_progress' => $order->workOrders->where('status', 'in_progress')->count(),
];
// WorkOrder 목록 가공
$workOrders = $order->workOrders->map(function ($wo) {
return [
'id' => $wo->id,
'work_order_no' => $wo->work_order_no,
'process_name' => $wo->process?->process_name ?? '',
'quantity' => $wo->items->count(),
'status' => $wo->status,
'assignees' => $wo->assignees->map(fn ($a) => $a->user?->name ?? '')->filter()->values()->toArray(),
];
});
// BOM 데이터 (order_nodes에서 추출)
$bomProcessGroups = $this->extractBomProcessGroups($order->nodes);
return [
'order' => $order->makeHidden(['workOrders', 'nodes']),
'production_ordered_at' => $order->production_ordered_at,
'production_status' => $order->production_status,
'work_order_progress' => $workOrderProgress,
'work_orders' => $workOrders,
'bom_process_groups' => $bomProcessGroups,
];
}
/**
* Order status_code → 프론트 production_status 매핑
*/
private function mapProductionStatus(string $statusCode): string
{
return match ($statusCode) {
Order::STATUS_IN_PROGRESS => 'waiting',
Order::STATUS_IN_PRODUCTION => 'in_production',
default => 'completed',
};
}
/**
* order_nodes에서 BOM 공정 분류 추출
*/
private function extractBomProcessGroups($nodes): array
{
$groups = [];
foreach ($nodes as $node) {
$bomResult = $node->options['bom_result'] ?? null;
if (! $bomResult) {
continue;
}
// bom_result 구조에 따라 공정별 그룹화
foreach ($bomResult as $item) {
$processName = $item['process_name'] ?? '기타';
if (! isset($groups[$processName])) {
$groups[$processName] = [
'process_name' => $processName,
'size_spec' => $item['size_spec'] ?? null,
'items' => [],
];
}
$groups[$processName]['items'][] = [
'id' => $item['id'] ?? null,
'item_code' => $item['item_code'] ?? '',
'item_name' => $item['item_name'] ?? '',
'spec' => $item['spec'] ?? '',
'lot_no' => $item['lot_no'] ?? '',
'required_qty' => $item['required_qty'] ?? 0,
'qty' => $item['qty'] ?? 0,
];
}
}
return array_values($groups);
}
}

View File

@@ -0,0 +1,186 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(name="ProductionOrders", description="생산지시 관리")
*
* @OA\Schema(
* schema="ProductionOrderListItem",
* type="object",
* description="생산지시 목록 아이템",
*
* @OA\Property(property="id", type="integer", example=1, description="수주 ID"),
* @OA\Property(property="order_no", type="string", example="ORD-20260301-0001", description="수주번호 (= 생산지시번호)"),
* @OA\Property(property="site_name", type="string", example="서울현장", nullable=true, description="현장명"),
* @OA\Property(property="client_name", type="string", example="(주)고객사", nullable=true, description="거래처명"),
* @OA\Property(property="quantity", type="number", example=10, description="수량"),
* @OA\Property(property="delivery_date", type="string", format="date", example="2026-03-15", nullable=true, description="납기일"),
* @OA\Property(property="production_ordered_at", type="string", format="date-time", nullable=true, description="생산지시일 (첫 WorkOrder 생성일)"),
* @OA\Property(property="production_status", type="string", enum={"waiting","in_production","completed"}, example="waiting", description="생산 상태"),
* @OA\Property(property="work_orders_count", type="integer", example=3, description="작업지시 수"),
* @OA\Property(property="work_order_progress", type="object",
* @OA\Property(property="total", type="integer", example=3),
* @OA\Property(property="completed", type="integer", example=1),
* @OA\Property(property="in_progress", type="integer", example=1)
* ),
* @OA\Property(property="client", type="object", nullable=true,
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="name", type="string", example="(주)고객사")
* )
* )
*
* @OA\Schema(
* schema="ProductionOrderStats",
* type="object",
* description="생산지시 통계",
*
* @OA\Property(property="total", type="integer", example=25, description="전체"),
* @OA\Property(property="waiting", type="integer", example=10, description="생산대기"),
* @OA\Property(property="in_production", type="integer", example=8, description="생산중"),
* @OA\Property(property="completed", type="integer", example=7, description="생산완료")
* )
*
* @OA\Schema(
* schema="ProductionOrderDetail",
* type="object",
* description="생산지시 상세",
*
* @OA\Property(property="order", ref="#/components/schemas/ProductionOrderListItem"),
* @OA\Property(property="production_ordered_at", type="string", format="date-time", nullable=true),
* @OA\Property(property="production_status", type="string", enum={"waiting","in_production","completed"}),
* @OA\Property(property="work_order_progress", type="object",
* @OA\Property(property="total", type="integer"),
* @OA\Property(property="completed", type="integer"),
* @OA\Property(property="in_progress", type="integer")
* ),
* @OA\Property(property="work_orders", type="array",
*
* @OA\Items(type="object",
*
* @OA\Property(property="id", type="integer"),
* @OA\Property(property="work_order_no", type="string"),
* @OA\Property(property="process_name", type="string"),
* @OA\Property(property="quantity", type="integer"),
* @OA\Property(property="status", type="string"),
* @OA\Property(property="assignees", type="array", @OA\Items(type="string"))
* )
* ),
* @OA\Property(property="bom_process_groups", type="array",
*
* @OA\Items(type="object",
*
* @OA\Property(property="process_name", type="string"),
* @OA\Property(property="size_spec", type="string", nullable=true),
* @OA\Property(property="items", type="array",
*
* @OA\Items(type="object",
*
* @OA\Property(property="id", type="integer", nullable=true),
* @OA\Property(property="item_code", type="string"),
* @OA\Property(property="item_name", type="string"),
* @OA\Property(property="spec", type="string"),
* @OA\Property(property="lot_no", type="string"),
* @OA\Property(property="required_qty", type="number"),
* @OA\Property(property="qty", type="number")
* )
* )
* )
* )
* )
*/
class ProductionOrderApi
{
/**
* @OA\Get(
* path="/api/v1/production-orders",
* tags={"ProductionOrders"},
* summary="생산지시 목록 조회",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Parameter(name="search", in="query", required=false,
*
* @OA\Schema(type="string"), description="검색어 (수주번호, 거래처명, 현장명)"
* ),
*
* @OA\Parameter(name="production_status", in="query", required=false,
*
* @OA\Schema(type="string", enum={"waiting","in_production","completed"}), description="생산 상태 필터"
* ),
*
* @OA\Parameter(name="sort_by", in="query", required=false,
*
* @OA\Schema(type="string", enum={"created_at","delivery_date","order_no"}), description="정렬 기준"
* ),
*
* @OA\Parameter(name="sort_dir", in="query", required=false,
*
* @OA\Schema(type="string", enum={"asc","desc"}), description="정렬 방향"
* ),
*
* @OA\Parameter(name="page", in="query", required=false, @OA\Schema(type="integer")),
* @OA\Parameter(name="per_page", in="query", required=false, @OA\Schema(type="integer")),
*
* @OA\Response(response=200, description="성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", type="object",
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/ProductionOrderListItem")),
* @OA\Property(property="current_page", type="integer"),
* @OA\Property(property="last_page", type="integer"),
* @OA\Property(property="per_page", type="integer"),
* @OA\Property(property="total", type="integer")
* )
* )
* )
* )
*/
public function index() {}
/**
* @OA\Get(
* path="/api/v1/production-orders/stats",
* tags={"ProductionOrders"},
* summary="생산지시 상태별 통계",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Response(response=200, description="성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", ref="#/components/schemas/ProductionOrderStats")
* )
* )
* )
*/
public function stats() {}
/**
* @OA\Get(
* path="/api/v1/production-orders/{orderId}",
* tags={"ProductionOrders"},
* summary="생산지시 상세 조회",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Parameter(name="orderId", in="path", required=true, @OA\Schema(type="integer"), description="수주 ID"),
*
* @OA\Response(response=200, description="성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", ref="#/components/schemas/ProductionOrderDetail")
* )
* ),
*
* @OA\Response(response=404, description="생산지시를 찾을 수 없음")
* )
*/
public function show() {}
}

View File

@@ -10,6 +10,7 @@
*/
use App\Http\Controllers\Api\V1\InspectionController;
use App\Http\Controllers\Api\V1\ProductionOrderController;
use App\Http\Controllers\Api\V1\WorkOrderController;
use App\Http\Controllers\Api\V1\WorkResultController;
use App\Http\Controllers\V1\ProcessController;
@@ -121,3 +122,10 @@
Route::delete('/{id}', [InspectionController::class, 'destroy'])->whereNumber('id')->name('v1.inspections.destroy'); // 삭제
Route::patch('/{id}/complete', [InspectionController::class, 'complete'])->whereNumber('id')->name('v1.inspections.complete'); // 완료 처리
});
// Production Order API (생산지시 조회)
Route::prefix('production-orders')->group(function () {
Route::get('', [ProductionOrderController::class, 'index'])->name('v1.production-orders.index');
Route::get('/stats', [ProductionOrderController::class, 'stats'])->name('v1.production-orders.stats');
Route::get('/{orderId}', [ProductionOrderController::class, 'show'])->whereNumber('orderId')->name('v1.production-orders.show');
});