Merge pull request 'hjjeong' (#15) from hjjeong into main
Build and Push Images / build-and-push (push) Has been cancelled

Reviewed-on: https://g.wace.me/chpark/vexplor_rps/pulls/15
This commit is contained in:
hjjeong
2026-05-15 06:08:31 +00:00
24 changed files with 3769 additions and 47 deletions
+2
View File
@@ -179,6 +179,7 @@ import salesEstimateRoutes from "./routes/salesEstimateRoutes"; // 영업관리>
import salesOrderMgmtRoutes from "./routes/salesOrderMgmtRoutes"; // 영업관리>주문서 (wace_plm 도메인 이식)
import salesSaleRoutes from "./routes/salesSaleRoutes"; // 영업관리>판매+매출 (wace_plm 도메인)
import salesCommonRoutes from "./routes/salesCommonRoutes"; // 영업관리 4개 메뉴 공통 옵션 (codes/parts/customers)
import salesPurchaseRequestRoutes from "./routes/salesPurchaseRequestRoutes"; // 영업관리>구매요청서관리·품의서관리 (wace_plm)
import projectMgmtRoutes from "./routes/projectMgmtRoutes"; // 프로젝트관리>진행관리 (wace_plm 도메인 이식)
import wbsTemplateRoutes from "./routes/wbsTemplateRoutes"; // 프로젝트관리>제품구분_WBS관리 (wace_plm 도메인 이식)
import devPartRoutes from "./routes/devPartRoutes"; // 개발관리>PART 등록/조회 (wace_plm 도메인 이식)
@@ -433,6 +434,7 @@ app.use("/api/sales/estimate", salesEstimateRoutes); // 영업관리>견적 (wac
app.use("/api/sales/order-mgmt", salesOrderMgmtRoutes); // 영업관리>주문서 (wace_plm 도메인)
app.use("/api/sales", salesSaleRoutes); // 영업관리>판매+매출 (wace_plm 도메인)
app.use("/api/sales", salesCommonRoutes); // 영업관리 공통 옵션 (codes/parts/customers)
app.use("/api/sales", salesPurchaseRequestRoutes); // 영업관리>구매요청서관리·품의서관리 (wace_plm)
app.use("/api/project/progress", projectMgmtRoutes); // 프로젝트관리>진행관리 (wace_plm 도메인)
app.use("/api/project/wbs-template", wbsTemplateRoutes); // 프로젝트관리>제품구분_WBS관리 (wace_plm 도메인)
app.use("/api/development", devPartRoutes); // 개발관리>PART 등록/조회 (wace_plm 도메인)
@@ -86,6 +86,50 @@ export async function previewEbomTree(req: AuthenticatedRequest, res: Response)
}
}
// PR-B5+ — 할당 가능한 M-BOM 검색 (운영 BOM 복사 다이얼로그 M-BOM 셀렉트 옵션)
export async function searchAssignableMboms(req: AuthenticatedRequest, res: Response) {
try {
const q = req.query as Record<string, any>;
const data = await svc.searchAssignableMboms({
search_part_no: String(q.search_part_no ?? "").trim() || undefined,
search_part_name: String(q.search_part_name ?? "").trim() || undefined,
search_from_date: String(q.search_from_date ?? "").trim() || undefined,
search_to_date: String(q.search_to_date ?? "").trim() || undefined,
limit: q.limit ? Number(q.limit) : undefined,
});
return res.json({ success: true, data });
} catch (e: any) {
logger.error("할당 가능 M-BOM 조회 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
// PR-B5+ — M-BOM 미리보기 트리 (운영 BOM 복사 다이얼로그 M-BOM 선택 시)
export async function previewMbomTree(req: AuthenticatedRequest, res: Response) {
try {
const mbomHeaderObjid = String(req.params.mbomHeaderObjid ?? "").trim();
if (!mbomHeaderObjid) return res.status(400).json({ success: false, message: "mbomHeaderObjid 누락" });
const data = await svc.previewMbomTree(mbomHeaderObjid);
return res.json({ success: true, data });
} catch (e: any) {
logger.error("M-BOM 미리보기 조회 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
// PR-B5+ — 동일 partNo 의 최신 M-BOM 조회 (운영 getLatestMbomByPartNo, Machine 외 자동 추천)
export async function getLatestMbomByPartNo(req: AuthenticatedRequest, res: Response) {
try {
const partNo = String(req.params.partNo ?? "").trim();
if (!partNo) return res.status(400).json({ success: false, message: "partNo 누락" });
const data = await svc.getLatestMbomByPartNo(partNo);
return res.json({ success: true, data });
} catch (e: any) {
logger.error("최신 M-BOM 조회 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
// PR-B5 — BOM 할당 (운영 saveBomAssignment.do 1:1)
export async function assignBom(req: AuthenticatedRequest, res: Response) {
try {
@@ -19,5 +19,8 @@ router.post("/sales-request", ctrl.createSalesRequest); // PR-B3 구매리스트
router.get("/assignable-eboms", ctrl.searchAssignableEboms); // PR-B5 할당 가능 E-BOM 검색
router.get("/ebom-preview/:bomReportObjid", ctrl.previewEbomTree); // PR-B5 E-BOM 미리보기
router.post("/assign", ctrl.assignBom); // PR-B5 BOM 할당
router.get("/assignable-mboms", ctrl.searchAssignableMboms); // PR-B5+ BOM 복사 - M-BOM 셀렉트 옵션
router.get("/mbom-preview/:mbomHeaderObjid", ctrl.previewMbomTree); // PR-B5+ BOM 복사 - M-BOM 미리보기
router.get("/latest-mbom-by-partno/:partNo", ctrl.getLatestMbomByPartNo); // PR-B5+ Machine 외 자동 추천
export default router;
@@ -0,0 +1,151 @@
// ============================================================
// 영업관리 > 구매요청서관리 / 품의서관리 라우트
// app.ts: app.use("/api/sales", salesPurchaseRequestRoutes)
// 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";
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);
function parseFilter(q: Record<string, any>): svc.SalesPurchaseRequestFilter {
const f: svc.SalesPurchaseRequestFilter = { ...q };
if (q.page) f.page = Number(q.page);
if (q.page_size) f.page_size = Number(q.page_size);
return f;
}
async function run(
fn: (f: svc.SalesPurchaseRequestFilter) => Promise<any>,
req: AuthenticatedRequest,
res: Response,
name: string,
) {
try {
const data = await fn(parseFilter(req.query as Record<string, any>));
return res.json({ success: true, data });
} catch (e: any) {
logger.error(`${name} 실패`, { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
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, "영업>품의서관리"));
// 공급업체 옵션 (client_mng 기반 — vendor/partner 직접 OBJID)
router.get("/purchase-request/vendors", async (_req, res) => {
try {
const data = await svc.listVendorOptions();
return res.json({ success: true, data });
} catch (e: any) {
return handleError(res, e, "공급업체 옵션");
}
});
// 프로젝트 자동채움 정보 (주문유형/제품구분/국내외/고객사/유무상 + 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;
+106
View File
@@ -1357,6 +1357,112 @@ export async function assignBom(
return { success: true, source_bom_type: sourceBomType, source_obj_id: sourceBomObjId };
}
// ─── BOM 복사 다이얼로그 보조 (PR-B5+) ─────────────────────────
//
// 운영판 partMng/structureBomCopyFormPopup.jsp 진입점 보조 함수.
// 저장은 위 assignBom 재사용 (sourceBomType='EBOM'|'MBOM').
export interface AssignableMbomFilter {
search_part_no?: string;
search_part_name?: string;
search_from_date?: string;
search_to_date?: string;
limit?: number;
}
export interface AssignableMbomRow {
objid: string;
mbom_no: string | null;
part_no: string | null;
part_name: string | null;
reg_date: string | null;
}
// 매퍼 productionplanning.getMbomListForSelect2 (4007~4014) 1:1 + 검색 필터 확장.
export async function searchAssignableMboms(filter: AssignableMbomFilter = {}): Promise<AssignableMbomRow[]> {
const pool = getPool();
const conds: string[] = [];
const params: any[] = [];
let idx = 1;
if (filter.search_part_no) {
conds.push(`UPPER(MH.PART_NO) LIKE '%' || UPPER($${idx++}) || '%'`);
params.push(filter.search_part_no);
}
if (filter.search_part_name) {
conds.push(`UPPER(MH.PART_NAME) LIKE '%' || UPPER($${idx++}) || '%'`);
params.push(filter.search_part_name);
}
if (filter.search_from_date) {
conds.push(`MH.REGDATE >= TO_DATE($${idx++}, 'YYYY-MM-DD')`);
params.push(filter.search_from_date);
}
if (filter.search_to_date) {
conds.push(`MH.REGDATE < TO_DATE($${idx++}, 'YYYY-MM-DD') + INTERVAL '1 day'`);
params.push(filter.search_to_date);
}
const limit = Math.min(500, Math.max(1, Number(filter.limit) || 200));
const sql = `
SELECT
MH.OBJID::VARCHAR AS objid,
COALESCE(MH.MBOM_NO, '') AS mbom_no,
COALESCE(MH.PART_NO, '') AS part_no,
COALESCE(MH.PART_NAME, '') AS part_name,
TO_CHAR(MH.REGDATE, 'YYYY-MM-DD') AS reg_date
FROM MBOM_HEADER MH
WHERE MH.STATUS = 'Y'
${conds.length ? "AND " + conds.join(" AND ") : ""}
ORDER BY MH.REGDATE DESC, MH.MBOM_NO
LIMIT ${limit}
`;
const r = await pool.query(sql, params);
return r.rows;
}
// M-BOM 미리보기 트리 — mbom_header_objid 만으로 STRUCTURE_ONLY_SQL 호출.
// 운영판 structureBomCopyFormPopup 의 BOM 트리 미리보기 (M-BOM 선택 시).
export async function previewMbomTree(mbomHeaderObjid: string): Promise<MbomTreeResult> {
if (!mbomHeaderObjid) {
return { bom_data_type: "NONE", bom_report_objid: null, max_level: 1, rows: [] };
}
const rows = await getStructureOnly(mbomHeaderObjid);
return finalize("ASSIGNED_MBOM", mbomHeaderObjid, rows);
}
export interface LatestMbomByPartNoRow {
template_header_objid: string;
mbom_no: string | null;
project_objid: string | null;
part_no: string | null;
part_name: string | null;
save_date: string | null;
}
// 매퍼 productionplanning.getLatestMbomByPartNo (3426~3445) 1:1.
// Machine(0000928) 이외 제품 + 동일 partNo + STATUS='Y' 최신 1건.
export async function getLatestMbomByPartNo(partNo: string): Promise<LatestMbomByPartNoRow | null> {
if (!partNo || !partNo.trim()) return null;
const pool = getPool();
const sql = `
SELECT
MH.OBJID::VARCHAR AS template_header_objid,
MH.MBOM_NO AS mbom_no,
MH.PROJECT_OBJID::VARCHAR AS project_objid,
MH.PART_NO AS part_no,
MH.PART_NAME AS part_name,
TO_CHAR(MH.REGDATE, 'YYYY-MM-DD') AS save_date
FROM MBOM_HEADER MH
INNER JOIN PROJECT_MGMT PM ON MH.PROJECT_OBJID = PM.OBJID
INNER JOIN CONTRACT_MGMT CM ON PM.CONTRACT_OBJID = CM.OBJID
WHERE MH.PART_NO = $1
AND MH.STATUS = 'Y'
AND CM.PRODUCT != '0000928'
ORDER BY MH.REGDATE DESC
LIMIT 1
`;
const r = await pool.query(sql, [partNo.trim()]);
return r.rows[0] ?? null;
}
// ─── 변경이력 조회 (PR-B4) ──────────────────────────────────
//
// 매퍼 productionplanning.getMbomHistory (3448~3470) 1:1.
@@ -0,0 +1,855 @@
// ============================================================
// 영업관리 > 구매요청서관리 / 품의서관리 — wace_plm 1:1
//
// wace 매핑:
// /salesMng/purchaseRequestRegList.do
// → salesMng.getSalesRequestMasterGridList (DOC_TYPE_FILTER='PURCHASE_REG')
// /salesMng/purchaseRegProposalMngList.do
// → salesMng.getPurchaseRegProposalMngGridList (DOC_TYPE='PURCHASE_REG_PROPOSAL')
//
// 데이터 소스(RPS 존재 테이블 기준):
// ✓ sales_request_master, project_mgmt, contract_mgmt
// ✓ comm_code, client_mng, supply_mng
// ✓ mbom_header / mbom_detail / part_mng (품번/품명 fallback)
// ✓ amaranth_approval (품의서 결재상태)
// ✗ sales_request_part (운영DB 추출 후 신설 필요 — PART_NO fallback: MBOM_DETAIL)
// ============================================================
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;
part_no?: string;
part_name?: string;
purchase_type?: string;
writer?: string;
part_type?: string;
search_status?: string;
proposal_no?: string;
regdate_start?: string;
regdate_end?: string;
page?: number;
page_size?: number;
}
interface ListResult<T> {
rows: T[];
totalCount: number;
page: number;
pageSize: number;
}
function clampPaging(f: SalesPurchaseRequestFilter) {
const page = Math.max(1, Number(f.page ?? 1));
const pageSize = Math.max(1, Math.min(500, Number(f.page_size ?? 50)));
return { limit: pageSize, offset: (page - 1) * pageSize, page, pageSize };
}
// ─── 1) 구매요청서관리 ───────────────────────────────────────────
// wace: salesMng.getSalesRequestMasterGridList (DOC_TYPE_FILTER='PURCHASE_REG')
//
// 상태: 품의서생성/확정/작성중 (PURCHASE_REG_PROPOSAL 자식 존재 → '품의서생성')
// wace CASE: PROPOSAL 타입 SRM.PROJECT_NO=SRM.OBJID 존재 시 '품의서생성'
// RPS 보정: 동일 부모-자식 연결을 PURCHASE_REG_PROPOSAL.PROJECT_NO 로 추적
export async function listPurchaseRequestReg(
filter: SalesPurchaseRequestFilter,
): Promise<ListResult<any>> {
const pool = getPool();
const { limit, offset, page, pageSize } = clampPaging(filter);
const where: string[] = [`SRM.DOC_TYPE = 'PURCHASE_REG'`];
const params: any[] = [];
const p = (v: any) => { params.push(v); return `$${params.length}`; };
if (filter.project_no) where.push(`PM.PROJECT_NO ILIKE ${p(`%${filter.project_no}%`)}`);
if (filter.purchase_type) where.push(`SRM.PURCHASE_TYPE = ${p(filter.purchase_type)}`);
if (filter.writer) {
const ph = p(filter.writer);
where.push(`(SRM.REQUEST_USER_ID = ${ph} OR SRM.WRITER = ${ph})`);
}
if (filter.part_type) where.push(`SRM.PRODUCT_NAME = ${p(filter.part_type)}`);
if (filter.regdate_start) where.push(`SRM.REGDATE::DATE >= ${p(filter.regdate_start)}::DATE`);
if (filter.regdate_end) where.push(`SRM.REGDATE::DATE <= ${p(filter.regdate_end)}::DATE`);
if (filter.part_no) where.push(`EXISTS (SELECT 1 FROM MBOM_DETAIL MD JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR=PP.OBJID::VARCHAR WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID AND PP.PART_NO ILIKE ${p(`%${filter.part_no}%`)})`);
if (filter.part_name) where.push(`EXISTS (SELECT 1 FROM MBOM_DETAIL MD JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR=PP.OBJID::VARCHAR WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID AND PP.PART_NAME ILIKE ${p(`%${filter.part_name}%`)})`);
const whereSql = `WHERE ${where.join(" AND ")}`;
const dataSql = `
SELECT
SRM.OBJID AS objid,
SRM.REQUEST_MNG_NO AS request_mng_no,
SRM.STATUS AS status,
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,
-- 주문유형: 프로젝트.CATEGORY_CD 우선
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,
-- 제품구분: 프로젝트→계약 PRODUCT 우선
COALESCE(
(SELECT CC.CODE_NAME
FROM CONTRACT_MGMT CM
LEFT JOIN COMM_CODE CC ON CC.CODE_ID = CM.PRODUCT
WHERE CM.OBJID = PM.CONTRACT_OBJID LIMIT 1),
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = SRM.PRODUCT_NAME LIMIT 1),
''
) AS product_name_full,
-- 고객사: 프로젝트→계약 CUSTOMER 우선
COALESCE(
(SELECT CASE WHEN CM.CUSTOMER_OBJID LIKE 'C_%'
THEN (SELECT CLIENT_NM FROM CLIENT_MNG WHERE 'C_' || OBJID::VARCHAR = CM.CUSTOMER_OBJID LIMIT 1)
ELSE (SELECT SUPPLY_NAME FROM SUPPLY_MNG WHERE OBJID::VARCHAR = CM.CUSTOMER_OBJID::VARCHAR LIMIT 1)
END
FROM CONTRACT_MGMT CM WHERE CM.OBJID = PM.CONTRACT_OBJID LIMIT 1),
''
) AS customer_name,
-- 유/무상
CASE COALESCE(
(SELECT CM.PAID_TYPE FROM CONTRACT_MGMT CM WHERE CM.OBJID = PM.CONTRACT_OBJID LIMIT 1),
SRM.PAID_TYPE)
WHEN 'paid' THEN '유상'
WHEN 'free' THEN '무상'
ELSE ''
END AS paid_type_name,
PM.PROJECT_NO AS project_number,
-- 품번/품명 (MBOM 우선)
COALESCE((SELECT PP.PART_NO FROM MBOM_DETAIL MD
JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR = PP.OBJID::VARCHAR
WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID
ORDER BY MD.REGDATE LIMIT 1), '') AS part_no,
COALESCE((SELECT PP.PART_NAME FROM MBOM_DETAIL MD
JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR = PP.OBJID::VARCHAR
WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID
ORDER BY MD.REGDATE LIMIT 1), '') AS part_name,
GREATEST(
(SELECT COUNT(DISTINCT PP.PART_NO)::int
FROM MBOM_DETAIL MD
JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR = PP.OBJID::VARCHAR
WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID) - 1,
0
) AS part_extra_count,
-- 구매요청서 작성여부 (MBOM_HEADER 존재 시 Y)
CASE WHEN SRM.MBOM_HEADER_OBJID IS NOT NULL THEN 'Y' ELSE 'N' END
AS has_purchase_request,
-- 상태 라벨 (wace CASE 1:1)
CASE
WHEN EXISTS (
SELECT 1 FROM SALES_REQUEST_MASTER P
WHERE P.DOC_TYPE = 'PURCHASE_REG_PROPOSAL'
AND P.PROJECT_NO = SRM.OBJID::VARCHAR
) THEN '품의서생성'
WHEN SRM.STATUS = 'confirmed' THEN '확정'
WHEN SRM.STATUS = 'create' THEN '작성중'
ELSE COALESCE(SRM.STATUS, '')
END AS status_title,
COALESCE(SRM.REQUEST_USER_ID, SRM.WRITER) AS request_user_id,
COALESCE(user_name(COALESCE(SRM.REQUEST_USER_ID, SRM.WRITER)),
COALESCE(SRM.REQUEST_USER_ID, SRM.WRITER), '')
AS request_user_name,
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
LEFT JOIN PROJECT_MGMT PM ON PM.OBJID::VARCHAR = SRM.PROJECT_NO
${whereSql}
ORDER BY SRM.REGDATE DESC
LIMIT ${p(limit)} OFFSET ${p(offset)}
`;
const countSql = `
SELECT COUNT(*)::int AS cnt
FROM SALES_REQUEST_MASTER SRM
LEFT JOIN PROJECT_MGMT PM ON PM.OBJID::VARCHAR = SRM.PROJECT_NO
${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("listPurchaseRequestReg 실패", { error: e.message });
return { rows: [], totalCount: 0, page, pageSize };
}
}
// ─── 2) 영업관리 > 품의서관리 (구매요청서 → 품의서) ─────────────
// wace: salesMng.getPurchaseRegProposalMngGridList (DOC_TYPE='PURCHASE_REG_PROPOSAL')
//
// 결재상태 우선순위: AMR.STATUS > 내부 결재 > SRM.STATUS('create'→'등록중')
export async function listPurchaseRegProposal(
filter: SalesPurchaseRequestFilter,
): Promise<ListResult<any>> {
const pool = getPool();
const { limit, offset, page, pageSize } = clampPaging(filter);
const where: string[] = [
`SRM.STATUS IN ('create','approvalRequest','approvalComplete','reject')`,
`SRM.DOC_TYPE = 'PURCHASE_REG_PROPOSAL'`,
];
const params: any[] = [];
const p = (v: any) => { params.push(v); return `$${params.length}`; };
if (filter.proposal_no) where.push(`SRM.REQUEST_MNG_NO ILIKE ${p(`%${filter.proposal_no}%`)}`);
if (filter.project_no) where.push(`PM.PROJECT_NO ILIKE ${p(`%${filter.project_no}%`)}`);
if (filter.purchase_type) where.push(`SRM.PURCHASE_TYPE = ${p(filter.purchase_type)}`);
if (filter.writer) where.push(`SRM.WRITER = ${p(filter.writer)}`);
if (filter.part_type) where.push(`SRM.PRODUCT_NAME = ${p(filter.part_type)}`);
if (filter.regdate_start) where.push(`SRM.REGDATE::DATE >= ${p(filter.regdate_start)}::DATE`);
if (filter.regdate_end) where.push(`SRM.REGDATE::DATE <= ${p(filter.regdate_end)}::DATE`);
if (filter.search_status) {
// amaranth/내부 결재 종합 status = SEARCH_STATUS
where.push(`(
CASE
WHEN AMR.STATUS = 'complete' THEN 'approvalComplete'
WHEN AMR.STATUS = 'inProcess' THEN 'inProcess'
WHEN AMR.STATUS = 'reject' THEN 'reject'
ELSE SRM.STATUS
END
) = ${p(filter.search_status)}`);
}
const whereSql = `WHERE ${where.join(" AND ")}`;
const dataSql = `
SELECT
SRM.OBJID AS objid,
SRM.REQUEST_MNG_NO AS proposal_no,
PM.PROJECT_NO AS project_number,
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,
SRM.PRODUCT_NAME AS product_name,
COALESCE(
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = SRM.PRODUCT_NAME LIMIT 1), ''
) AS product_name_title,
COALESCE((SELECT PP.PART_NO FROM MBOM_DETAIL MD
JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR = PP.OBJID::VARCHAR
WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID
ORDER BY MD.REGDATE LIMIT 1), '') AS part_no,
COALESCE((SELECT PP.PART_NAME FROM MBOM_DETAIL MD
JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR = PP.OBJID::VARCHAR
WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID
ORDER BY MD.REGDATE LIMIT 1), '') AS part_name,
GREATEST(
(SELECT COUNT(DISTINCT PP.PART_NO)::int
FROM MBOM_DETAIL MD
JOIN PART_MNG PP ON MD.PART_OBJID::VARCHAR = PP.OBJID::VARCHAR
WHERE MD.MBOM_HEADER_OBJID = SRM.MBOM_HEADER_OBJID) - 1,
0
) AS part_extra_count,
CASE
WHEN AMR.STATUS = 'complete' THEN 'approvalComplete'
WHEN AMR.STATUS = 'inProcess' THEN 'inProcess'
WHEN AMR.STATUS = 'reject' THEN 'reject'
ELSE SRM.STATUS
END AS status,
CASE
WHEN AMR.STATUS = 'complete' THEN '결재완료'
WHEN AMR.STATUS = 'inProcess' THEN '결재 상신중'
WHEN AMR.STATUS = 'reject' THEN '반려'
ELSE '등록중'
END AS status_title,
COALESCE(AMR.STATUS, '') AS amaranth_status,
SRM.WRITER AS writer,
COALESCE(user_name(SRM.WRITER), SRM.WRITER, '') AS writer_name,
TO_CHAR(SRM.REGDATE, 'YYYY-MM-DD') AS regdate_title,
SRM.MBOM_HEADER_OBJID AS mbom_header_objid
FROM SALES_REQUEST_MASTER SRM
LEFT JOIN PROJECT_MGMT PM ON PM.OBJID::VARCHAR = SRM.PROJECT_NO
LEFT JOIN AMARANTH_APPROVAL AMR
ON SRM.OBJID::VARCHAR = AMR.TARGET_OBJID
AND AMR.TARGET_TYPE = 'PROPOSAL'
${whereSql}
ORDER BY SRM.REGDATE DESC
LIMIT ${p(limit)} OFFSET ${p(offset)}
`;
const countSql = `
SELECT COUNT(*)::int AS cnt
FROM SALES_REQUEST_MASTER SRM
LEFT JOIN PROJECT_MGMT PM ON PM.OBJID::VARCHAR = SRM.PROJECT_NO
LEFT JOIN AMARANTH_APPROVAL AMR
ON SRM.OBJID::VARCHAR = AMR.TARGET_OBJID
AND AMR.TARGET_TYPE = 'PROPOSAL'
${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("listPurchaseRegProposal 실패", { error: e.message });
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(FLOOR(NULLIF(SRP.QTY, '')::NUMERIC)::INTEGER, 0)::TEXT AS QTY,
SRP.ORG_QTY, SRP.PARTNER_OBJID,
COALESCE(NULLIF(SRP.PARTNER_PRICE, '')::NUMERIC, 0)::NUMERIC(18,2) AS PARTNER_PRICE,
COALESCE(SRP.UNIT_PRICE, 0)::NUMERIC(18,2) 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-0) 공급업체 옵션 — client_mng 기반 ──────────────────────
// wace partMng 의 fnc_getClientMngListAppend 와 동일.
// M-BOM.vendor / sales_request_part.partner_objid 는 'C_' prefix 없이 client_mng.OBJID 직접 저장.
// → 옵션 코드는 OBJID 그대로(접두 X). 기존 listSupplierOptions(supply_mng)는 다른 메뉴 호환용으로 유지.
export async function listVendorOptions(): Promise<{ code: string; label: string }[]> {
const pool = getPool();
try {
const r = await pool.query(
`SELECT OBJID::VARCHAR AS code, CLIENT_NM AS label
FROM CLIENT_MNG
WHERE COALESCE(USE_YN, '1') IN ('1', 'Y', 'y')
AND CLIENT_NM IS NOT NULL AND CLIENT_NM <> ''
ORDER BY CLIENT_NM`,
);
return r.rows;
} catch (e: any) {
logger.error("listVendorOptions 실패", { error: e.message });
return [];
}
}
// ─── 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(FLOOR(COALESCE(MD.PRODUCTION_QTY, MD.PO_QTY, 0))::INTEGER, 0) AS qty,
COALESCE(MD.UNIT_PRICE, 0)::NUMERIC(18,2) 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(FLOOR(NULLIF(SRP.QTY, '')::NUMERIC)::INTEGER, 0)::TEXT AS QTY,
COALESCE(SRP.UNIT_PRICE, NULLIF(SRP.PARTNER_PRICE,'')::NUMERIC, 0)::NUMERIC(18,2) AS UNIT_PRICE,
SRP.TOTAL_PRICE::NUMERIC(18,2) AS 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(FLOOR(NULLIF(SRP.QTY, '')::NUMERIC)::INTEGER, 0)::TEXT 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();
}
}
+136
View File
@@ -0,0 +1,136 @@
# vexplor_rps 이식 통합 진행 상태
> 작성: 2026-05-15 / 작성자: hjjeong
> 대상: vexplor_rps (RPS 전용 분기, COMPANY_16 단독)
> 원본: wace_plm (Java 7 / Spring 3.2.4 / JSP / MyBatis) — `waceplm.esgrin.com` 운영판이 1순위 진실
## 0. 전체 한눈에 보기
**6개 도메인 / 27개 메뉴** 진행 중. (영업 4 + 구매요청 2 + 프로젝트 2 + 개발 5 + 구매 9 + 생산 5).
| 상태 | 메뉴 수 | 의미 |
|---|---|---|
| ✅ 완료 | 19 | wace 1:1 검증 PASS 또는 마이너 차이만 (기능/SQL 일치) |
| 🟡 베이스 | 5 | 그리드/검색 완료, 액션 모달 또는 detail SQL 일부 미진 |
| 🟠 빈 그리드 | 3 | 화면은 있으나 데이터 SQL 미연결 (의존 테이블 DDL 추출 선행 필요) |
| 🔴 미진 | 0 | — |
## 1. 27개 메뉴 매트릭스
| # | 도메인 | 메뉴명 | RPS 위치 | 상태 | 핵심 미진 |
|---|---|---|---|---|---|
| 1 | 영업관리 | 견적관리 | `sales/estimate` | ✅ | — |
| 2 | 영업관리 | 주문서관리 | `sales/order` | ✅ | — |
| 3 | 영업관리 | 판매관리 | `sales/sale` | ✅ | — |
| 4 | 영업관리 | 매출관리 | `sales/revenue` | ✅ | — |
| 5 | 영업/구매요청 | 구매요청서관리 | `purchase-request/request` | ✅ | — |
| 6 | 영업/구매요청 | 품의서관리(영업) | `purchase-request/proposal` | ✅ | — |
| 7 | 프로젝트관리 | 진행관리 | `project/progress` | ✅ | — |
| 8 | 프로젝트관리 | 제품구분_WBS관리 | `project/wbs-template` | ✅ | — |
| 9 | 개발관리 | PART 등록 | `development/part-regist` | ✅ | — |
| 10 | 개발관리 | PART 조회 | `development/part-search` | ✅ | — |
| 11 | 개발관리 | E-BOM 등록 | `development/ebom-regist` | ✅ | — |
| 12 | 개발관리 | E-BOM 조회 | `development/ebom-search` | ✅ | — |
| 13 | 개발관리 | 설계변경 리스트 | `development/change-list` | ✅ | — |
| 14 | 구매관리 | 구매리스트 | `purchase/list` | 🟡 | detail SQL 보강 |
| 15 | 구매관리 | 품의서(구매) | `purchase/proposal` | 🟡 | 발주서 생성 액션 |
| 16 | 구매관리 | 프로젝트 현황 | `purchase/project-status` | 🟡 | — |
| 17 | 구매관리 | 견적요청 | `purchase/quote-request` | 🟠 | `sales_request_part` DDL 추출 + detail SQL |
| 18 | 구매관리 | 입고관리 | `purchase/inbound` | 🟠 | inbound detail SQL |
| 19 | 구매관리 | 입고관리-일자별 | `purchase/inbound-by-date` | 🟠 | inbound detail SQL |
| 20 | 구매관리 | 입고관리-품목별 | `purchase/inbound-by-item` | 🟠 | inbound detail SQL |
| 21 | 구매관리 | 발주관리 | `purchase/order` | ✅ | — |
| 22 | 구매관리 | M-BOM (중복) | `purchase/mbom` | ✅ | `production/mbom` re-export |
| 23 | 생산관리 | M-BOM 관리 | `production/mbom` | ✅ PR-B5+ | PR-B6 Excel / PR-B7 행이동 (후순위) |
| 24 | 생산관리 | 생산계획&실적 | `production/plan-result` | 🟡 | [생산계획 생성] · [생산실적 등록] 모달 |
| 25 | 생산관리 | 생산계획&실적(장비) | `production/plan-result-equip` | ✅ | WBS할당 모달 의도적 보류 |
| 26 | 생산관리 | 반제품 소요량 | `production/semi-product-req` | ✅ | 단위/소재/규격 3컬럼 UX 추가 (의도적) |
| 27 | 생산관리 | 원자재 소요량 | `production/raw-material-req` | ✅ | — |
## 2. 도메인별 진행 상태
| 도메인 | 메뉴 | 마감도 | 대표 커밋 | 상세 문서 |
|---|---|---|---|---|
| **영업관리** | 4 | 100% (G6 메일 발송까지) | (다수) | [sales/README.md](./sales/README.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` | (메모리만) |
| **생산관리** | 5 | 100% + PR-B5+ BOM 복사 다이얼로그 | `bd47ca80` / `63682587` | [production/README.md](./production/README.md) |
부록 — **품질관리**: chpark 가 베이스 4메뉴(`incoming-request/incoming-mgmt/process-inspection/semi-product-inspection`) + 고객CS/ECR 리팩토링 (`d7c645d2`). 후속 상세화 별 PR.
## 3. 공통 인프라 (도메인 가로지름)
### 3.1 공용 컴포넌트 (의무 사용)
- **PageHeader** — 메뉴명 자동 매칭, 우측 액션 영역, 조회/초기화 표준 위치
- **CompactFilterBar** + **CompactFilterField** + **CompactDateRange** — 컴팩트 검색 그리드
- **SmartSelect** / **CustomerSelect** — native `<select>` 금지, 자동 완성/공통코드 바인딩
- **DataGrid** (logicstudio 스타일 6종) — toolbar(⟳⬇⚙️📊) + footer 통계 + range 페이지네이션 + 차트 패널 (`36c1f357`)
- **AttachFileDropZone** — attach_file_info 다중 파일 업로드, 도메인별 신규 드롭존 금지
### 3.2 데이터 마이그레이션 패턴
- 운영 `211.115.91.141:11133/waceplm` (PG 16.8) → RPS `:11134` DDL/data CSV staging
- 도메인별 폴더 `docs/migration/{sales,project,development,quality,production}/` + `ddl-extracted/` + `data-sync/`
- 누락 컬럼 발견 시: CSV export → TEMP staging → UPDATE FROM JOIN (`is_last` NULL 함정 동시 해결)
### 3.3 결재 시스템 (Amaranth 연동)
- 영업관리 G7~G11 (견적 메일 + Amaranth 결재상신)
- 영업/구매요청 품의서(`target_type='PROPOSAL'`) — 결재완료 시 구매관리 발주풀로 자동 노출
### 3.4 채번/식별자 패턴
- numeric/bigint objid: UUID v4 → Java String.hashCode → int32 String (createObjId 1:1 패턴)
- varchar prefix: 영업관리 prefix-string 패턴 (예: `R-YYYYMMDD-NNN`)
- DataGrid `row.id` ↔ 백엔드 `objid` useMemo 매핑 필수 (누락 시 체크박스 빈 상태)
## 4. 핵심 정책 (사용자 확정)
1. **vexplor 로코드툴 프레임워크 안 씀** — 모든 메뉴 wace 도메인으로 풀-커스텀 재개발
2. **`wace_plm` 이 항상 1순위 진실** — 이전 이식물(영업/개발 일부)은 검증 안 된 변형 가능
3. **JSP/매퍼 비활성 보존**: `/* */` · `<!-- -->` · `//` 블록 = 이식 대상 아님 (grep 카운트 함정 주의)
4. **company_code 멀티테넌시 불필요** — COMPANY_16 단독, 새 코드/SQL에 분기 만들지 말 것
5. **숫자 포맷**: 금액 1,234.00 / 수량 1,234 / 모든 숫자 right align
6. **존댓말 사용** — 모든 한국어 응답 (2026-05-08 직접 지적)
7. **UI 문자열에 내부 참조 금지** — PageHeader description / toast 에 wace · 매퍼명 · 테이블명 · 개발자 메모 금지 (2026-05-15 직접 지적)
8. **커밋 범위 엄격** — 내 세션 작업만, 사용자 병행 작업/untracked 절대 포함 금지 (2026-05-14 직접 지적)
## 5. 도메인 핵심 함정 (이식 시 참고)
| 함정 | 메모리 |
|---|---|
| 큰 파일(570MB SQL · 45KB+ JSP) 통째 Read 시 surrogate 에러 → grep + offset/limit | `feedback_large_file_handling` |
| wace JSP 컬럼/검색폼 끝 주석 블록 (비활성 보존) | `feedback_wace_jsp_columns` |
| wace "Excel" 명칭 메뉴가 실제는 CSV 운영 (`fnc_setFileDropZone(...,"csv")` 확인) | `feedback_wace_csv_vs_excel` |
| wace partMng 폼 colgroup 5컬럼(12/12/25/12/*) + colspan=2 | `feedback_wace_colgroup_pattern` |
| BOM_PART_QTY 트리 재귀 CTE: 자식.parent_objid = 부모.child_objid (objid 아님) | `feedback_bom_part_qty_tree_join` |
| 없는 테이블 → 운영 DDL 추출 (211.115.91.141:11133) | `feedback_missing_tables_workflow` |
| wace 매퍼는 request 파라미터 암묵 수신 — Java paramMap 만 보고 옮기면 핵심 필터 누락 | `feedback_wace_mapper_implicit_params` |
| numeric(15,4) 캐스팅: `NULLIF::INTEGER` 패턴 시 "invalid input syntax" → `COALESCE(x,0)::INTEGER` | `feedback_data_migration_sync` |
## 6. 다음 작업 우선순위 (제안)
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메뉴 상세화) / **자재관리 신규 도메인 진입**
6. **M-BOM 후순위** — PR-B6 Excel / PR-B7 행이동
## 7. 인덱스
### 7.1 도메인별 README / Gap 문서
- [sales/README.md](./sales/README.md) — 영업관리 + 구매요청 (메뉴 매핑 / 도메인 테이블 / 마스터 매핑 / 채번 / UX)
- [sales/00-gap.md](./sales/00-gap.md) · [01-estimate.md](./sales/01-estimate.md) · [02-order.md](./sales/02-order.md) · [03-sale.md](./sales/03-sale.md) · [04-revenue.md](./sales/04-revenue.md) · [05-master-mapping.md](./sales/05-master-mapping.md) · [09-purchase-request.md](./sales/09-purchase-request.md)
- [project/00-gap.md](./project/00-gap.md) · [01-progress.md](./project/01-progress.md) · [02-wbs-template.md](./project/02-wbs-template.md)
- [development/00-gap.md](./development/00-gap.md) · [01-part.md](./development/01-part.md) · [02-ebom.md](./development/02-ebom.md) · [03-eo-history.md](./development/03-eo-history.md)
- [production/README.md](./production/README.md) — 생산관리 (5메뉴 + PR-B5+ BOM 복사 + 23 매퍼 매핑)
- [quality/01_quality_tables_from_ilshin.sql](./quality/01_quality_tables_from_ilshin.sql) · [02_wace_plm_quality_tables.sql](./quality/02_wace_plm_quality_tables.sql)
### 7.2 공통
- [common/menu_desc_sync.sql](./common/menu_desc_sync.sql) — 메뉴 설명 동기화
### 7.3 외부 환경
- 운영 DB: `211.115.91.141:11133/waceplm` (PG 16.8) — DDL/data 추출 소스
- RPS DB: `211.115.91.141:11134/vexplor_rps` — 이식 대상
- 운영 화면: `waceplm.esgrin.com`
- wace_plm 소스: `/Users/jhj/wace_plm` (JSP + Controller + 매퍼 XML)
+188
View File
@@ -0,0 +1,188 @@
# 생산관리 이식 (wace_plm → vexplor_rps)
> 작성: 2026-05-15 / 작성자: hjjeong
> 대상: vexplor_rps (RPS 전용 분기, COMPANY_16 단독 운영)
> 원본: wace_plm (Java 7 / Spring 3.2.4 / JSP / MyBatis)
## 0. 정책 (사용자 확정 사항)
- **이식 방식**: JSP → Next.js 리라이트 (백엔드도 Java→Node 재작성, vexplor_rps `backend-node` 패턴)
- **스키마 정책**: wace_plm 도메인 테이블(`mbom_header`/`mbom_detail`/`mbom_history`/`sales_request_master`/`project_mgmt`/`contract_mgmt`/`part_bom_report` 등) **그대로 이식**. 마스터(`customer_mng`/`item_info`/`user_info` 등)는 RPS 기존 사용.
- **이식 대상 메뉴 = 5개** (2026-05-15 사용자 확정):
1. M-BOM 관리 (`/productionplanning/mBomMgmtList.do`)
2. 생산계획&실적관리 (`/productionplanning/prodPlanResultMgmtList.do`)
3. 생산계획&실적관리(장비) (`/productionplanning/prodPlanResultMgmtEquipList.do`)
4. 반제품소요량 (`/productionplanning/semiProductRequirementList.do`)
5. 원자재소요량 (`/productionplanning/rawMaterialRequirementList.do`)
- ⚠️ `production/{bom,plan-management,process-info,result,work-instruction}` 디렉토리는 **vexplor 잔재** — wace_plm 매뉴얼 5개에 없음, 이식 대상 아님.
- **메뉴 노출**: M-BOM 관리는 생산관리 + 구매관리 트리 양쪽 진입 허용 (`menu_info` 100016 + 100032 동시 활성). 구매관리 페이지는 production/mbom re-export.
## 1. 메뉴 매핑표
| # | 메뉴명 | wace_plm URL | wace_plm JSP | wace_plm Controller | RPS 신규 위치 |
|---|---|---|---|---|---|
| 1 | M-BOM 관리 | `/productionplanning/mBomMgmtList.do` | `productionplanning/mBomMgmtList.jsp` | `ProductionPlanningController` | `app/(main)/COMPANY_16/production/mbom/page.tsx` + `backend-node/src/{routes,services,controllers}/mbom*` (PR-A0~B5+) |
| 2 | 생산계획&실적관리 | `/productionplanning/prodPlanResultMgmtList.do` | `productionplanning/prodPlanResultMgmtList.jsp` | `ProductionPlanningController` | `app/(main)/COMPANY_16/production/plan-result/page.tsx` + `backend-node/src/{routes,services,controllers}/prodPlanResult*` |
| 3 | 생산계획&실적관리(장비) | `/productionplanning/prodPlanResultMgmtEquipList.do` | `productionplanning/prodPlanResultMgmtEquipList.jsp` | `ProductionPlanningController` | `app/(main)/COMPANY_16/production/plan-result-equip/page.tsx` + 위와 동일 service (listEquip 분기) |
| 4 | 반제품소요량 | `/productionplanning/semiProductRequirementList.do` | `productionplanning/semiProductRequirementList.jsp` | `ProductionPlanningController` | `app/(main)/COMPANY_16/production/semi-product-req/page.tsx` + `backend-node/src/services/mbomRequirementService.ts` (listSemi) |
| 5 | 원자재소요량 | `/productionplanning/rawMaterialRequirementList.do` | `productionplanning/rawMaterialRequirementList.jsp` | `ProductionPlanningController` | `app/(main)/COMPANY_16/production/raw-material-req/page.tsx` + 위와 동일 service (listRaw) |
| ─ | BOM 복사 다이얼로그 (M-BOM 보조) | `/partMng/structureBomCopyFormPopup.do` | `partMng/structureBomCopyFormPopup.jsp` | `PartMngController` | `components/production/BomCopyDialog.tsx` (PR-B5+) |
## 2. 도메인 테이블
이식 대상 — RPS DB에 CREATE 그대로 적용. DDL은 [ddl-extracted/](./ddl-extracted/), 동기화 SQL은 [data-sync/](./data-sync/).
| 우선순위 | 테이블 | 용도 | DDL 파일 |
|---|---|---|---|
| ★★★ | `mbom_header` | M-BOM 헤더 (project_objid 별 1+ 버전, status='Y' 가 활성) | [400_mbom.sql](./ddl-extracted/400_mbom.sql) |
| ★★★ | `mbom_detail` | M-BOM 상세 (parent/child 트리, qty/required_qty/order_qty/production_qty) | [400_mbom.sql](./ddl-extracted/400_mbom.sql) |
| ★★★ | `mbom_history` | M-BOM 변경이력 (CHANGE_TYPE = CREATE/UPDATE) | [401_mbom_dependencies.sql](./ddl-extracted/401_mbom_dependencies.sql) |
| ★★★ | `sales_request_master` | 구매리스트(R-YYYYMMDD-NNN), M-BOM 으로부터 단건 생성 | [401_mbom_dependencies.sql](./ddl-extracted/401_mbom_dependencies.sql) |
| ★★★ | `project_mgmt` | 프로젝트 헤더 (source_bom_type/source_ebom_objid/source_mbom_objid) | (sales 도메인 기존 테이블 재사용) |
| ★★★ | `contract_mgmt` / `contract_item` | 수주/계약 정보 (product != '0000928' = Machine 외 판별) | (sales 도메인 기존 테이블 재사용) |
| ★★ | `part_bom_report` / `bom_part_qty` | E-BOM 참조 (BOM 할당/복사 소스) | (개발관리 도메인 기존 테이블) |
| ★★ | `production_plan` | 생산계획(plan-result 보조) | [402_production_plan.sql](./ddl-extracted/402_production_plan.sql) |
| ★ | `attach_file_info` | 도면 다중 업로드 (PR-B5+ BomCopyDialog, doc_type='MBOM_DRAWING') | (공용 테이블) |
| ★ | `client_mng` | 고객사 (`user_name()` fn 동반) | (sales 기존) |
### 데이터 동기화 SQL ([data-sync/](./data-sync/))
| 파일 | 용도 |
|---|---|
| `01_mbom_sync.sql` | 운영 DB → RPS mbom_header/detail 데이터 이주 |
| `02_mbom_dependencies_sync.sql` | mbom_history + sales_request_master 동기화 |
| `03_mbom_menu_dedup.sql` | menu_info 중복 정리 (M-BOM 100016/100032) |
| `04_production_plan_sync.sql` | production_plan 동기화 |
| `05_mbom_menu_desc.sql` | M-BOM 관리 메뉴명 sync |
## 3. 매퍼 매핑표 (`productionplanning.xml` 1:1)
| wace 매퍼 ID | wace 라인 | RPS 함수 (mbomService) | PR |
|---|---|---|---|
| `mBomMgmtGridList` | 2874-3119 | `list()` | A1 |
| `getProjectMgmtDetail` | 3150-3218 | `getDetail()` | A2 |
| `getLatestMbomByProjectId` | 3555-3570 | `getLatestSavedMbom()` | A2 |
| `getLatestMbomTemplateByPartNo` | 3573-3591 | `getLatestTemplate()` | A2 |
| `getMbomTemplateDetails` | 3594-3794 | `getTemplateDetails()` | A2 |
| `getSavedMbomTreeList` | 4114-4359 | `getSavedTree()` | A2 |
| `getMbomStructureOnly` | 4362-4538 | `getStructureOnly()` | A2 |
| `insertMbomHeader/Detail` | 3873/3886 | `save()` CREATE | B1 |
| `updateMbomHeader/Detail` | 3917/3940 | `save()` UPDATE | B1 |
| `deleteMbomDetailByObjid` | 3934 | `save()` UPDATE 누락 행 | B1 |
| `insertMbomHistory` | 3973 | `insertHistory()` helper | B1/B4 |
| `updateProjectMbomStatus` | 3949 | `save()` CREATE | B1 |
| `getMbomHistory` | 3448-3470 | `getHistory()` | B4 |
| `partMng.getBOMTreeList` | partMng.xml 3289-3549 | `getEbomWorkingTree()` / `previewEbomTree()` | A2/B5 |
| `getEbomList` | 3221-3265 | `searchAssignableEboms()` | B5 |
| `saveBomAssignment` | 3545-3553 | `assignBom()` (EBOM/MBOM 둘 다) | B5 |
| `getLatestMbomByPartNo` | 3426-3445 | `getLatestMbomByPartNo()` | B5+ |
| `getMbomListForSelect2` | 4007-4014 | `searchAssignableMboms()` | B5+ |
| `salesMng.insertSalesRequestMasterFromMBom` | ~3975 | `createSalesRequest()` | B3 |
| `prodPlanResultMgmtGridList` | 4550~ | `prodPlanResultService.listGeneral()` | — |
| `prodPlanResultMgmtEquipGridList` | 4887~ | `prodPlanResultService.listEquip()` | — |
| `semiProductRequirementList` (Java LinkedHashMap) | ~5252 | `mbomRequirementService.listSemi()` | — |
| `rawMaterialRequirementList` (Java LinkedHashMap) | — | `mbomRequirementService.listRaw()` | — |
## 4. PR 진행 흐름
| PR | 내용 | 대표 커밋 |
|---|---|---|
| A0 | 의존 테이블 (mbom_history/sales_request_master/client_mng + user_name fn) | `7af366c5` |
| A0' | M-BOM 테이블 신설 (mbom_header/detail + 운영 sample) | `04cfac6e` |
| A1 | 메인 그리드/검색 (mBomMgmtGridList 1:1) | `66cee22b` |
| A2 | 단건 상세 + read-only 트리 4분기 (SAVED/ASSIGNED_EBOM/ASSIGNED_MBOM/TEMPLATE/NONE) | `dd88dc6e` |
| B1 | 본 편집 + 폴더 컬럼 + DataGrid 서버 페이지네이션 + bigint=varchar fix | `7a7f4f03` |
| B2 | 본 편집 행 추가/삭제 (팝업 방식, MbomAddPartDialog, temp-objid remap) | `dee03f60` |
| B3 | 구매리스트 생성 (createPurchaseListFromMBom 1:1) | `b38f5957` |
| B4 | 변경이력 다이얼로그 (getMbomHistory 1:1) | `8dd5f184` |
| B5 | BOM 할당 베이스 (mBomEbomSelectPopup 진입점) | `b38f5957` |
| B5 | BOM 할당 운영판 1:1 재구성 (카드+토글+미리보기 트리+제품구분/날짜 검색) | `bd47ca80` |
| 보정 | 4개 메뉴 디렉토리 rename(URL 일치) + 그리드 헤더 보정 + 소요량 numeric 캐스팅 fix | `c83a73a1` |
| **B5+** | **BOM 복사 다이얼로그 (structureBomCopyFormPopup 1:1) + 5메뉴 1:1 검증** | `63682587` |
## 5. 5개 메뉴 1:1 검증 결과 (2026-05-15)
Agent 5병렬 (read-only) 검증.
| 메뉴 | 판정 | 비고 |
|---|---|---|
| M-BOM 관리 | PASS (B5+ 후) | 검색 폼 / 그리드 16컬럼 / SQL 100% 일치. 품번/품명만 wace `Select2-part`(AJAX) → Input text. **B5+ 로 누락된 [BOM 복사] 보완**. |
| 생산계획&실적관리 | MINOR_DIFF | 검색 13필드 / 그리드 18컬럼 / SQL 100% 일치. **[생산계획 생성]·[생산실적 등록]** 액션 모달은 미구현(프로토타입). 품번/품명 Select2-part → Input text. |
| 생산계획&실적관리(장비) | PASS | 검색 9필드 / 그리드 14컬럼 / SQL 100% 일치. WBS할당 모달은 의도적 미구현 (주석 명시). |
| 반제품소요량 | MINOR_DIFF | 입력 폼 / 결과 4컬럼 / SQL / 누적 로직 100% 일치. RPS만 단위/소재/규격 3컬럼 추가 노출 (UX 향상). c83a73a1 에서 numeric 캐스팅 fix. |
| 원자재소요량 | PASS | 입력 / 8컬럼 / 구매품·원소재 SQL / Math.ceil 누적 / 숫자 포맷 모두 100% 일치. |
## 6. PR-B5+ BOM 복사 다이얼로그 (2026-05-15)
운영판 `partMng/structureBomCopyFormPopup.jsp` (774 lines) 1:1.
**진입**: 메인 그리드 [BOM 복사] 버튼 — 체크박스 단건 선택 + Machine(0000928) 외 동일 partNo 매칭 시 기존 M-BOM 자동 추천(`getLatestMbomByPartNo` 호출).
**레이아웃**:
- 상단: 품번 / 품명 readonly + [저장] [닫기]
- 중단: E-BOM 셀렉트 / M-BOM 셀렉트 (상호배타, 한쪽 선택 시 다른쪽 disable)
- 하단: 트리 미리보기 + **도면 다중 업로드** (공용 `AttachFileDropZone`, target=projectObjid, docType=`MBOM_DRAWING`, accept=`.stp,.step,.dwg,.dxf,.pdf`)
**저장**: `assignBom` 재사용 (PR-B5 매퍼 `saveBomAssignment` 1:1). 백엔드는 EBOM/MBOM 분기 이미 지원, 이번에 MBOM UI 진입점도 첫 노출.
**도면 업로드 차이**: 운영판 `fn_uploadDrawingFiles`는 placeholder ("구현 예정" 토스트). RPS는 공용 `AttachFileDropZone` 재사용해 wace 보다 앞서 실구현.
**신규 산출물**:
| 위치 | 역할 |
|---|---|
| `backend-node/src/services/mbomService.ts` | `searchAssignableMboms` / `previewMbomTree` / `getLatestMbomByPartNo` 3 함수 |
| `backend-node/src/controllers/mbomController.ts` | 3 핸들러 추가 |
| `backend-node/src/routes/productionMbomRoutes.ts` | `GET /assignable-mboms`, `GET /mbom-preview/:objid`, `GET /latest-mbom-by-partno/:partNo` |
| `frontend/lib/api/mbom.ts` | 3 메서드 + `AssignableMbomRow` / `LatestMbomByPartNoRow` 타입 |
| `frontend/components/production/BomCopyDialog.tsx` | 신규 다이얼로그 |
| `frontend/app/(main)/COMPANY_16/production/mbom/page.tsx` | [BOM 복사] 버튼 + Dialog 연결 |
## 7. 백엔드 산출물 ([backend-node/src/](../../../backend-node/src/))
| 파일 | 역할 |
|---|---|
| `services/mbomService.ts` | list / getDetail / getTree(4분기) / save / getHistory / searchAssignableEboms / previewEbomTree / assignBom / searchAssignableMboms / previewMbomTree / getLatestMbomByPartNo / createSalesRequest |
| `controllers/mbomController.ts` | 위 모든 핸들러 |
| `routes/productionMbomRoutes.ts` | 위 모든 라우트 |
| `services/prodPlanResultService.ts` | listGeneral (plan-result) + listEquip (plan-result-equip), buildWhere 공유 |
| `controllers/prodPlanResultController.ts` | 위 2 핸들러 |
| `routes/productionPlanResultRoutes.ts` | GET /list, GET /equip/list |
| `services/mbomRequirementService.ts` | listSemi (반제품) + listRaw (원자재), getOptions (M-BOM 셀렉트 + 품명) |
## 8. 프론트엔드 산출물 ([frontend/](../../../frontend/))
| 파일 | 역할 |
|---|---|
| `lib/api/mbom.ts` | 타입 + mbomApi 14 메서드 |
| `lib/api/prodPlanResult.ts` | 일반/장비 list 타입 |
| `components/production/MbomDetailDialog.tsx` | 단건 상세 + 트리 + 편집 + toolbar(변경이력/BOM 할당/본 편집/행 add-del) |
| `components/production/MbomHistoryDialog.tsx` | 변경이력 그리드 |
| `components/production/MbomAddPartDialog.tsx` | 행 추가 시 PART 검색 |
| `components/production/MbomAssignDialog.tsx` | BOM 할당 (운영 mBomEbomSelectPopup 1:1) |
| `components/production/BomCopyDialog.tsx` | **BOM 복사 (PR-B5+ 신규)** |
| `app/(main)/COMPANY_16/production/mbom/page.tsx` | 메인 리스트 + 폴더 컬럼 + 서버 페이지네이션 + [구매리스트 생성] + [BOM 복사] |
| `app/(main)/COMPANY_16/purchase/mbom/page.tsx` | re-export (구매관리 트리 노출) |
| `app/(main)/COMPANY_16/production/plan-result/page.tsx` | 생산계획&실적 |
| `app/(main)/COMPANY_16/production/plan-result-equip/page.tsx` | 장비 |
| `app/(main)/COMPANY_16/production/semi-product-req/page.tsx` | 반제품 소요량 |
| `app/(main)/COMPANY_16/production/raw-material-req/page.tsx` | 원자재 소요량 |
## 9. 작업 원칙 (도메인 공통)
- 운영판 1:1 정확 일치 — `waceplm.esgrin.com` 진실의 기준
- JSP/매퍼/Java `/* */` · `<!-- -->` · `//` 비활성 보존 — 이식 대상 아님
- bigint=varchar 함정: `ATTACH_FILE_INFO` 서브쿼리는 `P.OBJID::varchar = F.TARGET_OBJID` 캐스트 필수
- numeric 캐스팅: RPS `mbom_detail.qty/required_qty``numeric(15,4)`. wace 의 `NULLIF(x,'')::INTEGER` 패턴 적용 시 "invalid input syntax" 발생 → `COALESCE(x, 0)::INTEGER` 패턴 사용 (c83a73a1)
- 메뉴 등록은 menu_info 직접 UPDATE (data-sync 스크립트 동반)
- 다른 세션 작업물은 따로 커밋 — working tree 에 보여도 묶지 말 것
- 공용 컴포넌트 의무: PageHeader + CompactFilterBar + SmartSelect/CustomerSelect + DataGrid
## 10. 다음 작업 후보
1. **plan-result 액션 모달** — [생산계획 생성](`prodPlanFormPopup.jsp`) / [생산실적 등록](`prodResultFormPopup.jsp`) 1:1 이식
2. **공통 PartSelect 컴포넌트** — wace `Select2-part`(AJAX 자동완성) 복원, 4개 메뉴 공통 적용
3. **PR-B6** — Excel Upload/Download (운영판 mBomFormPopup 의 Excel 버튼 2종)
4. **PR-B7** — 행 순서 변경 (up/down) + 부모 변경 (drag drop)
5. **품질관리 후속** — chpark 가 베이스만 짠 4개 메뉴(incoming/process/semi-product inspection) 상세화 (별 PR)
6. **자재관리 도메인 진입** — 별 PR
+172
View File
@@ -0,0 +1,172 @@
# 영업관리 > 구매요청서관리 · 품의서관리 (2026-05-15 신규 2메뉴)
> 작성: 2026-05-15 / 작성자: hjjeong
> 원본: wace_plm `/salesMng/purchaseRequestRegList.do` + `/salesMng/purchaseRegProposalMngList.do`
> 신규 위치: `frontend/app/(main)/COMPANY_16/sales/{purchase-request,purchase-proposal}/page.tsx`
> 백엔드: `backend-node/src/{services/salesPurchaseRequestService.ts,routes/salesPurchaseRequestRoutes.ts}`
## 0. 한 문장 요약
`sales_request_master` 한 테이블을 `doc_type` 으로 갈라 **구매요청서(`PURCHASE_REG`)****품의서(`PURCHASE_REG_PROPOSAL`)** → 결재완료 시 **구매관리>품의서관리**로 흘러가는 3단 파이프라인의 앞단 2단을 영업관리 메뉴에 신설.
## 1. 메뉴 매핑
| # | 메뉴명 | RPS URL | wace URL | wace JSP | wace 매퍼 | DOC_TYPE 필터 |
|---|---|---|---|---|---|---|
| 5 | 구매요청서관리 | `/purchase-request/request` | `/salesMng/purchaseRequestRegList.do` | `salesMng/purchaseRequestRegList.jsp` (728줄) | `salesMng.getSalesRequestMasterGridList` (DOC_TYPE_FILTER='PURCHASE_REG') | `PURCHASE_REG` |
| 6 | 품의서관리 (영업) | `/purchase-request/proposal` | `/salesMng/purchaseRegProposalMngList.do` | `salesMng/purchaseRegProposalMngList.jsp` (313줄) | `salesMng.getPurchaseRegProposalMngGridList` | `PURCHASE_REG_PROPOSAL` |
## 2. 구매관리 > 품의서관리와의 차이 (중요)
| 구분 | 구매관리 > 품의서관리 (`/purchase/proposal`) | 영업관리 > 품의서관리 (`/purchase-request/proposal`) |
|---|---|---|
| wace URL | `/salesMng/proposalMngList.do` | `/salesMng/purchaseRegProposalMngList.do` |
| wace 매퍼 | `getProposalMngGridList` | `getPurchaseRegProposalMngGridList` |
| DOC_TYPE | `PROPOSAL` 전부 + `PURCHASE_REG_PROPOSAL`(결재완료만) | `PURCHASE_REG_PROPOSAL` 전용 |
| 화면 의미 | **발주서 생성 풀** — 결재완료된 품의서 모음 | **결재상신 화면** — 구매요청서에서 만든 품의서 |
| 컬럼 차이 | + 발주서 No, + 공급업체, + 총액 | 헤더 9개 (품의서No/프로젝트/구매유형/주문유형/제품구분/품번/품명/결재상태/작성일/작성자) |
| 액션 | 결재상신 / **발주서생성** | **결재상신** (Amaranth10 SSO) |
| 데이터 흐름 | 영업관리>품의서 결재완료 → 자동 노출 | 구매요청서에서 품의서생성 시 신규 row |
**핵심**: 같은 `sales_request_master` 테이블이지만 `doc_type` 으로 책임이 갈림. 영업관리 측은 작성·결재상신을 담당, 구매관리 측은 결재완료된 것들을 발주서로 변환.
## 3. 3단 파이프라인 흐름
```
[구매요청서관리] [영업>품의서관리] [구매>품의서관리]
PURCHASE_REG PURCHASE_REG_PROPOSAL PURCHASE_REG_PROPOSAL
sales_request_master ──┬─── sales_request_master ──┬─── (결재완료만 노출)
PROJECT_NO=프로젝트 │ PROJECT_NO=부모 PURCHASE_REG │ +
+ 작성중/품의서생성/확정 │ OBJID │ PROPOSAL 전부
│ │
품의서생성 결재상신 발주서생성
(선택 1건 → POST) (Amaranth SSO) (purchase_order_*)
```
부모-자식 연결: `PURCHASE_REG_PROPOSAL.PROJECT_NO = PURCHASE_REG.OBJID::VARCHAR` (wace 매퍼 1138줄 CASE 절 1:1).
## 4. 그리드 컬럼 매핑
### 4.1 구매요청서관리 (14컬럼)
| # | 라벨 | 필드 | 출처 |
|---|---|---|---|
| 1 | 요청번호 | `request_mng_no` | SRM.REQUEST_MNG_NO |
| 2 | 구매유형 | `purchase_type_name` | comm_code(0001814) |
| 3 | 프로젝트번호 | `project_number` | project_mgmt.PROJECT_NO |
| 4 | 주문유형 | `order_type_name` | project_mgmt.CATEGORY_CD 우선, fallback SRM.ORDER_TYPE |
| 5 | 제품구분 | `product_name_full` | contract_mgmt.PRODUCT 우선, fallback SRM.PRODUCT_NAME |
| 6 | 고객사 | `customer_name` | contract_mgmt.CUSTOMER_OBJID → CLIENT_MNG/SUPPLY_MNG 분기 |
| 7 | 유/무상 | `paid_type_name` | contract_mgmt.PAID_TYPE 우선, fallback SRM.PAID_TYPE |
| 8 | 품번 | `part_display` | MBOM_DETAIL → PART_MNG.PART_NO (+ "외 N건") |
| 9 | 품명 | `part_name_display` | MBOM_DETAIL → PART_MNG.PART_NAME (+ "외 N건") |
| 10 | 구매요청서 | `has_purchase_request_label` | MBOM_HEADER_OBJID 존재 시 "작성" else "미작성" |
| 11 | 작성자 | `request_user_name` | user_name(REQUEST_USER_ID ?? WRITER) |
| 12 | 입고요청일 | `delivery_request_date` | SRM.DELIVERY_REQUEST_DATE |
| 13 | 작성일 | `regdate_title` | TO_CHAR(SRM.REGDATE, 'YYYY-MM-DD') |
| 14 | 상태 | `status_title` | 품의서생성 / 확정 / 작성중 CASE |
### 4.2 품의서관리 (영업) (10컬럼)
| # | 라벨 | 필드 | 출처 |
|---|---|---|---|
| 1 | 품의서 No | `proposal_no` | SRM.REQUEST_MNG_NO |
| 2 | 프로젝트번호 | `project_number` | project_mgmt.PROJECT_NO |
| 3 | 구매유형 | `purchase_type_name` | comm_code(0001814) |
| 4 | 주문유형 | `order_type_name` | 위와 동일 |
| 5 | 제품구분 | `product_name_title` | comm_code(SRM.PRODUCT_NAME) |
| 6 | 품번 | `part_display` | MBOM_DETAIL → PART_MNG |
| 7 | 품명 | `part_name_display` | 위와 동일 |
| 8 | 결재상태 | `status_title` | Amaranth status 우선순위 (결재완료/결재 상신중/반려/등록중) |
| 9 | 작성일 | `regdate_title` | TO_CHAR(SRM.REGDATE) |
| 10 | 작성자 | `writer_name` | user_name(SRM.WRITER) |
## 5. 백엔드 SQL 정합성 메모
### 5.1 상태 CASE (구매요청서)
```sql
CASE
WHEN EXISTS (
SELECT 1 FROM SALES_REQUEST_MASTER P
WHERE P.DOC_TYPE = 'PURCHASE_REG_PROPOSAL'
AND P.PROJECT_NO = SRM.OBJID::VARCHAR
) THEN '품의서생성'
WHEN SRM.STATUS = 'confirmed' THEN '확정'
WHEN SRM.STATUS = 'create' THEN '작성중'
ELSE COALESCE(SRM.STATUS, '')
END
```
wace 매퍼 `salesMng.xml:1135~1141` 1:1.
### 5.2 결재상태 CASE (품의서)
```sql
CASE
WHEN AMR.STATUS = 'complete' THEN 'approvalComplete'
WHEN AMR.STATUS = 'inProcess' THEN 'inProcess'
WHEN AMR.STATUS = 'reject' THEN 'reject'
ELSE SRM.STATUS
END
```
JOIN: `amaranth_approval AMR ON SRM.OBJID::VARCHAR=AMR.TARGET_OBJID AND AMR.TARGET_TYPE='PROPOSAL'`.
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. 액션 구현 (2026-05-15 완료)
| 항목 | 상태 | 구현 위치 |
|---|---|---|
| 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차 스캐폴드 커밋
`7e7c6a0a` 영업관리 구매요청서관리·품의서관리 신규 2메뉴 (wace_plm 1:1)
- 6 files, +804 insertions
## 8. 검증 체크리스트
- [x] 백엔드/프론트 타입체크 통과 (사전부터 있던 multilang/zebraBluetooth 외 새 파일 0 에러)
- [ ] dev 서버 (8080/9771) 에서 두 메뉴 그리드 조회 동작 확인
- [ ] sales_request_master 에 `DOC_TYPE='PURCHASE_REG'` / `'PURCHASE_REG_PROPOSAL'` 행 존재 시 노출 확인 (현재 데이터 0건일 수 있음)
- [ ] 검색 필터 (구매유형/작성자/제품구분/작성일) 동작
- [ ] 구매관리>품의서관리 와 영업관리>품의서관리 가 동일 row 를 doc_type 기준으로 다르게 보여주는지 회귀 확인
+8 -3
View File
@@ -9,11 +9,13 @@
- **이식 방식**: JSP → Next.js 리라이트 (백엔드도 Java→Node 재작성, vexplor_rps `backend-node` 패턴 채택)
- **스키마 정책**: **하이브리드** — 도메인 테이블(`contract_mgmt`, `estimate_mgmt`, `sales_registration` 등)은 wace_plm 원본 스키마를 그대로 가져오고, 거래처/품목 등 **마스터는 vexplor_rps 기존 테이블(`customer_mng`, `item_info`)에 매핑**
- **관리자 메뉴**: 이식 대상 아님 (vexplor 그대로 사용)
- **이식 대상 메뉴 4개**:
- **이식 대상 메뉴 6개** (초기 4개 + 2026-05-15 확장 2개):
1. 견적관리 (`/contractMgmt/estimateList_new.do`)
2. 주문서관리 (`/contractMgmt/orderMgmtList.do`)
3. 판매관리 (`/contractMgmt/salesMgmtList.do` → SalesNcollect로 위임)
4. 매출관리 (`/revenueMgmt/revenueList.do`)
5. **구매요청서관리** (`/salesMng/purchaseRequestRegList.do`) — 2026-05-15 추가
6. **품의서관리(영업)** (`/salesMng/purchaseRegProposalMngList.do`) — 2026-05-15 추가
## 1. 메뉴 매핑표
@@ -23,6 +25,8 @@
| 2 | 주문서관리 | `/contractMgmt/orderMgmtList.do` | `contractMgmt/orderMgmtList.jsp` (45KB) | `ContractMgmtController` (25043169 line) / `ContractMgmtService` | `app/(main)/COMPANY_16/sales/order/page.tsx` (재작성) + `backend-node/src/{routes,services}/orderMgmtRoutes.ts` | [02-order.md](./02-order.md) |
| 3 | 판매관리 | `/contractMgmt/salesMgmtList.do` (위임) → `/salesNcollectMgmt/sales.do` | `salesmgmt/salesMgmt/*.jsp` | `SalesNcollectMgmtController` (line 763~) / `SalesNcollectMgmtService` | `app/(main)/COMPANY_16/sales/sale/page.tsx` + `backend-node/src/{routes,services}/saleRoutes.ts` | [03-sale.md](./03-sale.md) |
| 4 | 매출관리 | `/revenueMgmt/revenueList.do` | `salesmgmt/salesMgmt/revenueMgmtList.jsp` | `SalesNcollectMgmtController` (line 103, 214) / `SalesNcollectMgmtService` | `app/(main)/COMPANY_16/sales/revenue/page.tsx` + `backend-node/src/{routes,services}/revenueRoutes.ts` | [04-revenue.md](./04-revenue.md) |
| 5 | 구매요청서관리 | `/salesMng/purchaseRequestRegList.do` | `salesMng/purchaseRequestRegList.jsp` (728줄) | `SalesMngController` (210~258) / `salesMng.getSalesRequestMasterGridList` (DOC_TYPE_FILTER='PURCHASE_REG') | `app/(main)/COMPANY_16/purchase-request/request/page.tsx` + `backend-node/src/{routes,services}/salesPurchaseRequestRoutes.ts` | [09-purchase-request.md](./09-purchase-request.md) |
| 6 | 품의서관리 (영업) | `/salesMng/purchaseRegProposalMngList.do` | `salesMng/purchaseRegProposalMngList.jsp` (313줄) | `SalesMngController` (1363~1389) / `salesMng.getPurchaseRegProposalMngGridList` | `app/(main)/COMPANY_16/purchase-request/proposal/page.tsx` + `backend-node/src/{routes,services}/salesPurchaseRequestRoutes.ts` | [09-purchase-request.md](./09-purchase-request.md) |
| ─ | 마스터 매핑 | (전 메뉴 공통) | — | — | — | [05-master-mapping.md](./05-master-mapping.md) |
> ⚠️ vexplor_rps의 기존 [sales/quote](../../../frontend/app/(main)/COMPANY_16/sales/quote/page.tsx)/[sales/order](../../../frontend/app/(main)/COMPANY_16/sales/order/page.tsx) 페이지는 별도 도메인(`quote_mng`/`quote_detail`)으로 만들어져 있음. 이식 후 **사용 중지** 또는 **별도 모듈로 이름 변경** 검토 필요. 신규 페이지는 `estimate/`, `order/` 신규 경로로 작성하는 것을 권장.
@@ -143,8 +147,9 @@ import { useAuth } from "@/hooks/useAuth";
1. ~~운영 DB DDL 추출~~ 완료 (2026-05-07)
2. ~~01~04 상세 매핑 + 1차 이식~~ 완료 (2026-05-08)
3. **[00-gap.md](./00-gap.md) 우선** — 원본 흐름 10단계 vs 이식본 GAP 매트릭스. 다음 PR(A: 수주확정→프로젝트 자동생성, B: 직접등록 통합폼, C: 결재·메일·PDF) 합의 문서.
4. PR-A부터 착수: `salesOrderMgmtService.updateStatus`에 project_mgmt 자동생성 + project_no 채번 로직 이식.
3. ~~[00-gap.md](./00-gap.md) PR-A/B/C 흐름 + G7~G11 결재상신~~ 완료 (2026-05-11)
4. ~~구매요청서관리·품의서관리(영업) 1차 스캐폴드~~ 완료 (2026-05-15, 커밋 `7e7c6a0a`, [09-purchase-request.md](./09-purchase-request.md))
5. **다음**: 구매요청서작성 다이얼로그 + 품의서생성 액션 + 영업>품의서 Amaranth 결재상신(target_type='PROPOSAL', formId='1163'). sales_request_part 운영DB DDL 추출 선행.
## 8. 공통 UX 규칙 (검색 폼 / 영업관리 4개 메뉴 동일 적용)
@@ -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);
@@ -11,7 +11,7 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ShoppingCart, Loader2 } from "lucide-react";
import { ShoppingCart, Loader2, Copy } from "lucide-react";
import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
@@ -22,6 +22,7 @@ import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { apiClient } from "@/lib/api/client";
import { mbomApi, MbomListFilter, MbomRow } from "@/lib/api/mbom";
import { MbomDetailDialog } from "@/components/production/MbomDetailDialog";
import { BomCopyDialog } from "@/components/production/BomCopyDialog";
import { exportToExcel } from "@/lib/utils/excelExport";
const PARENT_CATEGORY = "0000167"; // 주문유형 comm_code parent_code_id
@@ -78,6 +79,16 @@ export default function MbomMgmtPage() {
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [creatingPurchaseList, setCreatingPurchaseList] = useState(false);
// PR-B5+: BOM 복사 — 단건 체크 + 다이얼로그 (wace fn_openBomCopyPopup 1:1)
const [bomCopyOpen, setBomCopyOpen] = useState(false);
const [bomCopyTarget, setBomCopyTarget] = useState<{
projectObjid: string;
partNo: string;
partName: string;
productCode: string;
hasMbom: boolean;
} | null>(null);
const fetchList = useCallback(async (override?: Partial<MbomListFilter>) => {
setLoading(true);
try {
@@ -224,6 +235,32 @@ export default function MbomMgmtPage() {
}
}, [checkedIds, gridRows, confirm, fetchList]);
// PR-B5+ — BOM 복사 (wace fn_openBomCopyPopup 1:1)
// 검증: 단건 체크 + objid 확인. M-BOM 이미 있으면 다이얼로그 내부에서 "초기화" 경고.
const handleOpenBomCopy = useCallback(() => {
if (checkedIds.length === 0) {
toast.info("BOM 을 복사할 프로젝트를 선택해주세요.");
return;
}
if (checkedIds.length > 1) {
toast.info("한 번에 하나의 프로젝트만 선택해주세요.");
return;
}
const row = gridRows.find((r: any) => r.id === checkedIds[0]) as any;
if (!row?.objid) {
toast.error("프로젝트 OBJID를 찾을 수 없습니다.");
return;
}
setBomCopyTarget({
projectObjid: String(row.objid),
partNo: String(row.part_no ?? ""),
partName: String(row.part_name ?? ""),
productCode: String(row.product ?? ""),
hasMbom: !!(row.mbom_header_objid && String(row.mbom_status ?? "") === "Y"),
});
setBomCopyOpen(true);
}, [checkedIds, gridRows]);
return (
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
<PageHeader
@@ -231,18 +268,30 @@ export default function MbomMgmtPage() {
onSearch={handleSearch}
onReset={handleReset}
actions={
<Button
size="sm"
variant="default"
className="h-8 gap-1 px-2 text-xs"
onClick={handleCreatePurchaseList}
disabled={creatingPurchaseList || checkedIds.length === 0}
>
{creatingPurchaseList
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <ShoppingCart className="h-3.5 w-3.5" />}
</Button>
<>
<Button
size="sm"
variant="outline"
className="h-8 gap-1 px-2 text-xs"
onClick={handleOpenBomCopy}
disabled={checkedIds.length === 0}
>
<Copy className="h-3.5 w-3.5" />
BOM
</Button>
<Button
size="sm"
variant="default"
className="h-8 gap-1 px-2 text-xs"
onClick={handleCreatePurchaseList}
disabled={creatingPurchaseList || checkedIds.length === 0}
>
{creatingPurchaseList
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <ShoppingCart className="h-3.5 w-3.5" />}
</Button>
</>
}
/>
<CompactFilterBar
@@ -365,6 +414,23 @@ export default function MbomMgmtPage() {
onSaved={fetchList}
/>
<BomCopyDialog
open={bomCopyOpen}
onOpenChange={(v) => {
setBomCopyOpen(v);
if (!v) setBomCopyTarget(null);
}}
projectObjid={bomCopyTarget?.projectObjid ?? null}
partNo={bomCopyTarget?.partNo ?? null}
partName={bomCopyTarget?.partName ?? null}
productCode={bomCopyTarget?.productCode ?? null}
hasMbom={bomCopyTarget?.hasMbom ?? false}
onSaved={() => {
setCheckedIds([]);
fetchList();
}}
/>
{ConfirmDialogComponent}
</div>
);
@@ -0,0 +1,239 @@
"use client";
// 영업관리 > 품의서관리 — wace salesMng/purchaseRegProposalMngList.jsp 1:1
// 그리드: sales_request_master (doc_type='PURCHASE_REG_PROPOSAL') + mbom 품번/품명
// 검색: 품의서No / 프로젝트번호 / 결재상태 / 작성일 / 구매유형 / 작성자 / 제품구분
// 액션: 조회 / 결재상신 (Amaranth10 SSO 연동 — 기존 견적 패턴 재사용 예정)
//
// 구매관리>품의서관리 vs 영업관리>품의서관리:
// - 구매관리: DOC_TYPE in ('PROPOSAL', 'PURCHASE_REG_PROPOSAL'(결재완료만)) → 발주서 생성 풀
// - 영업관리: DOC_TYPE = 'PURCHASE_REG_PROPOSAL' 만 → 구매요청서 → 품의서 결재상신 화면
// 결재완료 시 구매관리>품의서관리에 자동 노출됨.
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Send } from "lucide-react";
import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar";
import { PageHeader } from "@/components/common/PageHeader";
import { apiClient } from "@/lib/api/client";
import { purchaseApi, OptionItem } from "@/lib/api/purchase";
import {
salesPurchaseRequestApi,
SalesPurchaseRequestFilter,
} from "@/lib/api/salesPurchaseRequest";
import { exportToExcel } from "@/lib/utils/excelExport";
const PARENT_PURCHASE_TYPE = "0001814";
const PARENT_PART_TYPE = "0000001";
const STATUS_OPTS: SmartSelectOption[] = [
{ code: "create", label: "작성중" },
{ code: "inProcess", label: "결재중" },
{ code: "approvalComplete", label: "결재완료" },
{ code: "reject", label: "반려" },
];
const EMPTY_FILTER: SalesPurchaseRequestFilter = {
proposal_no: "", project_no: "", search_status: "",
purchase_type: "", writer: "", part_type: "",
regdate_start: "", regdate_end: "",
page: 1, page_size: 50,
};
export default function PurchaseRegProposalPage() {
const [rows, setRows] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<SalesPurchaseRequestFilter>(EMPTY_FILTER);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [purchaseTypeOpts, setPurchaseTypeOpts] = useState<SmartSelectOption[]>([]);
const [partTypeOpts, setPartTypeOpts] = useState<SmartSelectOption[]>([]);
const [userOpts, setUserOpts] = useState<OptionItem[]>([]);
const fetchList = useCallback(async (override?: Partial<SalesPurchaseRequestFilter>) => {
setLoading(true);
try {
const f = { ...filter, ...override };
const res = await salesPurchaseRequestApi.listPurchaseRegProposal(f);
setRows(res.rows ?? []);
setTotal(res.totalCount ?? 0);
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
} finally {
setLoading(false);
}
}, [filter]);
useEffect(() => {
let dead = false;
(async () => {
try {
const [pt, ptt, u] = await Promise.all([
apiClient.get(`/sales/codes/${PARENT_PURCHASE_TYPE}`),
apiClient.get(`/sales/codes/${PARENT_PART_TYPE}`),
purchaseApi.listUsers(),
]);
if (dead) return;
setPurchaseTypeOpts(pt.data?.data ?? []);
setPartTypeOpts(ptt.data?.data ?? []);
setUserOpts(u);
} catch { /* skip */ }
})();
fetchList(EMPTY_FILTER);
return () => { dead = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const gridRows = useMemo(() => rows.map((r, i) => ({
...r,
id: r.objid ?? `prp_${i}`,
part_display: r.part_extra_count > 0 ? `${r.part_no}${r.part_extra_count}` : r.part_no,
part_name_display: r.part_extra_count > 0 ? `${r.part_name}${r.part_extra_count}` : r.part_name,
})), [rows]);
const GRID_COLUMNS: DataGridColumn[] = useMemo(() => ([
{ key: "proposal_no", label: "품의서 No", width: "w-[140px]", align: "center" },
{ key: "project_number", label: "프로젝트번호", width: "w-[150px]", align: "center" },
{ key: "purchase_type_name", label: "구매유형", width: "w-[115px]", align: "center" },
{ key: "order_type_name", label: "주문유형", width: "w-[115px]", align: "center" },
{ key: "product_name_title", label: "제품구분", width: "w-[115px]", align: "center" },
{ key: "part_display", label: "품번", width: "w-[160px]" },
{ key: "part_name_display", label: "품명", minWidth: "min-w-[200px]" },
{ key: "status_title", label: "결재상태", width: "w-[120px]", align: "center" },
{ key: "regdate_title", label: "작성일", width: "w-[115px]", align: "center" },
{ key: "writer_name", label: "작성자", width: "w-[120px]", align: "center" },
]), []);
const summary = useMemo(() => {
const approved = gridRows.filter((r: any) => r.status === "approvalComplete").length;
const inProc = gridRows.filter((r: any) => r.status === "inProcess").length;
return [
{ label: "전체 건수", value: total.toLocaleString(), suffix: "건" },
{ label: "결재완료(페이지)", value: approved.toLocaleString(), suffix: "건" },
{ label: "결재중(페이지)", value: inProc.toLocaleString(), suffix: "건" },
{ label: "선택", value: checkedIds.length.toLocaleString(), suffix: "건" },
];
}, [gridRows, total, checkedIds]);
const handleSearch = () => { setFilter(f => ({ ...f, page: 1 })); fetchList({ page: 1 }); };
const handleReset = () => { setFilter(EMPTY_FILTER); fetchList(EMPTY_FILTER); };
const [approvalLoading, setApprovalLoading] = useState(false);
const onApproval = async () => {
if (checkedIds.length !== 1) {
toast.info("결재상신할 1건을 선택해주세요.");
return;
}
const sel = gridRows.find((r: any) => r.id === checkedIds[0]);
if (!sel) return;
if (sel.status === "inProcess") return toast.info("결재 진행중인 건은 상신할 수 없습니다.");
if (sel.status === "approvalComplete") return 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 (
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
<PageHeader
title="품의서관리"
loading={loading} onSearch={handleSearch} onReset={handleReset}
actions={<>
<Button size="sm" variant="default" className="h-8 gap-1 px-2 text-xs bg-cyan-600 hover:bg-cyan-700"
disabled={checkedIds.length !== 1 || approvalLoading}
onClick={onApproval}>
<Send className="h-3.5 w-3.5" /> {approvalLoading ? "처리 중..." : "결재상신"}
</Button>
</>}
/>
<CompactFilterBar totalText={<> {total.toLocaleString()}</>}>
<CompactFilterField label="품의서 No" width={150}>
<Input value={filter.proposal_no ?? ""}
onChange={(e) => setFilter({ ...filter, proposal_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="프로젝트번호" width={170}>
<Input value={filter.project_no ?? ""}
onChange={(e) => setFilter({ ...filter, project_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="결재상태" width={130}>
<SmartSelect options={STATUS_OPTS} value={filter.search_status ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_status: v })} />
</CompactFilterField>
<CompactFilterField label="작성일" width={280}>
<CompactDateRange
from={filter.regdate_start ?? ""} setFrom={(v) => setFilter({ ...filter, regdate_start: v })}
to={filter.regdate_end ?? ""} setTo={(v) => setFilter({ ...filter, regdate_end: v })}
/>
</CompactFilterField>
<CompactFilterField label="구매유형" width={150}>
<SmartSelect options={purchaseTypeOpts} value={filter.purchase_type ?? ""}
onValueChange={(v) => setFilter({ ...filter, purchase_type: v })} />
</CompactFilterField>
<CompactFilterField label="작성자" width={170}>
<SmartSelect options={userOpts} value={filter.writer ?? ""}
onValueChange={(v) => setFilter({ ...filter, writer: v })} />
</CompactFilterField>
<CompactFilterField label="제품구분" width={150}>
<SmartSelect options={partTypeOpts} value={filter.part_type ?? ""}
onValueChange={(v) => setFilter({ ...filter, part_type: v })} />
</CompactFilterField>
</CompactFilterBar>
<DataGrid
columns={GRID_COLUMNS}
data={gridRows}
loading={loading}
showCheckbox
checkedIds={checkedIds}
onCheckedChange={setCheckedIds}
emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"}
gridId="sales-purchase-proposal"
pageSizeOptions={[10, 15, 20, 50, 100]}
paginationStyle="range"
serverPaging
serverPage={filter.page ?? 1}
serverPageSize={filter.page_size ?? 50}
serverTotalItems={total}
onPageChange={(p) => { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }}
onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }}
showColumnSettings
summaryStats={summary}
systemColumnKeys={["writer_name", "regdate_title"]}
onRefresh={() => fetchList()}
onDownload={() => {
if (gridRows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
const exportRows = gridRows.map((r: any) => {
const out: Record<string, any> = {};
GRID_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; });
return out;
});
exportToExcel(exportRows, "영업_품의서관리.xlsx", "품의서");
}}
showChart
/>
</div>
);
}
@@ -0,0 +1,251 @@
"use client";
// 영업관리 > 구매요청서관리 — wace salesMng/purchaseRequestRegList.jsp 1:1
// 그리드: sales_request_master (doc_type='PURCHASE_REG') + mbom 품번/품명
// 검색: 품번 / 품명 / 작성일 / 구매유형(single) / 작성자 / 제품구분
// 액션: 조회 / 구매요청서작성(예정) / 품의서생성(예정)
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { FilePlus, ClipboardCheck } from "lucide-react";
import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar";
import { PageHeader } from "@/components/common/PageHeader";
import { apiClient } from "@/lib/api/client";
import { purchaseApi, OptionItem } from "@/lib/api/purchase";
import {
salesPurchaseRequestApi,
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"; // 제품구분
const EMPTY_FILTER: SalesPurchaseRequestFilter = {
project_no: "", part_no: "", part_name: "",
purchase_type: "", writer: "", part_type: "",
regdate_start: "", regdate_end: "",
page: 1, page_size: 50,
};
export default function PurchaseRequestRegPage() {
const [rows, setRows] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<SalesPurchaseRequestFilter>(EMPTY_FILTER);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [formOpen, setFormOpen] = useState(false);
const [editObjid, setEditObjid] = useState<string | undefined>(undefined);
const [proposalOpen, setProposalOpen] = useState(false);
const [purchaseTypeOpts, setPurchaseTypeOpts] = useState<SmartSelectOption[]>([]);
const [partTypeOpts, setPartTypeOpts] = useState<SmartSelectOption[]>([]);
const [userOpts, setUserOpts] = useState<OptionItem[]>([]);
const fetchList = useCallback(async (override?: Partial<SalesPurchaseRequestFilter>) => {
setLoading(true);
try {
const f = { ...filter, ...override };
const res = await salesPurchaseRequestApi.listPurchaseRequestReg(f);
setRows(res.rows ?? []);
setTotal(res.totalCount ?? 0);
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
} finally {
setLoading(false);
}
}, [filter]);
useEffect(() => {
let dead = false;
(async () => {
try {
const [pt, ptt, u] = await Promise.all([
apiClient.get(`/sales/codes/${PARENT_PURCHASE_TYPE}`),
apiClient.get(`/sales/codes/${PARENT_PART_TYPE}`),
purchaseApi.listUsers(),
]);
if (dead) return;
setPurchaseTypeOpts(pt.data?.data ?? []);
setPartTypeOpts(ptt.data?.data ?? []);
setUserOpts(u);
} catch { /* skip */ }
})();
fetchList(EMPTY_FILTER);
return () => { dead = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const gridRows = useMemo(() => rows.map((r, i) => ({
...r,
id: r.objid ?? `pr_${i}`,
part_display: r.part_extra_count > 0 ? `${r.part_no}${r.part_extra_count}` : r.part_no,
part_name_display: r.part_extra_count > 0 ? `${r.part_name}${r.part_extra_count}` : r.part_name,
has_purchase_request_label: r.has_purchase_request === "Y" ? "작성" : "미작성",
})), [rows]);
const GRID_COLUMNS: DataGridColumn[] = useMemo(() => ([
{ key: "request_mng_no", label: "요청번호", width: "w-[130px]", align: "center" },
{ key: "purchase_type_name", label: "구매유형", width: "w-[90px]", align: "center" },
{ key: "project_number", label: "프로젝트번호", width: "w-[140px]", align: "center" },
{ key: "order_type_name", label: "주문유형", width: "w-[80px]", align: "center" },
{ key: "product_name_full", label: "제품구분", width: "w-[90px]", align: "center" },
{ key: "customer_name", label: "고객사", width: "w-[140px]" },
{ key: "paid_type_name", label: "유/무상", width: "w-[70px]", align: "center" },
{ key: "part_display", label: "품번", width: "w-[140px]" },
{ key: "part_name_display", label: "품명", minWidth: "min-w-[180px]" },
{ key: "has_purchase_request_label",label: "구매요청서", width: "w-[80px]", align: "center" },
{ key: "request_user_name", label: "작성자", width: "w-[100px]", align: "center" },
{ key: "delivery_request_date", label: "입고요청일", width: "w-[100px]", align: "center" },
{ key: "regdate_title", label: "작성일", width: "w-[100px]", align: "center" },
{ key: "status_title", label: "상태", width: "w-[80px]", align: "center" },
]), []);
const summary = useMemo(() => {
const proposed = gridRows.filter((r: any) => r.status_title === "품의서생성").length;
const confirmed = gridRows.filter((r: any) => r.status_title === "확정").length;
return [
{ label: "전체 건수", value: total.toLocaleString(), suffix: "건" },
{ label: "품의서생성(페이지)", value: proposed.toLocaleString(), suffix: "건" },
{ label: "확정(페이지)", value: confirmed.toLocaleString(), suffix: "건" },
{ label: "선택", value: checkedIds.length.toLocaleString(), suffix: "건" },
];
}, [gridRows, total, checkedIds]);
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);
};
// 선택 1건 + 미확정·미상신 → 수정모드 / 그 외(미선택) → 신규
const handleOpenForm = () => {
if (selectedSrm) {
if (selectedSrm.status_title === "품의서생성") {
return toast.info("이미 품의서가 생성된 항목은 수정할 수 없습니다.");
}
if (selectedSrm.status_title === "확정") {
return toast.info("확정된 구매요청서는 수정할 수 없습니다.");
}
setEditObjid(selectedSrm.objid);
} else {
setEditObjid(undefined);
}
setFormOpen(true);
};
return (
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
<PageHeader
loading={loading} onSearch={handleSearch} onReset={handleReset}
actions={<>
<Button size="sm" variant="outline" className="h-8 gap-1 px-2 text-xs"
onClick={handleOpenForm}>
<FilePlus className="h-3.5 w-3.5" /> {selectedSrm ? "구매요청서수정" : "구매요청서작성"}
</Button>
<Button size="sm" variant="default" className="h-8 gap-1 px-2 text-xs"
disabled={checkedIds.length !== 1}
onClick={handleProposal}>
<ClipboardCheck className="h-3.5 w-3.5" />
</Button>
</>}
/>
<CompactFilterBar totalText={<> {total.toLocaleString()}</>}>
<CompactFilterField label="품번" width={160}>
<Input value={filter.part_no ?? ""}
onChange={(e) => setFilter({ ...filter, part_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="품명" width={170}>
<Input value={filter.part_name ?? ""}
onChange={(e) => setFilter({ ...filter, part_name: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="프로젝트번호" width={170}>
<Input value={filter.project_no ?? ""}
onChange={(e) => setFilter({ ...filter, project_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="작성일" width={280}>
<CompactDateRange
from={filter.regdate_start ?? ""} setFrom={(v) => setFilter({ ...filter, regdate_start: v })}
to={filter.regdate_end ?? ""} setTo={(v) => setFilter({ ...filter, regdate_end: v })}
/>
</CompactFilterField>
<CompactFilterField label="구매유형" width={150}>
<SmartSelect options={purchaseTypeOpts} value={filter.purchase_type ?? ""}
onValueChange={(v) => setFilter({ ...filter, purchase_type: v })} />
</CompactFilterField>
<CompactFilterField label="작성자" width={170}>
<SmartSelect options={userOpts} value={filter.writer ?? ""}
onValueChange={(v) => setFilter({ ...filter, writer: v })} />
</CompactFilterField>
<CompactFilterField label="제품구분" width={150}>
<SmartSelect options={partTypeOpts} value={filter.part_type ?? ""}
onValueChange={(v) => setFilter({ ...filter, part_type: v })} />
</CompactFilterField>
</CompactFilterBar>
<DataGrid
columns={GRID_COLUMNS}
data={gridRows}
loading={loading}
showCheckbox
checkedIds={checkedIds}
onCheckedChange={setCheckedIds}
emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"}
gridId="sales-purchase-request"
pageSizeOptions={[10, 15, 20, 50, 100]}
paginationStyle="range"
serverPaging
serverPage={filter.page ?? 1}
serverPageSize={filter.page_size ?? 50}
serverTotalItems={total}
onPageChange={(p) => { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }}
onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }}
showColumnSettings
summaryStats={summary}
systemColumnKeys={["request_user_name", "regdate_title"]}
onRefresh={() => fetchList()}
onDownload={() => {
if (gridRows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
const exportRows = gridRows.map((r: any) => {
const out: Record<string, any> = {};
GRID_COLUMNS.forEach((c) => { out[c.label] = r[c.key] ?? ""; });
return out;
});
exportToExcel(exportRows, "구매요청서관리.xlsx", "구매요청서");
}}
showChart
/>
<PurchaseRequestFormDialog
open={formOpen}
srmObjid={editObjid}
onClose={() => { setFormOpen(false); setEditObjid(undefined); }}
onSaved={() => { fetchList(); setCheckedIds([]); setEditObjid(undefined); }}
/>
{selectedSrm && (
<ProposalCreateDialog
open={proposalOpen}
onClose={() => setProposalOpen(false)}
srmObjid={selectedSrm.objid}
requestMngNo={selectedSrm.request_mng_no}
onCreated={() => { fetchList(); setCheckedIds([]); }}
/>
)}
</div>
);
}
@@ -30,6 +30,7 @@
import React from "react";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { DateInput } from "@/components/common/DateInput";
interface CompactFilterBarProps {
children: React.ReactNode;
@@ -98,34 +99,11 @@ export function CompactDateRange({
setTo: (v: string) => void;
disabled?: boolean;
}) {
// input[type=date] 는 브라우저별로 placeholder 영역 클릭 시 picker 미노출. showPicker() 강제 호출.
const openPicker = (e: React.MouseEvent<HTMLInputElement> | React.FocusEvent<HTMLInputElement>) => {
const el = e.currentTarget as any;
if (typeof el.showPicker === "function") {
try { el.showPicker(); } catch { /* 권한/포커스 제한 시 무시 */ }
}
};
return (
<div className="flex items-center gap-1">
<input
type="date"
className="h-7 w-[125px] rounded-md border bg-background px-2 text-xs disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
value={from}
onChange={(e) => setFrom(e.target.value)}
onClick={openPicker}
onFocus={openPicker}
disabled={disabled}
/>
<DateInput size="sm" value={from} onChange={setFrom} disabled={disabled} className="w-[140px]" />
<span className="text-xs text-muted-foreground">~</span>
<input
type="date"
className="h-7 w-[125px] rounded-md border bg-background px-2 text-xs disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
value={to}
onChange={(e) => setTo(e.target.value)}
onClick={openPicker}
onFocus={openPicker}
disabled={disabled}
/>
<DateInput size="sm" value={to} onChange={setTo} disabled={disabled} className="w-[140px]" />
</div>
);
}
+4 -4
View File
@@ -170,11 +170,11 @@ function SortableHeaderCell({
style={style}
className={cn(
widthPx == null && col.width, widthPx == null && col.minWidth,
"select-none relative group/th",
"select-none relative group/th !px-1.5",
col.frozen && cn("sticky z-20 bg-background", frozenLeftClass),
)}
>
<div className="inline-flex items-center gap-1 w-full">
<div className="inline-flex items-center gap-0.5 w-full">
<GripVertical
{...attributes}
{...listeners}
@@ -182,13 +182,13 @@ function SortableHeaderCell({
aria-label="컬럼 드래그"
/>
<div
className="flex items-center gap-0.5 cursor-pointer min-w-0 flex-1"
className="flex items-center gap-0.5 cursor-pointer min-w-0 flex-1 overflow-hidden"
onClick={(e) => {
e.stopPropagation();
if (col.sortable !== false) onSort(col.key);
}}
>
<span className="text-xs font-medium whitespace-nowrap" title={col.label}>{col.label}</span>
<span className="text-xs font-medium truncate min-w-0" title={col.label}>{col.label}</span>
{isSorted && (
<span className="text-primary text-xs shrink-0">{sortDir === "asc" ? "↑" : "↓"}</span>
)}
+165
View File
@@ -0,0 +1,165 @@
"use client";
/**
* DateInput — YYYY-MM-DD 형식 통일 공용 날짜 입력 컴포넌트
*
* 브라우저 `<input type="date">` 는 OS/로케일에 따라 "연도. 월. 일." 등 다른 placeholder 를
* 보여주는 문제가 있어, text input + Popover Calendar 로 표시·저장을 YYYY-MM-DD 로 통일.
*
* - 직접 타이핑: YYYY-MM-DD (8자리 숫자 입력 시 자동으로 - 삽입)
* - 캘린더 아이콘 클릭 → Popover Calendar 에서 날짜 선택
* - 항상 onChange 에 'YYYY-MM-DD' 또는 빈 문자열 전달
*/
import React, { useMemo, useState, useEffect, useRef } from "react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Calendar } from "@/components/ui/calendar";
import { Input } from "@/components/ui/input";
import { Calendar as CalendarIcon, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { format, parse, isValid } from "date-fns";
const FMT = "yyyy-MM-dd";
export interface DateInputProps {
value: string; // 'YYYY-MM-DD' 또는 ''
onChange: (v: string) => void;
disabled?: boolean;
placeholder?: string;
className?: string;
/** sm = h-7 (CompactFilter 행), md = h-9 (다이얼로그 폼) */
size?: "sm" | "md";
/** 값이 있을 때 ✕ 노출 (기본 true). 필수 필드는 false. */
clearable?: boolean;
}
function toDate(v: string): Date | null {
if (!v) return null;
const d = parse(v, FMT, new Date());
return isValid(d) ? d : null;
}
function isCompleteDate(v: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(v)) return false;
const d = parse(v, FMT, new Date());
return isValid(d);
}
/** 사용자 타이핑을 8자리 숫자로 받아 YYYY-MM-DD 로 슬라이스 */
function autoFormat(raw: string): string {
const digits = raw.replace(/\D/g, "").slice(0, 8);
if (digits.length <= 4) return digits;
if (digits.length <= 6) return `${digits.slice(0, 4)}-${digits.slice(4)}`;
return `${digits.slice(0, 4)}-${digits.slice(4, 6)}-${digits.slice(6, 8)}`;
}
export function DateInput({
value,
onChange,
disabled,
placeholder = "YYYY-MM-DD",
className,
size = "md",
clearable = true,
}: DateInputProps) {
const [open, setOpen] = useState(false);
const [text, setText] = useState(value || "");
const inputRef = useRef<HTMLInputElement>(null);
// 외부 value 동기화
useEffect(() => {
setText(value || "");
}, [value]);
const selectedDate = useMemo(() => toDate(value), [value]);
const invalid = text.length > 0 && !isCompleteDate(text);
const showClear = clearable && !disabled && !!value;
const onTextChange = (raw: string) => {
const formatted = autoFormat(raw);
setText(formatted);
if (formatted === "") {
onChange("");
} else if (isCompleteDate(formatted)) {
onChange(formatted);
}
// 아직 미완성이면 onChange 호출 안 함 (마지막 유효값 유지)
};
const onBlur = () => {
// 미완성 텍스트는 마지막 유효값으로 복귀
if (text && !isCompleteDate(text)) setText(value || "");
};
const handleCalendarSelect = (d: Date | undefined) => {
if (!d) { onChange(""); setText(""); }
else { const v = format(d, FMT); onChange(v); setText(v); }
setOpen(false);
};
const handleClear = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onChange("");
setText("");
inputRef.current?.focus();
};
const h = size === "sm" ? "h-7" : "h-9";
const textCls = size === "sm" ? "text-xs" : "text-sm";
return (
<div className={cn("relative inline-flex items-center", className)}>
<Input
ref={inputRef}
type="text"
inputMode="numeric"
placeholder={placeholder}
value={text}
disabled={disabled}
onChange={(e) => onTextChange(e.target.value)}
onBlur={onBlur}
className={cn(
h, textCls, "pr-16 w-full",
invalid && "border-destructive focus-visible:ring-destructive",
)}
aria-invalid={invalid || undefined}
/>
{showClear && (
<button
type="button"
tabIndex={-1}
aria-label="날짜 지우기"
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); }}
onClick={handleClear}
className="absolute right-8 top-1/2 -translate-y-1/2 z-10 inline-flex h-5 w-5 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted"
>
<X className="h-3.5 w-3.5" />
</button>
)}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={disabled}
aria-label="캘린더 열기"
className={cn(
"absolute right-1 top-1/2 -translate-y-1/2 z-10 inline-flex items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed",
size === "sm" ? "h-5 w-5" : "h-6 w-6",
)}
>
<CalendarIcon className="h-3.5 w-3.5" />
</button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end">
<Calendar
mode="single"
selected={selectedDate || undefined}
onSelect={handleCalendarSelect}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
);
}
+119
View File
@@ -0,0 +1,119 @@
"use client";
/**
* NumberInput — 숫자 표시 통일 공용 컴포넌트 (RPS 숫자 포맷 정책)
*
* - 금액(decimals=2): 1,234.00
* - 수량(decimals=0): 1,234
* - 표시: 콤마 + 소수점 자릿수 강제 (blur 시 정규화)
* - 편집: focus 시 raw 숫자 ("1234.5")로 전환되어 자유 입력 → blur 시 1,234.50 으로 재포맷
* - onChange 는 항상 number(또는 빈 문자열) 만 부모로 전달
*/
import React, { useEffect, useRef, useState } from "react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
export interface NumberInputProps {
value: number | string | null | undefined;
onChange: (v: number | "") => void;
decimals?: number; // 기본 0 (수량). 금액은 2.
min?: number;
max?: number;
disabled?: boolean;
placeholder?: string;
className?: string;
/** "right"=금액·수량 기본, 그 외도 가능 */
align?: "right" | "left" | "center";
}
function toNumOrEmpty(v: any): number | "" {
if (v === "" || v == null) return "";
const n = Number(v);
return Number.isFinite(n) ? n : "";
}
function formatFor(v: number | "", decimals: number): string {
if (v === "") return "";
return v.toLocaleString("ko-KR", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
}
export function NumberInput({
value,
onChange,
decimals = 0,
min,
max,
disabled,
placeholder,
className,
align = "right",
}: NumberInputProps) {
const num = toNumOrEmpty(value);
const [focused, setFocused] = useState(false);
const [draft, setDraft] = useState<string>("");
const inputRef = useRef<HTMLInputElement>(null);
// 외부 value 변경 시 draft 동기화 (focus 중 외부 강제 갱신 시도는 무시 — 자연스러운 편집)
useEffect(() => {
if (!focused) setDraft("");
}, [num, focused]);
const displayValue = focused
? draft
: (num === "" ? "" : formatFor(num, decimals));
const onFocus = (e: React.FocusEvent<HTMLInputElement>) => {
setFocused(true);
setDraft(num === "" ? "" : String(num)); // raw 숫자 (콤마 X)
// 전체 선택 — 빠른 재입력 편의
requestAnimationFrame(() => e.target.select());
};
const onBlur = () => {
setFocused(false);
if (draft.trim() === "") {
if (num !== "") onChange("");
return;
}
// 콤마/공백 제거 후 숫자화
const cleaned = draft.replace(/,/g, "").trim();
let n = Number(cleaned);
if (!Number.isFinite(n)) {
// 잘못된 입력 → 이전 값 유지
return;
}
if (decimals === 0) n = Math.floor(n);
else n = Number(n.toFixed(decimals));
if (min != null && n < min) n = min;
if (max != null && n > max) n = max;
onChange(n);
};
const onChangeInner = (e: React.ChangeEvent<HTMLInputElement>) => {
// 편집 중엔 자유 입력 허용 (콤마·소수점·- 모두 허용 — blur 시 정규화)
setDraft(e.target.value);
};
return (
<Input
ref={inputRef}
type="text"
inputMode={decimals > 0 ? "decimal" : "numeric"}
disabled={disabled}
placeholder={placeholder}
value={displayValue}
onFocus={onFocus}
onBlur={onBlur}
onChange={onChangeInner}
className={cn(
align === "right" && "text-right",
align === "center" && "text-center",
className,
)}
/>
);
}
+76 -2
View File
@@ -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<HTMLDivElement | null>(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<HTMLInputElement>) => {
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"
/>
</div>
@@ -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 (
<button
key={`${o.code}-${vItem.index}`}
type="button"
role="option"
aria-selected={isSelected}
onMouseEnter={() => setActiveIndex(vItem.index)}
onClick={() => {
onValueChange(o.code);
setOpen(false);
}}
className={cn(
"absolute left-0 top-0 w-full flex items-center px-2 text-sm text-left hover:bg-accent",
isSelected && "bg-accent/60",
"absolute left-0 top-0 w-full flex items-center px-2 text-sm text-left",
isActive ? "bg-accent" : "hover:bg-accent/40",
isSelected && !isActive && "bg-accent/60",
)}
style={{
height: `${vItem.size}px`,
@@ -0,0 +1,353 @@
"use client";
// 생산관리 > M-BOM 관리 — BOM 복사 다이얼로그 (PR-B5+).
//
// 운영판 partMng/structureBomCopyFormPopup.jsp (774 lines) 1:1 재구성.
// 진입: 메인 그리드 [BOM 복사] 버튼 (체크박스 1개 선택).
// 저장: /production/mbom/assign (saveBomAssignment) — MbomAssignDialog 와 동일 매퍼.
// 차이점: E-BOM/M-BOM 셀렉트 두 개 (상호배타) + 트리 미리보기 + 도면 다중 업로드.
//
// 운영판 fn_uploadDrawingFiles 는 placeholder ("구현 예정") — RPS 는 공용 AttachFileDropZone
// 재사용으로 실구현 (target_objid=projectObjid, docType="MBOM_DRAWING").
import React, { useEffect, useMemo, useState } from "react";
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Loader2, Copy, Folder, X as XIcon } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import {
mbomApi,
AssignableEbomRow,
AssignableMbomRow,
MbomTreeResponse,
MbomTreeRow,
LatestMbomByPartNoRow,
} from "@/lib/api/mbom";
import { AttachFileDropZone } from "@/components/common/AttachFileDropZone";
type SourceType = "EBOM" | "MBOM";
const MACHINE_PRODUCT_CD = "0000928";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
projectObjid: string | null;
partNo: string | null;
partName: string | null;
productCode?: string | null; // contract_mgmt.product — Machine 외 자동 추천 분기용
hasMbom?: boolean; // 이미 저장된 M-BOM 이 있으면 "초기화" 경고
onSaved: () => void;
}
export function BomCopyDialog({
open, onOpenChange, projectObjid, partNo, partName, productCode, hasMbom, onSaved,
}: Props) {
// 셀렉트 옵션 + 선택 상태
const [ebomOpts, setEbomOpts] = useState<AssignableEbomRow[]>([]);
const [mbomOpts, setMbomOpts] = useState<AssignableMbomRow[]>([]);
const [ebomLoading, setEbomLoading] = useState(false);
const [mbomLoading, setMbomLoading] = useState(false);
const [selectedType, setSelectedType] = useState<SourceType | null>(null);
const [selectedObjid, setSelectedObjid] = useState<string>("");
// 미리보기 트리
const [preview, setPreview] = useState<MbomTreeResponse | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
// 저장 진행 + 자동 추천 안내 1회 가드
const [saving, setSaving] = useState(false);
const autoCheckedRef = React.useRef(false);
// ── 오픈 시 초기화 + 옵션 로드 + Machine 외 자동 추천 ──
useEffect(() => {
if (!open) {
// 닫힐 때 상태 초기화
setSelectedType(null);
setSelectedObjid("");
setPreview(null);
autoCheckedRef.current = false;
return;
}
// E-BOM 옵션
setEbomLoading(true);
mbomApi.searchAssignableEboms({ limit: 500 })
.then(setEbomOpts)
.catch((e: any) => toast.error(e?.response?.data?.message ?? e?.message ?? "E-BOM 옵션 조회 실패"))
.finally(() => setEbomLoading(false));
// M-BOM 옵션
setMbomLoading(true);
mbomApi.searchAssignableMboms({ limit: 500 })
.then(setMbomOpts)
.catch((e: any) => toast.error(e?.response?.data?.message ?? e?.message ?? "M-BOM 옵션 조회 실패"))
.finally(() => setMbomLoading(false));
}, [open]);
// Machine 이외 제품 + partNo 있으면 자동 추천 (운영 fn_checkExistingMbom)
useEffect(() => {
if (!open) return;
if (autoCheckedRef.current) return;
if (!partNo || !partNo.trim()) return;
const isMachine = (productCode ?? "").trim() === MACHINE_PRODUCT_CD;
if (isMachine) return;
autoCheckedRef.current = true;
mbomApi.getLatestMbomByPartNo(partNo)
.then((row) => {
if (!row) return;
const ok = window.confirm(
`동일 품번(${partNo})의 M-BOM이 이미 존재합니다.\n` +
`M-BOM 품번: ${row.mbom_no ?? ""}\n저장일: ${row.save_date ?? ""}\n\n` +
`기존 M-BOM 을 자동으로 불러오시겠습니까?`
);
if (ok) {
setSelectedType("MBOM");
setSelectedObjid(String(row.template_header_objid));
}
})
.catch(() => { /* 실패 시 조용히 무시 — 자동 추천은 부가기능 */ });
}, [open, partNo, productCode]);
// ── 선택 변경 시 트리 미리보기 ────────────────────────────
useEffect(() => {
if (!selectedType || !selectedObjid) {
setPreview(null);
return;
}
setPreviewLoading(true);
const fetcher = selectedType === "EBOM"
? mbomApi.previewEbomTree(selectedObjid)
: mbomApi.previewMbomTree(selectedObjid);
fetcher
.then(setPreview)
.catch((e: any) => toast.error(e?.response?.data?.message ?? e?.message ?? "트리 미리보기 실패"))
.finally(() => setPreviewLoading(false));
}, [selectedType, selectedObjid]);
const previewTitle = useMemo(() => {
if (!selectedType || !selectedObjid) return "BOM 데이터를 조회하려면 E-BOM 또는 M-BOM 을 선택하세요.";
if (selectedType === "EBOM") {
const r = ebomOpts.find(o => String(o.objid) === String(selectedObjid));
return r ? `${r.part_no ?? ""} - ${r.part_name ?? ""}${r.revision ? ` (Rev.${r.revision})` : ""}` : "";
}
const r = mbomOpts.find(o => String(o.objid) === String(selectedObjid));
return r ? `${r.mbom_no ?? ""}${r.part_name ? ` - ${r.part_name}` : ""}` : "";
}, [selectedType, selectedObjid, ebomOpts, mbomOpts]);
// ── 저장 (운영 fn_saveBomCopy → saveBomAssignment.do) ─────
const handleSave = async () => {
if (!projectObjid) { toast.error("프로젝트가 선택되지 않았습니다."); return; }
if (!partNo || !partName) { toast.error("품번과 품명이 비어 있습니다."); return; }
if (!selectedType || !selectedObjid) { toast.error("복사할 BOM 을 선택하세요."); return; }
if (!preview || preview.rows.length === 0) { toast.error("복사할 BOM 데이터가 없습니다."); return; }
if (hasMbom) {
const ok = window.confirm("저장된 M-BOM 이 초기화 됩니다.\n계속하시겠습니까?");
if (!ok) return;
}
const ok = window.confirm(
`선택한 ${selectedType} 을(를) M-BOM 기준으로 할당하시겠습니까?\n` +
`실제 M-BOM 생성은 M-BOM 상세 팝업에서 저장 시 이루어집니다.`
);
if (!ok) return;
try {
setSaving(true);
await mbomApi.assignBom({
project_obj_id: projectObjid,
source_bom_type: selectedType,
source_bom_obj_id: selectedObjid,
});
toast.success("BOM 할당 정보가 저장되었습니다.");
onSaved();
onOpenChange(false);
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "BOM 할당 실패");
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[1400px] w-[95vw] max-h-[92vh] overflow-hidden flex flex-col p-0">
<DialogHeader className="px-6 pt-5 pb-3 border-b">
<DialogTitle className="flex items-center gap-2 text-base">
<Copy className="h-4 w-4 text-blue-600" />
BOM
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-auto px-6 py-4 space-y-4">
{/* 상단: 품번/품명 readonly + [저장] [닫기] (운영 1:1) */}
<div className="grid grid-cols-12 gap-3 items-end">
<div className="col-span-3">
<label className="text-xs text-muted-foreground"></label>
<Input value={partNo ?? ""} readOnly className="bg-muted h-9" />
</div>
<div className="col-span-5">
<label className="text-xs text-muted-foreground"></label>
<Input value={partName ?? ""} readOnly className="bg-muted h-9" />
</div>
<div className="col-span-4 flex gap-2 justify-end">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
<XIcon className="h-4 w-4 mr-1" />
</Button>
<Button onClick={handleSave} disabled={saving || !selectedType || !selectedObjid}>
{saving ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Copy className="h-4 w-4 mr-1" />}
</Button>
</div>
</div>
{/* 중단: E-BOM / M-BOM 셀렉트 (상호배타) */}
<div className="grid grid-cols-12 gap-3">
<div className="col-span-2 flex items-center text-sm font-medium">E-BOM </div>
<div className="col-span-10">
<select
className="h-9 w-full border rounded px-2 text-sm bg-background disabled:bg-muted disabled:opacity-60"
disabled={ebomLoading || selectedType === "MBOM"}
value={selectedType === "EBOM" ? selectedObjid : ""}
onChange={(e) => {
const v = e.target.value;
if (!v) { setSelectedType(null); setSelectedObjid(""); return; }
setSelectedType("EBOM");
setSelectedObjid(v);
}}
>
<option value="">{ebomLoading ? "로딩…" : "선택"}</option>
{ebomOpts.map(o => (
<option key={o.objid} value={o.objid}>
{(o.part_no ?? "")} - {(o.part_name ?? "")}{o.revision ? ` (Rev.${o.revision})` : ""}
</option>
))}
</select>
</div>
<div className="col-span-2 flex items-center text-sm font-medium">M-BOM </div>
<div className="col-span-10">
<select
className="h-9 w-full border rounded px-2 text-sm bg-background disabled:bg-muted disabled:opacity-60"
disabled={mbomLoading || selectedType === "EBOM"}
value={selectedType === "MBOM" ? selectedObjid : ""}
onChange={(e) => {
const v = e.target.value;
if (!v) { setSelectedType(null); setSelectedObjid(""); return; }
setSelectedType("MBOM");
setSelectedObjid(v);
}}
>
<option value="">{mbomLoading ? "로딩…" : "선택"}</option>
{mbomOpts.map(o => (
<option key={o.objid} value={o.objid}>
{(o.mbom_no ?? "")}{o.part_name ? ` - ${o.part_name}` : ""}
</option>
))}
</select>
</div>
</div>
{/* 하단: 미리보기 트리 + 도면 업로드 */}
<div className="border rounded">
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b text-sm">
<span className="font-medium truncate">{previewTitle}</span>
<span className="text-xs text-muted-foreground">
{previewLoading ? "로딩…" : preview ? `${preview.rows.length} 행 · 최대 ${preview.max_level} 레벨` : ""}
</span>
</div>
<PreviewTree preview={preview} loading={previewLoading} />
</div>
{/* 도면 다중 업로드 (운영판 fn_uploadDrawingFiles placeholder → RPS 실구현) */}
<div className="border rounded p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium flex items-center gap-1">
<Folder className="h-4 w-4 text-amber-600" />
</span>
<span className="text-xs text-muted-foreground">.stp .step .dwg .dxf .pdf</span>
</div>
{projectObjid ? (
<AttachFileDropZone
targetObjid={projectObjid}
docType="MBOM_DRAWING"
docTypeName="M-BOM 도면"
accept=".stp,.step,.dwg,.dxf,.pdf"
/>
) : (
<div className="text-xs text-muted-foreground"> .</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
// ─── 트리 미리보기 (간단 그리드) ───────────────────────────────
function PreviewTree({
preview, loading,
}: { preview: MbomTreeResponse | null; loading: boolean }) {
if (loading) {
return (
<div className="p-8 text-center text-sm text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin inline mr-1" />
</div>
);
}
if (!preview || preview.rows.length === 0) {
return (
<div className="p-8 text-center text-sm text-muted-foreground"> BOM .</div>
);
}
const maxLevel = preview.max_level || 1;
return (
<div className="overflow-auto max-h-[40vh]">
<table className="min-w-full text-xs">
<thead className="sticky top-0 bg-muted/80 border-b">
<tr>
<th className="px-2 py-1 text-center" style={{ width: 50 }}>#</th>
{Array.from({ length: maxLevel }).map((_, i) => (
<th key={i} className="px-1 py-1 text-center" style={{ width: 26 }}>{i + 1}</th>
))}
<th className="px-2 py-1 text-left"></th>
<th className="px-2 py-1 text-left"></th>
<th className="px-2 py-1 text-right" style={{ width: 80 }}></th>
<th className="px-2 py-1 text-left"></th>
<th className="px-2 py-1 text-left"></th>
</tr>
</thead>
<tbody>
{preview.rows.map((r: MbomTreeRow, idx: number) => (
<tr key={`${r.objid ?? ""}-${idx}`} className={cn("border-b", levelBg(r.level))}>
<td className="px-2 py-1 text-center text-muted-foreground">{idx + 1}</td>
{Array.from({ length: maxLevel }).map((_, i) => (
<td key={i} className="px-1 py-1 text-center">
{Number(r.level) === i + 1 ? "*" : ""}
</td>
))}
<td className="px-2 py-1 font-mono">{r.part_no ?? ""}</td>
<td className="px-2 py-1">{r.part_name ?? ""}</td>
<td className="px-2 py-1 text-right">{r.item_qty ?? r.qty ?? ""}</td>
<td className="px-2 py-1">{r.spec ?? ""}</td>
<td className="px-2 py-1">{r.material ?? ""}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function levelBg(level: number | string | null | undefined): string {
const n = Number(level) || 0;
switch (n) {
case 1: return "bg-white";
case 2: return "bg-blue-50";
case 3: return "bg-amber-50";
case 4: return "bg-emerald-50";
case 5: return "bg-rose-50";
default: return "";
}
}
@@ -0,0 +1,184 @@
"use client";
// 영업관리 > 구매요청서관리 — 품의서생성 확인 다이얼로그
// wace 1:1: createProposalFromPurchaseReg.do — 선택된 PURCHASE_REG 의 단가+공급업체 입력 품목만 필터해
// PURCHASE_REG_PROPOSAL row 신규 생성. 단가 또는 공급업체가 없는 품목은 제외 목록으로 표시.
import React, { useEffect, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { ClipboardCheck, X, AlertTriangle } from "lucide-react";
import { toast } from "sonner";
import { salesPurchaseRequestApi, ProposalTargetPart } from "@/lib/api/salesPurchaseRequest";
interface Props {
open: boolean;
onClose: () => void;
srmObjid: string; // 원본 구매요청서 OBJID
requestMngNo?: string;
onCreated: (proposalNo: string) => void;
}
export function ProposalCreateDialog({ open, onClose, srmObjid, requestMngNo, onCreated }: Props) {
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [targets, setTargets] = useState<ProposalTargetPart[]>([]);
const [excluded, setExcluded] = useState<ProposalTargetPart[]>([]);
useEffect(() => {
if (!open || !srmObjid) return;
setTargets([]); setExcluded([]);
setLoading(true);
(async () => {
try {
const data = await salesPurchaseRequestApi.getProposalTargets(srmObjid);
setTargets(data.targets ?? []);
setExcluded(data.excluded ?? []);
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
} finally {
setLoading(false);
}
})();
}, [open, srmObjid]);
const handleCreate = async () => {
setSubmitting(true);
try {
const res = await salesPurchaseRequestApi.createProposal(srmObjid);
toast.success(`품의서가 생성되었습니다. (${res.proposal_no})`);
onCreated(res.proposal_no);
onClose();
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "생성 실패");
} finally {
setSubmitting(false);
}
};
const sumTotal = targets.reduce((s, r) => s + (Number(r.total_price ?? 0) || Number(r.unit_price ?? 0) * Number(r.qty ?? 0)), 0);
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
{requestMngNo ? `${requestMngNo}` : ""}
.
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="py-10 text-center text-sm text-muted-foreground"> ...</div>
) : (
<>
<Section title={`품의서 생성 대상 (${targets.length}건)`} emptyMsg="대상 품목이 없습니다. 단가와 공급업체가 모두 입력되어야 합니다.">
{targets.length > 0 && (
<table className="w-full text-xs">
<thead className="bg-muted/30">
<tr>
<th className="px-2 py-1 text-left w-[140px]"></th>
<th className="px-2 py-1 text-left"></th>
<th className="px-2 py-1 text-right w-[90px]"></th>
<th className="px-2 py-1 text-right w-[110px]"></th>
<th className="px-2 py-1 text-right w-[120px]"></th>
<th className="px-2 py-1 text-left w-[160px]"></th>
</tr>
</thead>
<tbody>
{targets.map((r) => (
<tr key={r.objid} className="border-t">
<td className="px-2 py-1">{r.part_no}</td>
<td className="px-2 py-1">{r.part_name}</td>
<td className="px-2 py-1 text-right">{fmt(r.qty)}</td>
<td className="px-2 py-1 text-right">{fmtMoney(r.unit_price)}</td>
<td className="px-2 py-1 text-right">
{fmtMoney(Number(r.total_price ?? 0) || Number(r.unit_price ?? 0) * Number(r.qty ?? 0))}
</td>
<td className="px-2 py-1">{r.vendor_name || r.vendor_pm}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="bg-muted/20 font-medium">
<td className="px-2 py-1" colSpan={4}></td>
<td className="px-2 py-1 text-right">{fmtMoney(sumTotal)}</td>
<td></td>
</tr>
</tfoot>
</table>
)}
</Section>
{excluded.length > 0 && (
<Section
title={
<span className="inline-flex items-center gap-1 text-amber-700">
<AlertTriangle className="h-3.5 w-3.5" /> ({excluded.length})
</span>
}
emptyMsg=""
>
<table className="w-full text-xs">
<thead className="bg-muted/30">
<tr>
<th className="px-2 py-1 text-left w-[140px]"></th>
<th className="px-2 py-1 text-left"></th>
<th className="px-2 py-1 text-right w-[90px]"></th>
<th className="px-2 py-1 text-right w-[110px]"></th>
</tr>
</thead>
<tbody>
{excluded.map((r) => (
<tr key={r.objid} className="border-t">
<td className="px-2 py-1">{r.part_no}</td>
<td className="px-2 py-1">{r.part_name}</td>
<td className="px-2 py-1 text-right">{fmt(r.qty)}</td>
<td className="px-2 py-1 text-right">{fmtMoney(r.unit_price)}</td>
</tr>
))}
</tbody>
</table>
</Section>
)}
</>
)}
<DialogFooter className="mt-2">
<Button variant="outline" onClick={onClose} disabled={submitting}>
<X className="h-3.5 w-3.5 mr-1" />
</Button>
<Button onClick={handleCreate} disabled={submitting || loading || targets.length === 0}>
<ClipboardCheck className="h-3.5 w-3.5 mr-1" />
{submitting ? "생성 중..." : `품의서 생성 (${targets.length}건)`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function Section({ title, emptyMsg, children }: { title: React.ReactNode; emptyMsg: string; children: React.ReactNode }) {
const hasChildren = React.Children.count(children) > 0;
return (
<div className="mt-3 border rounded">
<div className="border-b px-2 py-1 bg-muted/40 text-xs font-medium">{title}</div>
<div className="max-h-[280px] overflow-auto">
{hasChildren ? children : (
<div className="py-6 text-center text-xs text-muted-foreground">{emptyMsg}</div>
)}
</div>
</div>
);
}
// 수량: 자연수 1,234 / 금액: 1,234.00 (RPS 숫자 포맷 정책)
function fmt(n: any) {
const v = Math.floor(Number(n ?? 0));
return v.toLocaleString("ko-KR");
}
function fmtMoney(n: any) {
const v = Number(n ?? 0);
return v.toLocaleString("ko-KR", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
@@ -0,0 +1,393 @@
"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 { DateInput } from "@/components/common/DateInput";
import { NumberInput } from "@/components/common/NumberInput";
import { purchaseApi, OptionItem } from "@/lib/api/purchase";
import {
salesPurchaseRequestApi,
MbomPartItem,
PurchaseRequestPartInput,
} from "@/lib/api/salesPurchaseRequest";
interface Props {
open: boolean;
onClose: () => void;
onSaved: () => void;
/** 수정 모드 시 기존 SRM OBJID — 미지정이면 신규 등록 */
srmObjid?: string;
}
interface FormState {
objid?: string; // 수정 모드 시 기존 OBJID
request_mng_no?: string; // 수정 모드 표시용
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, srmObjid }: Props) {
const isEdit = !!srmObjid;
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [parts, setParts] = useState<PartRow[]>([]);
const [saving, setSaving] = useState(false);
const [loadingProject, setLoadingProject] = useState(false);
const [projectOpts, setProjectOpts] = useState<SmartSelectOption[]>([]);
const [supplierOpts, setSupplierOpts] = useState<SmartSelectOption[]>([]);
// 선택된 프로젝트의 M-BOM 품목 풀 (행추가 시 품번 셀렉트 옵션)
const [mbomItems, setMbomItems] = useState<MbomPartItem[]>([]);
// 모달 열릴 때 옵션 로드 + (수정모드면) 상세 로드, (신규면) 폼 초기화
useEffect(() => {
if (!open) return;
setForm(EMPTY_FORM);
setParts([]);
setMbomItems([]);
(async () => {
try {
const [proj, vendors] = await Promise.all([
purchaseApi.listProjects(),
salesPurchaseRequestApi.listVendors(), // client_mng 기반 (M-BOM vendor / partner 매칭용)
]);
setProjectOpts(proj.map(toSmart));
setSupplierOpts(vendors.map((v) => ({ code: v.code, label: v.label })));
if (srmObjid) {
const detail = await salesPurchaseRequestApi.getDetail(srmObjid);
const h = detail.header ?? {};
const projectObjid = String(h.project_no ?? "");
// 수정 모드 → M-BOM 풀도 함께 로드 (품번 셀렉트 옵션)
const items = projectObjid
? await salesPurchaseRequestApi.listMbomParts(projectObjid)
: [];
setMbomItems(items ?? []);
setForm({
objid: String(h.objid ?? ""),
request_mng_no: h.request_mng_no ?? "",
project_no: projectObjid,
mbom_header_objid: String(h.mbom_header_objid ?? items?.[0]?.mbom_header_objid ?? ""),
purchase_type: h.purchase_type ?? "",
order_type: h.order_type ?? h.category_cd ?? "",
product_name: h.product_name ?? "",
area_cd: h.area_cd ?? "",
customer_objid: h.customer_objid ?? "",
paid_type: h.paid_type ?? "",
delivery_request_date: normalizeDate(h.delivery_request_date),
});
setParts((detail.parts ?? []).map((p: any) => ({
rowKey: nextKey(),
objid: p.objid,
part_objid: p.part_objid,
part_no: p.part_no,
part_name: p.part_name,
qty: String(Math.floor(Number(p.qty ?? 0))),
partner_objid: p.partner_objid ?? "",
partner_price: p.partner_price != null && Number(p.partner_price) > 0
? String(Number(p.partner_price))
: (p.unit_price != null && Number(p.unit_price) > 0 ? String(Number(p.unit_price)) : ""),
})));
}
} catch (e: any) {
toast.error(`옵션 로드 실패: ${e?.message ?? ""}`);
}
})();
}, [open, srmObjid]);
// 프로젝트 선택 → 자동채움 (주문유형/제품구분/국내외/고객사/유무상) + 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<PartRow>) =>
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: String(Math.max(1, Math.floor(Number(hit.qty ?? 0)))),
partner_objid: hit.vendor_objid || "",
partner_price: Number(hit.unit_price ?? 0) > 0 ? String(Number(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 { request_mng_no, ...rest } = form; // request_mng_no 는 표시 전용, 백엔드 미전송
void request_mng_no;
const payload = {
...rest,
parts: parts.map(({ rowKey, part_no, ...partRest }) => partRest), // eslint-disable-line @typescript-eslint/no-unused-vars
};
const res = await salesPurchaseRequestApi.save(payload);
toast.success(`${isEdit ? "수정" : "저장"}되었습니다. (${res.request_mng_no ?? form.request_mng_no ?? res.objid})`);
onSaved();
onClose();
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패");
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-5xl">
<DialogHeader>
<DialogTitle>{isEdit ? `구매요청서 수정${form.request_mng_no ? `${form.request_mng_no}` : ""}` : "구매요청서 작성"}</DialogTitle>
<DialogDescription> //// . .</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-4 gap-3 text-sm">
<Field label="구매유형 *">
<CommCodeSelect groupId="0001814" value={form.purchase_type}
onValueChange={(v) => setForm({ ...form, purchase_type: v })} withAll={false} />
</Field>
<Field label="프로젝트번호 *">
<SmartSelect options={projectOpts} value={form.project_no}
onValueChange={onProjectChange}
placeholder={loadingProject ? "프로젝트 정보 조회 중..." : "선택"} />
</Field>
<Field label="주문유형">
<CommCodeSelect groupId="0000167" value={form.order_type}
onValueChange={(v) => setForm({ ...form, order_type: v })} withAll={false} />
</Field>
<Field label="제품구분">
<CommCodeSelect groupId="0000001" value={form.product_name}
onValueChange={(v) => setForm({ ...form, product_name: v })} withAll={false} />
</Field>
<Field label="국내/해외">
<CommCodeSelect groupId="0001219" value={form.area_cd}
onValueChange={(v) => setForm({ ...form, area_cd: v })} withAll={false} />
</Field>
<Field label="고객사">
<CustomerSelect value={form.customer_objid}
onValueChange={(v) => setForm({ ...form, customer_objid: v })} />
</Field>
<Field label="유/무상">
<SmartSelect
options={[{ code: "paid", label: "유상" }, { code: "free", label: "무상" }]}
value={form.paid_type}
onValueChange={(v) => setForm({ ...form, paid_type: v })} />
</Field>
<Field label="입고요청일">
<DateInput value={form.delivery_request_date}
onChange={(v) => setForm({ ...form, delivery_request_date: v })} />
</Field>
</div>
<div className="mt-3 border rounded">
<div className="flex items-center justify-between border-b px-2 py-1 bg-muted/40 text-xs">
<div className="font-medium">
({parts.length})
{form.project_no ? (
<span className="ml-2 text-muted-foreground"> {partOpts.length}</span>
) : null}
</div>
<div className="flex gap-1">
<Button size="sm" variant="outline" className="h-7 gap-1 px-2 text-xs"
onClick={addRow}
disabled={!form.project_no}>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="max-h-[360px] overflow-auto">
<table className="w-full text-xs">
<thead className="bg-muted/30 sticky top-0">
<tr>
<th className="px-2 py-1 text-left w-[200px]"></th>
<th className="px-2 py-1 text-left"></th>
<th className="px-2 py-1 text-right w-[90px]"></th>
<th className="px-2 py-1 text-left w-[180px]"></th>
<th className="px-2 py-1 text-right w-[110px]"></th>
<th className="px-2 py-1 w-[36px]"></th>
</tr>
</thead>
<tbody>
{parts.length === 0 ? (
<tr>
<td colSpan={6} className="px-2 py-6 text-center text-muted-foreground">
{form.project_no
? "[행추가] 버튼을 눌러 품번을 선택해주세요."
: "먼저 프로젝트번호를 선택해주세요."}
</td>
</tr>
) : parts.map((r) => (
<tr key={r.rowKey} className="border-t">
<td className="px-2 py-1">
<SmartSelect options={partOpts} value={r.part_objid}
onValueChange={(v) => onPartSelect(r.rowKey, v)}
placeholder={partOpts.length === 0 ? "M-BOM 품목 없음" : "선택"} />
</td>
<td className="px-2 py-1">{r.part_name || ""}</td>
<td className="px-2 py-1">
<NumberInput value={r.qty} decimals={0} min={0} className="h-7"
onChange={(v) => updateRow(r.rowKey, { qty: v === "" ? "" : String(v) })} />
</td>
<td className="px-2 py-1">
<SmartSelect options={supplierOpts} value={r.partner_objid ?? ""}
onValueChange={(v) => updateRow(r.rowKey, { partner_objid: v })} />
</td>
<td className="px-2 py-1">
<NumberInput value={r.partner_price} decimals={2} min={0} className="h-7"
onChange={(v) => updateRow(r.rowKey, { partner_price: v === "" ? "" : String(v) })} />
</td>
<td className="px-2 py-1 text-center">
<Button size="icon" variant="ghost" className="h-6 w-6"
onClick={() => deleteRow(r.rowKey)}>
<Trash2 className="h-3.5 w-3.5 text-red-600" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<DialogFooter className="mt-2">
<Button variant="outline" onClick={onClose} disabled={saving}>
<X className="h-3.5 w-3.5 mr-1" />
</Button>
<Button onClick={handleSave} disabled={saving || !canSave}>
<Save className="h-3.5 w-3.5 mr-1" /> {saving ? "저장 중..." : "저장"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1">
<Label className="text-xs">{label}</Label>
{children}
</div>
);
}
function toSmart(o: OptionItem): SmartSelectOption {
return { code: o.code, label: o.label };
}
// 운영 데이터에 'YYYY.MM.DD' 또는 'YYYY/MM/DD' 가 섞여 있을 수 있어 DateInput 입력 형식으로 정규화
function normalizeDate(v: any): string {
if (!v) return "";
const s = String(v).trim();
if (!s) return "";
const m = s.match(/^(\d{4})[.\-/](\d{1,2})[.\-/](\d{1,2})/);
if (!m) return "";
const yyyy = m[1];
const mm = m[2].padStart(2, "0");
const dd = m[3].padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
}
+40
View File
@@ -267,6 +267,18 @@ export const mbomApi = {
const res = await apiClient.post("/production/mbom/assign", payload);
return res.data?.data as AssignBomResult;
},
async searchAssignableMboms(filter: AssignableMbomFilter = {}): Promise<AssignableMbomRow[]> {
const res = await apiClient.get("/production/mbom/assignable-mboms", { params: filter });
return (res.data?.data ?? []) as AssignableMbomRow[];
},
async previewMbomTree(mbomHeaderObjid: string): Promise<MbomTreeResponse> {
const res = await apiClient.get(`/production/mbom/mbom-preview/${encodeURIComponent(mbomHeaderObjid)}`);
return res.data?.data as MbomTreeResponse;
},
async getLatestMbomByPartNo(partNo: string): Promise<LatestMbomByPartNoRow | null> {
const res = await apiClient.get(`/production/mbom/latest-mbom-by-partno/${encodeURIComponent(partNo)}`);
return (res.data?.data ?? null) as LatestMbomByPartNoRow | null;
},
};
// ─── BOM 할당 (PR-B5) ───────────────────────────────────────
@@ -310,6 +322,34 @@ export interface AssignBomResult {
source_obj_id: string;
}
// ─── BOM 복사 보조 (PR-B5+) ─────────────────────────────────
// 운영판 partMng/structureBomCopyFormPopup.jsp 의 셀렉트/자동검색 보조 API.
export interface AssignableMbomFilter {
search_part_no?: string;
search_part_name?: string;
search_from_date?: string;
search_to_date?: string;
limit?: number;
}
export interface AssignableMbomRow {
objid: string;
mbom_no: string | null;
part_no: string | null;
part_name: string | null;
reg_date: string | null;
}
export interface LatestMbomByPartNoRow {
template_header_objid: string;
mbom_no: string | null;
project_objid: string | null;
part_no: string | null;
part_name: string | null;
save_date: string | null;
}
// ─── 변경이력 (PR-B4) ───────────────────────────────────────
export interface MbomHistoryRow {
+153
View File
@@ -0,0 +1,153 @@
// ============================================================
// 영업관리 > 구매요청서관리 / 품의서관리 (wace_plm 1:1)
// 백엔드: /api/sales/purchase-request, /api/sales/purchase-proposal
// ============================================================
import { apiClient } from "./client";
export interface SalesPurchaseRequestFilter {
project_no?: string;
part_no?: string;
part_name?: string;
purchase_type?: string;
writer?: string;
part_type?: string;
search_status?: string;
proposal_no?: string;
regdate_start?: string;
regdate_end?: string;
page?: number;
page_size?: number;
}
export interface SalesPurchaseRequestListResponse<T = any> {
rows: T[];
totalCount: number;
page: number;
pageSize: number;
}
async function getList<T = any>(
path: string,
filter: SalesPurchaseRequestFilter,
): Promise<SalesPurchaseRequestListResponse<T>> {
const res = await apiClient.get(`/sales/${path}`, { params: filter });
return res.data?.data as SalesPurchaseRequestListResponse<T>;
}
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 listVendors(): Promise<Array<{ code: string; label: string }>> {
const res = await apiClient.get("/sales/purchase-request/vendors");
return (res.data?.data ?? []) as Array<{ code: string; label: string }>;
},
async getProjectAutoFill(projectObjid: string): Promise<ProjectAutoFillInfo | null> {
const res = await apiClient.get(`/sales/purchase-request/project-info/${projectObjid}`);
return (res.data?.data ?? null) as ProjectAutoFillInfo | null;
},
async listMbomParts(projectObjid: string): Promise<MbomPartItem[]> {
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;
},
};