- Created a detailed modal for viewing outsourcing purchase order details, including basic information and status rows for processes, vendors, and shipping vs. receiving. - Developed an integrated status page for outsourcing purchase orders, featuring a dynamic search filter and a comprehensive table displaying order, process, vendor, and material information. - Implemented Excel export functionality for the integrated status page, allowing users to download order data easily. (TASK: ERP-025)
438 lines
15 KiB
TypeScript
438 lines
15 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: [] };
|
||
}
|
||
|
||
/**
|
||
* 외주발주번호 미리보기 — 등록된 채번 규칙 기반. 시퀀스 증가 없음.
|
||
* 응답이 빈 문자열이면 채번 규칙 미설정 — 백엔드 폴백(OPO-YYYY-NNN) 사용됨.
|
||
*/
|
||
export async function previewOrderNo(): Promise<string> {
|
||
try {
|
||
const res = await apiClient.get<ApiEnvelope<{ generatedCode: string }>>(
|
||
"/outsource-purchase/preview-order-no",
|
||
);
|
||
return res.data?.data?.generatedCode || "";
|
||
} catch {
|
||
return "";
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 공정 자재투입(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 || [];
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 외주발주현황 (TASK:ERP-025) — 발주 × 공정 × 자재 × 출고 × 입고 통합 펼친 뷰
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
export interface OrderStatusFilter {
|
||
keyword?: string;
|
||
source_type?: string;
|
||
order_status?: string;
|
||
release_status?: string;
|
||
date_from?: string;
|
||
date_to?: string;
|
||
}
|
||
|
||
export interface OrderStatusRow {
|
||
order_id: string;
|
||
order_no: string;
|
||
source_type: string;
|
||
source_no: string;
|
||
item_code: string;
|
||
item_name: string;
|
||
spec: string;
|
||
material: string;
|
||
quantity: number;
|
||
order_date: string;
|
||
due_date: string;
|
||
order_status: string;
|
||
manager: string;
|
||
process_id: string;
|
||
seq: number | null;
|
||
process_name: string;
|
||
vendor_code: string;
|
||
vendor_name: string;
|
||
material_needed: boolean;
|
||
material_id: string;
|
||
material_item_code: string;
|
||
material_item_name: string;
|
||
material_qty: number;
|
||
material_unit: string;
|
||
material_release_status: string;
|
||
outbound_id: string;
|
||
outbound_number: string;
|
||
outbound_status: string;
|
||
released_qty: number;
|
||
request_date: string;
|
||
received_qty: number;
|
||
remain_qty: number;
|
||
rate_pct: number;
|
||
release_status: string; // '출고완료' | '입고완료' | ''
|
||
}
|
||
|
||
export interface OrderStatusResponse {
|
||
rows: OrderStatusRow[];
|
||
total: number;
|
||
}
|
||
|
||
/** 외주발주현황 조회 (TASK:ERP-025) */
|
||
export async function listOutsourcePurchaseStatus(
|
||
filter: OrderStatusFilter = {},
|
||
): Promise<OrderStatusResponse> {
|
||
const params: Record<string, any> = {};
|
||
for (const [k, v] of Object.entries(filter)) {
|
||
if (v != null && v !== "") params[k] = v;
|
||
}
|
||
const res = await apiClient.get<ApiEnvelope<OrderStatusResponse>>(
|
||
"/outsource-purchase/status",
|
||
{ params },
|
||
);
|
||
return res.data?.data || { rows: [], total: 0 };
|
||
}
|