Files
sam-docs/dev/dev_plans/production-orders-page.md

28 KiB

생산지시 목록/상세 페이지 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

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 (기존 파일에 추가)

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

'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

// 생산지시 상태 (프론트 탭용)
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 (검증)

진행 상태

  • 계획 수립
  • 심층 분석 (상태 매핑, 누락 항목 보완)
  • 문서 자기 완결화 (레퍼런스 패턴 추가)
  • Phase 1: 백엔드 API
  • Phase 2: 목록 페이지
  • Phase 3: 상세 페이지
  • Phase 4: 흐름 검증

부록 A: 백엔드 레퍼런스 패턴

A-1. Base Service 클래스

파일: api/app/Services/Service.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
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 참조)

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 핵심 메서드

// 성공 응답 → { 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

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 블록 뒤)

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 기본 구조

'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 참조)

// 목록 조회
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)으로 변환

function transformApiToListItem(data: ShipmentApiData): ShipmentItem {
  return {
    id: String(data.id),
    shipmentNo: data.shipment_no,
    status: data.status,
    // ... snake_case → camelCase 매핑
  };
}

B-3. executeServerAction 패턴

// 단건 조회
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. 페이지 컴포넌트 패턴

'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

// 생산지시 생성 (이미 존재) — 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 활용.