diff --git a/backend-node/src/controllers/purchaseController.ts b/backend-node/src/controllers/purchaseController.ts index af91a227..6003b40a 100644 --- a/backend-node/src/controllers/purchaseController.ts +++ b/backend-node/src/controllers/purchaseController.ts @@ -47,6 +47,16 @@ export async function getSuppliers(_req: AuthenticatedRequest, res: Response) { } } +export async function getVendors(_req: AuthenticatedRequest, res: Response) { + try { + const data = await svc.listVendorOptions(); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("공급업체(client_mng) 옵션 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + export async function getUsers(_req: AuthenticatedRequest, res: Response) { try { const data = await svc.listUserOptions(); diff --git a/backend-node/src/routes/purchaseRoutes.ts b/backend-node/src/routes/purchaseRoutes.ts index 16f6083c..30f79a44 100644 --- a/backend-node/src/routes/purchaseRoutes.ts +++ b/backend-node/src/routes/purchaseRoutes.ts @@ -21,6 +21,7 @@ router.get("/project-status", ctrl.getProjectStatus); // 프로젝트별 // 공통 옵션 router.get("/options/suppliers", ctrl.getSuppliers); +router.get("/options/vendors", ctrl.getVendors); // wace client_mng 기반 router.get("/options/users", ctrl.getUsers); router.get("/options/projects", ctrl.getProjects); diff --git a/backend-node/src/services/purchaseService.ts b/backend-node/src/services/purchaseService.ts index 3c7be7ab..1fa02e9c 100644 --- a/backend-node/src/services/purchaseService.ts +++ b/backend-node/src/services/purchaseService.ts @@ -144,8 +144,11 @@ export async function listPurchaseRequest(filter: PurchaseListFilter): Promise> { - const { page, pageSize } = clampPaging(filter); - logger.warn("listQuotationRequest: quotation_request_master 테이블 미존재 — 빈 응답"); - return { rows: [], totalCount: 0, page, pageSize }; + const pool = getPool(); + const { limit, offset, page, pageSize } = clampPaging(filter); + + const where: string[] = []; + const params: any[] = []; + const addParam = (val: any) => { params.push(val); return `$${params.length}`; }; + + if (filter.year) where.push(`EXTRACT(YEAR FROM QRM.REG_DATE) = ${addParam(Number(filter.year))}`); + if (filter.project_no) where.push(`PM.PROJECT_NO ILIKE ${addParam(`%${filter.project_no}%`)}`); + if (filter.proposal_no) where.push(`QRM.QUOTATION_REQUEST_NO ILIKE ${addParam(`%${filter.proposal_no}%`)}`); + if (filter.partner_objid) where.push(`QRM.VENDOR_OBJID = ${addParam(filter.partner_objid)}`); + if (filter.mail_send_yn) where.push(`COALESCE(QRM.MAIL_SEND_YN, 'N') = ${addParam(filter.mail_send_yn)}`); + if (filter.writer) where.push(`QRM.WRITER = ${addParam(filter.writer)}`); + if (filter.product_cd) where.push(`COALESCE(CTM.PRODUCT, SRM.PRODUCT_NAME) = ${addParam(filter.product_cd)}`); + + const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : ""; + + const dataSql = ` + SELECT + QRM.OBJID AS objid, + QRM.QUOTATION_REQUEST_NO AS quotation_request_no, + QRM.SALES_REQUEST_MASTER_OBJID AS sales_request_master_objid, + QRM.PROJECT_MGMT_OBJID AS project_mgmt_objid, + QRM.VENDOR_OBJID AS vendor_objid, + QRM.VENDOR_TYPE AS vendor_type, + QRM.STATUS AS status, + CASE QRM.STATUS + WHEN 'create' THEN '작성중' + WHEN 'sent' THEN '발송완료' + WHEN 'received' THEN '견적수신' + WHEN 'completed' THEN '완료' + ELSE COALESCE(QRM.STATUS, '') + END AS status_name, + QRM.MAIL_SEND_DATE AS mail_send_date, + TO_CHAR(QRM.MAIL_SEND_DATE, 'YYYY-MM-DD') AS mail_send_date_title, + COALESCE(QRM.MAIL_SEND_YN, 'N') AS mail_send_yn, + QRM.DUE_DATE AS due_date, + TO_CHAR(QRM.DUE_DATE, 'YYYY-MM-DD') AS due_date_title, + QRM.REMARK AS remark, + QRM.WRITER AS writer, + COALESCE((SELECT USER_NAME FROM USER_INFO WHERE USER_ID = QRM.WRITER LIMIT 1), QRM.WRITER, '') AS writer_name, + QRM.REG_DATE AS reg_date, + TO_CHAR(QRM.REG_DATE, 'YYYY-MM-DD') AS reg_date_title, + SRM.REQUEST_MNG_NO AS request_mng_no, + SRM.PURCHASE_TYPE AS purchase_type, + COALESCE( + (SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = SRM.PURCHASE_TYPE LIMIT 1), '' + ) AS purchase_type_name, + COALESCE(PM.CATEGORY_CD, SRM.ORDER_TYPE) AS order_type, + COALESCE( + (SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = COALESCE(PM.CATEGORY_CD, SRM.ORDER_TYPE) LIMIT 1), '' + ) AS order_type_name, + COALESCE( + (SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CTM.PRODUCT LIMIT 1), + (SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = SRM.PRODUCT_NAME LIMIT 1), + '' + ) AS product_name_title, + PM.PROJECT_NO AS project_number, + CM.CLIENT_NM AS vendor_name, + (SELECT QRD.PART_NO FROM QUOTATION_REQUEST_DETAIL QRD + WHERE QRD.QUOTATION_REQUEST_MASTER_OBJID = QRM.OBJID + ORDER BY QRD.OBJID LIMIT 1) AS part_no, + (SELECT QRD.PART_NAME FROM QUOTATION_REQUEST_DETAIL QRD + WHERE QRD.QUOTATION_REQUEST_MASTER_OBJID = QRM.OBJID + ORDER BY QRD.OBJID LIMIT 1) AS part_name, + (SELECT COUNT(*)::int FROM QUOTATION_REQUEST_DETAIL QRD + WHERE QRD.QUOTATION_REQUEST_MASTER_OBJID = QRM.OBJID) AS detail_count, + (SELECT COUNT(*)::int FROM ATTACH_FILE_INFO + WHERE TARGET_OBJID = QRM.OBJID + AND DOC_TYPE = 'QUOTATION_RECEIVED' + AND COALESCE(STATUS, 'Active') = 'Active') AS attach_file_cnt + FROM QUOTATION_REQUEST_MASTER QRM + LEFT JOIN SALES_REQUEST_MASTER SRM ON QRM.SALES_REQUEST_MASTER_OBJID = SRM.OBJID + LEFT JOIN PROJECT_MGMT PM ON PM.OBJID = SRM.PROJECT_NO + LEFT JOIN CONTRACT_MGMT CTM ON CTM.OBJID = PM.CONTRACT_OBJID + LEFT JOIN CLIENT_MNG CM ON QRM.VENDOR_OBJID = CM.OBJID + ${whereSql} + ORDER BY QRM.REG_DATE DESC + LIMIT ${addParam(limit)} OFFSET ${addParam(offset)} + `; + const countSql = ` + SELECT COUNT(*)::int AS cnt + FROM QUOTATION_REQUEST_MASTER QRM + LEFT JOIN SALES_REQUEST_MASTER SRM ON QRM.SALES_REQUEST_MASTER_OBJID = SRM.OBJID + LEFT JOIN PROJECT_MGMT PM ON PM.OBJID = SRM.PROJECT_NO + LEFT JOIN CONTRACT_MGMT CTM ON CTM.OBJID = PM.CONTRACT_OBJID + LEFT JOIN CLIENT_MNG CM ON QRM.VENDOR_OBJID = CM.OBJID + ${whereSql} + `; + try { + const [d, c] = await Promise.all([ + pool.query(dataSql, params), + pool.query(countSql, params.slice(0, params.length - 2)), + ]); + return { rows: d.rows, totalCount: c.rows[0]?.cnt ?? 0, page, pageSize }; + } catch (e: any) { + logger.error("listQuotationRequest 실패", { error: e.message }); + return { rows: [], totalCount: 0, page, pageSize }; + } } // ─── 3) 품의서관리 (wace salesMng.xml proposalMngList) ── @@ -388,6 +494,24 @@ export async function listProjectStatus(filter: PurchaseListFilter): Promise { + const pool = getPool(); + try { + const r = await pool.query( + `SELECT OBJID AS code, CLIENT_NM AS label + FROM CLIENT_MNG + WHERE COALESCE(STATUS, 'Y') IN ('Y', 'active', 'ACTIVE', '활성') + AND CLIENT_NM IS NOT NULL AND CLIENT_NM <> '' + ORDER BY CLIENT_NM + LIMIT 2000`, + ); + return r.rows; + } catch { + return []; + } +} + export async function listSupplierOptions(): Promise<{ code: string; label: string }[]> { const pool = getPool(); try { diff --git a/docs/migration/purchase/data-sync/01_quotation_request_sync.sql b/docs/migration/purchase/data-sync/01_quotation_request_sync.sql new file mode 100644 index 00000000..d9c82242 --- /dev/null +++ b/docs/migration/purchase/data-sync/01_quotation_request_sync.sql @@ -0,0 +1,51 @@ +-- ============================================================ +-- 견적요청서 운영 sample 데이터 → RPS 이관 +-- 운영: 211.115.91.141:11133/waceplm +-- quotation_request_master 4건 / quotation_request_detail 4건 +-- 대상: 211.115.91.141:11134/vexplor_rps +-- +-- 함정: +-- 1) objid / sales_request_master_objid / project_mgmt_objid : numeric → varchar +-- 2) detail.part_objid : numeric → bigint (RPS part_mng.objid bigint 호환) +-- 3) FK 미매칭 sales_request_part_objid 는 NULL 처리 +-- +-- 멱등성: ON CONFLICT DO NOTHING. +-- ============================================================ + +-- ── master ──────────────────────────────────────────────────── +INSERT INTO quotation_request_master + (objid, quotation_request_no, sales_request_master_objid, project_mgmt_objid, + vendor_objid, vendor_type, status, mail_send_date, mail_send_yn, due_date, + remark, writer, reg_date, edit_date) +VALUES + ('-1554146727','Q20260401-115','-722096187','-1752090174','0000000007','SUPPLY','received','2026-04-03 04:36:11.666917','Y','2026-04-06',NULL,'ady1225','2026-04-01 07:11:30.812611','2026-04-01 07:16:21.513931'), + ('-1629785580','Q20260401-116','-722096187','-1752090174','0000000012','PROCESSING','create',NULL,'N','2026-04-06',NULL,'ady1225','2026-04-01 07:11:30.814099',NULL), + ('185180465','Q20260401-118','-722096187','-1752090174','0000008379','PROCESSING','create',NULL,'N','2026-04-06',NULL,'ady1225','2026-04-01 07:11:30.836506',NULL), + ('211976545','Q20260401-119','-722096187','-1752090174','0000012062','PROCESSING','create',NULL,'N','2026-04-06',NULL,'ady1225','2026-04-01 07:11:30.841764',NULL) +ON CONFLICT (objid) DO NOTHING; + +-- ── detail ──────────────────────────────────────────────────── +INSERT INTO quotation_request_detail + (objid, quotation_request_master_objid, sales_request_part_objid, part_objid, + part_no, part_name, raw_material, size, qty, unit_price, total_price, + remark, delivery_request_date, reg_date, edit_date) +VALUES + ('-1266428262','-1554146727','-1279349416',1868255637,'C3P50L22','Ti(GR5)','Ti(GR5)','Ø50*22',0,10000,0,NULL,'2026-04-03','2026-04-01 07:11:30.812611','2026-04-01 07:16:21.513931'), + ('-2130546975','-1629785580','1187291883',1868255516,'10024-0066','SHEET',NULL,NULL,4,0,0,NULL,NULL,'2026-04-01 07:11:30.814099',NULL), + ('-392083183','185180465','-1279349416',1868255637,'10026-0031','HOLDER',NULL,NULL,4,0,0,NULL,NULL,'2026-04-01 07:11:30.836506',NULL), + ('-563828077','211976545','-1291084031',1868257572,'30004-0098','NUT',NULL,NULL,4,0,0,NULL,NULL,'2026-04-01 07:11:30.841764',NULL) +ON CONFLICT (objid) DO NOTHING; + +-- FK 미매칭 sales_request_part_objid 는 NULL 처리 (현재 RPS sales_request_part 0건) +UPDATE quotation_request_detail + SET sales_request_part_objid = NULL + WHERE sales_request_part_objid IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM sales_request_part WHERE objid = quotation_request_detail.sales_request_part_objid + ); + +-- FK 미매칭 part_objid 는 NULL 처리 (RPS part_mng 와 매칭 안 되면) +UPDATE quotation_request_detail + SET part_objid = NULL + WHERE part_objid IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM part_mng WHERE objid = quotation_request_detail.part_objid); diff --git a/docs/migration/purchase/ddl-extracted/500_quotation_request.sql b/docs/migration/purchase/ddl-extracted/500_quotation_request.sql new file mode 100644 index 00000000..ab35980b --- /dev/null +++ b/docs/migration/purchase/ddl-extracted/500_quotation_request.sql @@ -0,0 +1,73 @@ +-- ============================================================ +-- 견적요청서 (Quotation Request) — 구매관리 단독 +-- 원본: 운영DB 211.115.91.141:11133/waceplm (quotation_request_master 4건, quotation_request_detail 4건) +-- 추출일: 2026-05-15 +-- 적용대상: vexplor_rps (11134) +-- +-- 운영 ↔ RPS 타입 차이 (feedback_createobjid_pattern.md): +-- 운영: quotation_request_master.objid numeric → RPS varchar(64) +-- 운영: sales_request_master_objid / project_mgmt_objid numeric → RPS varchar(64) (FK 호환) +-- 운영: detail.part_objid numeric → RPS bigint (part_mng.objid bigint 호환) +-- 운영: detail.sales_request_part_objid numeric → RPS varchar(64) +-- +-- 비즈니스 흐름: +-- 구매리스트(sales_request_master) → 견적요청서(quotation_request_master + detail) +-- → 품의서 → 발주서(purchase_order_master + part) → 입고(arrival_plan + inventory_*) +-- +-- 매퍼 본문(getQuotationRequestList): wace_plm/src/com/pms/mapper/salesMng.xml:5248-5349 +-- ============================================================ + +-- ── 1. quotation_request_master ────────────────────────────── +CREATE TABLE IF NOT EXISTS quotation_request_master ( + objid varchar(64) NOT NULL, + quotation_request_no varchar(50), + sales_request_master_objid varchar(64), + project_mgmt_objid varchar(64), + vendor_objid varchar(64), + vendor_type varchar(20), + status varchar(50) DEFAULT 'create', + mail_send_date timestamp, + mail_send_yn varchar(1) DEFAULT 'N', + due_date date, + remark text, + writer varchar(50), + reg_date timestamp DEFAULT now(), + edit_date timestamp, + CONSTRAINT quotation_request_master_pkey PRIMARY KEY (objid) +); + +CREATE INDEX IF NOT EXISTS idx_qrm_sales_request ON quotation_request_master (sales_request_master_objid); +CREATE INDEX IF NOT EXISTS idx_qrm_project ON quotation_request_master (project_mgmt_objid); +CREATE INDEX IF NOT EXISTS idx_qrm_vendor ON quotation_request_master (vendor_objid); +CREATE INDEX IF NOT EXISTS idx_qrm_status ON quotation_request_master (status); + +-- ── 2. quotation_request_detail ────────────────────────────── +CREATE TABLE IF NOT EXISTS quotation_request_detail ( + objid varchar(64) NOT NULL, + quotation_request_master_objid varchar(64), + sales_request_part_objid varchar(64), + part_objid bigint, + part_no varchar(100), + part_name varchar(200), + raw_material varchar(100), + size varchar(100), + qty numeric DEFAULT 0, + unit_price numeric DEFAULT 0, + total_price numeric DEFAULT 0, + remark text, + delivery_request_date varchar(10), + reg_date timestamp DEFAULT now(), + edit_date timestamp, + CONSTRAINT quotation_request_detail_pkey PRIMARY KEY (objid) +); + +CREATE INDEX IF NOT EXISTS idx_qrd_master ON quotation_request_detail (quotation_request_master_objid); +CREATE INDEX IF NOT EXISTS idx_qrd_part ON quotation_request_detail (sales_request_part_objid); + +ALTER TABLE quotation_request_detail + DROP CONSTRAINT IF EXISTS fk_qrd_master; +ALTER TABLE quotation_request_detail + ADD CONSTRAINT fk_qrd_master + FOREIGN KEY (quotation_request_master_objid) + REFERENCES quotation_request_master (objid) + ON DELETE CASCADE; diff --git a/frontend/app/(main)/COMPANY_16/purchase/quote-request/page.tsx b/frontend/app/(main)/COMPANY_16/purchase/quote-request/page.tsx index c5837bc6..a93a9fd9 100644 --- a/frontend/app/(main)/COMPANY_16/purchase/quote-request/page.tsx +++ b/frontend/app/(main)/COMPANY_16/purchase/quote-request/page.tsx @@ -4,7 +4,6 @@ // 검색: 년도 / 프로젝트번호 / 견적요청서No / 공급업체 / 메일발송 / 작성자 / 제품구분 // 그리드: 13컬럼 (견적번호 / 요청번호 / 구매유형 / 프로젝트번호 / 주문유형 / 제품구분 / 품번 / 품명 / 공급업체 / 견적요청서(파일) / 메일발송 / 수신견적서 / 작성자) // 액션: 메일발송 / 삭제 / 조회 -// ⚠️ 백엔드 quotation_request_master 미존재 → 빈 그리드 (UI 만 제공) import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Input } from "@/components/ui/input"; @@ -66,7 +65,7 @@ export default function QuoteRequestPage() { try { const [p, s, u] = await Promise.all([ apiClient.get(`/sales/codes/${PARENT_PRODUCT}`), - purchaseApi.listSuppliers(), + purchaseApi.listVendors(), purchaseApi.listUsers(), ]); if (dead) return; @@ -113,12 +112,12 @@ export default function QuoteRequestPage() { actions={<> } diff --git a/frontend/lib/api/purchase.ts b/frontend/lib/api/purchase.ts index 5d464b10..72bec054 100644 --- a/frontend/lib/api/purchase.ts +++ b/frontend/lib/api/purchase.ts @@ -71,6 +71,11 @@ export const purchaseApi = { const r = await apiClient.get("/purchase/options/suppliers"); return (r.data?.data ?? []) as OptionItem[]; }, + // 견적요청서 / 발주서 vendor (wace client_mng 매칭) + async listVendors(): Promise { + const r = await apiClient.get("/purchase/options/vendors"); + return (r.data?.data ?? []) as OptionItem[]; + }, async listUsers(): Promise { const r = await apiClient.get("/purchase/options/users"); return (r.data?.data ?? []) as OptionItem[];