Files
vexplor/frontend/lib/api/outsourcePurchase.ts
kjs 970a8f708a Implement process materials auto-fill functionality for outsource purchase orders
- Added a new endpoint to retrieve process materials based on routing details and work order ID.
- Introduced the `getProcessMaterials` function in the `outsourcePurchaseController` to handle the logic for fetching materials.
- Updated the `outsourcePurchaseRoutes` to include the new route for process materials.
- Enhanced the `RegistrationModal` component to toggle material needs and automatically fill materials when required.

(TASK:ERP-019)
2026-05-06 18:09:23 +09:00

353 lines
12 KiB
TypeScript

/**
* 외주발주관리 API 클라이언트 (TASK:ERP-019)
* 백엔드 라우트: /api/outsource-purchase
*
* 컨트롤러:
* GET / 목록
* GET /auto-processes 공정 자동표기
* GET /:id 상세
* POST / 등록
* PUT /:id 수정
* DELETE /:id 삭제
* POST /release-request 사급자재 출고요청
*/
import { apiClient } from "@/lib/api/client";
// ─────────────────────────────────────────────────────────────────────────────
// 타입
// ─────────────────────────────────────────────────────────────────────────────
export type OPOSourceType = "수주" | "작업지시" | "품목정보" | "생산계획";
export type OPOStatus = "등록" | "진행중" | "완료" | "취소" | "출고요청";
export type ReleaseStatus = "대기" | "요청" | "완료";
export interface OPOMaterial {
id?: string;
item_code?: string;
item_name?: string;
qty?: number | string;
unit?: string;
release_status?: ReleaseStatus;
release_date?: string | null;
outbound_id?: string | null;
}
export interface OPOProcess {
id?: string;
seq: number;
process_code?: string;
process_name?: string;
/** subcontractor_mng.id — 그룹핑·출고요청 키 */
vendor_id?: string;
/** 표시·하위호환용. 그룹핑 키로 사용하지 말 것 (사람이 바꿀 수 있음) */
vendor_code?: string;
vendor_name?: string;
material_needed?: boolean;
remark?: string;
materials?: OPOMaterial[];
/** 라우팅에 매핑된 외주사 후보 (드롭다운을 이 목록으로 제한) */
vendor_options?: { id: string; code: string; name: string }[];
/** item_routing_detail.id (사급자재 자동 채움 호출에 사용) */
routing_detail_id?: string;
}
export interface OPOMaster {
id?: string;
company_code?: string;
order_no?: string;
source_type: string;
source_no?: string;
item_code?: string;
item_name?: string;
spec?: string;
material?: string;
quantity?: number | string;
order_date?: string | null;
due_date?: string | null;
manager?: string;
memo?: string;
status?: OPOStatus;
writer?: string;
created_date?: string;
updated_date?: string;
// 목록 집계 필드
process_count?: number;
vendor_unassigned?: number;
material_count?: number;
material_pending?: number;
first_process_name?: string;
first_vendor_name?: string;
}
export interface OPODetail extends OPOMaster {
processes: OPOProcess[];
}
export interface OPOInputPayload {
order_no?: string;
source_type: string;
source_no?: string;
item_code?: string;
item_name?: string;
spec?: string;
material?: string;
quantity?: number | string;
order_date?: string | null;
due_date?: string | null;
manager?: string;
memo?: string;
status?: OPOStatus;
processes?: OPOProcess[];
}
export interface ListParams {
keyword?: string;
status?: string;
source_type?: string;
date_from?: string;
date_to?: string;
page?: number;
size?: number;
sort?: string;
}
export interface ListResponse {
rows: OPOMaster[];
total: number;
page: number;
size: number;
}
export interface AutoProcessRouting {
id: string;
seq_no: number | string;
process_code: string;
process_name?: string;
work_type?: string;
execution_type?: string;
outsource_supplier?: string;
standard_time?: string | number;
/** 작업지시 라우팅(work_order_process)에서 채워지는 필드 */
wo_id?: string;
plan_qty?: string | number;
routing_detail_id?: string;
result_status?: string;
}
export interface AutoProcessResponse {
/** 데이터 출처 — 'work_order' (작업지시 라우팅) | 'item_routing' (품목 라우팅 폴백) | 'none' */
source?: "work_order" | "item_routing" | "none";
routing: AutoProcessRouting[];
candidates: AutoProcessRouting[];
}
export interface OutsourceableWorkOrderRow {
id: string;
work_instruction_no: string;
qty?: string | number;
status?: string;
progress_status?: string;
item_id?: string;
start_date?: string;
end_date?: string;
created_date?: string;
/** 외주발주 가능한 첫 공정명 */
next_process_name?: string;
next_process_seq?: string | number;
/** 외주/선택가능 연속 공정 개수 */
outsourceable_count?: number;
}
export interface OutsourceableListResponse {
rows: OutsourceableWorkOrderRow[];
total: number;
page: number;
size: number;
}
export interface ReleaseRequestPayload {
material_ids: string[];
warehouse_code?: string;
memo?: string;
}
export interface ReleaseRequestResult {
outbound_ids: string[];
material_ids: string[];
outbound_count: number;
material_count: number;
}
interface ApiEnvelope<T> {
success: boolean;
data?: T;
message?: string;
}
// ─────────────────────────────────────────────────────────────────────────────
// API 호출
// ─────────────────────────────────────────────────────────────────────────────
/** 외주발주 목록 조회 */
export async function listOutsourcePurchaseOrders(params: ListParams = {}): Promise<ListResponse> {
const res = await apiClient.get<ApiEnvelope<ListResponse>>("/outsource-purchase", { params });
return res.data?.data || { rows: [], total: 0, page: 1, size: 50 };
}
/** 외주발주 상세 조회 (마스터+공정+자재) */
export async function getOutsourcePurchaseOrder(id: string): Promise<OPODetail | null> {
const res = await apiClient.get<ApiEnvelope<OPODetail>>(`/outsource-purchase/${id}`);
return res.data?.data || null;
}
/** 외주발주 등록 */
export async function createOutsourcePurchaseOrder(payload: OPOInputPayload): Promise<OPODetail> {
const res = await apiClient.post<ApiEnvelope<OPODetail>>("/outsource-purchase", payload);
if (!res.data?.success) throw new Error(res.data?.message || "외주발주 등록 실패");
return res.data.data!;
}
/** 외주발주 수정 */
export async function updateOutsourcePurchaseOrder(id: string, payload: OPOInputPayload): Promise<OPODetail> {
const res = await apiClient.put<ApiEnvelope<OPODetail>>(`/outsource-purchase/${id}`, payload);
if (!res.data?.success) throw new Error(res.data?.message || "외주발주 수정 실패");
return res.data.data!;
}
/** 외주발주 삭제 */
export async function deleteOutsourcePurchaseOrder(id: string): Promise<{ id: string; order_no: string }> {
const res = await apiClient.delete<ApiEnvelope<{ id: string; order_no: string }>>(
`/outsource-purchase/${id}`,
);
if (!res.data?.success) throw new Error(res.data?.message || "외주발주 삭제 실패");
return res.data.data!;
}
/**
* 공정 자동표기 (TASK:ERP-019 재구현)
*
* 우선순위:
* 1) work_order_id 가 있으면 → work_order_process 기준 (작업지시별 라우팅 + 진행상황)
* 2) work_order_id 가 없거나 작업지시 라우팅이 비어있으면 → item_code 기반 폴백
*
* 둘 중 하나는 반드시 제공해야 한다.
*/
export async function getAutoProcesses(
args: { workOrderId?: string; itemCode?: string },
): Promise<AutoProcessResponse> {
const params: Record<string, string> = {};
if (args.workOrderId) params.work_order_id = args.workOrderId;
if (args.itemCode) params.item_code = args.itemCode;
if (!params.work_order_id && !params.item_code) {
return { source: "none", routing: [], candidates: [] };
}
const res = await apiClient.get<ApiEnvelope<AutoProcessResponse>>(
"/outsource-purchase/auto-processes",
{ params },
);
return res.data?.data || { source: "none", routing: [], candidates: [] };
}
/**
* 공정 자재투입(material_input) 자동 채움 — 사급자재 체크 시 호출
* 응답: 외주발주 자재 입력 형식 배열 [{item_code, item_name, qty, unit}]
*/
export async function getProcessMaterials(
args: { workOrderId?: string; routingDetailId: string },
): Promise<Array<{ item_code: string; item_name: string; qty: string; unit: string }>> {
if (!args.routingDetailId) return [];
const params: Record<string, string> = { routing_detail_id: args.routingDetailId };
if (args.workOrderId) params.work_order_id = args.workOrderId;
const res = await apiClient.get<ApiEnvelope<Array<{ item_code: string; item_name: string; qty: string; unit: string }>>>(
"/outsource-purchase/process-materials",
{ params },
);
return res.data?.data || [];
}
/**
* 외주발주 가능 작업지시 목록 (TASK:ERP-019 재구현)
* "이전 공정 완료 + 다음 공정 외주/선택가능" 작업지시만 반환
*/
export async function listOutsourceableWorkOrders(
params: { keyword?: string; page?: number; size?: number } = {},
): Promise<OutsourceableListResponse> {
const res = await apiClient.get<ApiEnvelope<OutsourceableListResponse>>(
"/outsource-purchase/outsourceable-work-orders",
{ params },
);
return res.data?.data || { rows: [], total: 0, page: 1, size: 50 };
}
/** 사급자재 출고요청 (선택된 자재 ID 배열) */
export async function requestMaterialRelease(
payload: ReleaseRequestPayload,
): Promise<ReleaseRequestResult> {
const res = await apiClient.post<ApiEnvelope<ReleaseRequestResult>>(
"/outsource-purchase/release-request",
payload,
);
if (!res.data?.success) throw new Error(res.data?.message || "사급자재 출고요청 실패");
return res.data.data!;
}
// ─────────────────────────────────────────────────────────────────────────────
// 보조 마스터 조회 (공통 table-management API 활용)
// ─────────────────────────────────────────────────────────────────────────────
/** 외주업체 마스터 조회 (subcontractor_mng) — 기존 외주사 활용 */
export async function listSubcontractors(): Promise<any[]> {
const res = await apiClient.post(`/table-management/tables/subcontractor_mng/data`, {
page: 1,
size: 0,
autoFilter: true,
});
return res.data?.data?.data || res.data?.data?.rows || [];
}
/** 수주 목록 조회 (sales_order_mng) — 등록 모달 좌측 소스 */
export async function listSalesOrdersForOPO(keyword?: string): Promise<any[]> {
const filters: any[] = [];
if (keyword) {
filters.push({ columnName: "order_no", operator: "contains", value: keyword });
}
const res = await apiClient.post(`/table-management/tables/sales_order_mng/data`, {
page: 1,
size: 0,
dataFilter: filters.length ? { enabled: true, filters } : undefined,
autoFilter: true,
});
return res.data?.data?.data || res.data?.data?.rows || [];
}
/** 작업지시 목록 조회 (work_instruction) — 등록 모달 좌측 소스 */
export async function listWorkInstructionsForOPO(keyword?: string): Promise<any[]> {
const filters: any[] = [];
if (keyword) {
filters.push({ columnName: "work_instruction_no", operator: "contains", value: keyword });
}
const res = await apiClient.post(`/table-management/tables/work_instruction/data`, {
page: 1,
size: 0,
dataFilter: filters.length ? { enabled: true, filters } : undefined,
autoFilter: true,
});
return res.data?.data?.data || res.data?.data?.rows || [];
}
/** 품목 마스터 조회 (item_info) — 등록 모달 좌측 소스 */
export async function listItemsForOPO(keyword?: string): Promise<any[]> {
const filters: any[] = [];
if (keyword) {
filters.push({ columnName: "item_code", operator: "contains", value: keyword });
}
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1,
size: 0,
dataFilter: filters.length ? { enabled: true, filters } : undefined,
autoFilter: true,
});
return res.data?.data?.data || res.data?.data?.rows || [];
}