779 lines
28 KiB
Markdown
779 lines
28 KiB
Markdown
# 생산지시 목록/상세 페이지 API 연동 계획
|
|
|
|
## 전제 조건
|
|
|
|
이 문서는 아래 프로젝트 규칙 문서가 적용된 상태를 전제로 작성되었습니다.
|
|
새 세션에서 작업 시 이 문서와 함께 아래 문서들이 자동 로드됩니다.
|
|
|
|
| 문서 | 역할 | 핵심 규칙 |
|
|
|------|------|----------|
|
|
| `CLAUDE.md` (루트) | 프로젝트 아키텍처, Git 정책, 커밋 규칙 | Service-First, Multi-tenancy, 컬럼 추가 정책, 전체커밋 절차 |
|
|
| `api/CLAUDE.md` | API 개발 규칙 | FormRequest 필수, ApiResponse 패턴, Swagger 분리, i18n 메시지, 감사 로그 |
|
|
| `react/CLAUDE.md` | 프론트 개발 규칙 | 'use client' 필수, buildApiUrl 필수, executePaginatedAction, 컴포넌트 재사용 |
|
|
|
|
**이 문서 경로**: `docs/dev/dev_plans/production-orders-page.md`
|
|
|
|
**작업 시작 명령**: 이 문서를 읽은 후 Phase별 체크리스트를 순서대로 진행하면 됩니다.
|
|
|
|
---
|
|
|
|
## 현재 상태
|
|
- 목록/상세 모두 **샘플 데이터 하드코딩** (API 미연동)
|
|
- 백엔드: `POST /orders/{id}/production-order` (생산지시 생성)만 존재
|
|
- 프론트: `createProductionOrder()`, `revertProductionOrder()` Server Action 존재 (orders/actions.ts)
|
|
- "생산지시"는 독립 엔티티가 아님 → **수주(Order) 상태 + 하위 작업지시(WorkOrder) 집합**
|
|
|
|
## 아키텍처 결정
|
|
|
|
### API: 전용 엔드포인트 신규 생성
|
|
- `GET /api/v1/production-orders` — 생산지시 목록
|
|
- `GET /api/v1/production-orders/stats` — 상태별 통계
|
|
- `GET /api/v1/production-orders/{orderId}` — 상세 (수주+작업지시+BOM)
|
|
- 이유: 수주 API 비대화 방지, 독립 확장, 유지보수 분리
|
|
|
|
### 프론트: 별도 actions.ts
|
|
- 위치: `react/src/components/production/ProductionOrders/actions.ts`
|
|
- 패턴: `executePaginatedAction` + `buildApiUrl` (출하 패턴)
|
|
- 서버사이드 페이지네이션 (작업지시/출하 패턴)
|
|
- 기존 `createProductionOrder`, `revertProductionOrder`는 orders/actions.ts에서 import 재사용
|
|
|
|
## 핵심 설계 사항
|
|
|
|
### 상태 매핑 (백엔드 ↔ 프론트)
|
|
|
|
Order.status_code는 프론트에서 변환되어 사용됨 (orders/actions.ts 참조):
|
|
| 백엔드 status_code | 프론트 OrderStatus | 수주관리 라벨 | 생산지시 탭 |
|
|
|---|---|---|---|
|
|
| `DRAFT` | `order_registered` | 수주등록 | - |
|
|
| `CONFIRMED` | `order_confirmed` | 수주확정 | - |
|
|
| `IN_PROGRESS` | `production_ordered` | 생산지시완료 | **생산대기** |
|
|
| `IN_PRODUCTION` | `in_production` | 생산중 | **생산중** |
|
|
| `PRODUCED` | `produced` | 생산완료 | **생산완료** |
|
|
| `SHIPPING` | `shipping` | 출하중 | **생산완료** |
|
|
| `SHIPPED` | `shipped` | 출하완료 | **생산완료** |
|
|
|
|
생산지시 목록에 표시되는 대상: `IN_PROGRESS`, `IN_PRODUCTION`, `PRODUCED`, `SHIPPING`, `SHIPPED`
|
|
|
|
### 상태 전이 흐름
|
|
|
|
```
|
|
CONFIRMED → createProductionOrder() → IN_PROGRESS (생산지시완료/생산대기)
|
|
↓
|
|
WorkOrder가 in_progress 시작 →
|
|
↓
|
|
syncOrderStatus() → IN_PRODUCTION (생산중)
|
|
↓
|
|
WorkOrder 전체 completed →
|
|
↓
|
|
syncOrderStatus() → PRODUCED (생산완료)
|
|
↓
|
|
Shipment shipping → SHIPPING (출하중)
|
|
↓
|
|
Shipment completed → SHIPPED (출하완료)
|
|
```
|
|
|
|
주의: IN_PRODUCTION, PRODUCED, SHIPPING, SHIPPED는 Order.validateStatusTransition()에 정의되어 있지 않음.
|
|
WorkOrderService.syncOrderStatus()와 ShipmentService.syncOrderStatus()에서 직접 status_code를 덮어쓰는 방식.
|
|
|
|
### 생산지시번호
|
|
|
|
별도 엔티티/번호가 백엔드에 없음. **order_no를 그대로 사용**.
|
|
프론트 목록에서 `orderNumber` 컬럼 하나로 표시 (기존 샘플의 PO- prefix 제거).
|
|
|
|
### 생산지시일
|
|
|
|
Order 모델에 `production_ordered_at` 컬럼 없음.
|
|
**첫 번째 WorkOrder의 created_at** 사용 (= 생산지시 실행 시점에 WorkOrder가 생성되므로 동일 시점).
|
|
API 응답에서 `production_ordered_at: workOrders.min(created_at)` 형태로 제공.
|
|
|
|
### BOM 데이터 null 처리
|
|
|
|
`order_nodes.options.bom_result`는 BOM 계산 실행 후에만 존재.
|
|
bom_result가 null인 경우 → "BOM 계산 결과 없음" 빈 상태 표시.
|
|
|
|
---
|
|
|
|
## Phase 1: 백엔드 API (생산지시 전용)
|
|
|
|
### 1-1. ProductionOrderService 신규
|
|
파일: `api/app/Services/ProductionOrderService.php`
|
|
|
|
```
|
|
index(params) → 생산지시 목록
|
|
- Order 기준, status_code IN ('IN_PROGRESS','IN_PRODUCTION','PRODUCED','SHIPPING','SHIPPED')
|
|
- with: client, workOrders.process, workOrders.assignees.user
|
|
- withCount: workOrders
|
|
- 가공 필드: production_ordered_at (= workOrders.min(created_at))
|
|
- 가공 필드: workOrderProgress { total, completed, inProgress }
|
|
- 검색: order_no, client_name, site_name
|
|
- 필터: production_status 파라미터
|
|
- 'waiting' → status_code = IN_PROGRESS
|
|
- 'in_production' → status_code = IN_PRODUCTION
|
|
- 'completed' → status_code IN (PRODUCED, SHIPPING, SHIPPED)
|
|
- 정렬: created_at desc (기본)
|
|
|
|
stats() → 상태별 카운트
|
|
- total: 전체 (IN_PROGRESS + IN_PRODUCTION + PRODUCED + SHIPPING + SHIPPED)
|
|
- waiting: IN_PROGRESS
|
|
- in_production: IN_PRODUCTION
|
|
- completed: PRODUCED + SHIPPING + SHIPPED
|
|
|
|
show(orderId) → 생산지시 상세
|
|
- Order 기본정보 + client
|
|
- WorkOrders (공정별) + items + assignees + process
|
|
- order_nodes (options.bom_result 포함)
|
|
- bom_result null 처리 (없으면 빈 배열)
|
|
```
|
|
|
|
### 1-2. ProductionOrderIndexRequest 신규
|
|
파일: `api/app/Http/Requests/ProductionOrder/ProductionOrderIndexRequest.php`
|
|
|
|
```php
|
|
rules:
|
|
'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'
|
|
```
|
|
|
|
### 1-3. ProductionOrderController 신규
|
|
파일: `api/app/Http/Controllers/Api/V1/ProductionOrderController.php`
|
|
|
|
```
|
|
index(ProductionOrderIndexRequest) → ApiResponse::handle(service->index($request->validated()))
|
|
stats(Request) → ApiResponse::handle(service->stats())
|
|
show($orderId) → ApiResponse::handle(service->show($orderId))
|
|
```
|
|
|
|
### 1-4. 라우트 등록
|
|
파일: `api/routes/api/v1/production.php` (기존 파일에 추가)
|
|
|
|
```php
|
|
Route::prefix('production-orders')->group(function () {
|
|
Route::get('/', [ProductionOrderController::class, 'index']);
|
|
Route::get('/stats', [ProductionOrderController::class, 'stats']);
|
|
Route::get('/{orderId}', [ProductionOrderController::class, 'show'])->whereNumber('orderId');
|
|
});
|
|
```
|
|
|
|
### 1-5. Swagger 문서
|
|
파일: `api/app/Swagger/v1/ProductionOrderApi.php` (신규)
|
|
|
|
---
|
|
|
|
## Phase 2: 프론트 — 목록 페이지 API 연동
|
|
|
|
### 2-1. Server Action 신규
|
|
파일: `react/src/components/production/ProductionOrders/actions.ts`
|
|
|
|
```typescript
|
|
'use server';
|
|
import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
|
|
import { executeServerAction } from '@/lib/api/execute-server-action';
|
|
import { buildApiUrl } from '@/lib/api/query-params';
|
|
|
|
// 목록 조회
|
|
export async function getProductionOrders(params: ProductionOrderListParams) {
|
|
return executePaginatedAction<ApiProductionOrder, ProductionOrder>({
|
|
url: buildApiUrl('/api/v1/production-orders', {
|
|
search: params.search,
|
|
production_status: params.productionStatus,
|
|
sort_by: params.sortBy,
|
|
sort_dir: params.sortDir,
|
|
page: params.page,
|
|
per_page: params.perPage,
|
|
}),
|
|
transform: transformApiToFrontend,
|
|
errorMessage: '생산지시 목록 조회에 실패했습니다.',
|
|
});
|
|
}
|
|
|
|
// 상태별 통계
|
|
export async function getProductionOrderStats() {
|
|
return executeServerAction<ProductionOrderStats>({
|
|
url: buildApiUrl('/api/v1/production-orders/stats'),
|
|
errorMessage: '생산지시 통계 조회에 실패했습니다.',
|
|
});
|
|
}
|
|
|
|
// 상세 조회
|
|
export async function getProductionOrderDetail(orderId: string) {
|
|
return executeServerAction<ProductionOrderDetail>({
|
|
url: buildApiUrl(`/api/v1/production-orders/${orderId}`),
|
|
errorMessage: '생산지시 상세 조회에 실패했습니다.',
|
|
});
|
|
}
|
|
|
|
// createProductionOrder, revertProductionOrder는 orders/actions.ts에서 직접 import
|
|
```
|
|
|
|
### 2-2. 타입 정의
|
|
파일: `react/src/components/production/ProductionOrders/types.ts`
|
|
|
|
```typescript
|
|
// 생산지시 상태 (프론트 탭용)
|
|
export type ProductionStatus = 'waiting' | 'in_production' | 'completed';
|
|
|
|
// 생산지시 목록 아이템
|
|
export interface ProductionOrder {
|
|
id: number; // order.id
|
|
orderNumber: string; // order_no (생산지시번호 = 수주번호)
|
|
siteName: string; // site_name
|
|
clientName: string; // client.name
|
|
quantity: number; // quantity
|
|
deliveryDate: string; // delivery_date (납기)
|
|
productionOrderedAt: string; // 첫 WorkOrder.created_at (생산지시일)
|
|
productionStatus: ProductionStatus;
|
|
workOrderCount: number;
|
|
workOrderProgress: {
|
|
total: number;
|
|
completed: number;
|
|
inProgress: number;
|
|
};
|
|
}
|
|
|
|
// 생산지시 통계
|
|
export interface ProductionOrderStats {
|
|
total: number;
|
|
waiting: number;
|
|
inProduction: number;
|
|
completed: number;
|
|
}
|
|
|
|
// 생산지시 상세
|
|
export interface ProductionOrderDetail extends ProductionOrder {
|
|
clientId: number;
|
|
productType: string;
|
|
workOrders: ProductionWorkOrder[];
|
|
bomProcessGroups: BomProcessGroup[]; // order_nodes BOM 기반
|
|
}
|
|
|
|
// 상세 내 작업지시 정보
|
|
export interface ProductionWorkOrder {
|
|
id: number;
|
|
workOrderNo: string;
|
|
processName: string;
|
|
quantity: number;
|
|
status: string; // WorkOrder 상태 (unassigned/pending/waiting/in_progress/completed/shipped)
|
|
assignees: string[];
|
|
}
|
|
|
|
// BOM 공정 분류
|
|
export interface BomProcessGroup {
|
|
processName: string;
|
|
sizeSpec?: string;
|
|
items: BomItem[];
|
|
}
|
|
|
|
export interface BomItem {
|
|
id: number;
|
|
itemCode: string;
|
|
itemName: string;
|
|
spec: string;
|
|
lotNo: string;
|
|
requiredQty: number;
|
|
qty: number;
|
|
}
|
|
|
|
// 조회 파라미터
|
|
export interface ProductionOrderListParams {
|
|
search?: string;
|
|
productionStatus?: ProductionStatus;
|
|
sortBy?: string;
|
|
sortDir?: 'asc' | 'desc';
|
|
page?: number;
|
|
perPage?: number;
|
|
}
|
|
```
|
|
|
|
### 2-3. 목록 페이지 수정
|
|
파일: `react/src/app/[locale]/(protected)/sales/order-management-sales/production-orders/page.tsx`
|
|
|
|
변경사항:
|
|
- [ ] SAMPLE_PRODUCTION_ORDERS 제거
|
|
- [ ] ProductionOrder 인터페이스 → types.ts에서 import
|
|
- [ ] 클라이언트 필터링 로직 제거 (filteredData, paginatedData 등)
|
|
- [ ] externalSelection, externalPagination 제거
|
|
- [ ] config.actions.getList → getProductionOrders() API 호출
|
|
- [ ] config.clientSideFiltering: false (서버사이드 페이지네이션)
|
|
- [ ] tabs 카운트 → getProductionOrderStats() 동적 반영
|
|
- [ ] ProgressSteps → 동적화 (Order 상태 기반 active/completed 판단)
|
|
- [ ] getStatusBadge, TABLE_COLUMNS 유지 (필드명 조정)
|
|
- [ ] renderTableRow, renderMobileCard → API 응답 필드명에 맞게 조정
|
|
- [ ] "생산지시번호" 컬럼 → "수주번호" 로 변경 (별도 PO 번호 없음)
|
|
|
|
### 2-4. ProgressSteps 동적화
|
|
|
|
현재 하드코딩된 5단계를 Order 상태 기반으로 동적 표시:
|
|
```
|
|
수주확정 → 생산지시 → 작업지시 → 생산 → 검사출하
|
|
```
|
|
|
|
| 단계 | completed 조건 | active 조건 |
|
|
|------|---------------|-------------|
|
|
| 수주확정 | 항상 (생산지시 목록에 있으면 이미 확정됨) | - |
|
|
| 생산지시 | 항상 (목록에 있으면 이미 생산지시됨) | - |
|
|
| 작업지시 | workOrders 존재 + 하나라도 배정(pending+) | workOrders 미배정 |
|
|
| 생산 | status >= PRODUCED | status = IN_PRODUCTION |
|
|
| 검사출하 | status >= SHIPPED | status = SHIPPING |
|
|
|
|
---
|
|
|
|
## Phase 3: 프론트 — 상세 페이지 API 연동
|
|
|
|
### 3-1. 상세 페이지 수정
|
|
파일: `react/src/app/[locale]/(protected)/sales/order-management-sales/production-orders/[id]/page.tsx`
|
|
|
|
변경사항:
|
|
- [ ] SAMPLE_PRODUCTION_ORDER_DETAILS 제거
|
|
- [ ] SAMPLE_PROCESSES 제거
|
|
- [ ] SAMPLE_BOM_PROCESS_GROUPS 제거
|
|
- [ ] setTimeout 로드 → useEffect + getProductionOrderDetail()
|
|
- [ ] 공정 진행 현황 → 실제 작업지시(workOrders) 상태 기반
|
|
- [ ] BOM 품목별 공정 분류 → API 응답의 bomProcessGroups 기반
|
|
- [ ] bom_result null 처리 → "BOM 계산 결과 없음" 빈 상태 표시
|
|
- [ ] 작업지시 생성 다이얼로그 → createProductionOrder() 실제 API 호출 (orders/actions.ts에서 import)
|
|
- [ ] 작업지시 생성 확인 팝업 → API에서 받은 실제 공정 목록 표시
|
|
- [ ] ProcessProgress, InfoItem 컴포넌트 유지
|
|
- [ ] 작업지시 목록 테이블 → 실제 WorkOrder 데이터
|
|
|
|
### 3-2. 작업지시 생성 연동
|
|
- 기존 `createProductionOrder()` (orders/actions.ts) 재사용
|
|
- 성공 시 `getProductionOrderDetail()` 재호출로 데이터 새로고침
|
|
- 작업지시 생성 성공 후 → 작업지시 관리 페이지 이동 (기존 동작 유지)
|
|
|
|
---
|
|
|
|
## Phase 4: 흐름 검증
|
|
|
|
### 검증 시나리오
|
|
1. 수주 상세 → "생산지시 생성" 클릭 → 생산지시 목록에 나타남
|
|
2. 생산지시 목록 → 탭 필터 (생산대기/생산중/생산완료) 정상 동작
|
|
3. 생산지시 목록 → 검색 (수주번호, 현장명, 거래처) 정상 동작
|
|
4. 생산지시 상세 → 공정 진행 현황 실제 반영
|
|
5. 생산지시 상세 → BOM 품목별 공정 분류 실제 데이터
|
|
6. 생산지시 상세 → BOM 없는 수주의 경우 빈 상태 정상 표시
|
|
7. 생산지시 상세 → 작업지시 생성 → 작업지시 목록 이동
|
|
8. 작업지시 완료 → 출하 자동 생성 → 출하 목록에 나타남
|
|
9. ProgressSteps 바가 실제 Order 상태 + WorkOrder 상태 반영
|
|
10. 수주관리 목록에서 "생산지시 보기" 버튼 → 생산지시 목록 정상 이동
|
|
|
|
---
|
|
|
|
## 파일 목록 요약
|
|
|
|
### 신규 생성
|
|
| 파일 | 설명 |
|
|
|------|------|
|
|
| `api/app/Services/ProductionOrderService.php` | 생산지시 서비스 |
|
|
| `api/app/Http/Controllers/Api/V1/ProductionOrderController.php` | 컨트롤러 |
|
|
| `api/app/Http/Requests/ProductionOrder/ProductionOrderIndexRequest.php` | 목록 FormRequest |
|
|
| `api/app/Swagger/v1/ProductionOrderApi.php` | Swagger 문서 |
|
|
| `react/src/components/production/ProductionOrders/actions.ts` | Server Actions |
|
|
| `react/src/components/production/ProductionOrders/types.ts` | 타입 정의 |
|
|
|
|
### 수정
|
|
| 파일 | 설명 |
|
|
|------|------|
|
|
| `api/routes/api/v1/production.php` | 라우트 추가 |
|
|
| `react/.../production-orders/page.tsx` | 목록 API 연동 + ProgressSteps 동적화 |
|
|
| `react/.../production-orders/[id]/page.tsx` | 상세 API 연동 + BOM null 처리 |
|
|
|
|
### 작업 순서
|
|
```
|
|
Phase 1 (백엔드 4파일) → Phase 2 (목록 3파일) → Phase 3 (상세 1파일) → Phase 4 (검증)
|
|
```
|
|
|
|
## 진행 상태
|
|
- [x] 계획 수립
|
|
- [x] 심층 분석 (상태 매핑, 누락 항목 보완)
|
|
- [x] 문서 자기 완결화 (레퍼런스 패턴 추가)
|
|
- [x] Phase 1: 백엔드 API
|
|
- [x] Phase 2: 목록 페이지
|
|
- [x] Phase 3: 상세 페이지
|
|
- [ ] Phase 4: 흐름 검증
|
|
|
|
---
|
|
|
|
## 부록 A: 백엔드 레퍼런스 패턴
|
|
|
|
### A-1. Base Service 클래스
|
|
파일: `api/app/Services/Service.php`
|
|
|
|
```php
|
|
<?php
|
|
namespace App\Services;
|
|
|
|
use Illuminate\Auth\AuthenticationException;
|
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
|
|
|
abstract class Service
|
|
{
|
|
protected function tenantIdOrNull(): ?int
|
|
{
|
|
$id = app('tenant_id');
|
|
return $id ? (int) $id : null;
|
|
}
|
|
|
|
protected function tenantId(): int
|
|
{
|
|
$id = $this->tenantIdOrNull();
|
|
if (! $id) {
|
|
throw new BadRequestHttpException(__('error.tenant_id'));
|
|
}
|
|
return $id;
|
|
}
|
|
|
|
protected function apiUserId(): int
|
|
{
|
|
$uid = app('api_user');
|
|
if (! $uid) {
|
|
throw new AuthenticationException(__('auth.unauthenticated'));
|
|
}
|
|
return (int) $uid;
|
|
}
|
|
}
|
|
```
|
|
|
|
### A-2. Controller 패턴 (ShipmentController 참조)
|
|
파일: `api/app/Http/Controllers/Api/V1/ShipmentController.php`
|
|
|
|
```php
|
|
<?php
|
|
namespace App\Http\Controllers\Api\V1;
|
|
|
|
use App\Helpers\ApiResponse;
|
|
use App\Http\Controllers\Controller;
|
|
use App\Services\ShipmentService;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
|
|
class ShipmentController extends Controller
|
|
{
|
|
public function __construct(
|
|
private readonly ShipmentService $service
|
|
) {}
|
|
|
|
public function index(Request $request): JsonResponse
|
|
{
|
|
$params = $request->only([...]);
|
|
$shipments = $this->service->index($params);
|
|
return ApiResponse::success($shipments, __('message.fetched'));
|
|
}
|
|
|
|
public function stats(): JsonResponse
|
|
{
|
|
$stats = $this->service->stats();
|
|
return ApiResponse::success($stats, __('message.fetched'));
|
|
}
|
|
|
|
public function show(int $id): JsonResponse
|
|
{
|
|
try {
|
|
$shipment = $this->service->show($id);
|
|
return ApiResponse::success($shipment, __('message.fetched'));
|
|
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
|
|
return ApiResponse::error(__('error.shipment.not_found'), 404);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**핵심 규칙**:
|
|
- `use App\Helpers\ApiResponse;` (경로 정확히)
|
|
- 생성자: `private readonly` DI
|
|
- 목록: `ApiResponse::success($data, __('message.fetched'))`
|
|
- 상세: `ModelNotFoundException` catch → `ApiResponse::error()` 404
|
|
- FormRequest 사용 필수 (Controller에서 직접 validate 금지)
|
|
|
|
### A-3. Service 목록 조회 패턴 (ShipmentService 참조)
|
|
|
|
```php
|
|
public function index(array $params): LengthAwarePaginator
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
$query = Shipment::query()
|
|
->where('tenant_id', $tenantId)
|
|
->with(['items', 'order.client']);
|
|
|
|
// 검색어 필터
|
|
if (! empty($params['search'])) {
|
|
$search = $params['search'];
|
|
$query->where(function ($q) use ($search) {
|
|
$q->where('field1', 'like', "%{$search}%")
|
|
->orWhere('field2', 'like', "%{$search}%");
|
|
});
|
|
}
|
|
|
|
// 상태 필터
|
|
if (! empty($params['status'])) {
|
|
$query->where('status', $params['status']);
|
|
}
|
|
|
|
// 정렬
|
|
$sortBy = $params['sort_by'] ?? 'created_at';
|
|
$sortDir = $params['sort_dir'] ?? 'desc';
|
|
$query->orderBy($sortBy, $sortDir);
|
|
|
|
// 페이지네이션
|
|
$perPage = $params['per_page'] ?? 20;
|
|
return $query->paginate($perPage);
|
|
}
|
|
```
|
|
|
|
### A-4. ApiResponse 핵심 메서드
|
|
|
|
```php
|
|
// 성공 응답 → { success: true, message: "...", data: {...} }
|
|
ApiResponse::success($data, __('message.fetched'));
|
|
ApiResponse::success($data, __('message.fetched'), [], 201); // 201 Created
|
|
|
|
// 에러 응답 → { success: false, message: "[code] ...", error: { code, details } }
|
|
ApiResponse::error(__('error.not_found'), 404);
|
|
|
|
// 콜백 래퍼 (자동 예외 처리)
|
|
ApiResponse::handle(fn () => $this->service->doSomething(), '요청');
|
|
```
|
|
|
|
**참고**: `ApiResponse::success()`는 자동으로 ISO 8601 날짜를 `Y-m-d` 형식으로 변환합니다.
|
|
|
|
### A-5. Order 모델 상태 상수
|
|
파일: `api/app/Models/Orders/Order.php`
|
|
|
|
```php
|
|
class Order extends Model
|
|
{
|
|
use Auditable, BelongsToTenant, SoftDeletes;
|
|
|
|
public const STATUS_DRAFT = 'DRAFT';
|
|
public const STATUS_CONFIRMED = 'CONFIRMED';
|
|
public const STATUS_IN_PROGRESS = 'IN_PROGRESS';
|
|
public const STATUS_IN_PRODUCTION = 'IN_PRODUCTION';
|
|
public const STATUS_PRODUCED = 'PRODUCED';
|
|
public const STATUS_SHIPPING = 'SHIPPING';
|
|
public const STATUS_SHIPPED = 'SHIPPED';
|
|
public const STATUS_COMPLETED = 'COMPLETED';
|
|
public const STATUS_CANCELLED = 'CANCELLED';
|
|
|
|
// 관계: workOrders
|
|
public function workOrders() { return $this->hasMany(WorkOrder::class, 'sales_order_id'); }
|
|
public function client() { return $this->belongsTo(Client::class); }
|
|
}
|
|
```
|
|
|
|
### A-6. 라우트 파일 (삽입 위치)
|
|
파일: `api/routes/api/v1/production.php`
|
|
|
|
현재 파일 구조:
|
|
```
|
|
use ...Controllers\ProcessController;
|
|
use ...Controllers\WorkOrderController;
|
|
// ... etc
|
|
|
|
Route::prefix('processes')->group(function () { ... }); // 공정 관리
|
|
Route::prefix('work-orders')->group(function () { ... }); // 작업지시 관리 (96줄까지)
|
|
Route::prefix('work-results')->group(function () { ... }); // 작업실적 관리
|
|
Route::prefix('inspections')->group(function () { ... }); // 검사 관리
|
|
```
|
|
|
|
**새 라우트 삽입 위치**: 파일 최하단 (inspections 블록 뒤)
|
|
```php
|
|
use App\Http\Controllers\Api\V1\ProductionOrderController;
|
|
|
|
// 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');
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## 부록 B: 프론트엔드 레퍼런스 패턴
|
|
|
|
### B-1. Server Action 기본 구조
|
|
|
|
```typescript
|
|
'use server';
|
|
|
|
import { executeServerAction } from '@/lib/api/execute-server-action';
|
|
import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
|
|
import { buildApiUrl } from '@/lib/api/query-params';
|
|
```
|
|
|
|
**규칙**:
|
|
- 파일 상단 `'use server'` 필수
|
|
- URL 빌딩: `buildApiUrl()` 사용 필수 (직접 URLSearchParams 금지)
|
|
- 페이지네이션 조회: `executePaginatedAction()` 사용
|
|
- 단건/목록 조회: `executeServerAction()` 사용
|
|
- `'use server'` 파일에서 `export type { X } from '...'` 금지 (인라인 interface/type 선언은 허용)
|
|
|
|
### B-2. executePaginatedAction 패턴 (ShipmentManagement 참조)
|
|
|
|
```typescript
|
|
// 목록 조회
|
|
export async function getShipments(params: ShipmentListParams) {
|
|
return executePaginatedAction<ShipmentApiData, ShipmentItem>({
|
|
url: buildApiUrl('/api/v1/shipments', {
|
|
search: params.search,
|
|
status: params.status,
|
|
page: params.page,
|
|
per_page: params.perPage,
|
|
}),
|
|
transform: transformApiToListItem, // 개별 아이템 변환 함수
|
|
errorMessage: '출고 목록 조회에 실패했습니다.',
|
|
});
|
|
}
|
|
|
|
// 반환 타입: { success, data: T[], pagination: PaginationMeta, error? }
|
|
```
|
|
|
|
**transform 함수**: API 응답의 개별 아이템(snake_case)을 프론트 타입(camelCase)으로 변환
|
|
```typescript
|
|
function transformApiToListItem(data: ShipmentApiData): ShipmentItem {
|
|
return {
|
|
id: String(data.id),
|
|
shipmentNo: data.shipment_no,
|
|
status: data.status,
|
|
// ... snake_case → camelCase 매핑
|
|
};
|
|
}
|
|
```
|
|
|
|
### B-3. executeServerAction 패턴
|
|
|
|
```typescript
|
|
// 단건 조회
|
|
export async function getShipmentDetail(id: string) {
|
|
return executeServerAction<ShipmentApiData, ShipmentDetail>({
|
|
url: buildApiUrl(`/api/v1/shipments/${id}`),
|
|
transform: transformApiToDetail,
|
|
errorMessage: '출고 상세 조회에 실패했습니다.',
|
|
});
|
|
}
|
|
|
|
// 통계 조회 (transform 없이)
|
|
export async function getStats() {
|
|
return executeServerAction<StatsResponse>({
|
|
url: buildApiUrl('/api/v1/shipments/stats'),
|
|
errorMessage: '통계 조회에 실패했습니다.',
|
|
});
|
|
}
|
|
|
|
// 반환 타입: { success, data?: T, error? }
|
|
```
|
|
|
|
### B-4. API 응답 ↔ 프론트 타입 매핑 규칙
|
|
|
|
| API (Laravel) | Frontend (Next.js) | 설명 |
|
|
|---|---|---|
|
|
| `snake_case` 필드명 | `camelCase` 필드명 | transform 함수에서 변환 |
|
|
| `id: number` | `id: string` (또는 number) | 필요에 따라 `String(data.id)` |
|
|
| `{ success, message, data }` | `{ success, data, error? }` | executeServerAction이 자동 변환 |
|
|
| `LengthAwarePaginator` | `PaginatedActionResult<T>` | executePaginatedAction이 자동 변환 |
|
|
|
|
### B-5. 페이지 컴포넌트 패턴
|
|
|
|
```typescript
|
|
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { getData } from '@/components/.../actions';
|
|
|
|
export default function Page() {
|
|
const [data, setData] = useState(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
getData()
|
|
.then(result => setData(result.data))
|
|
.finally(() => setIsLoading(false));
|
|
}, []);
|
|
|
|
if (isLoading) return <div>로딩 중...</div>;
|
|
return <Component initialData={data} />;
|
|
}
|
|
```
|
|
|
|
**핵심 규칙**:
|
|
- 모든 페이지 `'use client'` 필수 (Server Component 사용 금지)
|
|
- 데이터 로딩: `useEffect`에서 Server Action 호출
|
|
|
|
### B-6. 기존 관련 Server Action (재사용 대상)
|
|
파일: `react/src/components/orders/actions.ts`
|
|
|
|
```typescript
|
|
// 생산지시 생성 (이미 존재) — line ~1028
|
|
export async function createProductionOrder(orderId: string) {
|
|
return executeServerAction({
|
|
url: buildApiUrl(`/api/v1/orders/${orderId}/production-order`),
|
|
method: 'POST',
|
|
errorMessage: '생산지시 생성에 실패했습니다.',
|
|
});
|
|
}
|
|
|
|
// 생산지시 취소 (이미 존재) — line ~1077
|
|
export async function revertProductionOrder(orderId: string) {
|
|
return executeServerAction({
|
|
url: buildApiUrl(`/api/v1/orders/${orderId}/revert-production`),
|
|
method: 'POST',
|
|
errorMessage: '생산지시 취소에 실패했습니다.',
|
|
});
|
|
}
|
|
|
|
// 상태 매핑 (이미 존재)
|
|
const STATUS_MAP: Record<string, OrderStatus> = {
|
|
'IN_PROGRESS': 'production_ordered',
|
|
'IN_PRODUCTION': 'in_production',
|
|
'PRODUCED': 'produced',
|
|
'SHIPPING': 'shipping',
|
|
'SHIPPED': 'shipped',
|
|
// ...
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## 부록 C: 핵심 파일 경로 맵
|
|
|
|
### 백엔드 (api/)
|
|
| 경로 | 설명 |
|
|
|------|------|
|
|
| `api/app/Services/Service.php` | Base Service (tenantId, apiUserId) |
|
|
| `api/app/Services/OrderService.php` | 수주 서비스 (createProductionOrder 포함) |
|
|
| `api/app/Services/WorkOrderService.php` | 작업지시 서비스 (syncOrderStatus 포함) |
|
|
| `api/app/Services/ShipmentService.php` | 출하 서비스 (참고 패턴) |
|
|
| `api/app/Models/Orders/Order.php` | 수주 모델 (상태 상수, workOrders 관계) |
|
|
| `api/app/Models/Production/WorkOrder.php` | 작업지시 모델 (상태, process 관계) |
|
|
| `api/app/Helpers/ApiResponse.php` | API 응답 헬퍼 (success, error, handle) |
|
|
| `api/app/Http/Controllers/Api/V1/ShipmentController.php` | 출하 컨트롤러 (참고 패턴) |
|
|
| `api/routes/api/v1/production.php` | 생산 라우트 (새 라우트 삽입 위치) |
|
|
|
|
### 프론트엔드 (react/)
|
|
| 경로 | 설명 |
|
|
|------|------|
|
|
| `react/src/lib/api/execute-server-action.ts` | 서버 액션 실행 유틸 |
|
|
| `react/src/lib/api/execute-paginated-action.ts` | 페이지네이션 조회 유틸 |
|
|
| `react/src/lib/api/query-params.ts` | buildApiUrl 유틸 |
|
|
| `react/src/components/orders/actions.ts` | 수주 서버 액션 (createProductionOrder 등) |
|
|
| `react/src/components/outbound/ShipmentManagement/actions.ts` | 출하 서버 액션 (참고 패턴) |
|
|
| `react/src/app/[locale]/(protected)/sales/order-management-sales/production-orders/page.tsx` | **목록 페이지 (수정 대상)** |
|
|
| `react/src/app/[locale]/(protected)/sales/order-management-sales/production-orders/[id]/page.tsx` | **상세 페이지 (수정 대상)** |
|
|
|
|
---
|
|
|
|
## 부록 D: 프로젝트 규칙 요약 (작업 시 준수)
|
|
|
|
1. **Service-First**: 비즈니스 로직은 Service 클래스에만. Controller는 DI + 호출만.
|
|
2. **FormRequest**: Controller에서 직접 validate 금지. 별도 FormRequest 클래스 필수.
|
|
3. **Multi-tenancy**: `BelongsToTenant` 스코프. Service에서 `$this->tenantId()` 사용.
|
|
4. **ApiResponse**: `use App\Helpers\ApiResponse;` 경로 정확히. `success()` / `error()` 패턴.
|
|
5. **i18n**: 메시지는 `__('message.fetched')`, `__('error.xxx')` 형태. 직접 문자열 금지.
|
|
6. **프론트 'use client'**: 모든 페이지 컴포넌트에 필수.
|
|
7. **buildApiUrl**: URL 빌딩 시 필수 사용. URLSearchParams 직접 사용 금지.
|
|
8. **컬럼 추가 정책**: FK/조인키만 컬럼 추가. 나머지 속성은 `options` JSON 활용.
|