Merge pull request 'hjjeong' (#15) from hjjeong into main
Build and Push Images / build-and-push (push) Has been cancelled
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:
@@ -179,6 +179,7 @@ import salesEstimateRoutes from "./routes/salesEstimateRoutes"; // 영업관리>
|
|||||||
import salesOrderMgmtRoutes from "./routes/salesOrderMgmtRoutes"; // 영업관리>주문서 (wace_plm 도메인 이식)
|
import salesOrderMgmtRoutes from "./routes/salesOrderMgmtRoutes"; // 영업관리>주문서 (wace_plm 도메인 이식)
|
||||||
import salesSaleRoutes from "./routes/salesSaleRoutes"; // 영업관리>판매+매출 (wace_plm 도메인)
|
import salesSaleRoutes from "./routes/salesSaleRoutes"; // 영업관리>판매+매출 (wace_plm 도메인)
|
||||||
import salesCommonRoutes from "./routes/salesCommonRoutes"; // 영업관리 4개 메뉴 공통 옵션 (codes/parts/customers)
|
import salesCommonRoutes from "./routes/salesCommonRoutes"; // 영업관리 4개 메뉴 공통 옵션 (codes/parts/customers)
|
||||||
|
import salesPurchaseRequestRoutes from "./routes/salesPurchaseRequestRoutes"; // 영업관리>구매요청서관리·품의서관리 (wace_plm)
|
||||||
import projectMgmtRoutes from "./routes/projectMgmtRoutes"; // 프로젝트관리>진행관리 (wace_plm 도메인 이식)
|
import projectMgmtRoutes from "./routes/projectMgmtRoutes"; // 프로젝트관리>진행관리 (wace_plm 도메인 이식)
|
||||||
import wbsTemplateRoutes from "./routes/wbsTemplateRoutes"; // 프로젝트관리>제품구분_WBS관리 (wace_plm 도메인 이식)
|
import wbsTemplateRoutes from "./routes/wbsTemplateRoutes"; // 프로젝트관리>제품구분_WBS관리 (wace_plm 도메인 이식)
|
||||||
import devPartRoutes from "./routes/devPartRoutes"; // 개발관리>PART 등록/조회 (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/order-mgmt", salesOrderMgmtRoutes); // 영업관리>주문서 (wace_plm 도메인)
|
||||||
app.use("/api/sales", salesSaleRoutes); // 영업관리>판매+매출 (wace_plm 도메인)
|
app.use("/api/sales", salesSaleRoutes); // 영업관리>판매+매출 (wace_plm 도메인)
|
||||||
app.use("/api/sales", salesCommonRoutes); // 영업관리 공통 옵션 (codes/parts/customers)
|
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/progress", projectMgmtRoutes); // 프로젝트관리>진행관리 (wace_plm 도메인)
|
||||||
app.use("/api/project/wbs-template", wbsTemplateRoutes); // 프로젝트관리>제품구분_WBS관리 (wace_plm 도메인)
|
app.use("/api/project/wbs-template", wbsTemplateRoutes); // 프로젝트관리>제품구분_WBS관리 (wace_plm 도메인)
|
||||||
app.use("/api/development", devPartRoutes); // 개발관리>PART 등록/조회 (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)
|
// PR-B5 — BOM 할당 (운영 saveBomAssignment.do 1:1)
|
||||||
export async function assignBom(req: AuthenticatedRequest, res: Response) {
|
export async function assignBom(req: AuthenticatedRequest, res: Response) {
|
||||||
try {
|
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("/assignable-eboms", ctrl.searchAssignableEboms); // PR-B5 할당 가능 E-BOM 검색
|
||||||
router.get("/ebom-preview/:bomReportObjid", ctrl.previewEbomTree); // PR-B5 E-BOM 미리보기
|
router.get("/ebom-preview/:bomReportObjid", ctrl.previewEbomTree); // PR-B5 E-BOM 미리보기
|
||||||
router.post("/assign", ctrl.assignBom); // PR-B5 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;
|
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;
|
||||||
@@ -1357,6 +1357,112 @@ export async function assignBom(
|
|||||||
return { success: true, source_bom_type: sourceBomType, source_obj_id: sourceBomObjId };
|
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) ──────────────────────────────────
|
// ─── 변경이력 조회 (PR-B4) ──────────────────────────────────
|
||||||
//
|
//
|
||||||
// 매퍼 productionplanning.getMbomHistory (3448~3470) 1:1.
|
// 매퍼 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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 기준으로 다르게 보여주는지 회귀 확인
|
||||||
@@ -9,11 +9,13 @@
|
|||||||
- **이식 방식**: JSP → Next.js 리라이트 (백엔드도 Java→Node 재작성, vexplor_rps `backend-node` 패턴 채택)
|
- **이식 방식**: JSP → Next.js 리라이트 (백엔드도 Java→Node 재작성, vexplor_rps `backend-node` 패턴 채택)
|
||||||
- **스키마 정책**: **하이브리드** — 도메인 테이블(`contract_mgmt`, `estimate_mgmt`, `sales_registration` 등)은 wace_plm 원본 스키마를 그대로 가져오고, 거래처/품목 등 **마스터는 vexplor_rps 기존 테이블(`customer_mng`, `item_info`)에 매핑**
|
- **스키마 정책**: **하이브리드** — 도메인 테이블(`contract_mgmt`, `estimate_mgmt`, `sales_registration` 등)은 wace_plm 원본 스키마를 그대로 가져오고, 거래처/품목 등 **마스터는 vexplor_rps 기존 테이블(`customer_mng`, `item_info`)에 매핑**
|
||||||
- **관리자 메뉴**: 이식 대상 아님 (vexplor 그대로 사용)
|
- **관리자 메뉴**: 이식 대상 아님 (vexplor 그대로 사용)
|
||||||
- **이식 대상 메뉴 4개**:
|
- **이식 대상 메뉴 6개** (초기 4개 + 2026-05-15 확장 2개):
|
||||||
1. 견적관리 (`/contractMgmt/estimateList_new.do`)
|
1. 견적관리 (`/contractMgmt/estimateList_new.do`)
|
||||||
2. 주문서관리 (`/contractMgmt/orderMgmtList.do`)
|
2. 주문서관리 (`/contractMgmt/orderMgmtList.do`)
|
||||||
3. 판매관리 (`/contractMgmt/salesMgmtList.do` → SalesNcollect로 위임)
|
3. 판매관리 (`/contractMgmt/salesMgmtList.do` → SalesNcollect로 위임)
|
||||||
4. 매출관리 (`/revenueMgmt/revenueList.do`)
|
4. 매출관리 (`/revenueMgmt/revenueList.do`)
|
||||||
|
5. **구매요청서관리** (`/salesMng/purchaseRequestRegList.do`) — 2026-05-15 추가
|
||||||
|
6. **품의서관리(영업)** (`/salesMng/purchaseRegProposalMngList.do`) — 2026-05-15 추가
|
||||||
|
|
||||||
## 1. 메뉴 매핑표
|
## 1. 메뉴 매핑표
|
||||||
|
|
||||||
@@ -23,6 +25,8 @@
|
|||||||
| 2 | 주문서관리 | `/contractMgmt/orderMgmtList.do` | `contractMgmt/orderMgmtList.jsp` (45KB) | `ContractMgmtController` (2504–3169 line) / `ContractMgmtService` | `app/(main)/COMPANY_16/sales/order/page.tsx` (재작성) + `backend-node/src/{routes,services}/orderMgmtRoutes.ts` | [02-order.md](./02-order.md) |
|
| 2 | 주문서관리 | `/contractMgmt/orderMgmtList.do` | `contractMgmt/orderMgmtList.jsp` (45KB) | `ContractMgmtController` (2504–3169 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) |
|
| 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) |
|
| 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) |
|
| ─ | 마스터 매핑 | (전 메뉴 공통) | — | — | — | [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/` 신규 경로로 작성하는 것을 권장.
|
> ⚠️ 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)
|
1. ~~운영 DB DDL 추출~~ 완료 (2026-05-07)
|
||||||
2. ~~01~04 상세 매핑 + 1차 이식~~ 완료 (2026-05-08)
|
2. ~~01~04 상세 매핑 + 1차 이식~~ 완료 (2026-05-08)
|
||||||
3. **[00-gap.md](./00-gap.md) 우선** — 원본 흐름 10단계 vs 이식본 GAP 매트릭스. 다음 PR(A: 수주확정→프로젝트 자동생성, B: 직접등록 통합폼, C: 결재·메일·PDF) 합의 문서.
|
3. ~~[00-gap.md](./00-gap.md) PR-A/B/C 흐름 + G7~G11 결재상신~~ 완료 (2026-05-11)
|
||||||
4. PR-A부터 착수: `salesOrderMgmtService.updateStatus`에 project_mgmt 자동생성 + project_no 채번 로직 이식.
|
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개 메뉴 동일 적용)
|
## 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 React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ShoppingCart, Loader2 } from "lucide-react";
|
import { ShoppingCart, Loader2, Copy } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
||||||
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
|
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
|
||||||
@@ -22,6 +22,7 @@ import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
|||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { mbomApi, MbomListFilter, MbomRow } from "@/lib/api/mbom";
|
import { mbomApi, MbomListFilter, MbomRow } from "@/lib/api/mbom";
|
||||||
import { MbomDetailDialog } from "@/components/production/MbomDetailDialog";
|
import { MbomDetailDialog } from "@/components/production/MbomDetailDialog";
|
||||||
|
import { BomCopyDialog } from "@/components/production/BomCopyDialog";
|
||||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
|
|
||||||
const PARENT_CATEGORY = "0000167"; // 주문유형 comm_code parent_code_id
|
const PARENT_CATEGORY = "0000167"; // 주문유형 comm_code parent_code_id
|
||||||
@@ -78,6 +79,16 @@ export default function MbomMgmtPage() {
|
|||||||
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
||||||
const [creatingPurchaseList, setCreatingPurchaseList] = useState(false);
|
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>) => {
|
const fetchList = useCallback(async (override?: Partial<MbomListFilter>) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -224,6 +235,32 @@ export default function MbomMgmtPage() {
|
|||||||
}
|
}
|
||||||
}, [checkedIds, gridRows, confirm, fetchList]);
|
}, [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 (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
|
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -231,18 +268,30 @@ export default function MbomMgmtPage() {
|
|||||||
onSearch={handleSearch}
|
onSearch={handleSearch}
|
||||||
onReset={handleReset}
|
onReset={handleReset}
|
||||||
actions={
|
actions={
|
||||||
<Button
|
<>
|
||||||
size="sm"
|
<Button
|
||||||
variant="default"
|
size="sm"
|
||||||
className="h-8 gap-1 px-2 text-xs"
|
variant="outline"
|
||||||
onClick={handleCreatePurchaseList}
|
className="h-8 gap-1 px-2 text-xs"
|
||||||
disabled={creatingPurchaseList || checkedIds.length === 0}
|
onClick={handleOpenBomCopy}
|
||||||
>
|
disabled={checkedIds.length === 0}
|
||||||
{creatingPurchaseList
|
>
|
||||||
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
|
<Copy className="h-3.5 w-3.5" />
|
||||||
: <ShoppingCart className="h-3.5 w-3.5" />}
|
BOM 복사
|
||||||
구매리스트 생성
|
</Button>
|
||||||
</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
|
<CompactFilterBar
|
||||||
@@ -365,6 +414,23 @@ export default function MbomMgmtPage() {
|
|||||||
onSaved={fetchList}
|
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}
|
{ConfirmDialogComponent}
|
||||||
</div>
|
</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 React from "react";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { DateInput } from "@/components/common/DateInput";
|
||||||
|
|
||||||
interface CompactFilterBarProps {
|
interface CompactFilterBarProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -98,34 +99,11 @@ export function CompactDateRange({
|
|||||||
setTo: (v: string) => void;
|
setTo: (v: string) => void;
|
||||||
disabled?: boolean;
|
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 (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<input
|
<DateInput size="sm" value={from} onChange={setFrom} disabled={disabled} className="w-[140px]" />
|
||||||
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}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-muted-foreground">~</span>
|
<span className="text-xs text-muted-foreground">~</span>
|
||||||
<input
|
<DateInput size="sm" value={to} onChange={setTo} disabled={disabled} className="w-[140px]" />
|
||||||
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}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,11 +170,11 @@ function SortableHeaderCell({
|
|||||||
style={style}
|
style={style}
|
||||||
className={cn(
|
className={cn(
|
||||||
widthPx == null && col.width, widthPx == null && col.minWidth,
|
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),
|
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
|
<GripVertical
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
@@ -182,13 +182,13 @@ function SortableHeaderCell({
|
|||||||
aria-label="컬럼 드래그"
|
aria-label="컬럼 드래그"
|
||||||
/>
|
/>
|
||||||
<div
|
<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) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (col.sortable !== false) onSort(col.key);
|
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 && (
|
{isSorted && (
|
||||||
<span className="text-primary text-xs shrink-0">{sortDir === "asc" ? "↑" : "↓"}</span>
|
<span className="text-primary text-xs shrink-0">{sortDir === "asc" ? "↑" : "↓"}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ export function SmartSelect({
|
|||||||
}: SmartSelectProps) {
|
}: SmartSelectProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
// code가 비어있는 옵션은 자동 제외 (Radix Select value 제약 + key 중복 방지)
|
// code가 비어있는 옵션은 자동 제외 (Radix Select value 제약 + key 중복 방지)
|
||||||
@@ -87,6 +88,73 @@ export function SmartSelect({
|
|||||||
return () => cancelAnimationFrame(id);
|
return () => cancelAnimationFrame(id);
|
||||||
}, [open, virtualizer, filtered.length]);
|
}, [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;
|
const showClear = clearable && !disabled && !!value;
|
||||||
// Radix Select/Popover trigger는 onPointerDown으로 열린다 → 같은 단계에서 차단해야 X 클릭이 trigger를 안 깨움
|
// Radix Select/Popover trigger는 onPointerDown으로 열린다 → 같은 단계에서 차단해야 X 클릭이 trigger를 안 깨움
|
||||||
const stopAndClear = (e: React.PointerEvent | React.MouseEvent) => {
|
const stopAndClear = (e: React.PointerEvent | React.MouseEvent) => {
|
||||||
@@ -162,6 +230,7 @@ export function SmartSelect({
|
|||||||
placeholder="검색..."
|
placeholder="검색..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
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"
|
className="h-9 border-0 px-1 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -183,17 +252,22 @@ export function SmartSelect({
|
|||||||
{virtualizer.getVirtualItems().map((vItem) => {
|
{virtualizer.getVirtualItems().map((vItem) => {
|
||||||
const o = filtered[vItem.index];
|
const o = filtered[vItem.index];
|
||||||
const isSelected = value === o.code;
|
const isSelected = value === o.code;
|
||||||
|
const isActive = activeIndex === vItem.index;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={`${o.code}-${vItem.index}`}
|
key={`${o.code}-${vItem.index}`}
|
||||||
type="button"
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={isSelected}
|
||||||
|
onMouseEnter={() => setActiveIndex(vItem.index)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onValueChange(o.code);
|
onValueChange(o.code);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute left-0 top-0 w-full flex items-center px-2 text-sm text-left hover:bg-accent",
|
"absolute left-0 top-0 w-full flex items-center px-2 text-sm text-left",
|
||||||
isSelected && "bg-accent/60",
|
isActive ? "bg-accent" : "hover:bg-accent/40",
|
||||||
|
isSelected && !isActive && "bg-accent/60",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
height: `${vItem.size}px`,
|
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}`;
|
||||||
|
}
|
||||||
@@ -267,6 +267,18 @@ export const mbomApi = {
|
|||||||
const res = await apiClient.post("/production/mbom/assign", payload);
|
const res = await apiClient.post("/production/mbom/assign", payload);
|
||||||
return res.data?.data as AssignBomResult;
|
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) ───────────────────────────────────────
|
// ─── BOM 할당 (PR-B5) ───────────────────────────────────────
|
||||||
@@ -310,6 +322,34 @@ export interface AssignBomResult {
|
|||||||
source_obj_id: string;
|
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) ───────────────────────────────────────
|
// ─── 변경이력 (PR-B4) ───────────────────────────────────────
|
||||||
|
|
||||||
export interface MbomHistoryRow {
|
export interface MbomHistoryRow {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user