diff --git a/backend-node/src/routes/salesPurchaseRequestRoutes.ts b/backend-node/src/routes/salesPurchaseRequestRoutes.ts index c7332071..606029ca 100644 --- a/backend-node/src/routes/salesPurchaseRequestRoutes.ts +++ b/backend-node/src/routes/salesPurchaseRequestRoutes.ts @@ -1,8 +1,14 @@ // ============================================================ // 영업관리 > 구매요청서관리 / 품의서관리 라우트 // app.ts: app.use("/api/sales", salesPurchaseRequestRoutes) -// GET /api/sales/purchase-request — DOC_TYPE='PURCHASE_REG' 그리드 -// GET /api/sales/purchase-proposal — DOC_TYPE='PURCHASE_REG_PROPOSAL' 그리드 +// GET /api/sales/purchase-request — 구매요청서 그리드 +// GET /api/sales/purchase-request/mbom-parts — 프로젝트별 M-BOM 품목 목록 (다이얼로그용) +// GET /api/sales/purchase-request/:objid — 단건 + 라인 +// GET /api/sales/purchase-request/:objid/proposal-targets — 품의서 대상 품목 +// POST /api/sales/purchase-request — 저장(UPSERT master + 라인 재생성) +// POST /api/sales/purchase-request/:objid/proposal — 품의서 생성 +// GET /api/sales/purchase-proposal — 영업>품의서 그리드 +// POST /api/sales/purchase-proposal/:objid/approval — Amaranth SSO 결재상신 // ============================================================ import { Router, Response } from "express"; @@ -10,6 +16,7 @@ import { authenticateToken } from "../middleware/authMiddleware"; import { AuthenticatedRequest } from "../types/auth"; import * as svc from "../services/salesPurchaseRequestService"; import { logger } from "../utils/logger"; +import { AppError } from "../middleware/errorHandler"; const router = Router(); router.use(authenticateToken); @@ -36,7 +43,99 @@ async function run( } } +function handleError(res: Response, e: any, label: string) { + if (e instanceof AppError) { + return res.status(e.statusCode).json({ success: false, message: e.message }); + } + logger.error(`${label} 실패`, { error: e?.message }); + return res.status(500).json({ success: false, message: e?.message ?? `${label} 실패` }); +} + router.get("/purchase-request", (req, res) => run(svc.listPurchaseRequestReg, req as AuthenticatedRequest, res, "구매요청서관리")); router.get("/purchase-proposal", (req, res) => run(svc.listPurchaseRegProposal, req as AuthenticatedRequest, res, "영업>품의서관리")); +// 프로젝트 자동채움 정보 (주문유형/제품구분/국내외/고객사/유무상 + mbom_header_objid) +router.get("/purchase-request/project-info/:projectObjid", async (req, res) => { + try { + const info = await svc.getProjectAutoFillInfo(req.params.projectObjid); + return res.json({ success: true, data: info }); + } catch (e: any) { + return handleError(res, e, "프로젝트 자동채움 정보"); + } +}); + +// 프로젝트별 M-BOM 품목 (행추가 시 품번 셀렉트 옵션) +router.get("/purchase-request/mbom-parts", async (req, res) => { + try { + const projectObjid = String(req.query.project_objid ?? ""); + if (!projectObjid) return res.json({ success: true, data: [] }); + const rows = await svc.listMbomPartsForProject(projectObjid); + return res.json({ success: true, data: rows }); + } catch (e: any) { + return handleError(res, e, "M-BOM 품목 조회"); + } +}); + +// 단건 + 라인 +router.get("/purchase-request/:objid", async (req, res) => { + try { + const detail = await svc.getPurchaseRequestDetail(req.params.objid); + return res.json({ success: true, data: detail }); + } catch (e: any) { + return handleError(res, e, "구매요청서 상세"); + } +}); + +// 품의서 대상 품목 +router.get("/purchase-request/:objid/proposal-targets", async (req, res) => { + try { + const data = await svc.getProposalTargetParts(req.params.objid); + return res.json({ success: true, data }); + } catch (e: any) { + return handleError(res, e, "품의서 대상 품목"); + } +}); + +// 저장 (신규/수정 UPSERT) +router.post("/purchase-request", async (req, res) => { + const ar = req as AuthenticatedRequest; + try { + const userId = ar.user?.userId; + if (!userId) return res.status(401).json({ success: false, message: "인증 필요" }); + const out = await svc.savePurchaseRequest(userId, req.body); + return res.json({ success: true, data: out }); + } catch (e: any) { + return handleError(res, e, "구매요청서 저장"); + } +}); + +// 품의서 생성 +router.post("/purchase-request/:objid/proposal", async (req, res) => { + const ar = req as AuthenticatedRequest; + try { + const userId = ar.user?.userId; + if (!userId) return res.status(401).json({ success: false, message: "인증 필요" }); + const out = await svc.createProposalFromPurchaseReg(userId, req.params.objid); + return res.json({ success: true, data: out }); + } catch (e: any) { + return handleError(res, e, "품의서 생성"); + } +}); + +// 결재상신 (Amaranth SSO) +router.post("/purchase-proposal/:objid/approval", async (req, res) => { + const ar = req as AuthenticatedRequest; + try { + const userId = ar.user?.userId; + if (!userId) return res.status(401).json({ success: false, message: "인증 필요" }); + const out = await svc.startProposalApproval(userId, req.params.objid, { + approvalTitle: req.body?.approvalTitle, + subjectStr: req.body?.subjectStr, + }); + return res.json({ success: true, data: out }); + } catch (e: any) { + return handleError(res, e, "품의서 결재상신"); + } +}); + export default router; diff --git a/backend-node/src/services/salesPurchaseRequestService.ts b/backend-node/src/services/salesPurchaseRequestService.ts index 8d43c60e..34286926 100644 --- a/backend-node/src/services/salesPurchaseRequestService.ts +++ b/backend-node/src/services/salesPurchaseRequestService.ts @@ -17,6 +17,9 @@ import { getPool } from "../database/db"; import { logger } from "../utils/logger"; +import { createObjId } from "../utils/objidUtil"; +import { AppError } from "../middleware/errorHandler"; +import * as amaranth from "./amaranthApprovalClient"; export interface SalesPurchaseRequestFilter { project_no?: string; @@ -151,7 +154,8 @@ export async function listPurchaseRequestReg( COALESCE(user_name(COALESCE(SRM.REQUEST_USER_ID, SRM.WRITER)), COALESCE(SRM.REQUEST_USER_ID, SRM.WRITER), '') AS request_user_name, - SRM.DELIVERY_REQUEST_DATE AS delivery_request_date, + REPLACE(COALESCE(SRM.DELIVERY_REQUEST_DATE, ''), '.', '-') + AS delivery_request_date, TO_CHAR(SRM.REGDATE, 'YYYY-MM-DD') AS regdate_title, SRM.MBOM_HEADER_OBJID AS mbom_header_objid FROM SALES_REQUEST_MASTER SRM @@ -294,3 +298,537 @@ export async function listPurchaseRegProposal( return { rows: [], totalCount: 0, page, pageSize }; } } + +// ─── 3) 구매요청서 단건 + 라인 조회 ───────────────────────────── +// wace: getSalesRequestMasterInfo + getSalesRequestTargetList +export async function getPurchaseRequestDetail(srmObjid: string) { + const pool = getPool(); + const headRes = await pool.query( + `SELECT + SRM.OBJID, SRM.REQUEST_MNG_NO, SRM.PROJECT_NO, SRM.MBOM_HEADER_OBJID, + SRM.PURCHASE_TYPE, SRM.ORDER_TYPE, SRM.PRODUCT_NAME, SRM.AREA_CD, + SRM.CUSTOMER_OBJID, SRM.PAID_TYPE, SRM.DELIVERY_REQUEST_DATE, + SRM.REQUEST_USER_ID, SRM.WRITER, SRM.STATUS, SRM.DOC_TYPE, + PM.PROJECT_NO AS PROJECT_NUMBER, PM.PROJECT_NAME, + PM.CATEGORY_CD, PM.CONTRACT_OBJID + FROM SALES_REQUEST_MASTER SRM + LEFT JOIN PROJECT_MGMT PM ON PM.OBJID::VARCHAR = SRM.PROJECT_NO + WHERE SRM.OBJID = $1`, + [srmObjid], + ); + if (headRes.rowCount === 0) throw new AppError("구매요청서를 찾을 수 없습니다.", 404); + const partRes = await pool.query( + `SELECT SRP.OBJID, SRP.PART_OBJID, + COALESCE(PM.PART_NO, '') AS PART_NO, + COALESCE(PM.PART_NAME, '') AS PART_NAME, + COALESCE(SRP.QTY, '0') AS QTY, + SRP.ORG_QTY, SRP.PARTNER_OBJID, + COALESCE(SRP.PARTNER_PRICE, '') AS PARTNER_PRICE, + COALESCE(SRP.UNIT_PRICE, 0) AS UNIT_PRICE, + COALESCE(SRP.VENDOR_PM, '') AS VENDOR_PM, + SRP.DELIVERY_REQUEST_DATE, SRP.STATUS, SRP.PROPOSAL_DATE + FROM SALES_REQUEST_PART SRP + LEFT JOIN PART_MNG PM ON PM.OBJID::VARCHAR = SRP.PART_OBJID::VARCHAR + WHERE SRP.SALES_REQUEST_MASTER_OBJID = $1 + ORDER BY SRP.REGDATE`, + [srmObjid], + ); + return { header: headRes.rows[0], parts: partRes.rows }; +} + +// ─── 3-1) 프로젝트 자동채움 정보 (wace purchaseOrderAdminSupplyInfo 1:1) ─ +// 프로젝트 선택 시 주문유형(CATEGORY_CD) · 제품구분(PRODUCT) · 국내/해외(AREA_CD) · +// 고객사(CUSTOMER_OBJID) · 유/무상(PAID_TYPE) 자동 채움 + M-BOM 헤더. +export async function getProjectAutoFillInfo(projectObjid: string) { + const pool = getPool(); + const sql = ` + SELECT + PM.OBJID, + PM.PROJECT_NO, + PM.PROJECT_NAME, + PM.CATEGORY_CD, + (SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = PM.CATEGORY_CD LIMIT 1) AS CATEGORY_NAME, + PM.CUSTOMER_OBJID, + CASE + WHEN PM.CUSTOMER_OBJID LIKE 'C_%' + THEN (SELECT CLIENT_NM FROM CLIENT_MNG WHERE 'C_' || OBJID::VARCHAR = PM.CUSTOMER_OBJID LIMIT 1) + ELSE (SELECT SUPPLY_NAME FROM SUPPLY_MNG WHERE OBJID::VARCHAR = PM.CUSTOMER_OBJID::VARCHAR LIMIT 1) + END AS CUSTOMER_NAME, + PM.PRODUCT, + (SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = PM.PRODUCT LIMIT 1) AS PRODUCT_NAME, + PM.AREA_CD, + (SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = PM.AREA_CD LIMIT 1) AS AREA_NAME, + CM.PAID_TYPE, + CM.OBJID AS CONTRACT_OBJID, + (SELECT MH.OBJID FROM MBOM_HEADER MH + WHERE MH.PROJECT_OBJID::VARCHAR = PM.OBJID::VARCHAR + ORDER BY MH.REGDATE DESC LIMIT 1) AS MBOM_HEADER_OBJID + FROM PROJECT_MGMT PM + LEFT JOIN CONTRACT_MGMT CM ON PM.CONTRACT_OBJID = CM.OBJID::VARCHAR + WHERE PM.OBJID::VARCHAR = $1 + LIMIT 1 + `; + try { + const r = await pool.query(sql, [projectObjid]); + return r.rows[0] || null; + } catch (e: any) { + logger.error("getProjectAutoFillInfo 실패", { error: e.message, projectObjid }); + return null; + } +} + +// ─── 4) 프로젝트별 M-BOM 품목 (구매요청서 신규 작성용) ───────── +// wace: salesMng.SalesBomPartListByProjectUnit (mbom_detail → part_mng) +export async function listMbomPartsForProject(projectObjid: string) { + const pool = getPool(); + const sql = ` + SELECT + MD.OBJID AS mbom_detail_objid, + MD.PART_OBJID AS part_objid, + MH.OBJID AS mbom_header_objid, + COALESCE(PM.PART_NO, '') AS part_no, + COALESCE(PM.PART_NAME, '') AS part_name, + COALESCE(MD.UNIT, '') AS unit, + COALESCE(MD.PRODUCTION_QTY, MD.PO_QTY, 0) AS qty, + COALESCE(MD.UNIT_PRICE, 0) AS unit_price, + COALESCE(MD.VENDOR, '') AS vendor_objid, + CASE + WHEN MD.VENDOR IS NULL OR MD.VENDOR = '' THEN '' + WHEN MD.VENDOR LIKE 'C_%' + THEN (SELECT CLIENT_NM FROM CLIENT_MNG WHERE 'C_' || OBJID::VARCHAR = MD.VENDOR LIMIT 1) + ELSE (SELECT CLIENT_NM FROM CLIENT_MNG WHERE OBJID::VARCHAR = MD.VENDOR LIMIT 1) + END AS vendor_name + FROM MBOM_HEADER MH + JOIN MBOM_DETAIL MD ON MD.MBOM_HEADER_OBJID = MH.OBJID + LEFT JOIN PART_MNG PM ON PM.OBJID::VARCHAR = MD.PART_OBJID::VARCHAR + WHERE MH.PROJECT_OBJID::VARCHAR = $1 + AND COALESCE(MD.USE_YN, 'Y') = 'Y' + ORDER BY MD.REGDATE + `; + try { + const r = await pool.query(sql, [projectObjid]); + return r.rows; + } catch (e: any) { + logger.error("listMbomPartsForProject 실패", { error: e.message, projectObjid }); + return []; + } +} + +// ─── 5) 구매요청서 저장 (UPSERT master + 라인 재생성) ──────────── +// wace: saveSalesRequestInfo → mergeSalesRequestMasterInfo + initSalesRequestPart + mergeSalesRequestPartInfo +// doc_type='PURCHASE_REG' 명시 (구매요청서관리 신규 메뉴 흐름). +// request_mng_no 채번: R + YYYYMMDD + - + 3자리 (wace mergeSalesRequestMasterInfo 1:1) +export interface SavePurchaseRequestPayload { + objid?: string; + project_no?: string; + mbom_header_objid?: string; + purchase_type?: string; + order_type?: string; + product_name?: string; + area_cd?: string; + customer_objid?: string; + paid_type?: string; + delivery_request_date?: string; + parts: Array<{ + objid?: string; + part_objid: string; + part_name?: string; + qty?: string | number; + org_qty?: string | number; + partner_objid?: string; + partner_price?: string | number; + delivery_request_date?: string; + status?: string; + }>; +} +export async function savePurchaseRequest(userId: string, payload: SavePurchaseRequestPayload) { + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + let srmObjid = payload.objid; + const isNew = !srmObjid; + if (isNew) srmObjid = createObjId(); + + // 채번 — wace mergeSalesRequestMasterInfo 의 SELECT 절 1:1 + const nextNoSql = ` + SELECT 'R'||TO_CHAR(NOW(),'YYYYMMDD')||'-'|| + LPAD((COALESCE(MAX(SUBSTR(REQUEST_MNG_NO,11,13)),'0')::INTEGER+1)::TEXT,3,'0') AS no + FROM SALES_REQUEST_MASTER + WHERE DOC_TYPE IN ('PURCHASE_REQUEST','PURCHASE_REG') OR DOC_TYPE IS NULL + `; + let requestMngNo: string | null = null; + if (isNew) { + const noRes = await client.query(nextNoSql); + requestMngNo = noRes.rows[0]?.no ?? null; + } + + if (isNew) { + await client.query( + `INSERT INTO SALES_REQUEST_MASTER + (OBJID, REQUEST_MNG_NO, PROJECT_NO, MBOM_HEADER_OBJID, + PURCHASE_TYPE, ORDER_TYPE, PRODUCT_NAME, AREA_CD, + CUSTOMER_OBJID, PAID_TYPE, DELIVERY_REQUEST_DATE, + REQUEST_USER_ID, STATUS, WRITER, REGDATE, DOC_TYPE) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'create',$12,NOW(),'PURCHASE_REG')`, + [ + srmObjid, requestMngNo, payload.project_no, payload.mbom_header_objid, + payload.purchase_type, payload.order_type, payload.product_name, payload.area_cd, + payload.customer_objid, payload.paid_type, payload.delivery_request_date || null, + userId, + ], + ); + } else { + await client.query( + `UPDATE SALES_REQUEST_MASTER + SET PROJECT_NO=$2, MBOM_HEADER_OBJID=$3, + PURCHASE_TYPE=$4, ORDER_TYPE=$5, PRODUCT_NAME=$6, AREA_CD=$7, + CUSTOMER_OBJID=$8, PAID_TYPE=$9, DELIVERY_REQUEST_DATE=$10 + WHERE OBJID=$1`, + [ + srmObjid, payload.project_no, payload.mbom_header_objid, + payload.purchase_type, payload.order_type, payload.product_name, payload.area_cd, + payload.customer_objid, payload.paid_type, payload.delivery_request_date || null, + ], + ); + } + + // 라인 재생성 (wace initSalesRequestPart + mergeSalesRequestPartInfo) + await client.query(`DELETE FROM SALES_REQUEST_PART WHERE SALES_REQUEST_MASTER_OBJID=$1`, [srmObjid]); + + for (const p of payload.parts || []) { + const partObjid = p.objid || createObjId(); + const qtyVal = p.qty == null ? "0" : String(p.qty); + const orgQtyVal = p.org_qty == null ? "0" : String(p.org_qty); + await client.query( + `INSERT INTO SALES_REQUEST_PART + (OBJID, SALES_REQUEST_MASTER_OBJID, PART_OBJID, PART_NAME, + QTY, ORG_QTY, PARTNER_OBJID, PARTNER_PRICE, + DELIVERY_REQUEST_DATE, WRITER, REGDATE, STATUS) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NOW(),$11)`, + [ + partObjid, srmObjid, p.part_objid, p.part_name || null, + qtyVal, orgQtyVal, p.partner_objid || null, + p.partner_price == null ? null : String(p.partner_price), + p.delivery_request_date || null, + userId, p.status || "create", + ], + ); + } + + await client.query("COMMIT"); + return { objid: srmObjid, request_mng_no: requestMngNo, isNew }; + } catch (e: any) { + await client.query("ROLLBACK"); + logger.error("savePurchaseRequest 실패", { error: e.message }); + throw e; + } finally { + client.release(); + } +} + +// ─── 6) 품의서생성 대상 품목 조회 ────────────────────────────── +// wace: getProposalTargetPartsFromPurchaseReg +// 조건: 단가(UNIT_PRICE 또는 PARTNER_PRICE) > 0 AND 공급업체(VENDOR_PM 또는 PARTNER_OBJID) 입력 AND PROPOSAL_DATE 미입력 +export async function getProposalTargetParts(srmObjid: string) { + const pool = getPool(); + const targetSql = ` + SELECT + SRP.OBJID, SRP.PART_OBJID, + COALESCE(PM.PART_NO, '') AS PART_NO, + COALESCE(PM.PART_NAME, '') AS PART_NAME, + COALESCE(SRP.QTY, '0') AS QTY, + COALESCE(SRP.UNIT_PRICE, NULLIF(SRP.PARTNER_PRICE,'')::NUMERIC, 0) AS UNIT_PRICE, + SRP.TOTAL_PRICE, + COALESCE(SRP.VENDOR_PM, SRP.PARTNER_OBJID, '') AS VENDOR_PM, + CASE + WHEN COALESCE(SRP.VENDOR_PM, SRP.PARTNER_OBJID) IS NULL THEN '' + WHEN COALESCE(SRP.VENDOR_PM, SRP.PARTNER_OBJID) LIKE 'C_%' + THEN (SELECT CLIENT_NM FROM CLIENT_MNG WHERE 'C_' || OBJID::VARCHAR = COALESCE(SRP.VENDOR_PM, SRP.PARTNER_OBJID) LIMIT 1) + ELSE (SELECT CLIENT_NM FROM CLIENT_MNG WHERE OBJID::VARCHAR = COALESCE(SRP.VENDOR_PM, SRP.PARTNER_OBJID) LIMIT 1) + END AS VENDOR_NAME + FROM SALES_REQUEST_PART SRP + LEFT JOIN PART_MNG PM ON SRP.PART_OBJID::VARCHAR = PM.OBJID::VARCHAR + WHERE SRP.SALES_REQUEST_MASTER_OBJID = $1 + AND ( + (SRP.UNIT_PRICE IS NOT NULL AND SRP.UNIT_PRICE > 0) + OR (SRP.PARTNER_PRICE IS NOT NULL AND SRP.PARTNER_PRICE <> '' AND SRP.PARTNER_PRICE::NUMERIC > 0) + ) + AND ( + (SRP.VENDOR_PM IS NOT NULL AND SRP.VENDOR_PM <> '') + OR (SRP.PARTNER_OBJID IS NOT NULL AND SRP.PARTNER_OBJID <> '') + ) + AND SRP.PROPOSAL_DATE IS NULL + ORDER BY SRP.REGDATE + `; + const excludedSql = ` + SELECT + SRP.OBJID, SRP.PART_OBJID, + COALESCE(PM.PART_NO,'') AS PART_NO, + COALESCE(PM.PART_NAME,'') AS PART_NAME, + COALESCE(SRP.QTY,'0') AS QTY + FROM SALES_REQUEST_PART SRP + LEFT JOIN PART_MNG PM ON SRP.PART_OBJID::VARCHAR = PM.OBJID::VARCHAR + WHERE SRP.SALES_REQUEST_MASTER_OBJID = $1 + AND ( + (SRP.UNIT_PRICE IS NOT NULL AND SRP.UNIT_PRICE > 0) + OR (SRP.PARTNER_PRICE IS NOT NULL AND SRP.PARTNER_PRICE <> '' AND SRP.PARTNER_PRICE::NUMERIC > 0) + ) + AND (SRP.VENDOR_PM IS NULL OR SRP.VENDOR_PM = '') + AND (SRP.PARTNER_OBJID IS NULL OR SRP.PARTNER_OBJID = '') + AND SRP.PROPOSAL_DATE IS NULL + ORDER BY SRP.REGDATE + `; + const [t, x] = await Promise.all([pool.query(targetSql, [srmObjid]), pool.query(excludedSql, [srmObjid])]); + return { targets: t.rows, excluded: x.rows }; +} + +// ─── 7) 품의서 생성 (PURCHASE_REG → PURCHASE_REG_PROPOSAL) ───── +// wace: createProposalFromPurchaseReg +// 1) request_mng_no 채번 (P + YYYYMMDD + - + 3자리) +// 2) sales_request_master INSERT (DOC_TYPE='PURCHASE_REG_PROPOSAL', PROJECT_NO=원본 OBJID, STATUS='create') +// 3) 선택된 SRP 행을 새 master 로 복사 (UNIT_PRICE/VENDOR_PM 보정, PROPOSAL_DATE=NOW) +// 4) 원본 SRP.PROPOSAL_DATE = NOW (재생성 방지) +export async function createProposalFromPurchaseReg(userId: string, srmObjid: string) { + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // 1) 원본 master 정보 조회 + const masterRes = await client.query( + `SELECT OBJID, PROJECT_NO, PURCHASE_TYPE, ORDER_TYPE, PRODUCT_NAME, AREA_CD, + CUSTOMER_OBJID, PAID_TYPE, DELIVERY_REQUEST_DATE, MBOM_HEADER_OBJID, DOC_TYPE + FROM SALES_REQUEST_MASTER WHERE OBJID=$1`, + [srmObjid], + ); + if (masterRes.rowCount === 0) throw new AppError("구매요청서를 찾을 수 없습니다.", 404); + const m = masterRes.rows[0]; + if (m.doc_type && m.doc_type !== "PURCHASE_REG" && m.doc_type !== "PURCHASE_REQUEST") { + throw new AppError("구매요청서만 품의서를 생성할 수 있습니다.", 400); + } + + // 2) 대상 품목 조회 (위 getProposalTargetParts 와 동일 SQL — 트랜잭션 내 client 재사용) + const partsRes = await client.query( + `SELECT SRP.OBJID, SRP.PART_OBJID, SRP.QTY, SRP.UNIT_PRICE, SRP.TOTAL_PRICE, + SRP.PARTNER_PRICE, SRP.VENDOR_PM, SRP.PARTNER_OBJID, SRP.NET_QTY, SRP.USE_YN + FROM SALES_REQUEST_PART SRP + WHERE SRP.SALES_REQUEST_MASTER_OBJID = $1 + AND ( + (SRP.UNIT_PRICE IS NOT NULL AND SRP.UNIT_PRICE > 0) + OR (SRP.PARTNER_PRICE IS NOT NULL AND SRP.PARTNER_PRICE <> '' AND SRP.PARTNER_PRICE::NUMERIC > 0) + ) + AND ( + (SRP.VENDOR_PM IS NOT NULL AND SRP.VENDOR_PM <> '') + OR (SRP.PARTNER_OBJID IS NOT NULL AND SRP.PARTNER_OBJID <> '') + ) + AND SRP.PROPOSAL_DATE IS NULL + ORDER BY SRP.REGDATE`, + [srmObjid], + ); + if (partsRes.rowCount === 0) { + throw new AppError("품의서 생성 대상 품목이 없습니다. (단가+공급업체 입력 + 품의서 미생성 품목만 대상)", 400); + } + + // 3) 품의서 채번 + const proposalNoRes = await client.query( + `SELECT 'P'||TO_CHAR(NOW(),'YYYYMMDD')||'-'|| + LPAD((COALESCE(MAX(SUBSTR(REQUEST_MNG_NO,11,3))::INTEGER, 0)+1)::TEXT,3,'0') AS no + FROM SALES_REQUEST_MASTER + WHERE DOC_TYPE IN ('PROPOSAL','PURCHASE_REG_PROPOSAL') + AND REQUEST_MNG_NO LIKE 'P'||TO_CHAR(NOW(),'YYYYMMDD')||'%'`, + ); + const proposalNo = proposalNoRes.rows[0]?.no; + + // 4) 품의서 master INSERT (PROJECT_NO=원본 OBJID — 자식 관계 추적) + const proposalObjid = createObjId(); + await client.query( + `INSERT INTO SALES_REQUEST_MASTER + (OBJID, REQUEST_MNG_NO, PROJECT_NO, PURCHASE_TYPE, ORDER_TYPE, PRODUCT_NAME, + AREA_CD, CUSTOMER_OBJID, PAID_TYPE, REQUEST_USER_ID, DELIVERY_REQUEST_DATE, + STATUS, WRITER, REGDATE, DOC_TYPE) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'create',$10,NOW(),'PURCHASE_REG_PROPOSAL')`, + [ + proposalObjid, proposalNo, m.objid, + m.purchase_type, m.order_type, m.product_name, + m.area_cd, m.customer_objid, m.paid_type, userId, + m.delivery_request_date, + ], + ); + + // 5) 품의서 part INSERT (원본 SRP 복사, PROPOSAL_DATE=NOW) + const sourceObjids: string[] = []; + for (const p of partsRes.rows) { + sourceObjids.push(p.objid); + const newPartObjid = createObjId(); + await client.query( + `INSERT INTO SALES_REQUEST_PART + (OBJID, SALES_REQUEST_MASTER_OBJID, PART_OBJID, QTY, UNIT_PRICE, TOTAL_PRICE, + VENDOR_PM, NET_QTY, PO_QTY, USE_YN, PROPOSAL_DATE, WRITER, REGDATE) + VALUES ($1, $2, $3, $4, + CASE + WHEN $5::NUMERIC IS NOT NULL AND $5::NUMERIC > 0 THEN $5::NUMERIC + WHEN $6 IS NOT NULL AND $6 <> '' THEN $6::NUMERIC + ELSE 0 + END, + CASE + WHEN $7::NUMERIC IS NOT NULL AND $7::NUMERIC > 0 THEN $7::NUMERIC + ELSE COALESCE(NULLIF($4,'')::NUMERIC,0) * + CASE + WHEN $5::NUMERIC IS NOT NULL AND $5::NUMERIC > 0 THEN $5::NUMERIC + WHEN $6 IS NOT NULL AND $6 <> '' THEN $6::NUMERIC + ELSE 0 + END + END, + COALESCE(NULLIF($8,''), $9), + $10, + COALESCE(NULLIF($4,'')::NUMERIC, 0), + $11, + NOW(), $12, NOW())`, + [ + newPartObjid, proposalObjid, p.part_objid, p.qty, + p.unit_price, p.partner_price, p.total_price, + p.vendor_pm, p.partner_objid, p.net_qty, + p.use_yn || "Y", userId, + ], + ); + } + + // 6) 원본 SRP.PROPOSAL_DATE 업데이트 (재생성 방지) + await client.query( + `UPDATE SALES_REQUEST_PART SET PROPOSAL_DATE = NOW() WHERE OBJID = ANY($1::varchar[])`, + [sourceObjids], + ); + + await client.query("COMMIT"); + logger.info("품의서 생성", { srmObjid, proposalObjid, proposalNo, partCount: sourceObjids.length }); + return { proposal_objid: proposalObjid, proposal_no: proposalNo, part_count: sourceObjids.length }; + } catch (e: any) { + await client.query("ROLLBACK"); + if (e instanceof AppError) throw e; + logger.error("createProposalFromPurchaseReg 실패", { error: e.message }); + throw new AppError(`품의서 생성 실패: ${e.message}`, 500); + } finally { + client.release(); + } +} + +// ─── 8) 품의서 결재상신 (Amaranth SSO) ────────────────────────── +// wace: purchaseRegProposalMngList.jsp:75~99 fn_openAmaranthApproval + ApprovalService.getAmaranthSsoUrl +// target_type='PROPOSAL', formId='1163', compSeq='1000' +// 재상신/재사용 로직은 G11(견적) 동일 패턴. +export async function startProposalApproval( + userId: string, + proposalSrmObjid: string, + opts: { approvalTitle?: string; subjectStr?: string } = {}, +): Promise<{ fullUrl: string; approKey: string; status: string; proposalObjid: string }> { + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const userRes = await client.query( + `SELECT user_id, user_name, emp_seq FROM user_info WHERE user_id=$1 LIMIT 1`, + [userId], + ); + const u = userRes.rows[0]; + if (!u) throw new AppError("사용자 정보를 찾을 수 없습니다.", 401); + const empSeq = String(u.emp_seq ?? "").trim(); + if (!empSeq) throw new AppError(`empSeq 정보가 없습니다. (userId: ${userId}) 관리자에게 문의하세요.`, 400); + + const headRes = await client.query( + `SELECT OBJID, REQUEST_MNG_NO, DOC_TYPE, STATUS + FROM SALES_REQUEST_MASTER WHERE OBJID=$1`, + [proposalSrmObjid], + ); + if (headRes.rowCount === 0) throw new AppError("품의서를 찾을 수 없습니다.", 404); + const head = headRes.rows[0]; + if (head.doc_type !== "PURCHASE_REG_PROPOSAL" && head.doc_type !== "PROPOSAL") { + throw new AppError("품의서만 결재상신할 수 있습니다.", 400); + } + + const targetType = "PROPOSAL"; + const targetObjid = String(proposalSrmObjid); + const approvalTitle = opts.approvalTitle || `품의서 결재${head.request_mng_no ? " - " + head.request_mng_no : ""}`; + const outProcessCode = + process.env.AMARANTH_OUT_PROCESS_CODE_PROPOSAL || + process.env.AMARANTH_OUT_PROCESS_CODE || ""; + const formId = process.env.AMARANTH_FORM_ID_PROPOSAL || "1163"; + const compSeq = process.env.AMARANTH_COMP_SEQ || "1000"; + + const existRes = await client.query( + `SELECT objid, appro_key, status FROM amaranth_approval + WHERE target_type=$1 AND target_objid=$2 + ORDER BY regdate DESC LIMIT 1`, + [targetType, targetObjid], + ); + + let approKey: string; + let mode: "insert" | "update_resubmit" | "update_reuse"; + let existingObjid: number | null = null; + + if (existRes.rowCount === 0) { + approKey = "UB_" + Date.now().toString(36).toUpperCase(); + mode = "insert"; + } else { + existingObjid = existRes.rows[0].objid; + const existingStatus = String(existRes.rows[0].status || ""); + if (["reject", "delete", "create"].includes(existingStatus)) { + approKey = "UB_" + Date.now().toString(36).toUpperCase(); + mode = "update_resubmit"; + } else { + approKey = String(existRes.rows[0].appro_key || ""); + mode = "update_reuse"; + } + } + + const ssoRes = await amaranth.getSsoUrl({ + empSeq, + outProcessCode: outProcessCode || undefined, + formId, + approKey, + subjectStr: opts.subjectStr || approvalTitle, + mod: "W", + compSeq, + deptSeq: "", + loginId: u.user_id, + }); + + const fullUrl: string = ssoRes?.resultData?.fullUrl || ssoRes?.fullUrl || ""; + const resultCode = String(ssoRes?.resultCode ?? ssoRes?.resultData?.resultCode ?? ""); + if (!fullUrl || (resultCode !== "0" && resultCode !== "")) { + const msg = ssoRes?.resultMsg || ssoRes?.resultData?.resultMsg || "SSO URL 생성 실패"; + throw new AppError(`결재 연동 오류: ${msg}`, 502); + } + + if (mode === "insert") { + const objid = Date.now(); + await client.query( + `INSERT INTO amaranth_approval + (objid, target_objid, target_type, appro_key, out_process_code, form_id, + status, emp_seq, comp_seq, dept_seq, writer, sso_url, regdate) + VALUES ($1, $2, $3, $4, $5, $6, 'create', $7, $8, '', $9, $10, NOW())`, + [objid, targetObjid, targetType, approKey, outProcessCode || null, formId, empSeq, compSeq, userId, fullUrl], + ); + } else { + const resetStatus = mode === "update_resubmit" ? "create" : null; + await client.query( + `UPDATE amaranth_approval + SET appro_key=$2, sso_url=$3, writer=$4, + status=COALESCE($5, status), + editdate=NOW() + WHERE objid=$1`, + [existingObjid, approKey, fullUrl, userId, resetStatus], + ); + } + + await client.query("COMMIT"); + logger.info("품의서 결재상신", { proposalSrmObjid, approKey, mode }); + return { fullUrl, approKey, status: "create", proposalObjid: targetObjid }; + } catch (e) { + await client.query("ROLLBACK"); + throw e; + } finally { + client.release(); + } +} diff --git a/docs/migration/README.md b/docs/migration/README.md index fdcba2ed..42ea63e0 100644 --- a/docs/migration/README.md +++ b/docs/migration/README.md @@ -10,8 +10,8 @@ | 상태 | 메뉴 수 | 의미 | |---|---|---| -| ✅ 완료 | 17 | wace 1:1 검증 PASS 또는 마이너 차이만 (기능/SQL 일치) | -| 🟡 베이스 | 7 | 그리드/검색 완료, 액션 모달 또는 detail SQL 일부 미진 | +| ✅ 완료 | 19 | wace 1:1 검증 PASS 또는 마이너 차이만 (기능/SQL 일치) | +| 🟡 베이스 | 5 | 그리드/검색 완료, 액션 모달 또는 detail SQL 일부 미진 | | 🟠 빈 그리드 | 3 | 화면은 있으나 데이터 SQL 미연결 (의존 테이블 DDL 추출 선행 필요) | | 🔴 미진 | 0 | — | @@ -23,8 +23,8 @@ | 2 | 영업관리 | 주문서관리 | `sales/order` | ✅ | — | | 3 | 영업관리 | 판매관리 | `sales/sale` | ✅ | — | | 4 | 영업관리 | 매출관리 | `sales/revenue` | ✅ | — | -| 5 | 영업/구매요청 | 구매요청서관리 | `purchase-request/request` (이동 중) | 🟡 | 구매요청서작성 다이얼로그 · 품의서생성 액션 | -| 6 | 영업/구매요청 | 품의서관리(영업) | `purchase-request/proposal` (이동 중) | 🟡 | Amaranth 결재상신 (`target_type='PROPOSAL'`, `formId='1163'`) | +| 5 | 영업/구매요청 | 구매요청서관리 | `purchase-request/request` | ✅ | — | +| 6 | 영업/구매요청 | 품의서관리(영업) | `purchase-request/proposal` | ✅ | — | | 7 | 프로젝트관리 | 진행관리 | `project/progress` | ✅ | — | | 8 | 프로젝트관리 | 제품구분_WBS관리 | `project/wbs-template` | ✅ | — | | 9 | 개발관리 | PART 등록 | `development/part-regist` | ✅ | — | @@ -52,7 +52,7 @@ | 도메인 | 메뉴 | 마감도 | 대표 커밋 | 상세 문서 | |---|---|---|---|---| | **영업관리** | 4 | 100% (G6 메일 발송까지) | (다수) | [sales/README.md](./sales/README.md) | -| **구매요청** (영업↔구매 교차) | 2 | 베이스 (액션 모달 미진) | `7e7c6a0a` | [sales/09-purchase-request.md](./sales/09-purchase-request.md) | +| **구매요청** (영업↔구매 교차) | 2 | 100% (작성·품의서생성·결재상신 SSO 완료) | `7e7c6a0a` + 본 작업 | [sales/09-purchase-request.md](./sales/09-purchase-request.md) | | **프로젝트관리** | 2 | 100% | `a1ace226` / `332688a4` / `7c4817b0` / `50669a66` | [project/00-gap.md](./project/00-gap.md) | | **개발관리** | 5 | 100% + Import + 도면 다중 업로드 (16 커밋) | (PR-A/B/C 다수) | [development/00-gap.md](./development/00-gap.md) | | **구매관리** | 9 | 1차 스캐폴드 (마스터 3 데이터 노출 / detail 4 빈 그리드 / 발주·M-BOM 완료) | `b38f5957` | (메모리만) | @@ -109,12 +109,8 @@ ## 6. 다음 작업 우선순위 (제안) -1. **구매요청 2메뉴 액션 완성** — 영업↔구매 교차 핵심 - - 구매요청서작성 다이얼로그 - - 품의서생성 액션 - - 결재상신 (`target_type='PROPOSAL'`, `formId='1163'`) - - 선행: `sales_request_part` 운영DB DDL 추출 -2. **구매관리 빈 그리드 4개 보강** — `sales_request_part` 추출 후 quote-request / inbound 3종 detail SQL +1. ~~**구매요청 2메뉴 액션 완성**~~ — ✅ 2026-05-15 완료 ([sales/09-purchase-request.md §6](./sales/09-purchase-request.md)) +2. **구매관리 빈 그리드 4개 보강** — `sales_request_part` 추출 완료, quote-request / inbound 3종 detail SQL 연결만 남음 3. **plan-result 액션 모달** — `prodPlanFormPopup.jsp` / `prodResultFormPopup.jsp` 1:1 4. **공통 PartSelect 컴포넌트** — wace `Select2-part`(AJAX 자동완성), 영업/생산/구매 다수 메뉴 공통 5. **품질관리 후속** (chpark 베이스 4메뉴 상세화) / **자재관리 신규 도메인 진입** diff --git a/docs/migration/sales/09-purchase-request.md b/docs/migration/sales/09-purchase-request.md index 71342799..4fc45d1e 100644 --- a/docs/migration/sales/09-purchase-request.md +++ b/docs/migration/sales/09-purchase-request.md @@ -113,14 +113,50 @@ wace 매퍼 `salesMng.xml:4805~4812` 1:1. ### 5.3 sales_request_part 누락 처리 wace 원본은 `SALES_REQUEST_PART` (구매요청 라인) 테이블을 사용해 품번/품명 집계. RPS 에는 미존재 → **MBOM_DETAIL → PART_MNG fallback** (구매관리 패턴 동일). 운영DB DDL 추출 후 전환 예정. 메모리 [feedback_missing_tables_workflow](../../../../.claude/projects/-Users-jhj-vexplor-rps/memory/feedback_missing_tables_workflow.md). -## 6. 백로그 (placeholder 상태) +## 6. 액션 구현 (2026-05-15 완료) -| 우선 | 항목 | 원본 위치 | 권장 작업 | -|---|---|---|---| -| 🟠 | **구매요청서작성 다이얼로그** | wace `salesRequestFormPopUp.jsp` + `purchaseListFormPopUp.jsp` | M-BOM 선택 → sales_request_master INSERT (DOC_TYPE='PURCHASE_REG') + 라인 입력 (sales_request_part 신설 후) | -| 🟠 | **품의서생성** | wace `salesMng/createProposalFromPurchaseReg.do` + `getProposalTargetPartsFromPurchaseReg.do` | 선택된 구매요청서 → 단가+공급업체 입력된 품목만 필터 → 새 sales_request_master INSERT (DOC_TYPE='PURCHASE_REG_PROPOSAL', PROJECT_NO=원본 OBJID, STATUS='create') | -| 🟠 | **결재상신 (Amaranth SSO)** | wace `purchaseRegProposalMngList.jsp:75~99` + `getAmaranthSsoUrl` | target_type=`PROPOSAL`, formId=`1163`, compSeq=`1000`. 기존 견적/수주 결재상신 패턴(G11/G11E) 재사용. `AMARANTH_OUT_PROCESS_CODE_PROPOSAL` 환경변수 신설 | -| 🟡 | sales_request_part 신설 | 운영DB 211.115.91.141:11133 | DDL 추출 → `db/migrations/NNN_create_sales_request_part.sql` | +| 항목 | 상태 | 구현 위치 | +|---|---|---| +| sales_request_part 신설 | ✅ | `docs/migration/sales/ddl-extracted/106_create_sales_request_part.sql` (운영 DDL 1:1, RPS 적용 완료) | +| 구매요청서작성 다이얼로그 | ✅ | `components/sales/PurchaseRequestFormDialog.tsx` + 백엔드 `POST /api/sales/purchase-request` (savePurchaseRequest) | +| 품의서생성 액션 | ✅ | `components/sales/ProposalCreateDialog.tsx` + 백엔드 `POST /api/sales/purchase-request/:objid/proposal` (createProposalFromPurchaseReg) | +| 결재상신 (Amaranth SSO) | ✅ | 백엔드 `POST /api/sales/purchase-proposal/:objid/approval` (startProposalApproval) — target_type='PROPOSAL', formId=`AMARANTH_FORM_ID_PROPOSAL` 기본 `'1163'` | + +### 6.1 신규 백엔드 엔드포인트 + +| Method | Path | 용도 | +|---|---|---| +| GET | `/api/sales/purchase-request/mbom-parts?project_objid=…` | 프로젝트별 M-BOM 품목 (다이얼로그 자동 채움) | +| GET | `/api/sales/purchase-request/:objid` | 헤더 + 라인 단건 | +| GET | `/api/sales/purchase-request/:objid/proposal-targets` | 품의서 대상 품목 + 제외 품목 | +| POST | `/api/sales/purchase-request` | 신규/수정 UPSERT (라인 재생성) | +| POST | `/api/sales/purchase-request/:objid/proposal` | 품의서 생성 (PURCHASE_REG → PURCHASE_REG_PROPOSAL) | +| POST | `/api/sales/purchase-proposal/:objid/approval` | Amaranth SSO 결재상신 (TARGET_TYPE='PROPOSAL') | + +### 6.2 환경변수 (신규) + +| 키 | 기본값 | 비고 | +|---|---|---| +| `AMARANTH_FORM_ID_PROPOSAL` | `1163` | 품의서 결재 폼 ID | +| `AMARANTH_OUT_PROCESS_CODE_PROPOSAL` | — | 미설정 시 공통 `AMARANTH_OUT_PROCESS_CODE` fallback | +| `AMARANTH_COMP_SEQ` | `1000` | 영업 메뉴 전체 공통 | + +### 6.3 데이터 흐름 (확정) + +``` +[구매요청서관리] [영업>품의서관리] [구매>품의서관리] +DOC_TYPE='PURCHASE_REG' DOC_TYPE='PURCHASE_REG_PROPOSAL' 결재완료 시 자동 노출 +SRM + SRP (라인) SRM(PROJECT_NO=원본 OBJID) + SRP(PROPOSAL_DATE=NOW) + + + [품의서생성] 액션 [발주서생성] + (단가+공급업체 입력 라인만) (기존 구현) + + [결재상신] Amaranth SSO + TARGET_TYPE='PROPOSAL', formId=1163 +``` + +부모-자식 연결: 품의서 SRM 의 `PROJECT_NO` 컬럼이 원본 구매요청서 SRM 의 `OBJID`(varchar)를 담음. +원본 SRP 행의 `PROPOSAL_DATE` 가 채워지면 재생성 대상에서 제외. ## 7. 1차 스캐폴드 커밋 diff --git a/docs/migration/sales/ddl-extracted/106_create_sales_request_part.sql b/docs/migration/sales/ddl-extracted/106_create_sales_request_part.sql new file mode 100644 index 00000000..214bd1e3 --- /dev/null +++ b/docs/migration/sales/ddl-extracted/106_create_sales_request_part.sql @@ -0,0 +1,45 @@ +-- ==================================================================== +-- sales_request_part — 구매요청서/품의서 라인 +-- ==================================================================== +-- 출처: wace_plm 운영 DB 211.115.91.141:11133/waceplm (PG 16.8) +-- 추출일: 2026-05-15 +-- 부모: sales_request_master (doc_type 으로 갈래 분기) +-- PURCHASE_REG → 구매요청서 라인 (단가/공급업체 입력 후 품의서 생성 대상) +-- PURCHASE_REG_PROPOSAL → 품의서 라인 (PROPOSAL_DATE 가 채워진 사본) +-- ==================================================================== + +CREATE TABLE IF NOT EXISTS sales_request_part ( + objid VARCHAR NOT NULL, + sales_bom_qty_objid VARCHAR, + part_objid VARCHAR, + sales_request_master_objid VARCHAR, + qty VARCHAR, + partner_objid VARCHAR, + partner_price VARCHAR, + delivery_request_date VARCHAR, + writer VARCHAR, + regdate TIMESTAMP, + status VARCHAR, + remark VARCHAR, + order_qty VARCHAR, + org_qty VARCHAR, + spec VARCHAR, + part_name VARCHAR, + use_yn VARCHAR(1) DEFAULT 'Y', + net_qty NUMERIC DEFAULT 0, + po_qty NUMERIC DEFAULT 0, + unit_price NUMERIC DEFAULT 0, + total_price NUMERIC DEFAULT 0, + proposal_date DATE, + vendor_pm VARCHAR(50), + unit VARCHAR(50), + processing_vendor VARCHAR(50), + processing_proposal_date DATE, + production_qty NUMERIC(15,4), + material_yn VARCHAR(1) DEFAULT 'N', + currency VARCHAR(50), + CONSTRAINT sales_request_part_pkey PRIMARY KEY (objid) +); + +CREATE INDEX IF NOT EXISTS idx_srp_master ON sales_request_part (sales_request_master_objid); +CREATE INDEX IF NOT EXISTS idx_srp_part ON sales_request_part (part_objid); diff --git a/frontend/app/(main)/COMPANY_16/purchase-request/proposal/page.tsx b/frontend/app/(main)/COMPANY_16/purchase-request/proposal/page.tsx index 3677bd29..1ebcad14 100644 --- a/frontend/app/(main)/COMPANY_16/purchase-request/proposal/page.tsx +++ b/frontend/app/(main)/COMPANY_16/purchase-request/proposal/page.tsx @@ -123,7 +123,9 @@ export default function PurchaseRegProposalPage() { const handleSearch = () => { setFilter(f => ({ ...f, page: 1 })); fetchList({ page: 1 }); }; const handleReset = () => { setFilter(EMPTY_FILTER); fetchList(EMPTY_FILTER); }; - const onApproval = () => { + const [approvalLoading, setApprovalLoading] = useState(false); + + const onApproval = async () => { if (checkedIds.length !== 1) { toast.info("결재상신할 1건을 선택해주세요."); return; @@ -132,7 +134,26 @@ export default function PurchaseRegProposalPage() { if (!sel) return; if (sel.status === "inProcess") return toast.info("결재 진행중인 건은 상신할 수 없습니다."); if (sel.status === "approvalComplete") return toast.info("결재 완료된 건은 상신할 수 없습니다."); - toast.info("결재상신 기능은 준비 중입니다."); + + setApprovalLoading(true); + try { + const res = await salesPurchaseRequestApi.startApproval(sel.objid, { + approvalTitle: `품의서 결재 - ${sel.proposal_no}`, + subjectStr: `품의서 결재 - ${sel.proposal_no}`, + }); + if (!res?.fullUrl) { + toast.error("결재 SSO URL을 받지 못했습니다."); + return; + } + window.open(res.fullUrl, "approvalPopup", "width=900,height=900"); + toast.success("결재 화면을 새 창으로 열었습니다."); + // 사용자가 결재상신 완료 후 새로고침해야 status 반영 + setTimeout(() => fetchList(), 500); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "결재상신 실패"); + } finally { + setApprovalLoading(false); + } }; return ( @@ -142,9 +163,9 @@ export default function PurchaseRegProposalPage() { loading={loading} onSearch={handleSearch} onReset={handleReset} actions={<> } /> diff --git a/frontend/app/(main)/COMPANY_16/purchase-request/request/page.tsx b/frontend/app/(main)/COMPANY_16/purchase-request/request/page.tsx index 5aea142f..60c2b1d9 100644 --- a/frontend/app/(main)/COMPANY_16/purchase-request/request/page.tsx +++ b/frontend/app/(main)/COMPANY_16/purchase-request/request/page.tsx @@ -21,6 +21,8 @@ import { SalesPurchaseRequestFilter, } from "@/lib/api/salesPurchaseRequest"; import { exportToExcel } from "@/lib/utils/excelExport"; +import { PurchaseRequestFormDialog } from "@/components/sales/PurchaseRequestFormDialog"; +import { ProposalCreateDialog } from "@/components/sales/ProposalCreateDialog"; const PARENT_PURCHASE_TYPE = "0001814"; // 구매유형 const PARENT_PART_TYPE = "0000001"; // 제품구분 @@ -38,6 +40,8 @@ export default function PurchaseRequestRegPage() { const [loading, setLoading] = useState(false); const [filter, setFilter] = useState(EMPTY_FILTER); const [checkedIds, setCheckedIds] = useState([]); + const [formOpen, setFormOpen] = useState(false); + const [proposalOpen, setProposalOpen] = useState(false); const [purchaseTypeOpts, setPurchaseTypeOpts] = useState([]); const [partTypeOpts, setPartTypeOpts] = useState([]); @@ -116,18 +120,29 @@ export default function PurchaseRequestRegPage() { const handleSearch = () => { setFilter(f => ({ ...f, page: 1 })); fetchList({ page: 1 }); }; const handleReset = () => { setFilter(EMPTY_FILTER); fetchList(EMPTY_FILTER); }; + const selectedSrm = useMemo(() => { + if (checkedIds.length !== 1) return null; + return gridRows.find((r: any) => r.id === checkedIds[0]) ?? null; + }, [checkedIds, gridRows]); + + const handleProposal = () => { + if (!selectedSrm) return toast.info("품의서를 생성할 1건을 선택해주세요."); + if (selectedSrm.status_title === "품의서생성") return toast.info("이미 품의서가 생성된 항목입니다."); + setProposalOpen(true); + }; + return (
} @@ -197,6 +212,22 @@ export default function PurchaseRequestRegPage() { }} showChart /> + + setFormOpen(false)} + onSaved={() => { fetchList(); setCheckedIds([]); }} + /> + + {selectedSrm && ( + setProposalOpen(false)} + srmObjid={selectedSrm.objid} + requestMngNo={selectedSrm.request_mng_no} + onCreated={() => { fetchList(); setCheckedIds([]); }} + /> + )}
); } diff --git a/frontend/components/common/SmartSelect.tsx b/frontend/components/common/SmartSelect.tsx index 757353c6..184a6b13 100644 --- a/frontend/components/common/SmartSelect.tsx +++ b/frontend/components/common/SmartSelect.tsx @@ -48,6 +48,7 @@ export function SmartSelect({ }: SmartSelectProps) { const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); + const [activeIndex, setActiveIndex] = useState(0); const scrollRef = useRef(null); // code가 비어있는 옵션은 자동 제외 (Radix Select value 제약 + key 중복 방지) @@ -87,6 +88,73 @@ export function SmartSelect({ return () => cancelAnimationFrame(id); }, [open, virtualizer, filtered.length]); + // 팝오버 열릴 때 현재 선택값 위치로 활성 인덱스 초기화 (없으면 0) + useEffect(() => { + if (!open) return; + const idx = filtered.findIndex((o) => o.code === value); + setActiveIndex(idx >= 0 ? idx : 0); + // 의도적으로 filtered.length 변화 시에도 재계산 안 함 (검색 입력 중 0번 유지) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + // 검색어가 바뀌면 첫 항목으로 리셋 + useEffect(() => { + if (open) setActiveIndex(0); + }, [search, open]); + + // 활성 인덱스가 바뀌면 가시 영역으로 스크롤 + useEffect(() => { + if (!open) return; + if (activeIndex < 0 || activeIndex >= filtered.length) return; + virtualizer.scrollToIndex(activeIndex, { align: "auto" }); + }, [activeIndex, open, virtualizer, filtered.length]); + + const onSearchKeyDown = (e: React.KeyboardEvent) => { + if (filtered.length === 0) { + if (e.key === "Escape") { e.preventDefault(); setOpen(false); } + return; + } + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setActiveIndex((i) => Math.min(filtered.length - 1, (i < 0 ? -1 : i) + 1)); + break; + case "ArrowUp": + e.preventDefault(); + setActiveIndex((i) => Math.max(0, (i < 0 ? 0 : i) - 1)); + break; + case "Home": + e.preventDefault(); + setActiveIndex(0); + break; + case "End": + e.preventDefault(); + setActiveIndex(filtered.length - 1); + break; + case "PageDown": + e.preventDefault(); + setActiveIndex((i) => Math.min(filtered.length - 1, (i < 0 ? 0 : i) + 8)); + break; + case "PageUp": + e.preventDefault(); + setActiveIndex((i) => Math.max(0, (i < 0 ? 0 : i) - 8)); + break; + case "Enter": { + e.preventDefault(); + const hit = filtered[activeIndex]; + if (hit) { + onValueChange(hit.code); + setOpen(false); + } + break; + } + case "Escape": + e.preventDefault(); + setOpen(false); + break; + } + }; + const showClear = clearable && !disabled && !!value; // Radix Select/Popover trigger는 onPointerDown으로 열린다 → 같은 단계에서 차단해야 X 클릭이 trigger를 안 깨움 const stopAndClear = (e: React.PointerEvent | React.MouseEvent) => { @@ -162,6 +230,7 @@ export function SmartSelect({ placeholder="검색..." value={search} onChange={(e) => setSearch(e.target.value)} + onKeyDown={onSearchKeyDown} className="h-9 border-0 px-1 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0" /> @@ -183,17 +252,22 @@ export function SmartSelect({ {virtualizer.getVirtualItems().map((vItem) => { const o = filtered[vItem.index]; const isSelected = value === o.code; + const isActive = activeIndex === vItem.index; return ( + + + + + ); +} + +function Section({ title, emptyMsg, children }: { title: React.ReactNode; emptyMsg: string; children: React.ReactNode }) { + const hasChildren = React.Children.count(children) > 0; + return ( +
+
{title}
+
+ {hasChildren ? children : ( +
{emptyMsg}
+ )} +
+
+ ); +} + +function fmt(n: any) { + const v = Number(n ?? 0); + return v.toLocaleString(); +} +function fmtMoney(n: any) { + const v = Number(n ?? 0); + return v.toLocaleString("ko-KR", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +} diff --git a/frontend/components/sales/PurchaseRequestFormDialog.tsx b/frontend/components/sales/PurchaseRequestFormDialog.tsx new file mode 100644 index 00000000..10b6bb2c --- /dev/null +++ b/frontend/components/sales/PurchaseRequestFormDialog.tsx @@ -0,0 +1,335 @@ +"use client"; + +// 영업관리 > 구매요청서관리 — 구매요청서작성 다이얼로그 +// wace 1:1: salesRequestFormPopUp.jsp +// - 프로젝트 선택 → purchaseOrderAdminSupplyInfo: 주문유형/제품구분/국내외/고객사/유무상 자동 채움 +// - 행추가: 품번 SmartSelect (해당 프로젝트 M-BOM 품목) → 선택 시 품명/공급업체/단가 자동 셋팅 + +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Plus, Trash2, Save, X } from "lucide-react"; +import { toast } from "sonner"; +import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect"; +import { CommCodeSelect } from "@/components/common/CommCodeSelect"; +import { CustomerSelect } from "@/components/common/CustomerSelect"; +import { purchaseApi, OptionItem } from "@/lib/api/purchase"; +import { + salesPurchaseRequestApi, + MbomPartItem, + PurchaseRequestPartInput, +} from "@/lib/api/salesPurchaseRequest"; + +interface Props { + open: boolean; + onClose: () => void; + onSaved: () => void; +} + +interface FormState { + project_no: string; // PROJECT_MGMT.OBJID + mbom_header_objid: string; + purchase_type: string; + order_type: string; + product_name: string; + area_cd: string; + customer_objid: string; + paid_type: string; + delivery_request_date: string; +} + +interface PartRow extends PurchaseRequestPartInput { + rowKey: string; + part_no?: string; +} + +const EMPTY_FORM: FormState = { + project_no: "", mbom_header_objid: "", + purchase_type: "", order_type: "", product_name: "", + area_cd: "", customer_objid: "", paid_type: "", + delivery_request_date: "", +}; + +let _rk = 0; +const nextKey = () => `r${++_rk}_${Date.now()}`; + +export function PurchaseRequestFormDialog({ open, onClose, onSaved }: Props) { + const [form, setForm] = useState(EMPTY_FORM); + const [parts, setParts] = useState([]); + const [saving, setSaving] = useState(false); + const [loadingProject, setLoadingProject] = useState(false); + + const [projectOpts, setProjectOpts] = useState([]); + const [supplierOpts, setSupplierOpts] = useState([]); + + // 선택된 프로젝트의 M-BOM 품목 풀 (행추가 시 품번 셀렉트 옵션) + const [mbomItems, setMbomItems] = useState([]); + + // 모달 열릴 때 옵션 1회 로드 + 폼 초기화 + useEffect(() => { + if (!open) return; + setForm(EMPTY_FORM); + setParts([]); + setMbomItems([]); + (async () => { + try { + const [proj, suppliers] = await Promise.all([ + purchaseApi.listProjects(), + purchaseApi.listSuppliers(), + ]); + setProjectOpts(proj.map(toSmart)); + setSupplierOpts(suppliers.map(toSmart)); + } catch (e: any) { + toast.error(`옵션 로드 실패: ${e?.message ?? ""}`); + } + })(); + }, [open]); + + // 프로젝트 선택 → 자동채움 (주문유형/제품구분/국내외/고객사/유무상) + M-BOM 품번 풀 갱신 + const onProjectChange = useCallback(async (newProjectObjid: string) => { + setForm((f) => ({ ...f, project_no: newProjectObjid })); + setParts([]); + setMbomItems([]); + if (!newProjectObjid) { + setForm((f) => ({ + ...f, project_no: "", + mbom_header_objid: "", order_type: "", product_name: "", + area_cd: "", customer_objid: "", paid_type: "", + })); + return; + } + setLoadingProject(true); + try { + const [info, items] = await Promise.all([ + salesPurchaseRequestApi.getProjectAutoFill(newProjectObjid), + salesPurchaseRequestApi.listMbomParts(newProjectObjid), + ]); + setMbomItems(items ?? []); + setForm((f) => ({ + ...f, + project_no: newProjectObjid, + mbom_header_objid: info?.mbom_header_objid ?? (items?.[0]?.mbom_header_objid ?? ""), + order_type: info?.category_cd ?? "", + product_name: info?.product ?? "", + area_cd: info?.area_cd ?? "", + customer_objid: info?.customer_objid ?? "", + paid_type: info?.paid_type ?? "", + })); + if (!items || items.length === 0) { + toast.info("선택한 프로젝트에 M-BOM 품목이 없습니다. 품번 선택지가 비어 있습니다."); + } + } catch (e: any) { + toast.error(`프로젝트 정보 조회 실패: ${e?.message ?? ""}`); + } finally { + setLoadingProject(false); + } + }, []); + + // M-BOM 품목 → 품번 셀렉트 옵션 + const partOpts: SmartSelectOption[] = useMemo( + () => mbomItems.map((it) => ({ code: it.part_objid, label: it.part_no || it.part_objid })), + [mbomItems], + ); + + const addRow = () => { + setParts((p) => [ + ...p, + { rowKey: nextKey(), part_objid: "", part_no: "", part_name: "", qty: "1", partner_objid: "", partner_price: "" }, + ]); + }; + const deleteRow = (rowKey: string) => setParts((p) => p.filter((r) => r.rowKey !== rowKey)); + const updateRow = (rowKey: string, patch: Partial) => + setParts((p) => p.map((r) => (r.rowKey === rowKey ? { ...r, ...patch } : r))); + + // 품번 선택 → M-BOM 메타데이터로 품명/공급업체/단가/수량 자동 셋팅 + const onPartSelect = (rowKey: string, partObjid: string) => { + const hit = mbomItems.find((it) => it.part_objid === partObjid); + if (!hit) { + updateRow(rowKey, { part_objid: partObjid, part_no: "", part_name: "" }); + return; + } + updateRow(rowKey, { + part_objid: hit.part_objid, + part_no: hit.part_no, + part_name: hit.part_name, + qty: hit.qty > 0 ? String(hit.qty) : "1", + partner_objid: hit.vendor_objid || "", + partner_price: hit.unit_price > 0 ? String(hit.unit_price) : "", + }); + }; + + const canSave = useMemo(() => { + if (!form.project_no) return false; + if (!form.purchase_type) return false; + if (parts.length === 0) return false; + return parts.every((r) => r.part_objid && Number(r.qty || 0) > 0); + }, [form, parts]); + + const handleSave = async () => { + if (!canSave) { + if (!form.project_no) return toast.error("프로젝트번호를 선택해주세요."); + if (!form.purchase_type) return toast.error("구매유형을 선택해주세요."); + if (parts.length === 0) return toast.error("품목이 1건 이상 필요합니다."); + return toast.error("품번/수량(0 초과)을 모두 입력해주세요."); + } + setSaving(true); + try { + const payload = { + ...form, + parts: parts.map(({ rowKey, part_no, ...rest }) => rest), // eslint-disable-line @typescript-eslint/no-unused-vars + }; + const res = await salesPurchaseRequestApi.save(payload); + toast.success(`저장되었습니다. (${res.request_mng_no ?? res.objid})`); + onSaved(); + onClose(); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패"); + } finally { + setSaving(false); + } + }; + + return ( + { if (!o) onClose(); }}> + + + 구매요청서 작성 + 프로젝트 선택 시 주문유형/제품구분/국내외/고객사/유무상이 자동 채워집니다. 품번은 행추가에서 선택하세요. + + +
+ + setForm({ ...form, purchase_type: v })} withAll={false} /> + + + + + + setForm({ ...form, order_type: v })} withAll={false} /> + + + setForm({ ...form, product_name: v })} withAll={false} /> + + + + setForm({ ...form, area_cd: v })} withAll={false} /> + + + setForm({ ...form, customer_objid: v })} /> + + + setForm({ ...form, paid_type: v })} /> + + + setForm({ ...form, delivery_request_date: e.target.value })} /> + +
+ +
+
+
+ 품목 ({parts.length}건) + {form.project_no ? ( + — 선택 가능 품번 {partOpts.length}건 + ) : null} +
+
+ +
+
+
+ + + + + + + + + + + + + {parts.length === 0 ? ( + + + + ) : parts.map((r) => ( + + + + + + + + + ))} + +
품번품명수량공급업체단가
+ {form.project_no + ? "[행추가] 버튼을 눌러 품번을 선택해주세요." + : "먼저 프로젝트번호를 선택해주세요."} +
+ onPartSelect(r.rowKey, v)} + placeholder={partOpts.length === 0 ? "M-BOM 품목 없음" : "선택"} /> + {r.part_name || ""} + updateRow(r.rowKey, { qty: e.target.value })} /> + + updateRow(r.rowKey, { partner_objid: v })} /> + + updateRow(r.rowKey, { partner_price: e.target.value })} /> + + +
+
+
+ + + + + +
+
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} + +function toSmart(o: OptionItem): SmartSelectOption { + return { code: o.code, label: o.label }; +} diff --git a/frontend/lib/api/salesPurchaseRequest.ts b/frontend/lib/api/salesPurchaseRequest.ts index a64a2d56..2b77b8d5 100644 --- a/frontend/lib/api/salesPurchaseRequest.ts +++ b/frontend/lib/api/salesPurchaseRequest.ts @@ -35,7 +35,114 @@ async function getList( return res.data?.data as SalesPurchaseRequestListResponse; } +export interface PurchaseRequestPartInput { + objid?: string; + part_objid: string; + part_name?: string; + qty?: string | number; + org_qty?: string | number; + partner_objid?: string; + partner_price?: string | number; + delivery_request_date?: string; + status?: string; +} + +export interface SavePurchaseRequestPayload { + objid?: string; + project_no?: string; + mbom_header_objid?: string; + purchase_type?: string; + order_type?: string; + product_name?: string; + area_cd?: string; + customer_objid?: string; + paid_type?: string; + delivery_request_date?: string; + parts: PurchaseRequestPartInput[]; +} + +export interface MbomPartItem { + mbom_detail_objid: string; + part_objid: string; + mbom_header_objid: string; + part_no: string; + part_name: string; + unit: string; + qty: number; + unit_price: number; + vendor_objid: string; + vendor_name: string; +} + +export interface ProjectAutoFillInfo { + objid: string; + project_no: string; + project_name: string; + category_cd: string | null; + category_name: string | null; + customer_objid: string | null; + customer_name: string | null; + product: string | null; + product_name: string | null; + area_cd: string | null; + area_name: string | null; + paid_type: string | null; + contract_objid: string | null; + mbom_header_objid: string | null; +} + +export interface ProposalTargetPart { + objid: string; + part_objid: string; + part_no: string; + part_name: string; + qty: string; + unit_price: number; + total_price: number | null; + vendor_pm: string; + vendor_name: string; +} + export const salesPurchaseRequestApi = { listPurchaseRequestReg: (f: SalesPurchaseRequestFilter = {}) => getList("purchase-request", f), listPurchaseRegProposal: (f: SalesPurchaseRequestFilter = {}) => getList("purchase-proposal", f), + + async getProjectAutoFill(projectObjid: string): Promise { + const res = await apiClient.get(`/sales/purchase-request/project-info/${projectObjid}`); + return (res.data?.data ?? null) as ProjectAutoFillInfo | null; + }, + + async listMbomParts(projectObjid: string): Promise { + const res = await apiClient.get("/sales/purchase-request/mbom-parts", { + params: { project_objid: projectObjid }, + }); + return (res.data?.data ?? []) as MbomPartItem[]; + }, + + async getDetail(objid: string): Promise<{ header: any; parts: any[] }> { + const res = await apiClient.get(`/sales/purchase-request/${objid}`); + return res.data?.data as { header: any; parts: any[] }; + }, + + async getProposalTargets(objid: string): Promise<{ targets: ProposalTargetPart[]; excluded: ProposalTargetPart[] }> { + const res = await apiClient.get(`/sales/purchase-request/${objid}/proposal-targets`); + return res.data?.data as { targets: ProposalTargetPart[]; excluded: ProposalTargetPart[] }; + }, + + async save(payload: SavePurchaseRequestPayload): Promise<{ objid: string; request_mng_no: string | null; isNew: boolean }> { + const res = await apiClient.post("/sales/purchase-request", payload); + return res.data?.data; + }, + + async createProposal(srmObjid: string): Promise<{ proposal_objid: string; proposal_no: string; part_count: number }> { + const res = await apiClient.post(`/sales/purchase-request/${srmObjid}/proposal`, {}); + return res.data?.data; + }, + + async startApproval(proposalObjid: string, opts: { approvalTitle?: string; subjectStr?: string } = {}): Promise<{ + fullUrl: string; approKey: string; status: string; proposalObjid: string; + }> { + const res = await apiClient.post(`/sales/purchase-proposal/${proposalObjid}/approval`, opts); + return res.data?.data; + }, };