Merge pull request 'hjjeong' (#16) from hjjeong into main

Reviewed-on: https://g.wace.me/chpark/vexplor_rps/pulls/16
This commit is contained in:
hjjeong
2026-05-19 02:53:51 +00:00
36 changed files with 4319 additions and 1352 deletions
+2
View File
@@ -122,6 +122,7 @@ import productionRoutes from "./routes/productionRoutes"; // 생산계획 관리
import productionMbomRoutes from "./routes/productionMbomRoutes"; // 생산관리>M-BOM 관리 (wace_plm 도메인)
import { planResultRouter as productionPlanResultRoutes, mbomReqRouter as productionMbomRequirementRoutes } from "./routes/productionPlanResultRoutes"; // 생산관리 4개 메뉴 (생산계획&실적, 소요량)
import purchaseRoutes from "./routes/purchaseRoutes"; // 구매관리 7개 메뉴 (wace_plm 도메인)
import inventoryMngRoutes from "./routes/inventoryMngRoutes"; // 자재관리 — 자재리스트 + 불출의뢰서 (wace_plm inventoryMng 도메인)
import itemInspectionRoutes from "./routes/itemInspectionRoutes"; // 품목검사정보
import qualityRoutes from "./routes/qualityRoutes"; // 품질관리 — 수입/공정/반제품 검사 (wace_plm 이식 1단계)
import crawlRoutes from "./routes/crawlRoutes"; // 웹 크롤링
@@ -389,6 +390,7 @@ app.use("/api/production/mbom", productionMbomRoutes); // 생산관리>M-BOM 관
app.use("/api/production/plan-result", productionPlanResultRoutes); // 생산계획&실적관리 (메뉴1+2)
app.use("/api/production/mbom-requirement", productionMbomRequirementRoutes); // 반제품/원자재 소요량 (메뉴3+4)
app.use("/api/purchase", purchaseRoutes); // 구매관리 7개 메뉴 (wace_plm 도메인)
app.use("/api/inventory-mng", inventoryMngRoutes); // 자재관리 — 자재리스트 + 불출의뢰서
app.use("/api/item-inspection", itemInspectionRoutes); // 품목검사정보 (그룹 페이징)
app.use("/api/quality", qualityRoutes); // 품질관리 — 수입/공정/반제품 검사
app.use("/api/crawl", crawlRoutes); // 웹 크롤링
@@ -0,0 +1,251 @@
// ============================================================
// 자재관리 — 자재리스트 + 불출의뢰서 컨트롤러
// ============================================================
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import * as svc from "../services/inventoryMngService";
import { logger } from "../utils/logger";
function userId(req: AuthenticatedRequest): string {
return (req as any)?.user?.user_id ?? (req as any)?.user?.username ?? "system";
}
// ── 자재리스트 ──────────────────────────────────────────────
export async function getInventoryList(req: AuthenticatedRequest, res: Response) {
try {
const q = req.query as Record<string, any>;
const data = await svc.listInventory({
...q,
page: q.page ? Number(q.page) : undefined,
page_size: q.page_size ? Number(q.page_size) : undefined,
});
return res.json({ success: true, data });
} catch (e: any) {
logger.error("자재리스트 조회 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
export async function postInventoryStock(req: AuthenticatedRequest, res: Response) {
try {
const body = req.body ?? {};
if (!body.project_objid || !body.part_objid || !body.qty || !body.location) {
return res.status(400).json({ success: false, message: "필수 항목 누락" });
}
const out = await svc.saveInventory({
project_objid: body.project_objid,
unit: body.unit ?? "",
part_objid: body.part_objid,
qty: Number(body.qty),
price: body.price ?? "",
location: body.location,
sub_location: body.sub_location ?? "",
cls_cd: body.cls_cd ?? "",
cau_cd: body.cau_cd ?? "",
receipt_date: body.receipt_date ?? "",
writer: body.writer ?? userId(req),
order_objid: body.order_objid ?? "",
});
return res.json({ success: true, data: out });
} catch (e: any) {
logger.error("재고 등록 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
export async function deleteInventoryStock(req: AuthenticatedRequest, res: Response) {
try {
const objids: string[] = Array.isArray(req.body?.objids)
? req.body.objids
: String(req.body?.checkArr ?? "").split(",").filter(Boolean);
if (!objids.length) return res.status(400).json({ success: false, message: "삭제 대상 없음" });
const data = await svc.deleteInventory(objids);
return res.json({ success: true, data });
} catch (e: any) {
logger.error("재고 삭제 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
export async function postInventoryMove(req: AuthenticatedRequest, res: Response) {
try {
const items = req.body?.items;
if (!Array.isArray(items) || !items.length) {
return res.status(400).json({ success: false, message: "이동 항목 없음" });
}
const writer = req.body?.writer ?? userId(req);
const data = await svc.moveInventoryBulk(items.map((it: any) => ({ ...it, writer })));
return res.json({ success: true, data });
} catch (e: any) {
logger.error("자재이동 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
export async function getInventoryHistory(req: AuthenticatedRequest, res: Response) {
try {
const objid = String(req.params.objid ?? "");
if (!objid) return res.status(400).json({ success: false, message: "objid 없음" });
const data = await svc.getInventoryHistory(objid);
return res.json({ success: true, data });
} catch (e: any) {
logger.error("자재이력 조회 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
// ── 불출의뢰서 ─────────────────────────────────────────────
export async function getIssueRequestList(req: AuthenticatedRequest, res: Response) {
try {
const q = req.query as Record<string, any>;
const data = await svc.listIssueRequest({
...q,
page: q.page ? Number(q.page) : undefined,
page_size: q.page_size ? Number(q.page_size) : undefined,
});
return res.json({ success: true, data });
} catch (e: any) {
logger.error("불출의뢰서 조회 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
export async function getIssueRequestDetail(req: AuthenticatedRequest, res: Response) {
try {
const objid = String(req.params.objid ?? "");
if (!objid) return res.status(400).json({ success: false, message: "objid 없음" });
const data = await svc.getIssueRequest(objid);
return res.json({ success: true, data });
} catch (e: any) {
logger.error("불출의뢰 상세 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
export async function getIssueRequestCandidates(req: AuthenticatedRequest, res: Response) {
try {
const ids: string[] = Array.isArray(req.body?.parent_objids)
? req.body.parent_objids
: String(req.query.parent_objids ?? "").split(",").filter(Boolean);
const data = await svc.getIssueRequestCandidates(ids);
return res.json({ success: true, data });
} catch (e: any) {
logger.error("불출 후보 조회 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
export async function postIssueRequest(req: AuthenticatedRequest, res: Response) {
try {
const body = req.body ?? {};
if (!Array.isArray(body.lines) || !body.lines.length) {
return res.status(400).json({ success: false, message: "라인이 비어있음" });
}
const writer = body.writer ?? userId(req);
const data = await svc.saveIssueRequest({
master_objid: body.master_objid ?? body.INVENTORY_REQUEST_MASTER_OBJID,
contract_mgmt_objid: body.contract_mgmt_objid ?? "",
request_date: body.request_date ?? "",
request_id: body.request_id ?? writer,
remark: body.remark ?? "",
writer,
lines: body.lines.map((l: any) => ({
parent_objid: l.parent_objid ?? l.OBJID,
request_qty: Number(l.request_qty ?? l.REQUEST_QTY ?? 0),
unit: l.unit ?? l.unit_code ?? "",
})),
});
return res.json({ success: true, data });
} catch (e: any) {
logger.error("불출의뢰 저장 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
export async function deleteIssueRequest(req: AuthenticatedRequest, res: Response) {
try {
const objid = String(req.params.objid ?? "");
if (!objid) return res.status(400).json({ success: false, message: "objid 없음" });
await svc.deleteIssueRequest(objid);
return res.json({ success: true });
} catch (e: any) {
logger.error("불출의뢰 삭제 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
export async function postIssueRequestReceive(req: AuthenticatedRequest, res: Response) {
try {
const ids: string[] = Array.isArray(req.body?.objids)
? req.body.objids
: String(req.body?.checkArr ?? "").split(",").filter(Boolean);
if (!ids.length) return res.status(400).json({ success: false, message: "접수 대상 없음" });
const data = await svc.receiveIssueRequest(ids, userId(req));
return res.json({ success: true, data });
} catch (e: any) {
logger.error("불출의뢰 접수 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
export async function postIssueRequestDispatch(req: AuthenticatedRequest, res: Response) {
try {
const body = req.body ?? {};
if (!body.master_objid || !Array.isArray(body.lines) || !body.lines.length) {
return res.status(400).json({ success: false, message: "필수 항목 누락" });
}
const writer = body.writer ?? userId(req);
const data = await svc.dispatchIssueRequest({
master_objid: body.master_objid,
lines: body.lines.map((l: any) => ({
objid: l.objid,
out_qty: Number(l.out_qty ?? 0),
out_date: l.out_date,
acq_user: l.acq_user,
sign: l.sign,
writer,
})),
});
return res.json({ success: true, data });
} catch (e: any) {
logger.error("자재불출 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
// ── 옵션 ───────────────────────────────────────────────────
export async function getProjectOptions(_req: AuthenticatedRequest, res: Response) {
try {
return res.json({ success: true, data: await svc.listProjectOptions() });
} catch (e: any) {
return res.status(500).json({ success: false, message: e.message });
}
}
export async function getUnitOptions(req: AuthenticatedRequest, res: Response) {
try {
const code = String(req.query.contract_objid ?? req.query.project_objid ?? "");
return res.json({ success: true, data: await svc.listUnitOptions(code) });
} catch (e: any) {
return res.status(500).json({ success: false, message: e.message });
}
}
export async function getUserOptions(_req: AuthenticatedRequest, res: Response) {
try {
return res.json({ success: true, data: await svc.listUserOptions() });
} catch (e: any) {
return res.status(500).json({ success: false, message: e.message });
}
}
export async function getPartOptions(req: AuthenticatedRequest, res: Response) {
try {
const kw = String(req.query.q ?? req.query.keyword ?? "");
const limit = Math.min(100, Number(req.query.limit ?? 30));
return res.json({ success: true, data: await svc.listPartOptions(kw, limit) });
} catch (e: any) {
return res.status(500).json({ success: false, message: e.message });
}
}
@@ -5,6 +5,7 @@
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import * as svc from "../services/purchaseService";
import * as formSvc from "../services/purchaseOrderFormService";
import { logger } from "../utils/logger";
function parseFilter(q: Record<string, any>): svc.PurchaseListFilter {
@@ -36,6 +37,41 @@ export const getInbound = (req: AuthenticatedRequest, res: Response)
export const getInboundByItem = (req: AuthenticatedRequest, res: Response) => runList(svc.listInboundByItem, req, res, "품목별 입고관리");
export const getInboundByDate = (req: AuthenticatedRequest, res: Response) => runList(svc.listInboundByDate, req, res, "입고일별 입고관리");
export const getProjectStatus = (req: AuthenticatedRequest, res: Response) => runList(svc.listProjectStatus, req, res, "프로젝트별 발주/입고 현황");
export const getPurchaseOrderList = (req: AuthenticatedRequest, res: Response) => runList(svc.listPurchaseOrderList, req, res, "발주서관리");
// ─── 발주서 폼 (general 양식) ─────────────────────────────────
/**
* GET /api/purchase/order-form/init?proposal_objid=...
* 품의서에서 발주서 등록 폼 데이터 자동 채움.
*/
export async function getPurchaseOrderFormInit(req: AuthenticatedRequest, res: Response) {
try {
const proposalObjid = String(req.query.proposal_objid ?? "").trim();
const data = await formSvc.getPurchaseOrderFormInit(proposalObjid);
return res.json({ success: true, data });
} catch (e: any) {
logger.error("발주서 폼 초기화 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
/**
* GET /api/purchase/order-form/:objid
* 발주서 마스터 + 파트 조회 (수정/조회 모드).
*/
export async function getPurchaseOrderForm(req: AuthenticatedRequest, res: Response) {
try {
const objid = String(req.params.objid ?? "").trim();
if (!objid) return res.status(400).json({ success: false, message: "objid required" });
const data = await formSvc.getPurchaseOrderForm(objid);
if (!data) return res.status(404).json({ success: false, message: "발주서를 찾을 수 없어요" });
return res.json({ success: true, data });
} catch (e: any) {
logger.error("발주서 폼 조회 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
export async function getSuppliers(_req: AuthenticatedRequest, res: Response) {
try {
@@ -47,6 +83,16 @@ export async function getSuppliers(_req: AuthenticatedRequest, res: Response) {
}
}
export async function getVendors(_req: AuthenticatedRequest, res: Response) {
try {
const data = await svc.listVendorOptions();
return res.json({ success: true, data });
} catch (e: any) {
logger.error("공급업체(client_mng) 옵션 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
export async function getUsers(_req: AuthenticatedRequest, res: Response) {
try {
const data = await svc.listUserOptions();
@@ -0,0 +1,35 @@
// ============================================================
// 자재관리 라우트 — 자재리스트(/list) + 불출의뢰서(/issue-request)
// app.ts: app.use("/api/inventory-mng", inventoryMngRoutes)
// ============================================================
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as ctrl from "../controllers/inventoryMngController";
const router = Router();
router.use(authenticateToken);
// 자재리스트
router.get("/list", ctrl.getInventoryList);
router.post("/stock", ctrl.postInventoryStock);
router.post("/stock/delete", ctrl.deleteInventoryStock);
router.post("/move", ctrl.postInventoryMove);
router.get("/history/:objid", ctrl.getInventoryHistory);
// 불출의뢰서
router.get("/issue-request", ctrl.getIssueRequestList);
router.get("/issue-request/:objid", ctrl.getIssueRequestDetail);
router.post("/issue-request/candidates", ctrl.getIssueRequestCandidates);
router.post("/issue-request", ctrl.postIssueRequest);
router.delete("/issue-request/:objid", ctrl.deleteIssueRequest);
router.post("/issue-request/receive", ctrl.postIssueRequestReceive);
router.post("/issue-request/dispatch", ctrl.postIssueRequestDispatch);
// 공통 옵션
router.get("/options/projects", ctrl.getProjectOptions);
router.get("/options/units", ctrl.getUnitOptions);
router.get("/options/users", ctrl.getUserOptions);
router.get("/options/parts", ctrl.getPartOptions);
export default router;
@@ -18,9 +18,15 @@ router.get("/inbound", ctrl.getInbound); // 입고관리
router.get("/inbound-by-item", ctrl.getInboundByItem); // 품목별 입고관리
router.get("/inbound-by-date", ctrl.getInboundByDate); // 입고일별 입고관리
router.get("/project-status", ctrl.getProjectStatus); // 프로젝트별 발주/입고 현황
router.get("/order-list", ctrl.getPurchaseOrderList); // 발주서관리 (wace purchaseOrderMasterList_new 1:1)
// 발주서 폼 (general 양식, wace purchaseOrderFormPopup_general.do 1:1)
router.get("/order-form/init", ctrl.getPurchaseOrderFormInit); // 품의서에서 자동 채움
router.get("/order-form/:objid", ctrl.getPurchaseOrderForm); // 수정/조회
// 공통 옵션
router.get("/options/suppliers", ctrl.getSuppliers);
router.get("/options/vendors", ctrl.getVendors); // wace client_mng 기반
router.get("/options/users", ctrl.getUsers);
router.get("/options/projects", ctrl.getProjects);
@@ -0,0 +1,767 @@
// ============================================================
// 자재관리 — 자재리스트 + 불출의뢰서 서비스
// wace_plm inventoryMng.xml + InventoryMngService.java 1:1 베이스
//
// 테이블:
// inventory_mgmt (자재 마스터: contract_objid+unit+part_objid PK, objid는 별도)
// inventory_mgmt_in (입고/이동 라인: parent_objid → inventory_mgmt.objid)
// inventory_mgmt_out (불출 라인: parent_objid → inventory_mgmt.objid,
// inventory_request_master_objid → inventory_mgmt_out_master.objid)
// inventory_mgmt_out_master (불출의뢰 마스터: inventory_out_no = Rfw-YYYY-seq)
// inventory_mgmt_history (자재 투입 이력 — 본 메뉴에서는 조회용)
//
// 보유수량 공식:
// USE_CNT = SUM(receipt_qty - move_qty) FROM inventory_mgmt_in WHERE parent=IM.objid
// - SUM(request_qty) FROM inventory_mgmt_out WHERE parent=IM.objid
//
// 불출 흐름: 의뢰(request_qty) → 접수(reception_status=reception) → 불출(out_qty, outstatus=complete)
// ============================================================
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
import { createObjId } from "../utils/objidUtil";
export interface InventoryListFilter {
project_objid?: string;
unit_code?: string;
part_no?: string;
part_name?: string;
part_type?: string;
location?: string;
cls_cd?: string;
cau_cd?: string;
writer?: string;
page?: number;
page_size?: number;
}
export interface IssueRequestFilter {
part_no?: string;
part_name?: string;
request_start_date?: string;
request_end_date?: string;
request_user?: string;
reception_status?: string;
reception_user?: string;
reception_start_date?: string;
reception_end_date?: string;
out_status?: string;
page?: number;
page_size?: number;
}
interface ListResult<T> {
rows: T[];
totalCount: number;
page: number;
pageSize: number;
}
function paging(f: { page?: number; page_size?: number }) {
const page = Math.max(1, Number(f.page ?? 1));
const pageSize = Math.max(1, Math.min(500, Number(f.page_size ?? 50)));
return { page, pageSize, limit: pageSize, offset: (page - 1) * pageSize };
}
// ─── 자재리스트 그리드 ─────────────────────────────────────────
// wace `inventoryMngNewGridList` 매퍼 1:1 베이스
// USE_CNT = inbound move out_request (각 부모 행 단위 합산)
// USE_CNT_ALL = 같은 part_objid 전체 가상 합계 (윈도우 함수)
// REQUEST_QTY = 누적 불출의뢰 수량
// LOCATION_NAME = inventory_mgmt_in.location distinct 목록 (콤마)
export async function listInventory(filter: InventoryListFilter): Promise<ListResult<any>> {
const pool = getPool();
const { page, pageSize, limit, offset } = paging(filter);
const where: string[] = [];
const params: any[] = [];
const addP = (v: any) => { params.push(v); return `$${params.length}`; };
if (filter.project_objid) where.push(`IM.contract_objid = ${addP(filter.project_objid)}`);
if (filter.unit_code) where.push(`IM.unit = ${addP(filter.unit_code)}`);
if (filter.part_no) where.push(`P.part_no ILIKE ${addP(`%${filter.part_no}%`)}`);
if (filter.part_name) where.push(`P.part_name ILIKE ${addP(`%${filter.part_name}%`)}`);
if (filter.part_type) where.push(`P.part_type = ${addP(filter.part_type)}`);
if (filter.location) where.push(`IM.location = ${addP(filter.location)}`);
if (filter.cls_cd) where.push(`IM.cls_cd = ${addP(filter.cls_cd)}`);
if (filter.cau_cd) where.push(`IM.cau_cd = ${addP(filter.cau_cd)}`);
if (filter.writer) where.push(`IM.writer = ${addP(filter.writer)}`);
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
const dataSql = `
WITH IN_SUM AS (
SELECT parent_objid,
SUM(COALESCE(NULLIF(receipt_qty,'')::numeric,0) - COALESCE(NULLIF(move_qty,'')::numeric,0)) AS in_qty
FROM inventory_mgmt_in
GROUP BY parent_objid
),
OUT_SUM AS (
SELECT parent_objid,
SUM(COALESCE(NULLIF(request_qty,'')::numeric,0)) AS req_qty,
SUM(COALESCE(NULLIF(out_qty,'')::numeric,0)) AS out_qty
FROM inventory_mgmt_out
GROUP BY parent_objid
),
LOC_LIST AS (
SELECT parent_objid, STRING_AGG(DISTINCT NULLIF(location,''), ',') AS loc_names
FROM inventory_mgmt_in
GROUP BY parent_objid
)
SELECT
IM.objid AS objid,
IM.contract_objid AS contract_objid,
COALESCE(PJ.project_no, CT.contract_no, '') AS project_no,
IM.unit AS unit,
COALESCE(WT.task_name, '') AS unit_name,
IM.part_objid AS part_objid,
P.part_no AS part_no,
P.part_name AS part_name,
P.material AS material,
P.spec AS spec,
P.part_type AS part_type,
P.part_type AS part_type_name,
IM.cls_cd AS cls_cd,
IM.cau_cd AS cau_cd,
IM.location AS location,
IM.sub_location AS sub_location,
COALESCE(LL.loc_names, IM.location, '') AS location_name,
IM.reg_date AS reg_date,
IM.price AS price,
IM.writer AS writer,
COALESCE(user_name(IM.writer), IM.writer, '') AS writer_name,
COALESCE(IS_.in_qty, 0) - COALESCE(OS.req_qty, 0) AS use_cnt,
COALESCE(OS.req_qty, 0) AS request_qty,
COALESCE(OS.out_qty, 0) AS out_qty_total,
SUM(COALESCE(IS_.in_qty, 0) - COALESCE(OS.req_qty, 0))
OVER (PARTITION BY IM.part_objid) AS use_cnt_all,
'' AS remark
FROM inventory_mgmt IM
LEFT JOIN part_mng P ON P.objid::varchar = IM.part_objid
LEFT JOIN contract_mgmt CT ON CT.objid = IM.contract_objid
LEFT JOIN project_mgmt PJ ON PJ.contract_objid = IM.contract_objid
LEFT JOIN pms_wbs_task WT ON WT.contract_objid = IM.contract_objid AND WT.task_seq = IM.unit
LEFT JOIN IN_SUM IS_ ON IS_.parent_objid = IM.objid
LEFT JOIN OUT_SUM OS ON OS.parent_objid = IM.objid
LEFT JOIN LOC_LIST LL ON LL.parent_objid = IM.objid
${whereSql}
ORDER BY IM.reg_date DESC NULLS LAST, IM.objid DESC
LIMIT ${addP(limit)} OFFSET ${addP(offset)}
`;
const countSql = `
SELECT COUNT(*)::int AS cnt
FROM inventory_mgmt IM
LEFT JOIN part_mng P ON P.objid::varchar = IM.part_objid
${whereSql}
`;
try {
const [d, c] = await Promise.all([
pool.query(dataSql, params),
pool.query(countSql, params.slice(0, params.length - 2)),
]);
return { rows: d.rows, totalCount: c.rows[0]?.cnt ?? 0, page, pageSize };
} catch (e: any) {
logger.error("listInventory 실패", { error: e.message });
return { rows: [], totalCount: 0, page, pageSize };
}
}
// ─── 재고 등록 (자재 신규/추가입고) ───────────────────────────
// 1) inventory_mgmt: (contract_objid+unit+part_objid) UNIQUE → 존재 시 재사용, 없으면 INSERT
// 2) inventory_mgmt_in: 입고 1건 INSERT
export interface SaveInventoryInput {
project_objid: string; // contract_objid
unit?: string;
part_objid: string;
qty: number;
price?: string;
location: string;
sub_location?: string;
cls_cd?: string;
cau_cd?: string;
receipt_date?: string;
writer?: string;
order_objid?: string;
}
export async function saveInventory(input: SaveInventoryInput): Promise<{ objid: string; in_objid: string }> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const probe = await client.query(
`SELECT objid FROM inventory_mgmt
WHERE contract_objid = $1 AND unit = $2 AND part_objid = $3
LIMIT 1`,
[input.project_objid, input.unit ?? "", input.part_objid],
);
let objid = probe.rows[0]?.objid as string | undefined;
if (!objid) {
objid = createObjId();
await client.query(
`INSERT INTO inventory_mgmt
(objid, contract_objid, unit, part_objid, cls_cd, cau_cd, qty, location, sub_location,
reg_date, price, writer)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9, TO_CHAR(NOW(),'YYYY-MM-DD'), $10, $11)`,
[objid, input.project_objid, input.unit ?? "", input.part_objid,
input.cls_cd ?? "", input.cau_cd ?? "", String(input.qty),
input.location, input.sub_location ?? "",
input.price ?? "", input.writer ?? ""],
);
}
const inObjid = createObjId();
await client.query(
`INSERT INTO inventory_mgmt_in
(objid, parent_objid, receipt_qty, location, sub_location, writer, regdate,
contract_mgmt_objid, purchase_order_master_objid, receipt_date)
VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7, $8, $9)`,
[inObjid, objid, String(input.qty), input.location, input.sub_location ?? "",
input.writer ?? "", input.project_objid,
input.order_objid ?? "", input.receipt_date ?? new Date().toISOString().slice(0, 10)],
);
await client.query("COMMIT");
return { objid, in_objid: inObjid };
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
// ─── 재고 삭제 ─────────────────────────────────────────────
export async function deleteInventory(objidArr: string[]): Promise<{ deleted: number }> {
if (!objidArr.length) return { deleted: 0 };
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query(`DELETE FROM inventory_mgmt_in WHERE parent_objid = ANY($1::varchar[])`, [objidArr]);
await client.query(`DELETE FROM inventory_mgmt_out WHERE parent_objid = ANY($1::varchar[])`, [objidArr]);
const r = await client.query(`DELETE FROM inventory_mgmt WHERE objid = ANY($1::varchar[])`, [objidArr]);
await client.query("COMMIT");
return { deleted: r.rowCount ?? 0 };
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
// ─── 자재이동: inventory_mgmt_in 행에 move_qty/move_date/move_user 누적 ──
export interface MoveInventoryInput {
in_objid: string;
move_qty: number;
location?: string;
sub_location?: string;
move_date?: string;
move_user?: string;
writer?: string;
}
export async function moveInventoryBulk(items: MoveInventoryInput[]): Promise<{ updated: number }> {
if (!items.length) return { updated: 0 };
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
for (const it of items) {
const prev = await client.query(
`SELECT COALESCE(NULLIF(move_qty,''),'0')::numeric AS mq, parent_objid
FROM inventory_mgmt_in WHERE objid = $1`,
[it.in_objid],
);
const parent = prev.rows[0]?.parent_objid as string | undefined;
if (!parent) continue;
const newMove = Number(prev.rows[0].mq) + Number(it.move_qty);
await client.query(
`UPDATE inventory_mgmt_in
SET move_qty = $1,
move_date = COALESCE($2, move_date),
move_user = COALESCE($3, move_user)
WHERE objid = $4`,
[String(newMove), it.move_date ?? null, it.move_user ?? null, it.in_objid],
);
// 이동 이력 라인 추가
await client.query(
`INSERT INTO inventory_mgmt_in
(objid, parent_objid, receipt_qty, location, sub_location, writer, regdate,
move_qty, move_date, move_user)
VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7, $8, $9)`,
[createObjId(), parent, String(it.move_qty),
it.location ?? "", it.sub_location ?? "",
it.writer ?? it.move_user ?? "",
String(it.move_qty), it.move_date ?? null, it.move_user ?? null],
);
}
await client.query("COMMIT");
return { updated: items.length };
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
// ─── 자재 입출고 이력 ─────────────────────────────────────────
export async function getInventoryHistory(parentObjid: string): Promise<any[]> {
const pool = getPool();
const sql = `
-- 입고
SELECT
II.objid AS objid,
P.part_no AS part_no,
P.part_name AS part_name,
'입고' AS gubun,
COALESCE(II.receipt_qty, '') AS qty,
II.location AS location_name,
II.sub_location AS sub_location_name,
II.regdate AS regdate,
II.writer AS writer,
COALESCE(user_name(II.writer), II.writer,'') AS writer_name
FROM inventory_mgmt_in II
LEFT JOIN inventory_mgmt IM ON IM.objid = II.parent_objid
LEFT JOIN part_mng P ON P.objid::varchar = IM.part_objid
WHERE II.parent_objid = $1 AND COALESCE(II.move_date, '') = ''
UNION ALL
-- 이동
SELECT
II.objid, P.part_no, P.part_name, '이동',
COALESCE(II.move_qty, II.receipt_qty, ''),
II.location, II.sub_location, II.regdate,
II.move_user,
COALESCE(user_name(II.move_user), II.move_user, '')
FROM inventory_mgmt_in II
LEFT JOIN inventory_mgmt IM ON IM.objid = II.parent_objid
LEFT JOIN part_mng P ON P.objid::varchar = IM.part_objid
WHERE II.parent_objid = $1 AND COALESCE(II.move_date, '') <> ''
UNION ALL
-- 출고
SELECT
IO.objid, P.part_no, P.part_name, '출고',
COALESCE(IO.out_qty, IO.request_qty, ''),
'', '', IO.regdate,
IO.writer,
COALESCE(user_name(IO.writer), IO.writer, '')
FROM inventory_mgmt_out IO
LEFT JOIN inventory_mgmt IM ON IM.objid = IO.parent_objid
LEFT JOIN part_mng P ON P.objid::varchar = IM.part_objid
WHERE IO.parent_objid = $1
ORDER BY regdate DESC NULLS LAST
`;
const r = await pool.query(sql, [parentObjid]);
return r.rows;
}
// ─── 불출의뢰서 그리드 ─────────────────────────────────────────
export async function listIssueRequest(filter: IssueRequestFilter): Promise<ListResult<any>> {
const pool = getPool();
const { page, pageSize, limit, offset } = paging(filter);
const where: string[] = [];
const params: any[] = [];
const addP = (v: any) => { params.push(v); return `$${params.length}`; };
if (filter.request_user) where.push(`OM.request_id = ${addP(filter.request_user)}`);
if (filter.reception_user) where.push(`OM.reception_id = ${addP(filter.reception_user)}`);
if (filter.reception_status) {
if (filter.reception_status === "reception") where.push(`OM.reception_status = 'reception'`);
else if (filter.reception_status === "AA") where.push(`COALESCE(OM.reception_status,'') <> 'reception'`);
}
if (filter.out_status) {
if (filter.out_status === "complete") where.push(`OM.outstatus = 'complete'`);
else if (filter.out_status === "NG") where.push(`COALESCE(OM.outstatus,'') <> 'complete'`);
}
if (filter.request_start_date) where.push(`OM.request_date >= ${addP(filter.request_start_date)}`);
if (filter.request_end_date) where.push(`OM.request_date <= ${addP(filter.request_end_date)}`);
if (filter.reception_start_date) where.push(`OM.reception_date >= ${addP(filter.reception_start_date)}`);
if (filter.reception_end_date) where.push(`OM.reception_date <= ${addP(filter.reception_end_date)}`);
if (filter.part_no) {
where.push(`EXISTS (
SELECT 1 FROM inventory_mgmt_out IO
JOIN inventory_mgmt IM ON IM.objid = IO.parent_objid
JOIN part_mng P ON P.objid::varchar = IM.part_objid
WHERE IO.inventory_request_master_objid = OM.objid
AND P.part_no ILIKE ${addP(`%${filter.part_no}%`)}
)`);
}
if (filter.part_name) {
where.push(`EXISTS (
SELECT 1 FROM inventory_mgmt_out IO
JOIN inventory_mgmt IM ON IM.objid = IO.parent_objid
JOIN part_mng P ON P.objid::varchar = IM.part_objid
WHERE IO.inventory_request_master_objid = OM.objid
AND P.part_name ILIKE ${addP(`%${filter.part_name}%`)}
)`);
}
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
const dataSql = `
SELECT
OM.objid AS objid,
OM.inventory_out_no AS inventory_out_no,
OM.request_date AS request_date,
OM.request_id AS request_id,
COALESCE(user_name(OM.request_id), OM.request_id,'') AS request_user_name,
OM.reception_status AS reception_status,
CASE WHEN OM.reception_status='reception' THEN '접수' ELSE '미접수' END AS reception_status_title,
OM.reception_id AS reception_id,
COALESCE(user_name(OM.reception_id), OM.reception_id,'') AS reception_user_name,
OM.reception_date AS reception_date,
OM.outstatus AS outstatus,
CASE WHEN OM.outstatus='complete' THEN '완료' ELSE '미완료' END AS outstatus_title,
OM.remark AS remark,
OM.writer AS writer,
OM.regdate AS regdate,
COALESCE((
SELECT STRING_AGG(P.part_no, ',' ORDER BY P.part_no)
FROM inventory_mgmt_out IO
JOIN inventory_mgmt IM ON IM.objid = IO.parent_objid
JOIN part_mng P ON P.objid::varchar = IM.part_objid
WHERE IO.inventory_request_master_objid = OM.objid
), '') AS part_no_arr,
COALESCE((
SELECT STRING_AGG(P.part_name, ',' ORDER BY P.part_no)
FROM inventory_mgmt_out IO
JOIN inventory_mgmt IM ON IM.objid = IO.parent_objid
JOIN part_mng P ON P.objid::varchar = IM.part_objid
WHERE IO.inventory_request_master_objid = OM.objid
), '') AS part_name_arr,
COALESCE((
SELECT SUM(COALESCE(NULLIF(IO.request_qty,'')::numeric,0))
FROM inventory_mgmt_out IO
WHERE IO.inventory_request_master_objid = OM.objid
), 0) AS request_qty_total,
COALESCE((
SELECT SUM(COALESCE(NULLIF(IO.out_qty,'')::numeric,0))
FROM inventory_mgmt_out IO
WHERE IO.inventory_request_master_objid = OM.objid
), 0) AS out_qty_total
FROM inventory_mgmt_out_master OM
${whereSql}
ORDER BY OM.regdate DESC NULLS LAST, OM.objid DESC
LIMIT ${addP(limit)} OFFSET ${addP(offset)}
`;
const countSql = `SELECT COUNT(*)::int AS cnt FROM inventory_mgmt_out_master OM ${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("listIssueRequest 실패", { error: e.message });
return { rows: [], totalCount: 0, page, pageSize };
}
}
// ─── 불출의뢰 상세: 마스터 + 라인 ────────────────────────────
export async function getIssueRequest(masterObjid: string): Promise<{ master: any; lines: any[] }> {
const pool = getPool();
const master = await pool.query(
`SELECT OM.*,
COALESCE(user_name(OM.request_id), OM.request_id, '') AS request_user_name,
COALESCE(user_name(OM.reception_id),OM.reception_id,'') AS reception_user_name
FROM inventory_mgmt_out_master OM
WHERE OM.objid = $1`,
[masterObjid],
);
const lines = await pool.query(
`SELECT
IO.objid AS objid,
IO.parent_objid AS parent_objid,
IO.request_qty AS request_qty,
IO.out_qty AS out_qty,
IO.out_date AS out_date,
IO.writer AS writer,
COALESCE(user_name(IO.writer), IO.writer, '') AS writer_name,
IO.acq_user AS acq_user,
COALESCE(user_name(IO.acq_user), IO.acq_user, '') AS acq_user_name,
IO.sign AS sign,
IO.unit AS unit,
IO.contract_mgmt_objid AS contract_mgmt_objid,
IM.location AS location,
IM.sub_location AS sub_location,
IM.part_objid AS part_objid,
P.part_no AS part_no,
P.part_name AS part_name,
P.material AS material,
P.spec AS spec,
P.part_type AS part_type
FROM inventory_mgmt_out IO
LEFT JOIN inventory_mgmt IM ON IM.objid = IO.parent_objid
LEFT JOIN part_mng P ON P.objid::varchar = IM.part_objid
WHERE IO.inventory_request_master_objid = $1
ORDER BY P.part_no NULLS LAST, IO.objid`,
[masterObjid],
);
return { master: master.rows[0] ?? null, lines: lines.rows };
}
// ─── 불출 가능 자재 후보 (자재리스트에서 선택한 OBJID들 기반) ────
export async function getIssueRequestCandidates(parentObjids: string[]): Promise<any[]> {
if (!parentObjids.length) return [];
const pool = getPool();
const r = await pool.query(
`SELECT
IM.objid AS objid,
IM.contract_objid AS contract_objid,
COALESCE(PJ.project_no, CT.contract_no, '') AS project_no,
IM.unit AS unit,
COALESCE(WT.task_name, '') AS unit_name,
P.part_no AS part_no,
P.part_name AS part_name,
P.material AS material,
P.spec AS spec,
P.part_type AS part_type_name,
IM.location AS location,
IM.sub_location AS sub_location,
(
COALESCE((SELECT SUM(
COALESCE(NULLIF(receipt_qty,'')::numeric,0)
- COALESCE(NULLIF(move_qty,'')::numeric,0))
FROM inventory_mgmt_in WHERE parent_objid = IM.objid), 0)
- COALESCE((SELECT SUM(COALESCE(NULLIF(request_qty,'')::numeric,0))
FROM inventory_mgmt_out WHERE parent_objid = IM.objid), 0)
) AS use_cnt
FROM inventory_mgmt IM
LEFT JOIN part_mng P ON P.objid::varchar = IM.part_objid
LEFT JOIN contract_mgmt CT ON CT.objid = IM.contract_objid
LEFT JOIN project_mgmt PJ ON PJ.contract_objid = IM.contract_objid
LEFT JOIN pms_wbs_task WT ON WT.contract_objid = IM.contract_objid AND WT.task_seq = IM.unit
WHERE IM.objid = ANY($1::varchar[])`,
[parentObjids],
);
return r.rows;
}
// ─── 불출의뢰 생성/수정 ───────────────────────────────────────
export interface IssueRequestSaveInput {
master_objid?: string; // 수정 시 기존 키
contract_mgmt_objid?: string;
request_date?: string;
request_id?: string;
remark?: string;
writer: string;
lines: Array<{
parent_objid: string;
request_qty: number;
unit?: string;
}>;
}
export async function saveIssueRequest(input: IssueRequestSaveInput): Promise<{ master_objid: string; inventory_out_no: string }> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const today = new Date().toISOString().slice(0, 10);
let masterObjid = input.master_objid;
let inventoryOutNo = "";
if (masterObjid) {
await client.query(
`UPDATE inventory_mgmt_out_master
SET remark = COALESCE($1, remark),
request_date = COALESCE($2, request_date),
contract_mgmt_objid = COALESCE($3, contract_mgmt_objid)
WHERE objid = $4`,
[input.remark ?? null, input.request_date ?? today, input.contract_mgmt_objid ?? null, masterObjid],
);
const m = await client.query(
`SELECT inventory_out_no FROM inventory_mgmt_out_master WHERE objid = $1`,
[masterObjid],
);
inventoryOutNo = m.rows[0]?.inventory_out_no ?? "";
await client.query(
`DELETE FROM inventory_mgmt_out WHERE inventory_request_master_objid = $1`,
[masterObjid],
);
} else {
masterObjid = createObjId();
const seq = await client.query(
`SELECT 'Rfw-' || TO_CHAR(NOW(),'YYYY') || '-' ||
(COALESCE(MAX(NULLIF(SPLIT_PART(inventory_out_no, '-', 3),'')::int), 0) + 1)::text
AS no
FROM inventory_mgmt_out_master
WHERE inventory_out_no LIKE 'Rfw-' || TO_CHAR(NOW(),'YYYY') || '-%'`,
);
inventoryOutNo = seq.rows[0]?.no ?? `Rfw-${new Date().getFullYear()}-1`;
await client.query(
`INSERT INTO inventory_mgmt_out_master
(objid, inventory_out_no, request_date, request_id, writer, regdate, remark, contract_mgmt_objid)
VALUES ($1, $2, $3, $4, $5, NOW(), $6, $7)`,
[masterObjid, inventoryOutNo, input.request_date ?? today,
input.request_id ?? input.writer, input.writer, input.remark ?? "",
input.contract_mgmt_objid ?? ""],
);
}
for (const ln of input.lines) {
if (!ln.parent_objid || !ln.request_qty || ln.request_qty <= 0) continue;
const ioObjid = createObjId();
await client.query(
`INSERT INTO inventory_mgmt_out
(objid, parent_objid, request_qty, writer, regdate,
inventory_request_master_objid, contract_mgmt_objid, unit)
VALUES ($1, $2, $3, $4, NOW(), $5, $6, $7)`,
[ioObjid, ln.parent_objid, String(ln.request_qty), input.writer,
masterObjid, input.contract_mgmt_objid ?? "", ln.unit ?? ""],
);
}
await client.query("COMMIT");
return { master_objid: masterObjid!, inventory_out_no: inventoryOutNo };
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
// ─── 불출의뢰 삭제 ─────────────────────────────────────────
export async function deleteIssueRequest(masterObjid: string): Promise<void> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query(`DELETE FROM inventory_mgmt_out WHERE inventory_request_master_objid = $1`, [masterObjid]);
await client.query(`DELETE FROM inventory_mgmt_out_master WHERE objid = $1`, [masterObjid]);
await client.query("COMMIT");
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
// ─── 접수 처리 ────────────────────────────────────────────
export async function receiveIssueRequest(objids: string[], receptionId: string): Promise<{ updated: number }> {
if (!objids.length) return { updated: 0 };
const pool = getPool();
const today = new Date().toISOString().slice(0, 10);
const r = await pool.query(
`UPDATE inventory_mgmt_out_master
SET reception_status = 'reception',
reception_date = $1,
reception_id = $2
WHERE objid = ANY($3::varchar[])
AND COALESCE(reception_status,'') <> 'reception'`,
[today, receptionId, objids],
);
return { updated: r.rowCount ?? 0 };
}
// ─── 자재불출 처리 (실제 불출 + 재고 차감) ──────────────────
export interface DispatchInput {
master_objid: string;
lines: Array<{
objid: string; // inventory_mgmt_out.objid
out_qty: number;
out_date: string;
acq_user: string;
sign?: string;
writer?: string;
}>;
}
export async function dispatchIssueRequest(input: DispatchInput): Promise<{ updated: number }> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
for (const ln of input.lines) {
await client.query(
`UPDATE inventory_mgmt_out
SET out_qty = $1,
out_date = $2,
acq_user = $3,
sign = COALESCE($4, sign),
writer = COALESCE($5, writer)
WHERE objid = $6`,
[String(ln.out_qty), ln.out_date, ln.acq_user, ln.sign ?? null, ln.writer ?? null, ln.objid],
);
}
await client.query(
`UPDATE inventory_mgmt_out_master
SET outstatus = 'complete'
WHERE objid = $1`,
[input.master_objid],
);
await client.query("COMMIT");
return { updated: input.lines.length };
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
// ─── 옵션: 프로젝트 / 유닛 / Location / 사용자 ──────────────
export async function listProjectOptions(): Promise<Array<{ code: string; label: string }>> {
const pool = getPool();
const r = await pool.query(
`SELECT DISTINCT PM.contract_objid AS code,
COALESCE(PM.project_no, CT.contract_no, PM.contract_objid) AS label
FROM project_mgmt PM
LEFT JOIN contract_mgmt CT ON CT.objid = PM.contract_objid
WHERE PM.contract_objid IS NOT NULL
ORDER BY label`,
);
return r.rows;
}
export async function listUnitOptions(contractObjid: string): Promise<Array<{ code: string; label: string }>> {
if (!contractObjid) return [];
const pool = getPool();
const r = await pool.query(
`SELECT task_seq AS code, task_name AS label
FROM pms_wbs_task
WHERE contract_objid = $1 AND COALESCE(task_seq,'') <> ''
ORDER BY task_seq`,
[contractObjid],
);
return r.rows;
}
export async function listUserOptions(): Promise<Array<{ code: string; label: string }>> {
const pool = getPool();
const r = await pool.query(
`SELECT user_id AS code, COALESCE(user_name, user_id) AS label
FROM user_info
WHERE COALESCE(status,'') <> 'inactive'
ORDER BY label`,
);
return r.rows;
}
export async function listPartOptions(keyword: string, limit: number = 30): Promise<Array<{ code: string; label: string; part_name: string }>> {
const pool = getPool();
const kw = keyword?.trim() ?? "";
const params: any[] = [];
const conds: string[] = [];
if (kw) {
params.push(`%${kw}%`);
conds.push(`(part_no ILIKE $1 OR part_name ILIKE $1)`);
}
params.push(limit);
const r = await pool.query(
`SELECT objid::varchar AS code, part_no AS label, part_name
FROM part_mng
${conds.length ? `WHERE ${conds.join(" AND ")}` : ""}
ORDER BY part_no
LIMIT $${params.length}`,
params,
);
return r.rows;
}
@@ -0,0 +1,240 @@
// ============================================================
// 발주서관리 — 등록/수정 폼 (general 양식) 서비스
//
// wace_plm 1:1 이식 베이스:
// - controller: purchaseOrder/purchaseOrderFormPopup_general.do
// purchaseOrder/purchaseOrderFormPopup_generalSave.do
// - service: PurchaseOrderService.savePurchaseOrder_new (1472-1817)
// - mapper: purchaseOrder.xml mergePurchaseOrderMaster (530-714) +
// mergePurchaseOrderPartInfo (1205-1325) +
// getPurchaseOrderMasterInfo (1343-1556) +
// getPURCHASE_ORDER_PART
// salesMng.xml getProposalPartList (5012-5125) +
// getProposalInfo (4919-)
//
// 운영 핵심 흐름: 품의서(/purchase/proposal)에서 "발주서생성" → general 다이얼로그 →
// 품의서 품목 자동 채움 → 마스터 입력 → 저장.
//
// 본 모듈은 form-init / form-get 두 GET 엔드포인트를 제공.
// (save / delete 는 다음 단계)
// ============================================================
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
export interface OrderFormInitResult {
master: Record<string, any>;
parts: Record<string, any>[];
}
/**
* GET /api/purchase/order-form/init?proposal_objid=...
*
* 품의서(sales_request_master)에서 발주서 등록 폼을 채울 데이터를 반환.
* wace controller `purchaseOrderFormPopup_general.do` 의 신규 등록 분기와 1:1.
*
* master 기본값:
* - PROPOSAL_OBJID / SALES_REQUEST_OBJID = proposal_objid
* - CONTRACT_MGMT_OBJID = proposal.PROJECT_NO
* - PURCHASE_ORDER_NO = "RPS{YY}-{MMDD}-{NN}" (NN: 당일 발주 카운트+1)
* - PURCHASE_DATE / ORDER_DATE = 오늘
* - STATUS = "create" / FORM_TYPE = "general"
* parts: salesMng.getProposalPartList SQL 1:1 → 발주서 그리드 형식 변환
* (ORDER_QTY=QTY, PARTNER_PRICE=UNIT_PRICE, SUPPLY_UNIT_PRICE=QTY*UNIT_PRICE 등)
*/
export async function getPurchaseOrderFormInit(proposalObjid: string): Promise<OrderFormInitResult> {
const pool = getPool();
// 1) 품의서 마스터 정보 (PROJECT_NO 등)
let proposal: Record<string, any> | null = null;
if (proposalObjid) {
try {
const r = await pool.query(
`SELECT OBJID, PROJECT_NO, MBOM_HEADER_OBJID, TITLE, REQUEST_USER_ID, PURCHASE_TYPE
FROM SALES_REQUEST_MASTER WHERE OBJID = $1`,
[proposalObjid],
);
proposal = r.rows[0] ?? null;
} catch (e: any) {
logger.warn("getProposalInfo 실패", { error: e.message });
}
}
// 2) 발주번호 채번 (wace mergePurchaseOrderMaster INSERT 절 1:1)
let purchaseOrderNo = "";
try {
const r = await pool.query(
`SELECT 'RPS' || TO_CHAR(NOW(),'YY') || '-' || TO_CHAR(NOW(),'MMDD') || '-' ||
LPAD((COALESCE(MAX(CASE WHEN PURCHASE_ORDER_NO LIKE 'RPS' || TO_CHAR(NOW(),'YY-MMDD') || '-%'
THEN SPLIT_PART(PURCHASE_ORDER_NO, '-', 3) ELSE '0' END)::INTEGER, 0) + 1)::TEXT, 2, '0')
AS po_no
FROM PURCHASE_ORDER_MASTER`,
);
purchaseOrderNo = r.rows[0]?.po_no ?? "";
} catch (e: any) {
logger.warn("발주번호 채번 실패", { error: e.message });
}
const todayIso = new Date().toISOString().slice(0, 10);
const master: Record<string, any> = {
objid: "", // 신규 — 클라이언트가 채워 보내거나 save 시 채번
purchase_order_no: purchaseOrderNo,
purchase_date: todayIso, // 발주일
order_date_kor: formatKorDate(new Date()), // 발주일자 한글 표기 (wace ORDER_DATE)
status: "create",
form_type: "general",
sales_request_objid: proposalObjid || "",
proposal_objid: proposalObjid || "",
contract_mgmt_objid: proposal?.project_no ?? "",
title: proposal?.title ?? "",
// wace controller _general 기본 담당자 (RPS 운영 고정값)
manager_name: "안동윤",
manager_position: "팀장",
manager_phone: "010-2313-2702",
manager_email: "ady1225@rps-korea.com",
manager_name2: "서동민",
manager_position2: "주임",
manager_phone2: "010-9538-9513",
manager_email2: "sdm0927@rps-korea.com",
};
// 3) 품의서 품목 → 발주서 파트 변환 (salesMng.getProposalPartList 1:1)
const parts: Record<string, any>[] = [];
if (proposalObjid) {
try {
const r = await pool.query(
`SELECT
ROW_NUMBER() OVER(ORDER BY SRP.REGDATE) AS rnum,
SRP.OBJID AS srp_objid,
SRP.PART_OBJID AS part_objid,
PM.PART_NO AS part_no,
PM.PART_NAME AS part_name,
PM.SPEC AS spec,
PM.MATERIAL AS material,
COALESCE(NULLIF(SRP.UNIT, ''), PM.UNIT_DC) AS unit,
COALESCE(
(SELECT CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = NULLIF(SRP.UNIT, '')),
(SELECT CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = PM.UNIT_DC)
) AS unit_title,
SRP.QTY AS qty,
COALESCE(SRP.UNIT_PRICE, 0) AS unit_price,
CASE WHEN COALESCE(SRP.TOTAL_PRICE::NUMERIC, 0) > 0
THEN SRP.TOTAL_PRICE::NUMERIC
ELSE COALESCE(SRP.QTY::NUMERIC, 0) * COALESCE(SRP.UNIT_PRICE::NUMERIC, 0)
END AS total_price,
SRP.VENDOR_PM AS vendor_pm,
(SELECT CLIENT_NM FROM CLIENT_MNG
WHERE OBJID::VARCHAR = SRP.VENDOR_PM) AS vendor_name,
SRP.REMARK AS remark,
SRP.DELIVERY_REQUEST_DATE AS delivery_request_date,
COALESCE(SRP.MATERIAL_YN, 'N') AS material_yn,
(SELECT PJ.PART_NAME FROM PROJECT_MGMT PJ
WHERE PJ.OBJID::VARCHAR = SRM.PROJECT_NO) AS project_product_name,
PM.PART_NAME AS component_part_name,
SRP.CURRENCY AS currency,
(SELECT CC.CODE_NAME FROM COMM_CODE CC
WHERE CC.CODE_ID = NULLIF(SRP.CURRENCY, '')) AS currency_name
FROM SALES_REQUEST_PART SRP
LEFT JOIN PART_MNG PM
ON SRP.PART_OBJID::VARCHAR = PM.OBJID::VARCHAR
LEFT JOIN SALES_REQUEST_MASTER SRM
ON SRP.SALES_REQUEST_MASTER_OBJID = SRM.OBJID
WHERE SRP.SALES_REQUEST_MASTER_OBJID = $1
ORDER BY SRP.REGDATE`,
[proposalObjid],
);
for (const row of r.rows) {
const qty = toNum(row.qty);
const unitPrice = toNum(row.unit_price);
const projectName = (row.project_product_name ?? "").toString().trim();
const componentName = (row.component_part_name ?? "").toString().trim();
const remark = projectName && componentName ? `${projectName} / ${componentName}`
: projectName || componentName || (row.remark ?? "");
parts.push({
// 발주서 그리드 형식 (wace controller _general 변환 1:1)
objid: "", // 신규 row
part_objid: row.part_objid ?? "",
row_num: row.rnum,
part_no: row.part_no ?? "",
part_name: row.part_name ?? "",
spec: row.spec ?? "",
material: row.material ?? "",
order_qty: qty,
unit: row.unit || "0001400", // wace 기본값 EA
unit_title: row.unit_title ?? "",
part_delivery_place: "RPS",
partner_price: unitPrice,
supply_unit_price: qty * unitPrice,
remark,
delivery_request_date: row.delivery_request_date ?? "",
currency: row.currency ?? "",
currency_name: row.currency_name ?? "",
// 추적용 (저장 시 신규 row 임을 구분)
_src: "proposal",
_src_objid: row.srp_objid,
});
}
} catch (e: any) {
logger.error("getProposalPartList 실패", { error: e.message });
}
}
return { master, parts };
}
/**
* GET /api/purchase/order-form/:objid
*
* 발주서 마스터 + 파트 조회 (수정/조회 모드).
* wace `getPurchaseOrderMasterInfo` (1343-1556) + `getPURCHASE_ORDER_PART` 의 RPS 압축판.
*/
export async function getPurchaseOrderForm(objid: string): Promise<OrderFormInitResult | null> {
const pool = getPool();
try {
const m = await pool.query(
`SELECT POM.*,
(SELECT CLIENT_NM FROM CLIENT_MNG
WHERE OBJID::VARCHAR = POM.PARTNER_OBJID) AS partner_name,
(SELECT USER_NAME FROM USER_INFO
WHERE USER_ID = POM.SALES_MNG_USER_ID) AS sales_mng_user_name,
(SELECT USER_NAME FROM USER_INFO
WHERE USER_ID = POM.WRITER) AS writer_name,
CM.PROJECT_NO AS project_no,
SRM.REQUEST_MNG_NO AS proposal_no
FROM PURCHASE_ORDER_MASTER POM
LEFT JOIN PROJECT_MGMT CM ON POM.CONTRACT_MGMT_OBJID = CM.OBJID
LEFT JOIN SALES_REQUEST_MASTER SRM ON POM.SALES_REQUEST_OBJID = SRM.OBJID
WHERE POM.OBJID = $1`,
[objid],
);
if (m.rows.length === 0) return null;
const p = await pool.query(
`SELECT POP.*,
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = POP.UNIT) AS unit_title,
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = POP.CURRENCY) AS currency_name
FROM PURCHASE_ORDER_PART POP
WHERE POP.PURCHASE_ORDER_MASTER_OBJID = $1
ORDER BY POP.REGDATE`,
[objid],
);
return { master: m.rows[0], parts: p.rows };
} catch (e: any) {
logger.error("getPurchaseOrderForm 실패", { error: e.message });
return null;
}
}
function toNum(v: any): number {
if (v == null || v === "") return 0;
const s = String(v).replace(/,/g, "");
const n = Number(s);
return Number.isFinite(n) ? n : 0;
}
function formatKorDate(d: Date): string {
return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}${String(d.getDate()).padStart(2, "0")}`;
}
+730 -39
View File
@@ -38,6 +38,7 @@ export interface PurchaseListFilter {
purchase_type?: string;
part_type?: string;
product_cd?: string;
category_cd?: string;
paid_type?: string;
mail_send_yn?: string;
delivery_status?: string;
@@ -144,8 +145,11 @@ export async function listPurchaseRequest(filter: PurchaseListFilter): Promise<L
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) AS part_extra_count,
-- 견적요청서 존재여부 (quotation_request_master 누락 → 일괄 'N')
'N' AS has_quotation_request,
-- 견적요청서 존재여부 (quotation_request_master 와 sales_request_master_objid 매칭)
CASE WHEN EXISTS (
SELECT 1 FROM QUOTATION_REQUEST_MASTER QRM
WHERE QRM.SALES_REQUEST_MASTER_OBJID = SRM.OBJID
) THEN 'Y' ELSE 'N' END AS has_quotation_request,
SRM.REQUEST_USER_ID AS request_user,
COALESCE(user_name(SRM.REQUEST_USER_ID), SRM.REQUEST_USER_ID, '') AS request_user_name,
SRM.DELIVERY_REQUEST_DATE AS delivery_request_date,
@@ -177,12 +181,115 @@ export async function listPurchaseRequest(filter: PurchaseListFilter): Promise<L
}
}
// ─── 2) 견적요청서관리 (wace salesMng.xml quotationRequestList) ──
// quotation_request_master 누락 → 빈 그리드.
// ─── 2) 견적요청서관리 (wace salesMng.xml getQuotationRequestList 매퍼 1:1) ──
//
// quotation_request_master + quotation_request_detail + sales_request_master +
// project_mgmt + contract_mgmt + client_mng (vendor) + comm_code + user_info +
// attach_file_info (DOC_TYPE='QUOTATION_RECEIVED').
//
// 매퍼 본문: wace_plm/src/com/pms/mapper/salesMng.xml:5248-5349
// 검색: year / project_no / quotation_request_no / vendor / mail_send_yn / writer / product_cd
export async function listQuotationRequest(filter: PurchaseListFilter): Promise<ListResult<any>> {
const { page, pageSize } = clampPaging(filter);
logger.warn("listQuotationRequest: quotation_request_master 테이블 미존재 — 빈 응답");
return { rows: [], totalCount: 0, page, pageSize };
const pool = getPool();
const { limit, offset, page, pageSize } = clampPaging(filter);
const where: string[] = [];
const params: any[] = [];
const addParam = (val: any) => { params.push(val); return `$${params.length}`; };
if (filter.year) where.push(`EXTRACT(YEAR FROM QRM.REG_DATE) = ${addParam(Number(filter.year))}`);
if (filter.project_no) where.push(`PM.PROJECT_NO ILIKE ${addParam(`%${filter.project_no}%`)}`);
if (filter.proposal_no) where.push(`QRM.QUOTATION_REQUEST_NO ILIKE ${addParam(`%${filter.proposal_no}%`)}`);
if (filter.partner_objid) where.push(`QRM.VENDOR_OBJID = ${addParam(filter.partner_objid)}`);
if (filter.mail_send_yn) where.push(`COALESCE(QRM.MAIL_SEND_YN, 'N') = ${addParam(filter.mail_send_yn)}`);
if (filter.writer) where.push(`QRM.WRITER = ${addParam(filter.writer)}`);
if (filter.product_cd) where.push(`COALESCE(CTM.PRODUCT, SRM.PRODUCT_NAME) = ${addParam(filter.product_cd)}`);
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
const dataSql = `
SELECT
QRM.OBJID AS objid,
QRM.QUOTATION_REQUEST_NO AS quotation_request_no,
QRM.SALES_REQUEST_MASTER_OBJID AS sales_request_master_objid,
QRM.PROJECT_MGMT_OBJID AS project_mgmt_objid,
QRM.VENDOR_OBJID AS vendor_objid,
QRM.VENDOR_TYPE AS vendor_type,
QRM.STATUS AS status,
CASE QRM.STATUS
WHEN 'create' THEN '작성중'
WHEN 'sent' THEN '발송완료'
WHEN 'received' THEN '견적수신'
WHEN 'completed' THEN '완료'
ELSE COALESCE(QRM.STATUS, '')
END AS status_name,
QRM.MAIL_SEND_DATE AS mail_send_date,
TO_CHAR(QRM.MAIL_SEND_DATE, 'YYYY-MM-DD') AS mail_send_date_title,
COALESCE(QRM.MAIL_SEND_YN, 'N') AS mail_send_yn,
QRM.DUE_DATE AS due_date,
TO_CHAR(QRM.DUE_DATE, 'YYYY-MM-DD') AS due_date_title,
QRM.REMARK AS remark,
QRM.WRITER AS writer,
COALESCE((SELECT USER_NAME FROM USER_INFO WHERE USER_ID = QRM.WRITER LIMIT 1), QRM.WRITER, '') AS writer_name,
QRM.REG_DATE AS reg_date,
TO_CHAR(QRM.REG_DATE, 'YYYY-MM-DD') AS reg_date_title,
SRM.REQUEST_MNG_NO AS request_mng_no,
SRM.PURCHASE_TYPE AS purchase_type,
COALESCE(
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = SRM.PURCHASE_TYPE LIMIT 1), ''
) AS purchase_type_name,
COALESCE(PM.CATEGORY_CD, SRM.ORDER_TYPE) AS order_type,
COALESCE(
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = COALESCE(PM.CATEGORY_CD, SRM.ORDER_TYPE) LIMIT 1), ''
) AS order_type_name,
COALESCE(
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CTM.PRODUCT LIMIT 1),
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = SRM.PRODUCT_NAME LIMIT 1),
''
) AS product_name_title,
PM.PROJECT_NO AS project_number,
CM.CLIENT_NM AS vendor_name,
(SELECT QRD.PART_NO FROM QUOTATION_REQUEST_DETAIL QRD
WHERE QRD.QUOTATION_REQUEST_MASTER_OBJID = QRM.OBJID
ORDER BY QRD.OBJID LIMIT 1) AS part_no,
(SELECT QRD.PART_NAME FROM QUOTATION_REQUEST_DETAIL QRD
WHERE QRD.QUOTATION_REQUEST_MASTER_OBJID = QRM.OBJID
ORDER BY QRD.OBJID LIMIT 1) AS part_name,
(SELECT COUNT(*)::int FROM QUOTATION_REQUEST_DETAIL QRD
WHERE QRD.QUOTATION_REQUEST_MASTER_OBJID = QRM.OBJID) AS detail_count,
(SELECT COUNT(*)::int FROM ATTACH_FILE_INFO
WHERE TARGET_OBJID = QRM.OBJID
AND DOC_TYPE = 'QUOTATION_RECEIVED'
AND COALESCE(STATUS, 'Active') = 'Active') AS attach_file_cnt
FROM QUOTATION_REQUEST_MASTER QRM
LEFT JOIN SALES_REQUEST_MASTER SRM ON QRM.SALES_REQUEST_MASTER_OBJID = SRM.OBJID
LEFT JOIN PROJECT_MGMT PM ON PM.OBJID = SRM.PROJECT_NO
LEFT JOIN CONTRACT_MGMT CTM ON CTM.OBJID = PM.CONTRACT_OBJID
LEFT JOIN CLIENT_MNG CM ON QRM.VENDOR_OBJID = CM.OBJID
${whereSql}
ORDER BY QRM.REG_DATE DESC
LIMIT ${addParam(limit)} OFFSET ${addParam(offset)}
`;
const countSql = `
SELECT COUNT(*)::int AS cnt
FROM QUOTATION_REQUEST_MASTER QRM
LEFT JOIN SALES_REQUEST_MASTER SRM ON QRM.SALES_REQUEST_MASTER_OBJID = SRM.OBJID
LEFT JOIN PROJECT_MGMT PM ON PM.OBJID = SRM.PROJECT_NO
LEFT JOIN CONTRACT_MGMT CTM ON CTM.OBJID = PM.CONTRACT_OBJID
LEFT JOIN CLIENT_MNG CM ON QRM.VENDOR_OBJID = CM.OBJID
${whereSql}
`;
try {
const [d, c] = await Promise.all([
pool.query(dataSql, params),
pool.query(countSql, params.slice(0, params.length - 2)),
]);
return { rows: d.rows, totalCount: c.rows[0]?.cnt ?? 0, page, pageSize };
} catch (e: any) {
logger.error("listQuotationRequest 실패", { error: e.message });
return { rows: [], totalCount: 0, page, pageSize };
}
}
// ─── 3) 품의서관리 (wace salesMng.xml proposalMngList) ──
@@ -274,27 +381,336 @@ export async function listProposal(filter: PurchaseListFilter): Promise<ListResu
}
}
// ─── 4) 입고관리 (wace purchaseOrder.xml deliveryMngAcceptanceList) ──
// purchase_order_master + purchase_order_part(누락) + arrival_plan(누락).
// 누락 의존 — purchase_order_master 단독으로 빈 그리드 처리.
// ─── 4) 입고관리 (wace purchaseOrder.xml deliveryMngList_new 매퍼 1:1) ──
//
// 발주서별 1행 (purchase_order_master + S1 집계: PURCHASE_ORDER_PART × ARRIVAL_PLAN).
// 매퍼 본문: wace_plm/src/com/pms/mapper/purchaseOrder.xml:4381-4580
// 검색: year / customer_cd / project_no / purchase_order_no / part_no / part_name / part_spec /
// partner_objid / sales_mng_user_id / delivery_date 범위 / reg_date 범위 / delivery_status
// 검사현황(IID_AGG/DEFECT_AGG)은 INVENTORY/INSPECTION 테이블 미존재 → 0 처리.
export async function listInbound(filter: PurchaseListFilter): Promise<ListResult<any>> {
const { page, pageSize } = clampPaging(filter);
logger.warn("listInbound: purchase_order_part / arrival_plan 미존재 — 빈 응답");
return { rows: [], totalCount: 0, page, pageSize };
const pool = getPool();
const { limit, offset, page, pageSize } = clampPaging(filter);
const where: string[] = [
`POM.MAIL_SEND_DATE IS NOT NULL`,
`POM.STATUS = 'create'`,
`(POM.MULTI_MASTER_YN = 'Y' OR COALESCE(POM.MULTI_MASTER_YN, '') <> 'Y' AND COALESCE(POM.MULTI_YN, '') <> 'Y')`,
];
const params: any[] = [];
const addParam = (val: any) => { params.push(val); return `$${params.length}`; };
if (filter.year) where.push(`TO_CHAR(POM.REGDATE, 'YYYY') = ${addParam(String(filter.year))}`);
if (filter.customer_cd) where.push(`CM.CUSTOMER_OBJID = REPLACE(${addParam(filter.customer_cd)}, 'C_', '')`);
if (filter.project_no) where.push(`CM.PROJECT_NO ILIKE ${addParam(`%${filter.project_no}%`)}`);
if (filter.purchase_order_no) where.push(`POM.PURCHASE_ORDER_NO ILIKE ${addParam(`%${filter.purchase_order_no}%`)}`);
if (filter.partner_objid) where.push(`POM.PARTNER_OBJID = REPLACE(${addParam(filter.partner_objid)}, 'C_', '')`);
if (filter.sales_mng_user_id) where.push(`POM.WRITER = ${addParam(filter.sales_mng_user_id)}`);
if (filter.delivery_start_date) where.push(`POM.DELIVERY_DATE >= ${addParam(filter.delivery_start_date)}`);
if (filter.delivery_end_date) where.push(`POM.DELIVERY_DATE <= ${addParam(filter.delivery_end_date)}`);
if (filter.reg_start_date) where.push(`TO_CHAR(POM.REGDATE, 'YYYY-MM-DD') >= ${addParam(filter.reg_start_date)}`);
if (filter.reg_end_date) where.push(`TO_CHAR(POM.REGDATE, 'YYYY-MM-DD') <= ${addParam(filter.reg_end_date)}`);
if (filter.part_no) where.push(`EXISTS (SELECT 1 FROM PURCHASE_ORDER_PART POPX WHERE POPX.PURCHASE_ORDER_MASTER_OBJID = POM.OBJID AND POPX.PART_NO ILIKE ${addParam(`%${filter.part_no}%`)})`);
if (filter.part_name) where.push(`EXISTS (SELECT 1 FROM PURCHASE_ORDER_PART POPX WHERE POPX.PURCHASE_ORDER_MASTER_OBJID = POM.OBJID AND POPX.PART_NAME ILIKE ${addParam(`%${filter.part_name}%`)})`);
if (filter.part_spec) where.push(`EXISTS (SELECT 1 FROM PURCHASE_ORDER_PART POPX WHERE POPX.PURCHASE_ORDER_MASTER_OBJID = POM.OBJID AND POPX.SPEC ILIKE ${addParam(`%${filter.part_spec}%`)})`);
const whereSql = `WHERE ${where.join(" AND ")}`;
const havingSql =
filter.delivery_status
? `HAVING (CASE WHEN COALESCE(S1.TOTAL_PO_QTY,0) - COALESCE(S1.TOTAL_DELIVERY_QTY,0) <= 0 THEN '입고완료'
WHEN TO_CHAR(NOW(),'YYYY-MM-DD') > POM.DELIVERY_DATE THEN '지연'
ELSE '입고중' END) = ${addParam(filter.delivery_status)}`
: "";
const fromSql = `
FROM PURCHASE_ORDER_MASTER POM
LEFT JOIN PROJECT_MGMT CM ON CM.OBJID = POM.CONTRACT_MGMT_OBJID
LEFT JOIN (
SELECT POP.PURCHASE_ORDER_MASTER_OBJID,
SUM(COALESCE(POP.ORDER_QTY::NUMERIC, 0)) AS TOTAL_PO_QTY,
MAX(AP_AGG.MAX_RECEIPT_DATE) AS CUR_DELIVERY_DATE,
SUM(COALESCE(AP_AGG.SUM_RECEIPT_QTY, 0)) AS TOTAL_DELIVERY_QTY,
SUM(COALESCE(POP.PARTNER_PRICE::NUMERIC, 0) * COALESCE(POP.ORDER_QTY::NUMERIC, 0)) AS TOTAL_SUPPLY_PRICE,
SUM(COALESCE(POP.PARTNER_PRICE::NUMERIC, 0) * COALESCE(AP_AGG.SUM_RECEIPT_QTY, 0)) AS TOTAL_DELIVERY_PRICE,
SUM(COALESCE(POP.PARTNER_PRICE::NUMERIC, 0) *
(COALESCE(POP.ORDER_QTY::NUMERIC, 0) - COALESCE(AP_AGG.SUM_RECEIPT_QTY, 0))) AS TOTAL_NOT_DELIVERY_PRICE
FROM PURCHASE_ORDER_PART POP
LEFT JOIN (
SELECT PARENT_OBJID, PART_OBJID,
SUM(COALESCE(RECEIPT_QTY::NUMERIC, 0)) AS SUM_RECEIPT_QTY,
MAX(RECEIPT_DATE) AS MAX_RECEIPT_DATE
FROM ARRIVAL_PLAN
GROUP BY PARENT_OBJID, PART_OBJID
) AP_AGG ON AP_AGG.PARENT_OBJID = POP.PURCHASE_ORDER_MASTER_OBJID
AND AP_AGG.PART_OBJID = POP.PART_OBJID
GROUP BY POP.PURCHASE_ORDER_MASTER_OBJID
) S1 ON POM.OBJID = S1.PURCHASE_ORDER_MASTER_OBJID
${whereSql}
`;
const groupBySql = havingSql ? `GROUP BY POM.OBJID, S1.TOTAL_PO_QTY, S1.TOTAL_DELIVERY_QTY, POM.DELIVERY_DATE` : "";
const dataSql = `
SELECT
POM.OBJID AS objid,
POM.PURCHASE_ORDER_NO AS purchase_order_no,
POM.STATUS AS status,
(SELECT REQUEST_MNG_NO FROM SALES_REQUEST_MASTER SRM WHERE SRM.OBJID = POM.SALES_REQUEST_OBJID LIMIT 1) AS proposal_no,
CM.PROJECT_NO AS project_no,
-- 첫 품번/품명 + "외 N건"
(SELECT CASE WHEN COUNT(*) > 1 THEN MIN(PART_NO) || ' 외 ' || (COUNT(*) - 1) || '건' ELSE MIN(PART_NO) END
FROM PURCHASE_ORDER_PART POPN WHERE POPN.PURCHASE_ORDER_MASTER_OBJID = POM.OBJID) AS part_no,
(SELECT CASE WHEN COUNT(*) > 1 THEN MIN(PART_NAME) || ' 외 ' || (COUNT(*) - 1) || '건' ELSE MIN(PART_NAME) END
FROM PURCHASE_ORDER_PART POPN WHERE POPN.PURCHASE_ORDER_MASTER_OBJID = POM.OBJID) AS part_name,
POM.PARTNER_OBJID AS partner_objid,
(SELECT CLIENT_NM FROM CLIENT_MNG WHERE OBJID = POM.PARTNER_OBJID LIMIT 1) AS partner_name,
(SELECT CC.CODE_NAME FROM COMM_CODE CC
WHERE CC.CODE_ID = (SELECT POP2.CURRENCY FROM PURCHASE_ORDER_PART POP2
WHERE POP2.PURCHASE_ORDER_MASTER_OBJID = POM.OBJID
AND POP2.CURRENCY IS NOT NULL AND POP2.CURRENCY <> '' LIMIT 1)
LIMIT 1) AS currency_name,
POM.WRITER AS writer,
COALESCE((SELECT USER_NAME FROM USER_INFO WHERE USER_ID = POM.WRITER LIMIT 1), POM.WRITER, '') AS writer_name,
(SELECT COALESCE((SELECT USER_NAME FROM USER_INFO WHERE USER_ID = AP.WRITER), AP.WRITER, '')
FROM ARRIVAL_PLAN AP
WHERE AP.PARENT_OBJID = POM.OBJID
AND AP.RECEIPT_QTY IS NOT NULL AND AP.RECEIPT_QTY::NUMERIC > 0
ORDER BY AP.RECEIPT_DATE DESC LIMIT 1) AS delivery_writer_name,
(SELECT AP.RECEIPT_DATE FROM ARRIVAL_PLAN AP
WHERE AP.PARENT_OBJID = POM.OBJID
AND AP.RECEIPT_QTY IS NOT NULL AND AP.RECEIPT_QTY::NUMERIC > 0
ORDER BY AP.RECEIPT_DATE DESC LIMIT 1) AS delivery_regdate,
COALESCE(S1.TOTAL_PO_QTY, 0) AS total_po_qty,
COALESCE(S1.TOTAL_DELIVERY_QTY, 0) AS total_delivery_qty,
COALESCE(S1.TOTAL_PO_QTY, 0) - COALESCE(S1.TOTAL_DELIVERY_QTY, 0) AS non_delivery_qty,
COALESCE(S1.TOTAL_SUPPLY_PRICE, 0) AS total_supply_price,
COALESCE(S1.TOTAL_DELIVERY_PRICE, 0) AS total_delivery_price,
COALESCE(S1.TOTAL_NOT_DELIVERY_PRICE, 0) AS total_not_delivery_price,
(SELECT COUNT(1)::int FROM ATTACH_FILE_INFO AF
WHERE AF.TARGET_OBJID = POM.OBJID
AND AF.DOC_TYPE = 'INSPECTION_FILE'
AND UPPER(COALESCE(AF.STATUS, 'Active')) = 'ACTIVE') AS inspection_file_cnt,
CASE WHEN COALESCE(S1.TOTAL_PO_QTY, 0) - COALESCE(S1.TOTAL_DELIVERY_QTY, 0) <= 0 THEN '입고완료'
WHEN TO_CHAR(NOW(),'YYYY-MM-DD') > POM.DELIVERY_DATE THEN '지연'
ELSE '입고중'
END AS delivery_status,
POM.PURCHASE_CLOSE_DATE AS purchase_close_date
${fromSql}
${groupBySql}
${havingSql}
ORDER BY POM.REGDATE DESC
LIMIT ${addParam(limit)} OFFSET ${addParam(offset)}
`;
const countSql = `SELECT COUNT(*)::int AS cnt ${fromSql} ${groupBySql} ${havingSql}`;
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("listInbound 실패", { error: e.message });
return { rows: [], totalCount: 0, page, pageSize };
}
}
// ─── 5) 품목별 입고관리 (wace deliveryMngPartList) ──
// ─── 5) 품목별 입고관리 (wace deliveryMngPartList 매퍼 1:1) ──
// 매퍼 본문: wace_plm/src/com/pms/mapper/purchaseOrder.xml:6309-6543
// PURCHASE_ORDER_PART 행별 1행 + AP_AGG (입고집계) + IID_AGG/DEFECT_AGG (검사 — RPS 미존재로 0).
export async function listInboundByItem(filter: PurchaseListFilter): Promise<ListResult<any>> {
const { page, pageSize } = clampPaging(filter);
logger.warn("listInboundByItem: purchase_order_part / arrival_plan 미존재 — 빈 응답");
return { rows: [], totalCount: 0, page, pageSize };
const pool = getPool();
const { limit, offset, page, pageSize } = clampPaging(filter);
const where: string[] = [
`POM.MAIL_SEND_DATE IS NOT NULL`,
`POM.STATUS = 'create'`,
`(POM.MULTI_MASTER_YN = 'Y' OR COALESCE(POM.MULTI_MASTER_YN, '') <> 'Y' AND COALESCE(POM.MULTI_YN, '') <> 'Y')`,
];
const params: any[] = [];
const addParam = (val: any) => { params.push(val); return `$${params.length}`; };
if (filter.year) where.push(`TO_CHAR(POM.REGDATE, 'YYYY') = ${addParam(String(filter.year))}`);
if (filter.customer_cd) where.push(`CM.CUSTOMER_OBJID = REPLACE(${addParam(filter.customer_cd)}, 'C_', '')`);
if (filter.project_no) where.push(`CM.PROJECT_NO ILIKE ${addParam(`%${filter.project_no}%`)}`);
if (filter.purchase_order_no) where.push(`POM.PURCHASE_ORDER_NO ILIKE ${addParam(`%${filter.purchase_order_no}%`)}`);
if (filter.partner_objid) where.push(`POM.PARTNER_OBJID = REPLACE(${addParam(filter.partner_objid)}, 'C_', '')`);
if (filter.sales_mng_user_id) where.push(`POM.WRITER = ${addParam(filter.sales_mng_user_id)}`);
if (filter.delivery_start_date) where.push(`POP.DELIVERY_REQUEST_DATE >= ${addParam(filter.delivery_start_date)}`);
if (filter.delivery_end_date) where.push(`POP.DELIVERY_REQUEST_DATE <= ${addParam(filter.delivery_end_date)}`);
if (filter.reg_start_date) where.push(`TO_CHAR(POM.REGDATE, 'YYYY-MM-DD') >= ${addParam(filter.reg_start_date)}`);
if (filter.reg_end_date) where.push(`TO_CHAR(POM.REGDATE, 'YYYY-MM-DD') <= ${addParam(filter.reg_end_date)}`);
if (filter.part_no) where.push(`POP.PART_NO ILIKE ${addParam(`%${filter.part_no}%`)}`);
if (filter.part_name) where.push(`POP.PART_NAME ILIKE ${addParam(`%${filter.part_name}%`)}`);
if (filter.part_spec) where.push(`POP.SPEC ILIKE ${addParam(`%${filter.part_spec}%`)}`);
if (filter.delivery_status) {
where.push(`(CASE WHEN COALESCE(POP.ORDER_QTY::NUMERIC, 0) - COALESCE(AP_AGG.DELIVERY_QTY, 0) <= 0 THEN '입고완료'
WHEN TO_CHAR(NOW(),'YYYY-MM-DD') > POM.DELIVERY_DATE THEN '지연'
ELSE '입고중' END) = ${addParam(filter.delivery_status)}`);
}
const whereSql = `WHERE ${where.join(" AND ")}`;
const fromSql = `
FROM PURCHASE_ORDER_PART POP
JOIN PURCHASE_ORDER_MASTER POM ON POM.OBJID = POP.PURCHASE_ORDER_MASTER_OBJID
LEFT JOIN PROJECT_MGMT CM ON CM.OBJID = POM.CONTRACT_MGMT_OBJID
LEFT JOIN (
SELECT PARENT_OBJID, PART_OBJID, SUM(COALESCE(RECEIPT_QTY::NUMERIC, 0)) AS DELIVERY_QTY
FROM ARRIVAL_PLAN
GROUP BY PARENT_OBJID, PART_OBJID
) AP_AGG ON AP_AGG.PARENT_OBJID = POM.OBJID AND AP_AGG.PART_OBJID = POP.PART_OBJID
${whereSql}
`;
const dataSql = `
SELECT
POP.OBJID AS objid,
POP.OBJID AS purchase_order_part_objid,
POM.OBJID AS purchase_order_master_objid,
POM.STATUS AS status,
(SELECT REQUEST_MNG_NO FROM SALES_REQUEST_MASTER SRM WHERE SRM.OBJID = POM.SALES_REQUEST_OBJID LIMIT 1) AS proposal_no,
POM.PURCHASE_ORDER_NO AS purchase_order_no,
CM.PROJECT_NO AS project_no,
-- 부품품번 (sales_request_part 미존재 → POP.PART_NO fallback)
POP.PART_NO AS component_part_no,
POP.PART_NO AS part_no,
POP.PART_NAME AS part_name,
POM.PARTNER_OBJID AS partner_objid,
(SELECT CLIENT_NM FROM CLIENT_MNG WHERE OBJID = POM.PARTNER_OBJID LIMIT 1) AS partner_name,
(SELECT CC.CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = POP.CURRENCY LIMIT 1) AS currency_name,
POP.DELIVERY_REQUEST_DATE AS delivery_request_date,
COALESCE((SELECT USER_NAME FROM USER_INFO WHERE USER_ID = POM.WRITER LIMIT 1), POM.WRITER, '') AS writer_name,
(SELECT COALESCE((SELECT USER_NAME FROM USER_INFO WHERE USER_ID = AP.WRITER), AP.WRITER, '')
FROM ARRIVAL_PLAN AP
WHERE AP.PARENT_OBJID = POM.OBJID
AND AP.PART_OBJID = POP.PART_OBJID
AND AP.RECEIPT_QTY IS NOT NULL AND AP.RECEIPT_QTY::NUMERIC > 0
ORDER BY AP.RECEIPT_DATE DESC LIMIT 1) AS delivery_writer_name,
(SELECT AP.RECEIPT_DATE FROM ARRIVAL_PLAN AP
WHERE AP.PARENT_OBJID = POM.OBJID
AND AP.PART_OBJID = POP.PART_OBJID
AND AP.RECEIPT_QTY IS NOT NULL AND AP.RECEIPT_QTY::NUMERIC > 0
ORDER BY AP.RECEIPT_DATE DESC LIMIT 1) AS delivery_regdate,
COALESCE(POP.ORDER_QTY::NUMERIC, 0) AS order_qty,
COALESCE(AP_AGG.DELIVERY_QTY, 0) AS delivery_qty,
COALESCE(POP.ORDER_QTY::NUMERIC, 0) - COALESCE(AP_AGG.DELIVERY_QTY, 0) AS non_delivery_qty,
COALESCE(POP.PARTNER_PRICE::NUMERIC, 0) * COALESCE(POP.ORDER_QTY::NUMERIC, 0) AS total_supply_price,
COALESCE(POP.PARTNER_PRICE::NUMERIC, 0) * COALESCE(AP_AGG.DELIVERY_QTY, 0) AS total_delivery_price,
COALESCE(POP.PARTNER_PRICE::NUMERIC, 0) *
(COALESCE(POP.ORDER_QTY::NUMERIC, 0) - COALESCE(AP_AGG.DELIVERY_QTY, 0)) AS total_not_delivery_price,
-- 검사현황/폐기/확정수량 (incoming_inspection_* 미존재 → 0)
'' AS inspection_status,
0 AS defect_qty,
COALESCE(AP_AGG.DELIVERY_QTY, 0) AS confirmed_qty,
CASE WHEN COALESCE(POP.ORDER_QTY::NUMERIC, 0) - COALESCE(AP_AGG.DELIVERY_QTY, 0) <= 0 THEN '입고완료'
WHEN TO_CHAR(NOW(),'YYYY-MM-DD') > POM.DELIVERY_DATE THEN '지연'
ELSE '입고중'
END AS delivery_status
${fromSql}
ORDER BY POM.REGDATE DESC, POP.OBJID
LIMIT ${addParam(limit)} OFFSET ${addParam(offset)}
`;
const countSql = `SELECT COUNT(*)::int AS cnt ${fromSql}`;
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("listInboundByItem 실패", { error: e.message });
return { rows: [], totalCount: 0, page, pageSize };
}
}
// ─── 6) 입고일별 입고관리 (wace purchaseCloseList) ──
// ─── 6) 입고일별 입고관리 (wace purchaseCloseList 매퍼 1:1) ──
// 매퍼 본문: wace_plm/src/com/pms/mapper/purchaseOrder.xml:6549-6765
// ARRIVAL_PLAN 행별 (RECEIPT_QTY > 0) + 매입마감/관세/세금계산서 컬럼.
export async function listInboundByDate(filter: PurchaseListFilter): Promise<ListResult<any>> {
const { page, pageSize } = clampPaging(filter);
logger.warn("listInboundByDate: arrival_plan / purchase_order_part 미존재 — 빈 응답");
return { rows: [], totalCount: 0, page, pageSize };
const pool = getPool();
const { limit, offset, page, pageSize } = clampPaging(filter);
const where: string[] = [
`POM.MAIL_SEND_DATE IS NOT NULL`,
`POM.STATUS = 'create'`,
`COALESCE(AP.RECEIPT_QTY, '0')::NUMERIC > 0`,
];
const params: any[] = [];
const addParam = (val: any) => { params.push(val); return `$${params.length}`; };
if (filter.year) where.push(`TO_CHAR(POM.REGDATE, 'YYYY') = ${addParam(String(filter.year))}`);
if (filter.customer_cd) where.push(`CM.CUSTOMER_OBJID = REPLACE(${addParam(filter.customer_cd)}, 'C_', '')`);
if (filter.project_no) where.push(`CM.PROJECT_NO ILIKE ${addParam(`%${filter.project_no}%`)}`);
if (filter.purchase_order_no) where.push(`POM.PURCHASE_ORDER_NO ILIKE ${addParam(`%${filter.purchase_order_no}%`)}`);
if (filter.partner_objid) where.push(`POM.PARTNER_OBJID = REPLACE(${addParam(filter.partner_objid)}, 'C_', '')`);
if (filter.sales_mng_user_id) where.push(`POM.WRITER = ${addParam(filter.sales_mng_user_id)}`);
if (filter.part_no) where.push(`POP.PART_NO ILIKE ${addParam(`%${filter.part_no}%`)}`);
if (filter.part_name) where.push(`POP.PART_NAME ILIKE ${addParam(`%${filter.part_name}%`)}`);
if (filter.part_spec) where.push(`POP.SPEC ILIKE ${addParam(`%${filter.part_spec}%`)}`);
if (filter.receipt_date_start) where.push(`AP.RECEIPT_DATE >= ${addParam(filter.receipt_date_start)}`);
if (filter.receipt_date_end) where.push(`AP.RECEIPT_DATE <= ${addParam(filter.receipt_date_end)}`);
if (filter.close_status === "Y") where.push(`AP.PURCHASE_CLOSE_DATE IS NOT NULL AND AP.PURCHASE_CLOSE_DATE <> ''`);
if (filter.close_status === "N") where.push(`(AP.PURCHASE_CLOSE_DATE IS NULL OR AP.PURCHASE_CLOSE_DATE = '')`);
const whereSql = `WHERE ${where.join(" AND ")}`;
const fromSql = `
FROM ARRIVAL_PLAN AP
JOIN PURCHASE_ORDER_MASTER POM ON POM.OBJID = AP.PARENT_OBJID
LEFT JOIN PURCHASE_ORDER_PART POP
ON POP.PURCHASE_ORDER_MASTER_OBJID = AP.PARENT_OBJID
AND POP.PART_OBJID = AP.PART_OBJID
LEFT JOIN PROJECT_MGMT CM ON CM.OBJID = POM.CONTRACT_MGMT_OBJID
${whereSql}
`;
const dataSql = `
SELECT
AP.OBJID AS objid,
AP.OBJID AS arrival_plan_objid,
POP.OBJID AS purchase_order_part_objid,
POM.OBJID AS purchase_order_master_objid,
(SELECT REQUEST_MNG_NO FROM SALES_REQUEST_MASTER SRM WHERE SRM.OBJID = POM.SALES_REQUEST_OBJID LIMIT 1) AS proposal_no,
POM.PURCHASE_ORDER_NO AS purchase_order_no,
CM.PROJECT_NO AS project_no,
POP.PART_NO AS component_part_no,
POP.PART_NO AS part_no,
POP.PART_NAME AS part_name,
(SELECT CLIENT_NM FROM CLIENT_MNG WHERE OBJID = POM.PARTNER_OBJID LIMIT 1) AS partner_name,
(SELECT CC.CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = POP.CURRENCY LIMIT 1) AS currency_name,
AP.RECEIPT_DATE AS receipt_date,
COALESCE((SELECT USER_NAME FROM USER_INFO WHERE USER_ID = POM.WRITER LIMIT 1), POM.WRITER, '') AS writer_name,
COALESCE((SELECT USER_NAME FROM USER_INFO WHERE USER_ID = AP.WRITER LIMIT 1), AP.WRITER, '') AS delivery_writer_name,
COALESCE(AP.RECEIPT_QTY::NUMERIC, 0) AS receipt_qty,
COALESCE(POP.PARTNER_PRICE::NUMERIC, 0) * COALESCE(AP.RECEIPT_QTY::NUMERIC, 0) AS total_delivery_price,
'' AS inspection_status,
0 AS defect_qty,
COALESCE(AP.RECEIPT_QTY::NUMERIC, 0) AS confirmed_qty,
AP.SUB_LOCATION AS sub_location_name,
(SELECT CC.CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = AP.FOREIGN_TYPE LIMIT 1) AS foreign_type_name,
AP.EXCHANGE_RATE AS exchange_rate,
(SELECT CC.CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = AP.TAX_TYPE LIMIT 1) AS tax_type_name,
AP.TAX_INVOICE_DATE AS tax_invoice_date,
AP.EXPORT_DECL_NO AS export_decl_no,
AP.LOADING_DATE AS loading_date,
AP.DUTY AS duty,
AP.IMPORT_VAT AS import_vat,
AP.PURCHASE_CLOSE_DATE AS purchase_close_date
${fromSql}
ORDER BY AP.RECEIPT_DATE DESC NULLS LAST, AP.OBJID
LIMIT ${addParam(limit)} OFFSET ${addParam(offset)}
`;
const countSql = `SELECT COUNT(*)::int AS cnt ${fromSql}`;
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("listInboundByDate 실패", { error: e.message });
return { rows: [], totalCount: 0, page, pageSize };
}
}
// ─── 7) 프로젝트별 발주/입고 현황 (wace projectPurchaseDeliveryStatus) ──
@@ -340,28 +756,95 @@ export async function listProjectStatus(filter: PurchaseListFilter): Promise<Lis
JOIN MBOM_HEADER MH2 ON MD.MBOM_HEADER_OBJID = MH2.OBJID
WHERE MH2.PROJECT_OBJID = PM.OBJID::VARCHAR
AND MH2.STATUS = 'Y'), 0) AS total_item_cnt,
COALESCE((SELECT SUM(NULLIF(MD.QTY, '')::numeric)
COALESCE((SELECT SUM(MD.QTY)
FROM MBOM_DETAIL MD
JOIN MBOM_HEADER MH2 ON MD.MBOM_HEADER_OBJID = MH2.OBJID
WHERE MH2.PROJECT_OBJID = PM.OBJID::VARCHAR
AND MH2.STATUS = 'Y'), 0) AS total_qty,
-- 발주/입고/미발주/미입고 — purchase_order_part / arrival_plan 누락이라 모두 0
0::int AS po_item_cnt,
0::numeric AS po_qty,
COALESCE((SELECT COUNT(DISTINCT MD.PART_OBJID)::int
FROM MBOM_DETAIL MD
JOIN MBOM_HEADER MH2 ON MD.MBOM_HEADER_OBJID = MH2.OBJID
WHERE MH2.PROJECT_OBJID = PM.OBJID::VARCHAR
AND MH2.STATUS = 'Y'), 0) AS non_po_item_cnt,
COALESCE((SELECT SUM(NULLIF(MD.QTY, '')::numeric)
FROM MBOM_DETAIL MD
JOIN MBOM_HEADER MH2 ON MD.MBOM_HEADER_OBJID = MH2.OBJID
WHERE MH2.PROJECT_OBJID = PM.OBJID::VARCHAR
AND MH2.STATUS = 'Y'), 0) AS non_po_qty,
0::int AS dlv_item_cnt,
0::numeric AS dlv_qty,
0::int AS non_dlv_item_cnt,
0::numeric AS non_dlv_qty
-- 발주 통계: PURCHASE_ORDER_PART × POM(STATUS='create' AND MAIL_SEND_DATE IS NOT NULL)
COALESCE((SELECT COUNT(DISTINCT POP.PART_OBJID)::int
FROM PURCHASE_ORDER_PART POP
JOIN PURCHASE_ORDER_MASTER POM2 ON POM2.OBJID = POP.PURCHASE_ORDER_MASTER_OBJID
WHERE POM2.CONTRACT_MGMT_OBJID = PM.OBJID
AND POM2.MAIL_SEND_DATE IS NOT NULL
AND POM2.STATUS = 'create'), 0) AS po_item_cnt,
COALESCE((SELECT SUM(COALESCE(POP.ORDER_QTY::numeric, 0))
FROM PURCHASE_ORDER_PART POP
JOIN PURCHASE_ORDER_MASTER POM2 ON POM2.OBJID = POP.PURCHASE_ORDER_MASTER_OBJID
WHERE POM2.CONTRACT_MGMT_OBJID = PM.OBJID
AND POM2.MAIL_SEND_DATE IS NOT NULL
AND POM2.STATUS = 'create'), 0) AS po_qty,
-- 미발주 = BOM 품목수 - 발주 품목수 (음수 방지 GREATEST)
GREATEST(
COALESCE((SELECT COUNT(DISTINCT MD.PART_OBJID)::int
FROM MBOM_DETAIL MD
JOIN MBOM_HEADER MH2 ON MD.MBOM_HEADER_OBJID = MH2.OBJID
WHERE MH2.PROJECT_OBJID = PM.OBJID::VARCHAR
AND MH2.STATUS = 'Y'), 0)
- COALESCE((SELECT COUNT(DISTINCT POP.PART_OBJID)::int
FROM PURCHASE_ORDER_PART POP
JOIN PURCHASE_ORDER_MASTER POM2 ON POM2.OBJID = POP.PURCHASE_ORDER_MASTER_OBJID
WHERE POM2.CONTRACT_MGMT_OBJID = PM.OBJID
AND POM2.MAIL_SEND_DATE IS NOT NULL
AND POM2.STATUS = 'create'), 0)
, 0) AS non_po_item_cnt,
GREATEST(
COALESCE((SELECT SUM(MD.QTY)
FROM MBOM_DETAIL MD
JOIN MBOM_HEADER MH2 ON MD.MBOM_HEADER_OBJID = MH2.OBJID
WHERE MH2.PROJECT_OBJID = PM.OBJID::VARCHAR
AND MH2.STATUS = 'Y'), 0)
- COALESCE((SELECT SUM(COALESCE(POP.ORDER_QTY::numeric, 0))
FROM PURCHASE_ORDER_PART POP
JOIN PURCHASE_ORDER_MASTER POM2 ON POM2.OBJID = POP.PURCHASE_ORDER_MASTER_OBJID
WHERE POM2.CONTRACT_MGMT_OBJID = PM.OBJID
AND POM2.MAIL_SEND_DATE IS NOT NULL
AND POM2.STATUS = 'create'), 0)
, 0) AS non_po_qty,
-- 입고 통계: ARRIVAL_PLAN × POM (RECEIPT_QTY > 0)
COALESCE((SELECT COUNT(DISTINCT AP.PART_OBJID)::int
FROM ARRIVAL_PLAN AP
JOIN PURCHASE_ORDER_MASTER POM3 ON POM3.OBJID = AP.PARENT_OBJID
WHERE POM3.CONTRACT_MGMT_OBJID = PM.OBJID
AND POM3.MAIL_SEND_DATE IS NOT NULL
AND POM3.STATUS = 'create'
AND COALESCE(AP.RECEIPT_QTY::numeric, 0) > 0), 0) AS dlv_item_cnt,
COALESCE((SELECT SUM(COALESCE(AP.RECEIPT_QTY::numeric, 0))
FROM ARRIVAL_PLAN AP
JOIN PURCHASE_ORDER_MASTER POM3 ON POM3.OBJID = AP.PARENT_OBJID
WHERE POM3.CONTRACT_MGMT_OBJID = PM.OBJID
AND POM3.MAIL_SEND_DATE IS NOT NULL
AND POM3.STATUS = 'create'), 0) AS dlv_qty,
-- 미입고 = 발주 - 입고 (음수 방지)
GREATEST(
COALESCE((SELECT COUNT(DISTINCT POP.PART_OBJID)::int
FROM PURCHASE_ORDER_PART POP
JOIN PURCHASE_ORDER_MASTER POM2 ON POM2.OBJID = POP.PURCHASE_ORDER_MASTER_OBJID
WHERE POM2.CONTRACT_MGMT_OBJID = PM.OBJID
AND POM2.MAIL_SEND_DATE IS NOT NULL
AND POM2.STATUS = 'create'), 0)
- COALESCE((SELECT COUNT(DISTINCT AP.PART_OBJID)::int
FROM ARRIVAL_PLAN AP
JOIN PURCHASE_ORDER_MASTER POM3 ON POM3.OBJID = AP.PARENT_OBJID
WHERE POM3.CONTRACT_MGMT_OBJID = PM.OBJID
AND POM3.MAIL_SEND_DATE IS NOT NULL
AND POM3.STATUS = 'create'
AND COALESCE(AP.RECEIPT_QTY::numeric, 0) > 0), 0)
, 0) AS non_dlv_item_cnt,
GREATEST(
COALESCE((SELECT SUM(COALESCE(POP.ORDER_QTY::numeric, 0))
FROM PURCHASE_ORDER_PART POP
JOIN PURCHASE_ORDER_MASTER POM2 ON POM2.OBJID = POP.PURCHASE_ORDER_MASTER_OBJID
WHERE POM2.CONTRACT_MGMT_OBJID = PM.OBJID
AND POM2.MAIL_SEND_DATE IS NOT NULL
AND POM2.STATUS = 'create'), 0)
- COALESCE((SELECT SUM(COALESCE(AP.RECEIPT_QTY::numeric, 0))
FROM ARRIVAL_PLAN AP
JOIN PURCHASE_ORDER_MASTER POM3 ON POM3.OBJID = AP.PARENT_OBJID
WHERE POM3.CONTRACT_MGMT_OBJID = PM.OBJID
AND POM3.MAIL_SEND_DATE IS NOT NULL
AND POM3.STATUS = 'create'), 0)
, 0) AS non_dlv_qty
FROM PROJECT_MGMT PM
LEFT JOIN CONTRACT_MGMT CTR ON CTR.OBJID = PM.CONTRACT_OBJID
${whereSql}
@@ -386,8 +869,216 @@ export async function listProjectStatus(filter: PurchaseListFilter): Promise<Lis
}
}
// ─── 8) 발주서관리 (wace 1:1 — purchaseOrder.xml purchaseOrderMasterList_new) ──
//
// 매퍼 본문: wace_plm/src/com/pms/mapper/purchaseOrder.xml:3295-3589
// 화면: wace_plm/.../purchaseOrder/purchaseOrderList_new.jsp (1085 lines)
// 라우트: GET /api/purchase/order-list
//
// 컬럼: 품의서No · 발주서No · 프로젝트번호 · 구매유형 · 주문유형 · 제품구분 ·
// 품번 · 품명 · 공급업체 · 환종 · 총액 · 메일발송 · 발주일 · 구매담당자 · 작성일
// 검색: 년도/고객사/프로젝트(CSV)/발주No/공급업체/품번/품명/입고요청일/발주일/
// 주문유형/제품구분/구매유형/구매담당자/메일발송
export async function listPurchaseOrderList(filter: PurchaseListFilter): Promise<ListResult<any>> {
const pool = getPool();
const { limit, offset, page, pageSize } = clampPaging(filter);
const where: string[] = [
// wace 운영판 동일: 동시발주 마스터 또는 비-동시발주만 노출
`(POM.MULTI_MASTER_YN = 'Y' OR COALESCE(POM.MULTI_MASTER_YN, '') <> 'Y' AND COALESCE(POM.MULTI_YN, '') <> 'Y')`,
];
const params: any[] = [];
const addParam = (val: any) => { params.push(val); return `$${params.length}`; };
if (filter.year) where.push(`TO_CHAR(POM.REGDATE, 'YYYY') = ${addParam(filter.year)}`);
if (filter.customer_cd) {
where.push(`EXISTS (SELECT 1 FROM PROJECT_MGMT S_P WHERE POM.CONTRACT_MGMT_OBJID = S_P.OBJID AND S_P.CUSTOMER_OBJID = REPLACE(${addParam(filter.customer_cd)}, 'C_', ''))`);
}
if (filter.project_no) {
// CSV 다중 선택 지원 (wace project_nos 와 동일)
const ids = String(filter.project_no).split(",").map((s) => s.trim()).filter(Boolean);
if (ids.length > 0) {
const ph = ids.map((v) => addParam(v)).join(",");
where.push(`POM.CONTRACT_MGMT_OBJID IN (${ph})`);
}
}
if (filter.purchase_order_no) where.push(`POM.PURCHASE_ORDER_NO ILIKE ${addParam(`%${filter.purchase_order_no}%`)}`);
if (filter.partner_objid) where.push(`POM.PARTNER_OBJID = REPLACE(${addParam(filter.partner_objid)}, 'C_', '')`);
if (filter.delivery_start_date) where.push(`POM.DELIVERY_DATE::DATE >= ${addParam(filter.delivery_start_date)}::DATE`);
if (filter.delivery_end_date) where.push(`POM.DELIVERY_DATE::DATE <= ${addParam(filter.delivery_end_date)}::DATE`);
if (filter.reg_start_date) where.push(`POM.MAIL_SEND_DATE::DATE >= ${addParam(filter.reg_start_date)}::DATE`);
if (filter.reg_end_date) where.push(`POM.MAIL_SEND_DATE::DATE <= ${addParam(filter.reg_end_date)}::DATE`);
if (filter.category_cd) where.push(`CM.CATEGORY_CD = ${addParam(filter.category_cd)}`);
if (filter.product_cd) where.push(`CM.PRODUCT = ${addParam(filter.product_cd)}`);
if (filter.purchase_type) where.push(`SRM.PURCHASE_TYPE = ${addParam(filter.purchase_type)}`);
if (filter.writer) where.push(`POM.WRITER = ${addParam(filter.writer)}`);
if (filter.part_no) {
where.push(`EXISTS (SELECT 1 FROM PURCHASE_ORDER_PART POP WHERE POP.PURCHASE_ORDER_MASTER_OBJID = POM.OBJID AND TRIM(UPPER(POP.PART_NO)) LIKE '%' || TRIM(UPPER(${addParam(filter.part_no)})) || '%')`);
}
if (filter.part_name) {
where.push(`EXISTS (SELECT 1 FROM PURCHASE_ORDER_PART POP WHERE POP.PURCHASE_ORDER_MASTER_OBJID = POM.OBJID AND TRIM(UPPER(POP.PART_NAME)) LIKE '%' || TRIM(UPPER(${addParam(filter.part_name)})) || '%')`);
}
if (filter.mail_send_yn === "Y") {
where.push(`POM.MAIL_SEND_YN = 'Y' AND (POM.STATUS IS NULL OR POM.STATUS <> 'orderCancel')`);
} else if (filter.mail_send_yn === "orderCancel") {
where.push(`POM.STATUS = 'orderCancel'`);
} else if (filter.mail_send_yn === "N") {
where.push(`(POM.MAIL_SEND_YN IS NULL OR POM.MAIL_SEND_YN = '' OR POM.MAIL_SEND_YN = 'N') AND (POM.STATUS IS NULL OR POM.STATUS <> 'orderCancel')`);
}
const whereSql = `WHERE ${where.join(" AND ")}`;
const fromJoinSql = `
FROM PURCHASE_ORDER_MASTER POM
LEFT JOIN (
SELECT B.OBJID AS ROUTE_OBJID,
B.STATUS AS APPR_STATUS,
CASE B.STATUS
WHEN 'inProcess' THEN '결재중'
WHEN 'complete' THEN '결재완료'
WHEN 'reject' THEN '반려'
WHEN 'cancel' THEN '취소'
ELSE '' END AS APPR_STATUS_NAME,
A.OBJID AS APPROVAL_OBJID,
A.TARGET_OBJID,
B.ROUTE_SEQ,
TO_CHAR(B.REGDATE, 'YYYY-MM-DD') AS APPR_DATE
FROM APPROVAL A,
(SELECT T1.*
FROM (SELECT TARGET_OBJID, MAX(T.ROUTE_SEQ) AS ROUTE_SEQ
FROM ROUTE T GROUP BY T.TARGET_OBJID) T,
ROUTE T1
WHERE T.TARGET_OBJID = T1.TARGET_OBJID
AND T.ROUTE_SEQ = T1.ROUTE_SEQ
) B
WHERE A.OBJID = B.APPROVAL_OBJID
AND A.TARGET_TYPE = 'PURCHASE_ORDER'
) A ON POM.OBJID::VARCHAR = A.TARGET_OBJID::VARCHAR
LEFT JOIN PROJECT_MGMT CM ON POM.CONTRACT_MGMT_OBJID = CM.OBJID
LEFT JOIN SALES_REQUEST_MASTER SRM
ON POM.SALES_REQUEST_OBJID = SRM.OBJID
AND SRM.DOC_TYPE IN ('PROPOSAL', 'PURCHASE_REG_PROPOSAL')
`;
const dataSql = `
SELECT
POM.OBJID AS objid,
(SELECT ARRAY_TO_STRING(ARRAY_AGG(OBJID), ',') FROM PURCHASE_ORDER_MASTER S
WHERE POM.OBJID = S.MULTI_MASTER_OBJID) AS multi_objids,
TO_CHAR(POM.REGDATE, 'YYYY') AS po_year,
(SELECT SUPPLY_NAME FROM SUPPLY_MNG O
WHERE O.OBJID::VARCHAR = CM.CUSTOMER_OBJID) AS customer_name,
CM.CUSTOMER_PROJECT_NAME AS customer_project_name,
CM.PROJECT_NO AS project_no,
POM.PURCHASE_ORDER_NO AS purchase_order_no,
(CASE WHEN POM.MULTI_YN = 'Y' AND POM.MULTI_MASTER_YN <> 'Y' THEN 'ㅡ' ELSE '' END)
|| COALESCE(POM.TITLE, '') AS title,
POM.DELIVERY_PLACE AS delivery_place,
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = POM.DELIVERY_PLACE) AS delivery_place_name,
POM.INSPECT_METHOD AS inspect_method,
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = POM.INSPECT_METHOD) AS inspect_method_name,
POM.PAYMENT_TERMS AS payment_terms,
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = POM.PAYMENT_TERMS) AS payment_terms_name,
POM.DELIVERY_DATE AS delivery_date,
POM.TYPE AS type,
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = POM.TYPE) AS type_name,
POM.PARTNER_OBJID AS partner_objid,
(SELECT CLIENT_NM FROM CLIENT_MNG WHERE OBJID::VARCHAR = POM.PARTNER_OBJID)
AS partner_name,
POM.SALES_MNG_USER_ID AS sales_mng_user_id,
(SELECT USER_NAME FROM USER_INFO WHERE USER_ID = POM.SALES_MNG_USER_ID) AS sales_mng_user_name,
TO_CHAR(POM.REGDATE, 'YYYY-MM-DD') AS regdate,
POM.TOTAL_PRICE AS total_price,
POM.TOTAL_PRICE_ALL AS total_price_all,
POM.DISCOUNT_PRICE AS discount_price,
POM.DISCOUNT_PRICE_ALL AS discount_price_all,
POM.TOTAL_SUPPLY_PRICE AS total_supply_price,
POM.TOTAL_SUPPLY_UNIT_PRICE AS total_supply_unit_price,
POM.TOTAL_REAL_SUPPLY_PRICE AS total_real_supply_price,
POM.NEGO_RATE AS nego_rate,
POM.WRITER AS writer,
(SELECT USER_NAME FROM USER_INFO WHERE USER_ID = POM.WRITER) AS writer_name,
POM.ORDER_TYPE_CD AS order_type_cd,
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = POM.ORDER_TYPE_CD) AS order_type_cd_name,
POM.UNIT_CODE AS unit_code,
(SELECT O.UNIT_NO || '-' || O.TASK_NAME FROM PMS_WBS_TASK O WHERE O.OBJID = POM.UNIT_CODE)
AS unit_name,
POM.MULTI_YN AS multi_yn,
POM.MULTI_MASTER_YN AS multi_master_yn,
POM.MULTI_MASTER_OBJID AS multi_master_objid,
CASE WHEN POM.MULTI_MASTER_YN = 'Y' THEN '' ELSE POM.MULTI_YN END AS multi_yn_maked,
POM.MAIL_SEND_YN AS mail_send_yn,
POM.MAIL_SEND_DATE AS mail_send_date,
POM.STATUS AS status,
COALESCE(POM.FORM_TYPE, 'general') AS form_type,
A.APPR_STATUS AS appr_status,
CASE WHEN POM.STATUS = 'cancel' THEN '취소'
ELSE COALESCE(A.APPR_STATUS_NAME, '작성중')
END AS appr_status_name,
A.ROUTE_OBJID AS route_objid,
A.APPROVAL_OBJID AS approval_objid,
A.APPR_DATE AS appr_date,
POM.SALES_REQUEST_OBJID AS sales_request_objid,
SRM.REQUEST_MNG_NO AS proposal_no,
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = SRM.PURCHASE_TYPE) AS purchase_type_name,
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.PRODUCT) AS product_name,
(SELECT CODE_NAME FROM COMM_CODE WHERE CODE_ID = CM.CATEGORY_CD) AS category_name,
(SELECT CASE WHEN COUNT(*) > 1
THEN MIN(POP.PART_NO) || ' 외 ' || (COUNT(*) - 1) || '건'
ELSE MIN(POP.PART_NO)
END
FROM PURCHASE_ORDER_PART POP
WHERE POP.PURCHASE_ORDER_MASTER_OBJID = POM.OBJID) AS part_no,
(SELECT CASE WHEN COUNT(*) > 1
THEN MIN(POP.PART_NAME) || ' 외 ' || (COUNT(*) - 1) || '건'
ELSE MIN(POP.PART_NAME)
END
FROM PURCHASE_ORDER_PART POP
WHERE POP.PURCHASE_ORDER_MASTER_OBJID = POM.OBJID) AS part_name,
(SELECT CC.CODE_NAME FROM COMM_CODE CC
WHERE CC.CODE_ID = (SELECT POP.CURRENCY FROM PURCHASE_ORDER_PART POP
WHERE POP.PURCHASE_ORDER_MASTER_OBJID = POM.OBJID::VARCHAR
AND POP.CURRENCY IS NOT NULL AND POP.CURRENCY <> ''
LIMIT 1)) AS currency_name
${fromJoinSql}
${whereSql}
ORDER BY POM.REGDATE DESC NULLS LAST,
COALESCE(NULLIF(SPLIT_PART(POM.PURCHASE_ORDER_NO, '-', 3), ''), '0')::NUMERIC DESC
LIMIT ${addParam(limit)} OFFSET ${addParam(offset)}
`;
const countSql = `SELECT COUNT(*)::int AS cnt ${fromJoinSql} ${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("listPurchaseOrderList 실패", { error: e.message });
return { rows: [], totalCount: 0, page, pageSize };
}
}
// ─── 옵션 — 공급업체 / 작성자 (구매메뉴 공용) ──────────────────
// 견적요청서 / 발주서 vendor — wace 매퍼는 client_mng 와 매칭 (supply_mng 아님)
export async function listVendorOptions(): Promise<{ code: string; label: string }[]> {
const pool = getPool();
try {
const r = await pool.query(
`SELECT OBJID AS code, CLIENT_NM AS label
FROM CLIENT_MNG
WHERE COALESCE(STATUS, 'Y') IN ('Y', 'active', 'ACTIVE', '활성')
AND CLIENT_NM IS NOT NULL AND CLIENT_NM <> ''
ORDER BY CLIENT_NM
LIMIT 2000`,
);
return r.rows;
} catch {
return [];
}
}
export async function listSupplierOptions(): Promise<{ code: string; label: string }[]> {
const pool = getPool();
try {
@@ -0,0 +1,31 @@
-- ====================================================================
-- inventory_mgmt — 자재 마스터 (품번 + Location 단위 자재 정보)
-- ====================================================================
-- 출처: wace_plm 운영 DB 211.115.91.141:11133/waceplm (PG 16.8)
-- 추출일: 2026-05-15
-- 자식: inventory_mgmt_in (입고/이동 히스토리), inventory_mgmt_out (불출 라인)
-- ====================================================================
CREATE TABLE IF NOT EXISTS inventory_mgmt (
objid VARCHAR NOT NULL,
contract_objid VARCHAR NOT NULL,
unit VARCHAR(100) NOT NULL,
part_objid VARCHAR(100) NOT NULL,
cls_cd VARCHAR(100),
cau_cd VARCHAR(100),
qty VARCHAR(20),
location VARCHAR(20) NOT NULL DEFAULT '',
sub_location VARCHAR(20) NOT NULL DEFAULT '',
reg_date VARCHAR(10),
price VARCHAR(20),
writer VARCHAR(20),
input_contract_objid VARCHAR,
input_qty VARCHAR,
input_date VARCHAR,
assumption_user VARCHAR,
successor_user VARCHAR,
CONSTRAINT inventory_mgmt_pkey PRIMARY KEY (contract_objid, unit, part_objid)
);
CREATE INDEX IF NOT EXISTS inventory_mgmt_objid_idx ON inventory_mgmt (objid);
CREATE INDEX IF NOT EXISTS inventory_mgmt_part_objid_idx ON inventory_mgmt (part_objid);
@@ -0,0 +1,33 @@
-- ====================================================================
-- inventory_mgmt_in — 자재 입고/이동 히스토리 라인
-- ====================================================================
-- 출처: wace_plm 운영 DB 211.115.91.141:11133/waceplm
-- 추출일: 2026-05-15
-- 부모: inventory_mgmt.objid → parent_objid
-- 외부키: out_objid (콤마분리 inventory_mgmt_out.objid 누적)
-- ====================================================================
CREATE TABLE IF NOT EXISTS inventory_mgmt_in (
objid VARCHAR NOT NULL,
parent_objid VARCHAR,
receipt_qty VARCHAR,
location VARCHAR,
sub_location VARCHAR,
writer VARCHAR,
regdate TIMESTAMP,
contract_mgmt_objid VARCHAR,
purchase_order_master_objid VARCHAR,
purchase_order_sub_objid VARCHAR,
out_objid VARCHAR,
out_qty VARCHAR,
move_objid VARCHAR,
move_qty VARCHAR,
move_date VARCHAR,
move_user VARCHAR,
request_qty VARCHAR,
receipt_date VARCHAR,
CONSTRAINT inventory_mgmt_in_pkey PRIMARY KEY (objid)
);
CREATE INDEX IF NOT EXISTS inventory_mgmt_in_parent_objid_idx ON inventory_mgmt_in (parent_objid);
CREATE INDEX IF NOT EXISTS inventory_mgmt_in_contract_mgmt_objid_idx ON inventory_mgmt_in (contract_mgmt_objid);
@@ -0,0 +1,28 @@
-- ====================================================================
-- inventory_mgmt_out — 불출 라인 (의뢰 + 실제 불출)
-- ====================================================================
-- 출처: wace_plm 운영 DB 211.115.91.141:11133/waceplm
-- 추출일: 2026-05-15
-- 부모: inventory_mgmt.objid → parent_objid (자재 마스터)
-- 부모2: inventory_mgmt_out_master.objid → inventory_request_master_objid
-- 흐름: REQUEST_QTY 먼저 입력 (의뢰) → OUT_QTY/OUT_DATE/ACQ_USER/SIGN 입력 (불출)
-- ====================================================================
CREATE TABLE IF NOT EXISTS inventory_mgmt_out (
objid VARCHAR NOT NULL,
parent_objid VARCHAR,
request_qty VARCHAR,
out_qty VARCHAR,
out_date VARCHAR,
writer VARCHAR,
acq_user VARCHAR,
regdate TIMESTAMP,
inventory_request_master_objid VARCHAR,
sign VARCHAR,
contract_mgmt_objid VARCHAR,
unit VARCHAR,
CONSTRAINT inventory_mgmt_out_pkey PRIMARY KEY (objid)
);
CREATE INDEX IF NOT EXISTS inventory_mgmt_out_parent_objid_idx ON inventory_mgmt_out (parent_objid);
CREATE INDEX IF NOT EXISTS inventory_mgmt_out_master_objid_idx ON inventory_mgmt_out (inventory_request_master_objid);
@@ -0,0 +1,32 @@
-- ====================================================================
-- inventory_mgmt_out_master — 불출의뢰 마스터 (Rfw-YYYY-seq)
-- ====================================================================
-- 출처: wace_plm 운영 DB 211.115.91.141:11133/waceplm
-- 추출일: 2026-05-15
-- 자식: inventory_mgmt_out (불출 라인) → inventory_request_master_objid
-- 상태:
-- reception_status='reception' / ''=미접수
-- outstatus='complete' / ''=미완료
-- 채번: inventory_out_no = 'Rfw-' || YYYY || '-' || seq
-- ====================================================================
CREATE TABLE IF NOT EXISTS inventory_mgmt_out_master (
objid VARCHAR NOT NULL,
parent_objid VARCHAR,
inventory_out_no VARCHAR,
request_date VARCHAR,
request_id VARCHAR,
reception_status VARCHAR,
reception_id VARCHAR,
reception_date VARCHAR,
outstatus VARCHAR,
writer VARCHAR,
regdate TIMESTAMP,
remark VARCHAR,
contract_mgmt_objid VARCHAR,
sign VARCHAR,
CONSTRAINT inventory_mgmt_out_master_pkey PRIMARY KEY (objid)
);
CREATE INDEX IF NOT EXISTS inventory_mgmt_out_master_no_idx ON inventory_mgmt_out_master (inventory_out_no);
CREATE INDEX IF NOT EXISTS inventory_mgmt_out_master_status_idx ON inventory_mgmt_out_master (reception_status, outstatus);
@@ -0,0 +1,18 @@
-- ====================================================================
-- inventory_mgmt_history — 자재 투입(인계/인수) 이력
-- ====================================================================
-- 출처: wace_plm 운영 DB 211.115.91.141:11133/waceplm
-- 추출일: 2026-05-15
-- ====================================================================
CREATE TABLE IF NOT EXISTS inventory_mgmt_history (
objid VARCHAR NOT NULL,
parent_objid VARCHAR,
contract_objid VARCHAR,
reg_date VARCHAR,
input_qty VARCHAR,
input_date VARCHAR,
assumption_user VARCHAR,
successor_user VARCHAR,
CONSTRAINT inventory_mgmt_history_pkey PRIMARY KEY (objid)
);
@@ -0,0 +1,51 @@
-- ============================================================
-- 견적요청서 운영 sample 데이터 → RPS 이관
-- 운영: 211.115.91.141:11133/waceplm
-- quotation_request_master 4건 / quotation_request_detail 4건
-- 대상: 211.115.91.141:11134/vexplor_rps
--
-- 함정:
-- 1) objid / sales_request_master_objid / project_mgmt_objid : numeric → varchar
-- 2) detail.part_objid : numeric → bigint (RPS part_mng.objid bigint 호환)
-- 3) FK 미매칭 sales_request_part_objid 는 NULL 처리
--
-- 멱등성: ON CONFLICT DO NOTHING.
-- ============================================================
-- ── master ────────────────────────────────────────────────────
INSERT INTO quotation_request_master
(objid, quotation_request_no, sales_request_master_objid, project_mgmt_objid,
vendor_objid, vendor_type, status, mail_send_date, mail_send_yn, due_date,
remark, writer, reg_date, edit_date)
VALUES
('-1554146727','Q20260401-115','-722096187','-1752090174','0000000007','SUPPLY','received','2026-04-03 04:36:11.666917','Y','2026-04-06',NULL,'ady1225','2026-04-01 07:11:30.812611','2026-04-01 07:16:21.513931'),
('-1629785580','Q20260401-116','-722096187','-1752090174','0000000012','PROCESSING','create',NULL,'N','2026-04-06',NULL,'ady1225','2026-04-01 07:11:30.814099',NULL),
('185180465','Q20260401-118','-722096187','-1752090174','0000008379','PROCESSING','create',NULL,'N','2026-04-06',NULL,'ady1225','2026-04-01 07:11:30.836506',NULL),
('211976545','Q20260401-119','-722096187','-1752090174','0000012062','PROCESSING','create',NULL,'N','2026-04-06',NULL,'ady1225','2026-04-01 07:11:30.841764',NULL)
ON CONFLICT (objid) DO NOTHING;
-- ── detail ────────────────────────────────────────────────────
INSERT INTO quotation_request_detail
(objid, quotation_request_master_objid, sales_request_part_objid, part_objid,
part_no, part_name, raw_material, size, qty, unit_price, total_price,
remark, delivery_request_date, reg_date, edit_date)
VALUES
('-1266428262','-1554146727','-1279349416',1868255637,'C3P50L22','Ti(GR5)','Ti(GR5)','Ø50*22',0,10000,0,NULL,'2026-04-03','2026-04-01 07:11:30.812611','2026-04-01 07:16:21.513931'),
('-2130546975','-1629785580','1187291883',1868255516,'10024-0066','SHEET',NULL,NULL,4,0,0,NULL,NULL,'2026-04-01 07:11:30.814099',NULL),
('-392083183','185180465','-1279349416',1868255637,'10026-0031','HOLDER',NULL,NULL,4,0,0,NULL,NULL,'2026-04-01 07:11:30.836506',NULL),
('-563828077','211976545','-1291084031',1868257572,'30004-0098','NUT',NULL,NULL,4,0,0,NULL,NULL,'2026-04-01 07:11:30.841764',NULL)
ON CONFLICT (objid) DO NOTHING;
-- FK 미매칭 sales_request_part_objid 는 NULL 처리 (현재 RPS sales_request_part 0건)
UPDATE quotation_request_detail
SET sales_request_part_objid = NULL
WHERE sales_request_part_objid IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM sales_request_part WHERE objid = quotation_request_detail.sales_request_part_objid
);
-- FK 미매칭 part_objid 는 NULL 처리 (RPS part_mng 와 매칭 안 되면)
UPDATE quotation_request_detail
SET part_objid = NULL
WHERE part_objid IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM part_mng WHERE objid = quotation_request_detail.part_objid);
@@ -0,0 +1,61 @@
-- ============================================================
-- 발주/입고 운영 sample 데이터 → RPS 이관
-- 운영: 211.115.91.141:11133/waceplm
-- purchase_order_master 1건 / purchase_order_part 1건 / arrival_plan 1건
-- 대상: 211.115.91.141:11134/vexplor_rps
--
-- FK 매칭 (확인):
-- sales_request_objid='-233034270' → RPS sales_request_master.objid (있음)
-- contract_mgmt_objid='-1752090174' → 운영DB project_mgmt.objid (RPS contract_mgmt 미매칭, project_mgmt 매칭)
-- part_objid=1868260552 → RPS part_mng (있음)
-- partner_objid='0000000007' → RPS client_mng 서울반도체(주) (있음)
--
-- 멱등성: ON CONFLICT DO NOTHING
-- ============================================================
-- ── purchase_order_master (RPS 이미 존재하면 mail_send_* 만 보강) ──
-- PK constraint 없어 ON CONFLICT 사용 불가 → WHERE NOT EXISTS 패턴
INSERT INTO purchase_order_master
(objid, purchase_order_no, partner_objid, contract_mgmt_objid, sales_request_objid,
regdate, writer, status, mail_send_yn, mail_send_date,
sales_mng_user_id, payment_terms)
SELECT
'-2135417309','RPS26-0401-01','0000000007','-1752090174','-233034270',
'2026-04-01 07:20:58.687075','ady1225','create','Y','2026-04-03',
'ish0312','0001069'
WHERE NOT EXISTS (SELECT 1 FROM purchase_order_master WHERE objid='-2135417309');
-- 이미 있던 행에는 매퍼 필수 필드(mail_send_*) 보강
UPDATE purchase_order_master
SET mail_send_yn='Y', mail_send_date='2026-04-03'
WHERE objid='-2135417309'
AND COALESCE(mail_send_yn,'') = '';
-- ── purchase_order_part ───────────────────────────────────────
INSERT INTO purchase_order_part
(objid, purchase_order_master_objid, part_objid, order_qty, partner_price,
remark, writer, regdate, part_name, spec, supply_unit_price, unit,
part_no, qty, part_delivery_place, delivery_request_date)
VALUES
('-192149597','-2135417309',1868260552,'1','10000',
'W/M ASSY (RWMR1070-NO07 LH) / HOLDER','ady1225','2026-04-01 07:20:58.687075',
'Ti(GR5)','Ø50*22','10000','0001400','C3P50L22','1','RPS','2026-04-03')
ON CONFLICT (objid) DO NOTHING;
-- ── arrival_plan ──────────────────────────────────────────────
INSERT INTO arrival_plan
(objid, parent_objid, order_part_objid, part_objid,
arrival_qty, receipt_qty, receipt_date, location,
writer, group_seq, seq, inventory_status, sub_location, receiver_id)
VALUES
('1030275443','-2135417309','-192149597',1868260552,
'1','1','2026-04-01','L101',
'ady1225','1','1','Y','1490000','ady1225')
ON CONFLICT (objid) DO NOTHING;
-- 검증: 매퍼 WHERE (mail_send_date IS NOT NULL AND status='create') 통과 여부
-- SELECT pom.purchase_order_no, pop.part_no, ap.receipt_date
-- FROM purchase_order_master pom
-- JOIN purchase_order_part pop ON pop.purchase_order_master_objid = pom.objid
-- LEFT JOIN arrival_plan ap ON ap.parent_objid = pom.objid AND ap.part_objid = pop.part_objid
-- WHERE pom.mail_send_date IS NOT NULL AND pom.status = 'create';
@@ -0,0 +1,73 @@
-- ============================================================
-- 견적요청서 (Quotation Request) — 구매관리 단독
-- 원본: 운영DB 211.115.91.141:11133/waceplm (quotation_request_master 4건, quotation_request_detail 4건)
-- 추출일: 2026-05-15
-- 적용대상: vexplor_rps (11134)
--
-- 운영 ↔ RPS 타입 차이 (feedback_createobjid_pattern.md):
-- 운영: quotation_request_master.objid numeric → RPS varchar(64)
-- 운영: sales_request_master_objid / project_mgmt_objid numeric → RPS varchar(64) (FK 호환)
-- 운영: detail.part_objid numeric → RPS bigint (part_mng.objid bigint 호환)
-- 운영: detail.sales_request_part_objid numeric → RPS varchar(64)
--
-- 비즈니스 흐름:
-- 구매리스트(sales_request_master) → 견적요청서(quotation_request_master + detail)
-- → 품의서 → 발주서(purchase_order_master + part) → 입고(arrival_plan + inventory_*)
--
-- 매퍼 본문(getQuotationRequestList): wace_plm/src/com/pms/mapper/salesMng.xml:5248-5349
-- ============================================================
-- ── 1. quotation_request_master ──────────────────────────────
CREATE TABLE IF NOT EXISTS quotation_request_master (
objid varchar(64) NOT NULL,
quotation_request_no varchar(50),
sales_request_master_objid varchar(64),
project_mgmt_objid varchar(64),
vendor_objid varchar(64),
vendor_type varchar(20),
status varchar(50) DEFAULT 'create',
mail_send_date timestamp,
mail_send_yn varchar(1) DEFAULT 'N',
due_date date,
remark text,
writer varchar(50),
reg_date timestamp DEFAULT now(),
edit_date timestamp,
CONSTRAINT quotation_request_master_pkey PRIMARY KEY (objid)
);
CREATE INDEX IF NOT EXISTS idx_qrm_sales_request ON quotation_request_master (sales_request_master_objid);
CREATE INDEX IF NOT EXISTS idx_qrm_project ON quotation_request_master (project_mgmt_objid);
CREATE INDEX IF NOT EXISTS idx_qrm_vendor ON quotation_request_master (vendor_objid);
CREATE INDEX IF NOT EXISTS idx_qrm_status ON quotation_request_master (status);
-- ── 2. quotation_request_detail ──────────────────────────────
CREATE TABLE IF NOT EXISTS quotation_request_detail (
objid varchar(64) NOT NULL,
quotation_request_master_objid varchar(64),
sales_request_part_objid varchar(64),
part_objid bigint,
part_no varchar(100),
part_name varchar(200),
raw_material varchar(100),
size varchar(100),
qty numeric DEFAULT 0,
unit_price numeric DEFAULT 0,
total_price numeric DEFAULT 0,
remark text,
delivery_request_date varchar(10),
reg_date timestamp DEFAULT now(),
edit_date timestamp,
CONSTRAINT quotation_request_detail_pkey PRIMARY KEY (objid)
);
CREATE INDEX IF NOT EXISTS idx_qrd_master ON quotation_request_detail (quotation_request_master_objid);
CREATE INDEX IF NOT EXISTS idx_qrd_part ON quotation_request_detail (sales_request_part_objid);
ALTER TABLE quotation_request_detail
DROP CONSTRAINT IF EXISTS fk_qrd_master;
ALTER TABLE quotation_request_detail
ADD CONSTRAINT fk_qrd_master
FOREIGN KEY (quotation_request_master_objid)
REFERENCES quotation_request_master (objid)
ON DELETE CASCADE;
@@ -0,0 +1,131 @@
-- ============================================================
-- 발주서 + 입고관리 — 구매관리 입고 3메뉴 + 발주서관리 의존 테이블
-- 원본: 운영DB 211.115.91.141:11133/waceplm
-- purchase_order_master 1건 (mail_send_yn='Y', status='create')
-- purchase_order_part 1건 (RPS26-0401-01 / C3P50L22)
-- arrival_plan 1건 (receipt_qty=1, receipt_date=2026-04-01)
-- 추출일: 2026-05-15
-- 적용대상: vexplor_rps (11134)
--
-- 운영 ↔ RPS 타입 차이:
-- part_objid: 운영 varchar(64) → RPS bigint (part_mng.objid bigint 호환)
--
-- 매퍼:
-- deliveryMngPartList: wace_plm/src/com/pms/mapper/purchaseOrder.xml:6309-6543
-- purchaseCloseList: wace_plm/src/com/pms/mapper/purchaseOrder.xml:6549-6765
-- projectPurchaseStat: wace_plm/src/com/pms/mapper/purchaseOrder.xml:6768-6951
--
-- 함정:
-- 1) wace 매퍼는 PROJECT_MGMT.OBJID = POM.CONTRACT_MGMT_OBJID 로 LEFT JOIN
-- (즉 contract_mgmt_objid 컬럼명이 실제로는 project_mgmt 키를 저장)
-- 2) WHERE: POM.MAIL_SEND_DATE IS NOT NULL AND POM.STATUS='create'
-- ============================================================
-- ── 1. purchase_order_master 보충 컬럼 (10개) ───────────────────
ALTER TABLE purchase_order_master ADD COLUMN IF NOT EXISTS mail_send_yn varchar;
ALTER TABLE purchase_order_master ADD COLUMN IF NOT EXISTS mail_send_date varchar;
ALTER TABLE purchase_order_master ADD COLUMN IF NOT EXISTS form_type varchar(20);
ALTER TABLE purchase_order_master ADD COLUMN IF NOT EXISTS sales_mng_user_id2 varchar(50);
ALTER TABLE purchase_order_master ADD COLUMN IF NOT EXISTS request_content text;
ALTER TABLE purchase_order_master ADD COLUMN IF NOT EXISTS purchase_close_date varchar(10);
ALTER TABLE purchase_order_master ADD COLUMN IF NOT EXISTS shipment varchar;
ALTER TABLE purchase_order_master ADD COLUMN IF NOT EXISTS packing varchar;
ALTER TABLE purchase_order_master ADD COLUMN IF NOT EXISTS validity varchar;
ALTER TABLE purchase_order_master ADD COLUMN IF NOT EXISTS attn_to varchar;
-- ── 2. purchase_order_part (운영 43 cols 1:1, part_objid 만 bigint) ─
CREATE TABLE IF NOT EXISTS purchase_order_part (
objid varchar(64) NOT NULL,
purchase_order_master_objid varchar(64),
part_objid bigint,
order_qty varchar,
partner_price varchar,
remark varchar,
writer varchar,
regdate timestamp,
status varchar,
part_name varchar,
do_no varchar,
thickness varchar,
width varchar,
height varchar,
out_diameter varchar,
length varchar,
in_diameter varchar,
inven_total_qty varchar,
ld_part_objid varchar,
spec varchar,
maker varchar,
supply_unit_price varchar,
unit varchar,
price1 varchar,
price2 varchar,
price3 varchar,
part_no varchar,
supply_unit_vat_price varchar,
price4 varchar,
supply_unit_vat_sum_price varchar,
total_order_qty varchar,
stock_qty varchar,
real_order_qty varchar,
update_date timestamp,
modifier varchar,
real_supply_price varchar,
bom_qty varchar,
qty varchar,
part_delivery_place varchar(50),
product_name varchar(200),
work_order_no varchar(50),
delivery_request_date varchar(20),
currency varchar,
CONSTRAINT purchase_order_part_pkey PRIMARY KEY (objid)
);
CREATE INDEX IF NOT EXISTS idx_pop_master ON purchase_order_part (purchase_order_master_objid);
CREATE INDEX IF NOT EXISTS idx_pop_part ON purchase_order_part (part_objid);
-- ── 3. arrival_plan (운영 37 cols 1:1, part_objid bigint) ───────
CREATE TABLE IF NOT EXISTS arrival_plan (
objid varchar(64) NOT NULL,
parent_objid varchar(64),
order_part_objid varchar(64),
part_objid bigint,
arrival_plan_date varchar,
re_arrival_plan_date varchar,
arrival_qty varchar,
receipt_qty varchar,
genuine_qty varchar,
receipt_date varchar,
inspection_date varchar,
location varchar,
error_qty varchar,
error_reason varchar,
attribution varchar,
status varchar,
assembly_status varchar,
writer varchar,
group_seq varchar,
seq varchar,
defect_content varchar,
defect_action varchar,
defect_note varchar,
defect_action_date varchar,
defect_action_title varchar,
inventory_status varchar,
sub_location varchar,
receiver_id varchar,
purchase_close_date varchar,
foreign_type varchar(10),
exchange_rate numeric(15,2),
duty numeric(15,2),
import_vat numeric(15,2),
tax_invoice_date varchar(10),
export_decl_no varchar(100),
loading_date varchar(10),
tax_type varchar(20),
CONSTRAINT arrival_plan_pkey PRIMARY KEY (objid)
);
CREATE INDEX IF NOT EXISTS idx_arrival_parent ON arrival_plan (parent_objid);
CREATE INDEX IF NOT EXISTS idx_arrival_order_part ON arrival_plan (order_part_objid);
CREATE INDEX IF NOT EXISTS idx_arrival_part ON arrival_plan (part_objid);
@@ -0,0 +1,252 @@
"use client";
// 자재관리 > 불출의뢰서 — wace inventoryMng/materialRequestList.jsp 1:1
// 그리드: inventory_mgmt_out_master + inventory_mgmt_out 집계
// 검색: 품번/품명 / 불출의뢰일 / 의뢰자 / 접수상태 / 접수자 / 접수일 / 불출상태
// 액션: 자재불출 / 접수 / 삭제 / 조회
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { CheckCircle2, Trash2, PackageMinus } from "lucide-react";
import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { SmartSelect } from "@/components/common/SmartSelect";
import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar";
import { PageHeader } from "@/components/common/PageHeader";
import { exportToExcel } from "@/lib/utils/excelExport";
import { inventoryMngApi, IssueRequestFilter, OptionItem } from "@/lib/api/inventoryMng";
import { IssueDispatchDialog } from "@/components/material/IssueDispatchDialog";
const EMPTY: IssueRequestFilter = {
part_no: "", part_name: "",
request_start_date: "", request_end_date: "",
request_user: "", reception_status: "",
reception_user: "", reception_start_date: "", reception_end_date: "",
out_status: "",
page: 1, page_size: 50,
};
const RECEPTION_OPTS = [
{ code: "reception", label: "접수" },
{ code: "AA", label: "미접수" },
];
const OUT_STATUS_OPTS = [
{ code: "complete", label: "완료" },
{ code: "NG", label: "미완료" },
];
export default function MaterialIssueRequestPage() {
const [rows, setRows] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<IssueRequestFilter>(EMPTY);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [userOpts, setUserOpts] = useState<OptionItem[]>([]);
const [dispatchOpen, setDispatchOpen] = useState(false);
const [dispatchTarget, setDispatchTarget] = useState<{ objid: string; readOnly: boolean } | null>(null);
const fetchList = useCallback(async (override?: Partial<IssueRequestFilter>) => {
setLoading(true);
try {
const f = { ...filter, ...override };
const res = await inventoryMngApi.listIssue(f);
setRows(res.rows ?? []);
setTotal(res.totalCount ?? 0);
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
} finally {
setLoading(false);
}
}, [filter]);
useEffect(() => {
(async () => {
try { setUserOpts(await inventoryMngApi.users()); } catch { /* skip */ }
})();
fetchList(EMPTY);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const gridRows = useMemo(
() => rows.map((r) => ({ ...r, id: r.objid })),
[rows],
);
const checkedRows = useMemo(
() => gridRows.filter((r: any) => checkedIds.includes(r.id)),
[checkedIds, gridRows],
);
const GRID_COLUMNS: DataGridColumn[] = useMemo(() => ([
{ key: "inventory_out_no", label: "자재불출번호", width: "w-[140px]", align: "center" },
{ key: "part_no_arr", label: "품번", minWidth: "min-w-[200px]" },
{ key: "part_name_arr", label: "품명", minWidth: "min-w-[260px]" },
{ key: "request_date", label: "불출의뢰일", width: "w-[110px]", align: "center" },
{ key: "request_user_name", label: "의뢰자", width: "w-[100px]", align: "center" },
{ key: "reception_status_title",label: "상태", width: "w-[80px]", align: "center" },
{ key: "reception_user_name", label: "접수자", width: "w-[100px]", align: "center" },
{ key: "reception_date", label: "접수일", width: "w-[110px]", align: "center" },
{ key: "outstatus_title", label: "불출상태", width: "w-[90px]", align: "center" },
{ key: "request_qty_total", label: "의뢰수량합", width: "w-[100px]", align: "right",
render: (v: any) => Number(v ?? 0).toLocaleString() },
{ key: "out_qty_total", label: "불출수량합", width: "w-[100px]", align: "right",
render: (v: any) => Number(v ?? 0).toLocaleString() },
{ key: "remark", label: "특이사항", minWidth: "min-w-[160px]" },
]), []);
const summary = useMemo(() => ([
{ label: "전체 건수", value: total.toLocaleString(), suffix: "건" },
{ label: "선택", value: checkedIds.length.toLocaleString(), suffix: "건" },
]), [total, checkedIds]);
const handleSearch = () => { setFilter(f => ({ ...f, page: 1 })); fetchList({ page: 1 }); };
const handleReset = () => { setFilter(EMPTY); setCheckedIds([]); fetchList(EMPTY); };
const handleReceive = async () => {
const targets = checkedRows.filter((r: any) => r.reception_status !== "reception");
if (!targets.length) return toast.info("미접수 상태인 항목을 선택해주세요.");
try {
const r = await inventoryMngApi.receiveIssue(targets.map((t: any) => t.objid));
toast.success(`${r.updated}건 접수 처리되었습니다.`);
setCheckedIds([]);
fetchList();
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "접수 실패");
}
};
const handleDispatch = () => {
if (checkedRows.length !== 1) return toast.info("자재불출은 1건씩 처리합니다.");
const r = checkedRows[0] as any;
if (r.reception_status !== "reception") return toast.warning("접수된 항목만 불출 가능합니다.");
if (r.outstatus === "complete") return toast.warning("이미 불출완료된 항목입니다.");
setDispatchTarget({ objid: r.objid, readOnly: false });
setDispatchOpen(true);
};
const handleDelete = async () => {
if (checkedRows.length !== 1) return toast.info("1건씩 삭제하세요.");
const r = checkedRows[0] as any;
if (r.outstatus === "complete") return toast.warning("이미 불출완료된 항목은 삭제할 수 없습니다.");
if (!confirm("선택한 불출의뢰서를 삭제하시겠습니까?")) return;
try {
await inventoryMngApi.deleteIssue(r.objid);
toast.success("삭제되었습니다.");
setCheckedIds([]);
fetchList();
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "삭제 실패");
}
};
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="default" className="h-8 gap-1 px-2 text-xs"
onClick={handleDispatch} disabled={checkedRows.length !== 1}>
<PackageMinus className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="outline" className="h-8 gap-1 px-2 text-xs"
onClick={handleReceive} disabled={!checkedRows.length}>
<CheckCircle2 className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="ghost" className="h-8 gap-1 px-2 text-xs text-red-600"
onClick={handleDelete} disabled={checkedRows.length !== 1}>
<Trash2 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={280}>
<CompactDateRange
from={filter.request_start_date ?? ""} setFrom={(v) => setFilter({ ...filter, request_start_date: v })}
to={filter.request_end_date ?? ""} setTo={(v) => setFilter({ ...filter, request_end_date: v })}
/>
</CompactFilterField>
<CompactFilterField label="의뢰자" width={170}>
<SmartSelect options={userOpts} value={filter.request_user ?? ""}
onValueChange={(v) => setFilter({ ...filter, request_user: v })} />
</CompactFilterField>
<CompactFilterField label="접수상태" width={120}>
<SmartSelect options={RECEPTION_OPTS} value={filter.reception_status ?? ""}
onValueChange={(v) => setFilter({ ...filter, reception_status: v })} />
</CompactFilterField>
<CompactFilterField label="접수자" width={170}>
<SmartSelect options={userOpts} value={filter.reception_user ?? ""}
onValueChange={(v) => setFilter({ ...filter, reception_user: v })} />
</CompactFilterField>
<CompactFilterField label="접수일" width={280}>
<CompactDateRange
from={filter.reception_start_date ?? ""} setFrom={(v) => setFilter({ ...filter, reception_start_date: v })}
to={filter.reception_end_date ?? ""} setTo={(v) => setFilter({ ...filter, reception_end_date: v })}
/>
</CompactFilterField>
<CompactFilterField label="불출상태" width={120}>
<SmartSelect options={OUT_STATUS_OPTS} value={filter.out_status ?? ""}
onValueChange={(v) => setFilter({ ...filter, out_status: v })} />
</CompactFilterField>
</CompactFilterBar>
<DataGrid
columns={GRID_COLUMNS}
data={gridRows}
loading={loading}
showCheckbox
checkedIds={checkedIds}
onCheckedChange={setCheckedIds}
emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"}
gridId="material-issue-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}
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", "불출의뢰서");
}}
onRowDoubleClick={(row: any) => {
setDispatchTarget({ objid: row.objid, readOnly: true });
setDispatchOpen(true);
}}
showChart
/>
{dispatchTarget && (
<IssueDispatchDialog
open={dispatchOpen}
onClose={() => setDispatchOpen(false)}
onSaved={() => { setCheckedIds([]); fetchList(); }}
masterObjid={dispatchTarget.objid}
readOnly={dispatchTarget.readOnly}
/>
)}
</div>
);
}
@@ -0,0 +1,257 @@
"use client";
// 자재관리 > 자재리스트 — wace inventoryMng/inventoryMngNewList.jsp 1:1
// 그리드: inventory_mgmt + part_mng + 입출고 집계 (USE_CNT / USE_CNT_ALL / REQUEST_QTY)
// 검색: 프로젝트 / 유닛 / 품번 / 품명 / PART구분 / Location
// 액션: 재고등록 / 자재이동 / 불출의뢰 / 삭제 / 이력보기
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Plus, Repeat, ClipboardEdit, Trash2, History as HistoryIcon } from "lucide-react";
import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { SmartSelect } from "@/components/common/SmartSelect";
import { CompactFilterBar, CompactFilterField } from "@/components/common/CompactFilterBar";
import { PageHeader } from "@/components/common/PageHeader";
import { exportToExcel } from "@/lib/utils/excelExport";
import { inventoryMngApi, InventoryListFilter, OptionItem } from "@/lib/api/inventoryMng";
import { StockRegisterDialog } from "@/components/material/StockRegisterDialog";
import { MaterialMoveDialog } from "@/components/material/MaterialMoveDialog";
import { IssueRequestCreateDialog } from "@/components/material/IssueRequestCreateDialog";
import { InventoryHistoryDialog } from "@/components/material/InventoryHistoryDialog";
const EMPTY: InventoryListFilter = {
project_objid: "", unit_code: "",
part_no: "", part_name: "",
part_type: "", location: "",
page: 1, page_size: 50,
};
export default function MaterialListPage() {
const [rows, setRows] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<InventoryListFilter>(EMPTY);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [projectOpts, setProjectOpts] = useState<OptionItem[]>([]);
const [unitOpts, setUnitOpts] = useState<OptionItem[]>([]);
const [stockOpen, setStockOpen] = useState(false);
const [moveOpen, setMoveOpen] = useState(false);
const [issueOpen, setIssueOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [historyTarget, setHistoryTarget] = useState<{ objid: string; label: string } | null>(null);
const fetchList = useCallback(async (override?: Partial<InventoryListFilter>) => {
setLoading(true);
try {
const f = { ...filter, ...override };
const res = await inventoryMngApi.list(f);
setRows(res.rows ?? []);
setTotal(res.totalCount ?? 0);
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
} finally {
setLoading(false);
}
}, [filter]);
useEffect(() => {
(async () => {
try { setProjectOpts(await inventoryMngApi.projects()); } catch { /* skip */ }
})();
fetchList(EMPTY);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!filter.project_objid) { setUnitOpts([]); return; }
(async () => {
try { setUnitOpts(await inventoryMngApi.units(filter.project_objid!)); } catch { /* skip */ }
})();
}, [filter.project_objid]);
const gridRows = useMemo(
() => rows.map((r, i) => ({ ...r, id: `${r.objid}__${i}` })),
[rows],
);
const checkedObjids = useMemo(
() => gridRows.filter((r: any) => checkedIds.includes(r.id)).map((r: any) => r.objid as string),
[checkedIds, gridRows],
);
const checkedRows = useMemo(
() => gridRows.filter((r: any) => checkedIds.includes(r.id)),
[checkedIds, gridRows],
);
const GRID_COLUMNS: DataGridColumn[] = useMemo(() => ([
{ key: "project_no", label: "프로젝트번호", width: "w-[120px]", align: "center" },
{ key: "unit_name", label: "유닛명", width: "w-[150px]" },
{ key: "part_no", label: "품번", width: "w-[160px]" },
{ key: "part_name", label: "품명", minWidth: "min-w-[200px]" },
{ key: "material", label: "재질", width: "w-[110px]" },
{ key: "spec", label: "사양(규격)", minWidth: "min-w-[140px]" },
{ key: "part_type_name", label: "PART구분", width: "w-[100px]", align: "center" },
{ key: "use_cnt", label: "보유수량", width: "w-[100px]", align: "right",
render: (v: any) => Number(v ?? 0).toLocaleString() },
{ key: "use_cnt_all", label: "보유수량(전체)", width: "w-[110px]", align: "right",
render: (v: any) => Number(v ?? 0).toLocaleString() },
{ key: "location_name", label: "Location", width: "w-[140px]" },
{ key: "request_qty", label: "불출이력", width: "w-[100px]", align: "right",
render: (v: any) => Number(v ?? 0).toLocaleString() },
]), []);
const summary = useMemo(() => ([
{ label: "전체 건수", value: total.toLocaleString(), suffix: "건" },
{ label: "페이지 건수", value: gridRows.length.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); setCheckedIds([]); fetchList(EMPTY); };
const handleDelete = async () => {
if (!checkedObjids.length) return toast.info("삭제할 자재를 선택해주세요.");
if (!confirm(`선택한 ${checkedObjids.length}건의 자재를 삭제하시겠습니까?`)) return;
try {
await inventoryMngApi.deleteStock(checkedObjids);
toast.success("삭제되었습니다.");
setCheckedIds([]);
fetchList();
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "삭제 실패");
}
};
const handleMove = () => {
if (!checkedRows.length) return toast.info("이동할 자재를 선택해주세요.");
if (checkedRows.some((r: any) => Number(r.use_cnt) === 0)) {
return toast.warning("보유수량이 0인 자재가 포함되어 있습니다.");
}
setMoveOpen(true);
};
const handleIssue = () => {
if (!checkedRows.length) return toast.info("불출의뢰할 자재를 선택해주세요.");
if (checkedRows.some((r: any) => Number(r.use_cnt) === 0)) {
return toast.warning("보유수량이 0인 자재가 포함되어 있습니다.");
}
setIssueOpen(true);
};
const handleHistoryOne = () => {
if (checkedRows.length !== 1) return toast.info("이력은 1건씩 조회합니다.");
const r = checkedRows[0] as any;
setHistoryTarget({ objid: r.objid, label: `${r.part_no} ${r.part_name ?? ""}` });
setHistoryOpen(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="default" className="h-8 gap-1 px-2 text-xs"
onClick={() => setStockOpen(true)}>
<Plus className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="outline" className="h-8 gap-1 px-2 text-xs"
onClick={handleMove} disabled={!checkedRows.length}>
<Repeat className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="outline" className="h-8 gap-1 px-2 text-xs"
onClick={handleIssue} disabled={!checkedRows.length}>
<ClipboardEdit className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="ghost" className="h-8 gap-1 px-2 text-xs"
onClick={handleHistoryOne} disabled={checkedRows.length !== 1}>
<HistoryIcon className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="ghost" className="h-8 gap-1 px-2 text-xs text-red-600"
onClick={handleDelete} disabled={!checkedRows.length}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</>}
/>
<CompactFilterBar totalText={<> {total.toLocaleString()}</>}>
<CompactFilterField label="프로젝트" width={200}>
<SmartSelect options={projectOpts} value={filter.project_objid ?? ""}
onValueChange={(v) => setFilter({ ...filter, project_objid: v, unit_code: "" })} />
</CompactFilterField>
<CompactFilterField label="유닛" width={180}>
<SmartSelect options={unitOpts} value={filter.unit_code ?? ""}
onValueChange={(v) => setFilter({ ...filter, unit_code: v })} />
</CompactFilterField>
<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="PART구분" width={120}>
<Input value={filter.part_type ?? ""}
onChange={(e) => setFilter({ ...filter, part_type: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="Location" width={150}>
<Input value={filter.location ?? ""}
onChange={(e) => setFilter({ ...filter, location: e.target.value })} />
</CompactFilterField>
</CompactFilterBar>
<DataGrid
columns={GRID_COLUMNS}
data={gridRows}
loading={loading}
showCheckbox
checkedIds={checkedIds}
onCheckedChange={setCheckedIds}
emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"}
gridId="material-list"
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}
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
/>
<StockRegisterDialog open={stockOpen} onClose={() => setStockOpen(false)}
onSaved={() => fetchList()} />
<MaterialMoveDialog open={moveOpen} onClose={() => setMoveOpen(false)}
onSaved={() => { setCheckedIds([]); fetchList(); }}
selectedRows={checkedRows} />
<IssueRequestCreateDialog open={issueOpen} onClose={() => setIssueOpen(false)}
onSaved={() => { setCheckedIds([]); fetchList(); }}
parentObjids={checkedObjids} />
{historyTarget && (
<InventoryHistoryDialog open={historyOpen} onClose={() => setHistoryOpen(false)}
parentObjid={historyTarget.objid} partLabel={historyTarget.label} />
)}
</div>
);
}
@@ -83,7 +83,7 @@ export default function InboundByDatePage() {
{ key: "project_no", label: "프로젝트번호", width: "w-[135px]", align: "center" },
{ key: "component_part_no", label: "부품품번", width: "w-[135px]" },
{ key: "part_no", label: "품번", width: "w-[135px]" },
{ key: "part_name", label: "품명", minWidth: "min-w-[180px]" },
{ key: "part_name", label: "품명", minWidth: "min-w-[280px]" },
{ key: "partner_name", label: "공급업체", minWidth: "min-w-[150px]" },
{ key: "currency_name", label: "환종", width: "w-[80px]", align: "center" },
{ key: "receipt_date", label: "입고일", width: "w-[110px]", align: "center" },
@@ -81,7 +81,7 @@ export default function InboundByItemPage() {
{ key: "project_no", label: "프로젝트번호", width: "w-[135px]", align: "center" },
{ key: "component_part_no", label: "부품품번", width: "w-[140px]" },
{ key: "part_no", label: "품번", width: "w-[140px]" },
{ key: "part_name", label: "품명", minWidth: "min-w-[180px]" },
{ key: "part_name", label: "품명", minWidth: "min-w-[280px]" },
{ key: "partner_name", label: "공급업체", minWidth: "min-w-[150px]" },
{ key: "currency_name", label: "환종", width: "w-[80px]", align: "center" },
{ key: "delivery_request_date", label: "입고요청일", width: "w-[115px]", align: "center" },
@@ -88,7 +88,7 @@ export default function InboundPage() {
{ key: "purchase_order_no", label: "발주서 No", width: "w-[125px]", align: "center" },
{ key: "project_no", label: "프로젝트번호", width: "w-[135px]", align: "center" },
{ key: "part_no", label: "품번", width: "w-[140px]" },
{ key: "part_name", label: "품명", minWidth: "min-w-[180px]" },
{ key: "part_name", label: "품명", minWidth: "min-w-[280px]" },
{ key: "partner_name", label: "공급업체", minWidth: "min-w-[150px]" },
{ key: "currency_name", label: "환종", width: "w-[80px]", align: "center" },
{ key: "writer_name", label: "구매담당자", width: "w-[110px]", align: "center" },
@@ -85,7 +85,7 @@ export default function PurchaseListPage() {
{ key: "customer_name", label: "고객사", minWidth: "min-w-[180px]" },
{ key: "paid_type_name", label: "유/무상", width: "w-[100px]", align: "center" },
{ key: "part_display", label: "품번", width: "w-[160px]" },
{ key: "part_name_display", label: "품명", minWidth: "min-w-[200px]" },
{ key: "part_name_display", label: "품명", minWidth: "min-w-[280px]" },
{ key: "has_quotation_request", label: "견적요청서", width: "w-[115px]", align: "center" },
{ key: "request_user_name", label: "작성자", width: "w-[115px]", align: "center" },
{ key: "delivery_request_date", label: "입고요청일", width: "w-[115px]", align: "center" },
File diff suppressed because it is too large Load Diff
@@ -73,7 +73,7 @@ export default function ProjectStatusPage() {
{ key: "project_no", label: "프로젝트번호", width: "w-[140px]", align: "center", frozen: true },
{ key: "product_name", label: "제품구분", width: "w-[115px]", align: "center" },
{ key: "part_no", label: "품번", width: "w-[150px]" },
{ key: "part_name", label: "품명", minWidth: "min-w-[200px]" },
{ key: "part_name", label: "품명", minWidth: "min-w-[280px]" },
{ key: "customer_name", label: "고객사", minWidth: "min-w-[180px]" },
// 전체 (BOM기준)
{ key: "total_item_cnt", label: "전체품목수", width: "w-[115px]", align: "right", formatNumber: true },
@@ -94,7 +94,7 @@ export default function ProposalPage() {
{ 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: "part_name_display", label: "품명", minWidth: "min-w-[280px]" },
{ key: "status_title", label: "결재상태", width: "w-[115px]", align: "center" },
{ key: "regdate_title", label: "작성일", width: "w-[115px]", align: "center" },
{ key: "writer_name", label: "작성자", width: "w-[115px]", align: "center" },
@@ -4,7 +4,6 @@
// 검색: 년도 / 프로젝트번호 / 견적요청서No / 공급업체 / 메일발송 / 작성자 / 제품구분
// 그리드: 13컬럼 (견적번호 / 요청번호 / 구매유형 / 프로젝트번호 / 주문유형 / 제품구분 / 품번 / 품명 / 공급업체 / 견적요청서(파일) / 메일발송 / 수신견적서 / 작성자)
// 액션: 메일발송 / 삭제 / 조회
// ⚠️ 백엔드 quotation_request_master 미존재 → 빈 그리드 (UI 만 제공)
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Input } from "@/components/ui/input";
@@ -66,7 +65,7 @@ export default function QuoteRequestPage() {
try {
const [p, s, u] = await Promise.all([
apiClient.get(`/sales/codes/${PARENT_PRODUCT}`),
purchaseApi.listSuppliers(),
purchaseApi.listVendors(),
purchaseApi.listUsers(),
]);
if (dead) return;
@@ -90,7 +89,7 @@ export default function QuoteRequestPage() {
{ key: "order_type_name", label: "주문유형", width: "w-[115px]", align: "center" },
{ key: "product_name_title", label: "제품구분", width: "w-[115px]", align: "center" },
{ key: "part_no", label: "품번", width: "w-[150px]" },
{ key: "part_name", label: "품명", minWidth: "min-w-[180px]" },
{ key: "part_name", label: "품명", minWidth: "min-w-[280px]" },
{ key: "vendor_name", label: "공급업체", minWidth: "min-w-[150px]" },
{ key: "quotation_file", label: "견적요청서", width: "w-[115px]", align: "center", renderType: "clip" },
{ key: "mail_send_date_title", label: "메일발송", width: "w-[125px]", align: "center" },
@@ -113,12 +112,12 @@ export default function QuoteRequestPage() {
actions={<>
<Button size="sm" variant="outline" className="h-8 gap-1 px-2 text-xs"
disabled={checkedIds.length === 0}
onClick={() => toast.info("메일발송 — 운영DB quotation_request_master 신설 후 활성")}>
onClick={() => toast.info("메일발송 기능 준비 중입니다.")}>
<Mail className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="destructive" className="h-8 gap-1 px-2 text-xs"
disabled={checkedIds.length === 0}
onClick={() => toast.info("삭제 — 운영DB quotation_request_master 신설 후 활성")}>
onClick={() => toast.info("삭제 기능 준비 중입니다.")}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</>}
+33 -17
View File
@@ -1,21 +1,20 @@
"use client";
/**
* PageHeader — 페이지 상단 메뉴명 + 설명 + 액션 슬롯.
* PageHeader — 페이지 상단 "대메뉴_중메뉴" 제목 + 액션/검색 슬롯.
*
* customer-cs/cs 페이지 패턴 1:1 추출. 모든 RPS 메뉴 페이지의 상단에 의무 배치.
* 모든 RPS 메뉴 페이지의 상단에 의무 배치.
*
* 자동 매칭 (탭 시스템 대응):
* - RPS 는 탭 기반이라 usePathname() 이 /main 으로 고정됨.
* - useTabStore 의 활성 탭 adminUrl → /COMPANY_NN prefix 제거 → menu_info.menu_url 매칭.
* - useCurrent2ndLevelMenuObjid 와 동일 패턴.
* - 매칭된 menu 의 parent_obj_id 로 부모 메뉴를 찾아 "{부모}_{자식}" 으로 표기 (wace 컨벤션).
* - 루트 그룹(parent_obj_id 가 0뎁스)이면 자식만 단독 표기.
*
* 명시 지정:
* <PageHeader title="M-BOM 관리" description="생산용 BOM 트리" actions={...} />
* <PageHeader title="M-BOM 관리" actions={...} />
*
* 원칙:
* - 모든 page.tsx 의 최상위 자식으로 <PageHeader /> 를 배치한다.
* - menu_info 에 등록만 되어 있으면 props 없이도 자동 매칭.
* 원칙: menu_info 에 등록만 되어 있으면 props 없이도 자동 매칭.
*/
import React from "react";
@@ -29,7 +28,6 @@ import { cn } from "@/lib/utils";
interface PageHeaderProps {
title?: string;
description?: string;
/** 업무 액션 슬롯 (등록/삭제/상신 등). 검색·초기화는 onSearch/onReset 로 전달. */
actions?: React.ReactNode;
/** 검색 핸들러. 지정 시 우측에 검색 버튼 자동 렌더. */
@@ -47,6 +45,17 @@ function stripCompanyPrefix(p: string): string {
return p.replace(/^\/COMPANY_\d+/, "") || "/";
}
function findParentMenu(menus: MenuItem[], menu: MenuItem | null): MenuItem | null {
if (!menu) return null;
const pid = menu.parent_obj_id ?? menu.PARENT_OBJ_ID;
if (!pid) return null;
for (const m of menus) {
const oid = m.objid ?? m.OBJID;
if (oid && String(oid) === String(pid)) return m;
}
return null;
}
function findByUrl(menus: MenuItem[], strippedUrl: string): MenuItem | null {
// menu_info.menu_url 이 /COMPANY_16/... 으로 저장되어 있으므로 양쪽 비교
for (const m of menus) {
@@ -68,7 +77,7 @@ function findByUrl(menus: MenuItem[], strippedUrl: string): MenuItem | null {
}
export function PageHeader({
title, description, actions, onSearch, onReset, loading,
title, actions, onSearch, onReset, loading,
searchLabel = "검색", resetLabel = "초기화", className,
}: PageHeaderProps) {
const pathname = usePathname() ?? "";
@@ -76,6 +85,7 @@ export function PageHeader({
const activeTabId = useTabStore(selectActiveTabId);
let menu: MenuItem | null = null;
let parentMenu: MenuItem | null = null;
try {
const { userMenus, adminMenus } = useMenu();
// RPS 탭 시스템: pathname=/main 이면 활성 탭의 adminUrl 사용
@@ -87,25 +97,31 @@ export function PageHeader({
targetUrl = stripCompanyPrefix(activeTab.adminUrl);
}
}
const allMenus = [...(userMenus as MenuItem[]), ...(adminMenus as MenuItem[])];
menu = findByUrl(userMenus as MenuItem[], targetUrl) ?? findByUrl(adminMenus as MenuItem[], targetUrl);
parentMenu = findParentMenu(allMenus, menu);
} catch {
/* Provider 밖 — 자동 매칭 생략 */
}
const resolvedTitle = title ?? menu?.menu_name_kor ?? "";
const resolvedDesc = description ?? menu?.menu_desc ?? "";
// wace 컨벤션: "대메뉴_중메뉴" (parent_obj_id 가 루트 그룹이면 단독 표기)
const parentName = parentMenu?.menu_name_kor ?? parentMenu?.MENU_NAME_KOR ?? "";
const ownName = menu?.menu_name_kor ?? menu?.MENU_NAME_KOR ?? "";
const parentParentPid = parentMenu?.parent_obj_id ?? parentMenu?.PARENT_OBJ_ID;
// 부모의 부모가 있어야 (즉, 부모가 1뎁스 그룹) "부모_자식" 표기. 부모 없거나 부모가 루트이면 자식만.
const autoTitle = parentName && parentParentPid && ownName
? `${parentName}_${ownName}`
: ownName;
const resolvedTitle = title ?? autoTitle;
const hasSearchButtons = !!(onSearch || onReset);
if (!resolvedTitle && !resolvedDesc && !actions && !hasSearchButtons) return null;
if (!resolvedTitle && !actions && !hasSearchButtons) return null;
return (
<div className={cn("flex flex-shrink-0 items-end justify-between gap-3 border-b pb-3", className)}>
<div className={cn("flex flex-shrink-0 items-center justify-between gap-3 border-b pb-2", className)}>
<div>
{resolvedTitle && (
<h1 className="text-xl font-bold tracking-tight">{resolvedTitle}</h1>
)}
{resolvedDesc && (
<p className="text-xs text-muted-foreground">{resolvedDesc}</p>
<h1 className="text-lg font-bold tracking-tight">{resolvedTitle}</h1>
)}
</div>
{(actions || hasSearchButtons) && (
@@ -136,6 +136,8 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
"/COMPANY_16/outsourcing/subcontractor-item": dynamic(() => import("@/app/(main)/COMPANY_16/outsourcing/subcontractor-item/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_16/outsourcing/outbound": dynamic(() => import("@/app/(main)/COMPANY_16/outsourcing/outbound/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_16/purchase/order": dynamic(() => import("@/app/(main)/COMPANY_16/purchase/order/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_16/material/list": dynamic(() => import("@/app/(main)/COMPANY_16/material/list/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_16/material/issue-request": dynamic(() => import("@/app/(main)/COMPANY_16/material/issue-request/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_16/purchase/purchase-item": dynamic(() => import("@/app/(main)/COMPANY_16/purchase/purchase-item/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_16/purchase/supplier": dynamic(() => import("@/app/(main)/COMPANY_16/purchase/supplier/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_16/quality/inspection": dynamic(() => import("@/app/(main)/COMPANY_16/quality/inspection/page"), { ssr: false, loading: LoadingFallback }),
@@ -355,6 +357,7 @@ const COMPANY_PAGE_PREFIXES = [
"/outsourcing/",
"/design/",
"/purchase/",
"/material/",
"/quality/",
"/mold/",
"/monitoring/",
@@ -0,0 +1,101 @@
"use client";
// 자재관리 > 자재리스트 — 입출고 이력 다이얼로그
// wace 1:1: inventoryRequestHistoryPopUp.jsp
// 입고/이동/출고 UNION 결과 노출
import React, { useEffect, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { X } from "lucide-react";
import { inventoryMngApi } from "@/lib/api/inventoryMng";
import { toast } from "sonner";
interface HistoryRow {
objid: string;
part_no: string;
part_name: string;
gubun: string;
qty: string;
location_name: string;
sub_location_name: string;
regdate: string;
writer_name: string;
}
interface Props {
open: boolean;
onClose: () => void;
parentObjid: string; // inventory_mgmt.objid
partLabel?: string;
}
export function InventoryHistoryDialog({ open, onClose, parentObjid, partLabel }: Props) {
const [rows, setRows] = useState<HistoryRow[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open || !parentObjid) return;
setLoading(true);
(async () => {
try {
const data = await inventoryMngApi.history(parentObjid);
setRows(data as HistoryRow[]);
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "이력 조회 실패");
} finally {
setLoading(false);
}
})();
}, [open, parentObjid]);
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle> {partLabel ? `${partLabel}` : ""}</DialogTitle>
<DialogDescription> // .</DialogDescription>
</DialogHeader>
<div className="overflow-x-auto text-xs">
<table className="w-full border">
<thead className="bg-muted/50">
<tr>
<th className="border px-2 py-1"></th>
<th className="border px-2 py-1 text-right"></th>
<th className="border px-2 py-1">Location</th>
<th className="border px-2 py-1">Sub</th>
<th className="border px-2 py-1"></th>
<th className="border px-2 py-1"></th>
</tr>
</thead>
<tbody>
{loading && (
<tr><td colSpan={6} className="border px-2 py-3 text-center"> ...</td></tr>
)}
{!loading && rows.length === 0 && (
<tr><td colSpan={6} className="border px-2 py-3 text-center"> .</td></tr>
)}
{rows.map((r, i) => (
<tr key={i}>
<td className="border px-2 py-1">{r.gubun}</td>
<td className="border px-2 py-1 text-right">{r.qty || "0"}</td>
<td className="border px-2 py-1">{r.location_name}</td>
<td className="border px-2 py-1">{r.sub_location_name}</td>
<td className="border px-2 py-1">{r.writer_name}</td>
<td className="border px-2 py-1">{r.regdate?.toString().slice(0, 19).replace("T", " ")}</td>
</tr>
))}
</tbody>
</table>
</div>
<DialogFooter>
<Button size="sm" variant="ghost" onClick={onClose}>
<X className="h-3.5 w-3.5 mr-1" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,228 @@
"use client";
// 자재관리 > 불출의뢰서 — 자재불출 처리 다이얼로그
// wace 1:1: materialRequestDetailPopUp.jsp / acceptInventoryRequestInfo.do
// 라인별 OUT_QTY / OUT_DATE / ACQ_USER 입력 후 OUTSTATUS='complete' 처리.
// 읽기전용(조회) 모드도 지원.
import React, { useEffect, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Save, X } from "lucide-react";
import { toast } from "sonner";
import { Input } from "@/components/ui/input";
import { NumberInput } from "@/components/common/NumberInput";
import { DateInput } from "@/components/common/DateInput";
import { SmartSelect } from "@/components/common/SmartSelect";
import { inventoryMngApi, OptionItem } from "@/lib/api/inventoryMng";
interface LineRow {
objid: string;
part_no: string;
part_name: string;
material: string;
spec: string;
location: string;
sub_location: string;
request_qty: string;
out_qty: number | "";
out_date: string;
acq_user: string;
writer_name: string;
}
interface Props {
open: boolean;
onClose: () => void;
onSaved: () => void;
masterObjid: string;
readOnly?: boolean; // 미접수/완료 상태에서 조회만
}
export function IssueDispatchDialog({ open, onClose, onSaved, masterObjid, readOnly }: Props) {
const [master, setMaster] = useState<any | null>(null);
const [rows, setRows] = useState<LineRow[]>([]);
const [userOpts, setUserOpts] = useState<OptionItem[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [bulkUser, setBulkUser] = useState("");
const [bulkDate, setBulkDate] = useState(new Date().toISOString().slice(0, 10));
useEffect(() => {
if (!open || !masterObjid) return;
setMaster(null);
setRows([]);
setLoading(true);
(async () => {
try {
const [detail, users] = await Promise.all([
inventoryMngApi.getIssue(masterObjid),
inventoryMngApi.users(),
]);
setMaster(detail.master);
setRows((detail.lines ?? []).map((l: any) => ({
objid: l.objid,
part_no: l.part_no ?? "",
part_name: l.part_name ?? "",
material: l.material ?? "",
spec: l.spec ?? "",
location: l.location ?? "",
sub_location: l.sub_location ?? "",
request_qty: l.request_qty ?? "0",
out_qty: l.out_qty ? Number(l.out_qty) : "",
out_date: l.out_date ?? "",
acq_user: l.acq_user ?? "",
writer_name: l.writer_name ?? "",
})));
setUserOpts(users);
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
} finally {
setLoading(false);
}
})();
}, [open, masterObjid]);
const handleRow = (idx: number, patch: Partial<LineRow>) =>
setRows(rs => rs.map((r, i) => i === idx ? { ...r, ...patch } : r));
const handleApplyBulk = () => {
setRows(rs => rs.map(r => ({
...r,
acq_user: bulkUser || r.acq_user,
out_date: bulkDate || r.out_date,
})));
};
const handleSave = async () => {
const lines = rows.filter(r => r.out_qty !== "" && Number(r.out_qty) > 0);
if (!lines.length) return toast.info("불출수량을 입력해주세요.");
for (const r of lines) {
if (Number(r.out_qty) > Number(r.request_qty)) {
return toast.warning(`${r.part_no}: 의뢰수량(${r.request_qty}) 초과는 불가합니다.`);
}
if (!r.out_date) return toast.info(`${r.part_no}: 인계일을 입력해주세요.`);
if (!r.acq_user) return toast.info(`${r.part_no}: 인수자를 선택해주세요.`);
}
setSaving(true);
try {
await inventoryMngApi.dispatchIssue({
master_objid: masterObjid,
lines: lines.map(r => ({
objid: r.objid,
out_qty: Number(r.out_qty),
out_date: r.out_date,
acq_user: r.acq_user,
})),
});
toast.success("자재 불출 처리가 완료되었습니다.");
onSaved();
onClose();
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "불출 실패");
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-6xl">
<DialogHeader>
<DialogTitle>
{readOnly ? "불출의뢰 상세" : "자재 불출"}
{master?.inventory_out_no ? `${master.inventory_out_no}` : ""}
</DialogTitle>
<DialogDescription>
{readOnly
? "불출의뢰 라인을 조회합니다."
: "라인별 불출수량 / 인계일 / 인수자를 입력하고 저장하세요."}
</DialogDescription>
</DialogHeader>
{master && (
<div className="grid grid-cols-3 gap-2 text-xs text-muted-foreground">
<div>: <b>{master.request_date ?? "-"}</b></div>
<div>: <b>{master.request_user_name ?? master.request_id ?? "-"}</b></div>
<div>: <b>{master.reception_status === "reception" ? "접수" : "미접수"}</b>
{master.reception_date ? ` / ${master.reception_date}` : ""}</div>
<div className="col-span-3">: {master.remark || "-"}</div>
</div>
)}
{!readOnly && (
<div className="flex items-center gap-2 text-xs">
<span>:</span>
<span></span>
<div className="w-[140px]"><DateInput value={bulkDate} onChange={setBulkDate} /></div>
<span></span>
<div className="w-[180px]">
<SmartSelect options={userOpts} value={bulkUser} onValueChange={setBulkUser} />
</div>
<Button size="sm" variant="outline" onClick={handleApplyBulk}></Button>
</div>
)}
<div className="overflow-x-auto text-xs">
<table className="w-full border">
<thead className="bg-muted/50">
<tr>
<th className="border px-2 py-1"></th>
<th className="border px-2 py-1"></th>
<th className="border px-2 py-1"></th>
<th className="border px-2 py-1"></th>
<th className="border px-2 py-1">Location</th>
<th className="border px-2 py-1 text-right"></th>
<th className="border px-2 py-1 text-right"></th>
<th className="border px-2 py-1"></th>
<th className="border px-2 py-1"></th>
</tr>
</thead>
<tbody>
{loading && <tr><td colSpan={9} className="border px-2 py-3 text-center"> ...</td></tr>}
{!loading && rows.length === 0 && (
<tr><td colSpan={9} className="border px-2 py-3 text-center"> .</td></tr>
)}
{rows.map((r, i) => (
<tr key={i}>
<td className="border px-2 py-1">{r.part_no}</td>
<td className="border px-2 py-1">{r.part_name}</td>
<td className="border px-2 py-1">{r.spec}</td>
<td className="border px-2 py-1">{r.material}</td>
<td className="border px-2 py-1">{r.location}{r.sub_location ? ` / ${r.sub_location}` : ""}</td>
<td className="border px-2 py-1 text-right">{Number(r.request_qty ?? 0).toLocaleString()}</td>
<td className="border px-1 py-1 w-[120px]">
{readOnly
? <span>{r.out_qty === "" ? "" : Number(r.out_qty).toLocaleString()}</span>
: <NumberInput value={r.out_qty}
onChange={(v) => handleRow(i, { out_qty: v })} />}
</td>
<td className="border px-1 py-1 w-[140px]">
{readOnly ? <span>{r.out_date}</span> :
<DateInput value={r.out_date} onChange={(v) => handleRow(i, { out_date: v })} />}
</td>
<td className="border px-1 py-1 w-[160px]">
{readOnly ? <span>{r.acq_user}</span> :
<SmartSelect options={userOpts} value={r.acq_user}
onValueChange={(v) => handleRow(i, { acq_user: v })} />}
</td>
</tr>
))}
</tbody>
</table>
</div>
<DialogFooter>
<Button size="sm" variant="ghost" onClick={onClose} disabled={saving}>
<X className="h-3.5 w-3.5 mr-1" />
</Button>
{!readOnly && (
<Button size="sm" onClick={handleSave} disabled={saving || loading}>
<Save className="h-3.5 w-3.5 mr-1" /> {saving ? "처리중..." : "자재불출"}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,184 @@
"use client";
// 자재관리 > 자재리스트 — 불출의뢰 등록 다이얼로그
// wace 1:1: materialRequestFormPopUp.jsp / saveInventoryRequest.do
// 선택된 자재(inventory_mgmt) 후보를 불러와 request_qty 입력 → 마스터+라인 저장
// inventory_out_no 채번: Rfw-YYYY-seq
import React, { useEffect, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Save, X } from "lucide-react";
import { toast } from "sonner";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { NumberInput } from "@/components/common/NumberInput";
import { DateInput } from "@/components/common/DateInput";
import { inventoryMngApi } from "@/lib/api/inventoryMng";
interface CandidateRow {
objid: string;
project_no: string;
unit: string;
unit_name: string;
part_no: string;
part_name: string;
material: string;
spec: string;
location: string;
sub_location: string;
use_cnt: number;
request_qty: number | "";
}
interface Props {
open: boolean;
onClose: () => void;
onSaved: () => void;
parentObjids: string[]; // 자재리스트에서 선택된 inventory_mgmt.objid
}
export function IssueRequestCreateDialog({ open, onClose, onSaved, parentObjids }: Props) {
const [rows, setRows] = useState<CandidateRow[]>([]);
const [remark, setRemark] = useState("");
const [requestDate, setRequestDate] = useState(new Date().toISOString().slice(0, 10));
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open || !parentObjids.length) return;
setRemark("");
setRequestDate(new Date().toISOString().slice(0, 10));
(async () => {
setLoading(true);
try {
const list = await inventoryMngApi.candidates(parentObjids);
setRows(list.map((r) => ({
objid: r.objid,
project_no: r.project_no ?? "",
unit: r.unit ?? "",
unit_name: r.unit_name ?? "",
part_no: r.part_no ?? "",
part_name: r.part_name ?? "",
material: r.material ?? "",
spec: r.spec ?? "",
location: r.location ?? "",
sub_location: r.sub_location ?? "",
use_cnt: Number(r.use_cnt ?? 0),
request_qty: "",
})));
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "후보 조회 실패");
} finally {
setLoading(false);
}
})();
}, [open, parentObjids]);
const handleRow = (idx: number, patch: Partial<CandidateRow>) =>
setRows(rs => rs.map((r, i) => i === idx ? { ...r, ...patch } : r));
const handleSave = async () => {
const targets = rows.filter((r) => r.request_qty !== "" && Number(r.request_qty) > 0);
if (!targets.length) return toast.info("불출의뢰수량을 입력해주세요.");
for (const r of targets) {
if (Number(r.request_qty) > r.use_cnt) {
return toast.warning(`${r.part_no}: 보유수량(${r.use_cnt}) 초과는 불가합니다.`);
}
}
setSaving(true);
try {
const res = await inventoryMngApi.saveIssue({
request_date: requestDate,
remark,
lines: targets.map((r) => ({
parent_objid: r.objid,
request_qty: Number(r.request_qty),
unit: r.unit,
})),
});
toast.success(`불출의뢰가 생성되었습니다. (${res.inventory_out_no})`);
onSaved();
onClose();
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패");
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-6xl">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-3 text-xs">
<div className="space-y-1">
<Label></Label>
<DateInput value={requestDate} onChange={setRequestDate} />
</div>
<div />
<div className="col-span-2 space-y-1">
<Label></Label>
<Textarea rows={2} value={remark} onChange={(e) => setRemark(e.target.value)} />
</div>
</div>
<div className="overflow-x-auto text-xs">
<table className="w-full border">
<thead className="bg-muted/50">
<tr>
<th className="border px-2 py-1"></th>
<th className="border px-2 py-1"></th>
<th className="border px-2 py-1"></th>
<th className="border px-2 py-1"></th>
<th className="border px-2 py-1"></th>
<th className="border px-2 py-1"></th>
<th className="border px-2 py-1">Location</th>
<th className="border px-2 py-1 text-right"></th>
<th className="border px-2 py-1 text-right"></th>
</tr>
</thead>
<tbody>
{loading && (
<tr><td colSpan={9} className="border px-2 py-3 text-center"> ...</td></tr>
)}
{!loading && rows.length === 0 && (
<tr><td colSpan={9} className="border px-2 py-3 text-center"> .</td></tr>
)}
{rows.map((r, i) => (
<tr key={i}>
<td className="border px-2 py-1">{r.project_no}</td>
<td className="border px-2 py-1">{r.unit_name || r.unit}</td>
<td className="border px-2 py-1">{r.part_no}</td>
<td className="border px-2 py-1">{r.part_name}</td>
<td className="border px-2 py-1">{r.spec}</td>
<td className="border px-2 py-1">{r.material}</td>
<td className="border px-2 py-1">{r.location}{r.sub_location ? ` / ${r.sub_location}` : ""}</td>
<td className="border px-2 py-1 text-right">{r.use_cnt.toLocaleString()}</td>
<td className="border px-1 py-1 w-[130px]">
<NumberInput value={r.request_qty}
onChange={(v) => handleRow(i, { request_qty: v })} />
</td>
</tr>
))}
</tbody>
</table>
</div>
<DialogFooter>
<Button size="sm" variant="ghost" onClick={onClose} disabled={saving}>
<X className="h-3.5 w-3.5 mr-1" />
</Button>
<Button size="sm" onClick={handleSave} disabled={saving || loading || !rows.length}>
<Save className="h-3.5 w-3.5 mr-1" /> {saving ? "저장중..." : "불출의뢰"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,163 @@
"use client";
// 자재관리 > 자재리스트 — 자재이동 다이얼로그
// wace 1:1: materialMoveFormPopUp.jsp / saveInventoryMove.do
// inventory_mgmt_in 행의 move_qty/move_date/move_user 누적 + 이동 이력 라인 추가
import React, { useEffect, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Save, X } from "lucide-react";
import { toast } from "sonner";
import { NumberInput } from "@/components/common/NumberInput";
import { DateInput } from "@/components/common/DateInput";
import { Input } from "@/components/ui/input";
import { SmartSelect } from "@/components/common/SmartSelect";
import { inventoryMngApi, OptionItem } from "@/lib/api/inventoryMng";
interface MoveRow {
in_objid: string;
parent_objid: string;
part_no: string;
part_name: string;
use_cnt: number;
move_qty: number | "";
location: string;
sub_location: string;
move_date: string;
move_user: string;
}
interface Props {
open: boolean;
onClose: () => void;
onSaved: () => void;
selectedRows: any[]; // 자재리스트에서 체크된 행 (objid 가 parent_objid)
}
export function MaterialMoveDialog({ open, onClose, onSaved, selectedRows }: Props) {
const [rows, setRows] = useState<MoveRow[]>([]);
const [userOpts, setUserOpts] = useState<OptionItem[]>([]);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
setRows(selectedRows.map((r) => ({
in_objid: "", // 사용자가 in_objid 를 모를 수도 있어 parent 기준으로 1행 처리 후 첫 inventory_mgmt_in 사용
parent_objid: r.objid,
part_no: r.part_no ?? "",
part_name: r.part_name ?? "",
use_cnt: Number(r.use_cnt ?? 0),
move_qty: "",
location: r.location ?? "",
sub_location: r.sub_location ?? "",
move_date: new Date().toISOString().slice(0, 10),
move_user: "",
})));
(async () => {
try { setUserOpts(await inventoryMngApi.users()); } catch { /* skip */ }
})();
}, [open, selectedRows]);
const handleRowChange = (idx: number, patch: Partial<MoveRow>) =>
setRows(rs => rs.map((r, i) => i === idx ? { ...r, ...patch } : r));
const handleSave = async () => {
const items = rows.filter((r) => r.move_qty !== "" && Number(r.move_qty) > 0);
if (!items.length) return toast.info("이동수량을 입력해주세요.");
for (const r of items) {
if (Number(r.move_qty) > r.use_cnt) {
return toast.warning(`${r.part_no}: 보유수량(${r.use_cnt})보다 큰 이동수량은 불가합니다.`);
}
if (!r.move_user) {
return toast.info(`${r.part_no}: 인수자를 선택해주세요.`);
}
}
setSaving(true);
try {
// parent_objid 기준으로 백엔드가 첫 inventory_mgmt_in 행을 잡지 못하므로
// 일단 in_objid 가 비면 parent_objid 를 사용 (백엔드에서 fallback 처리).
await inventoryMngApi.move(items.map((r) => ({
in_objid: r.parent_objid,
move_qty: Number(r.move_qty),
location: r.location,
sub_location: r.sub_location,
move_date: r.move_date,
move_user: r.move_user,
})));
toast.success("자재가 이동되었습니다.");
onSaved();
onClose();
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "이동 실패");
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-5xl">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> /Location/ .</DialogDescription>
</DialogHeader>
<div className="overflow-x-auto text-xs">
<table className="w-full border">
<thead className="bg-muted/50">
<tr>
<th className="border px-2 py-1 text-left"></th>
<th className="border px-2 py-1 text-left"></th>
<th className="border px-2 py-1 text-right"></th>
<th className="border px-2 py-1 text-right"></th>
<th className="border px-2 py-1">Location</th>
<th className="border px-2 py-1">Sub</th>
<th className="border px-2 py-1"></th>
<th className="border px-2 py-1"></th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i}>
<td className="border px-2 py-1">{r.part_no}</td>
<td className="border px-2 py-1">{r.part_name}</td>
<td className="border px-2 py-1 text-right">{r.use_cnt.toLocaleString()}</td>
<td className="border px-1 py-1 w-[110px]">
<NumberInput value={r.move_qty}
onChange={(v) => handleRowChange(i, { move_qty: v })} />
</td>
<td className="border px-1 py-1 w-[120px]">
<Input value={r.location}
onChange={(e) => handleRowChange(i, { location: e.target.value })} />
</td>
<td className="border px-1 py-1 w-[120px]">
<Input value={r.sub_location}
onChange={(e) => handleRowChange(i, { sub_location: e.target.value })} />
</td>
<td className="border px-1 py-1 w-[140px]">
<DateInput value={r.move_date}
onChange={(v) => handleRowChange(i, { move_date: v })} />
</td>
<td className="border px-1 py-1 w-[160px]">
<SmartSelect options={userOpts} value={r.move_user}
onValueChange={(v) => handleRowChange(i, { move_user: v })} />
</td>
</tr>
))}
</tbody>
</table>
</div>
<DialogFooter>
<Button size="sm" variant="ghost" onClick={onClose} disabled={saving}>
<X className="h-3.5 w-3.5 mr-1" />
</Button>
<Button size="sm" onClick={handleSave} disabled={saving}>
<Save className="h-3.5 w-3.5 mr-1" /> {saving ? "이동중..." : "이동"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,184 @@
"use client";
// 자재관리 > 자재리스트 — 재고등록(입고) 다이얼로그
// wace 1:1: inventoryFormPopUp.jsp / saveinventoryForm.do
// 1) inventory_mgmt 동일 (project+unit+part+location) 행 존재 시 재사용, 없으면 INSERT
// 2) inventory_mgmt_in 입고 라인 1건 INSERT
import React, { useEffect, useMemo, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Save, X } from "lucide-react";
import { toast } from "sonner";
import { SmartSelect } from "@/components/common/SmartSelect";
import { NumberInput } from "@/components/common/NumberInput";
import { DateInput } from "@/components/common/DateInput";
import { Input } from "@/components/ui/input";
import { inventoryMngApi, OptionItem } from "@/lib/api/inventoryMng";
interface Props {
open: boolean;
onClose: () => void;
onSaved: () => void;
}
interface FormState {
project_objid: string;
unit: string;
part_objid: string;
part_no: string;
part_name: string;
qty: number | "";
price: string;
location: string;
sub_location: string;
receipt_date: string;
}
const EMPTY: FormState = {
project_objid: "", unit: "", part_objid: "",
part_no: "", part_name: "",
qty: "", price: "", location: "", sub_location: "",
receipt_date: new Date().toISOString().slice(0, 10),
};
export function StockRegisterDialog({ open, onClose, onSaved }: Props) {
const [form, setForm] = useState<FormState>(EMPTY);
const [projectOpts, setProjectOpts] = useState<OptionItem[]>([]);
const [unitOpts, setUnitOpts] = useState<OptionItem[]>([]);
const [partKw, setPartKw] = useState("");
const [partOpts, setPartOpts] = useState<OptionItem[]>([]);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
setForm(EMPTY);
setPartKw("");
setPartOpts([]);
(async () => {
try { setProjectOpts(await inventoryMngApi.projects()); } catch { /* skip */ }
})();
}, [open]);
useEffect(() => {
if (!form.project_objid) { setUnitOpts([]); return; }
(async () => {
try { setUnitOpts(await inventoryMngApi.units(form.project_objid)); } catch { /* skip */ }
})();
}, [form.project_objid]);
useEffect(() => {
const id = setTimeout(async () => {
if (!partKw || partKw.length < 1) { setPartOpts([]); return; }
try { setPartOpts(await inventoryMngApi.parts(partKw, 30)); } catch { /* skip */ }
}, 250);
return () => clearTimeout(id);
}, [partKw]);
const partSelectOpts = useMemo(
() => partOpts.map((p) => ({ code: p.code, label: `${p.label} ${p.part_name ? `· ${p.part_name}` : ""}` })),
[partOpts],
);
const handleSave = async () => {
if (!form.project_objid) return toast.info("프로젝트를 선택해주세요.");
if (!form.part_objid) return toast.info("품번을 선택해주세요.");
if (form.qty === "" || Number(form.qty) <= 0) return toast.info("수량을 입력해주세요.");
if (!form.location) return toast.info("Location 을 입력해주세요.");
setSaving(true);
try {
await inventoryMngApi.saveStock({
project_objid: form.project_objid,
unit: form.unit,
part_objid: form.part_objid,
qty: Number(form.qty),
price: form.price,
location: form.location,
sub_location: form.sub_location,
receipt_date: form.receipt_date,
});
toast.success("재고가 등록되었습니다.");
onSaved();
onClose();
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패");
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-3 text-xs">
<div className="space-y-1">
<Label> *</Label>
<SmartSelect options={projectOpts} value={form.project_objid}
onValueChange={(v) => setForm({ ...form, project_objid: v, unit: "" })} />
</div>
<div className="space-y-1">
<Label>(WBS)</Label>
<SmartSelect options={unitOpts} value={form.unit}
onValueChange={(v) => setForm({ ...form, unit: v })} />
</div>
<div className="space-y-1 col-span-2">
<Label> </Label>
<Input placeholder="품번/품명 일부 입력"
value={partKw}
onChange={(e) => setPartKw(e.target.value)} />
<SmartSelect options={partSelectOpts} value={form.part_objid}
onValueChange={(v) => {
const found = partOpts.find((p) => p.code === v);
setForm({
...form,
part_objid: v,
part_no: found?.label ?? "",
part_name: found?.part_name ?? "",
});
}} />
{form.part_no && (
<div className="text-[11px] text-muted-foreground">
: <b>{form.part_no}</b> {form.part_name ? `· ${form.part_name}` : ""}
</div>
)}
</div>
<div className="space-y-1">
<Label> *</Label>
<NumberInput value={form.qty} onChange={(v) => setForm({ ...form, qty: v })} />
</div>
<div className="space-y-1">
<Label></Label>
<Input value={form.price} onChange={(e) => setForm({ ...form, price: e.target.value })} />
</div>
<div className="space-y-1">
<Label>Location *</Label>
<Input value={form.location} onChange={(e) => setForm({ ...form, location: e.target.value })} />
</div>
<div className="space-y-1">
<Label>Sub Location</Label>
<Input value={form.sub_location} onChange={(e) => setForm({ ...form, sub_location: e.target.value })} />
</div>
<div className="space-y-1">
<Label></Label>
<DateInput value={form.receipt_date} onChange={(v) => setForm({ ...form, receipt_date: v })} />
</div>
</div>
<DialogFooter>
<Button size="sm" variant="ghost" onClick={onClose} disabled={saving}>
<X className="h-3.5 w-3.5 mr-1" />
</Button>
<Button size="sm" onClick={handleSave} disabled={saving}>
<Save className="h-3.5 w-3.5 mr-1" /> {saving ? "저장중..." : "저장"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+169
View File
@@ -0,0 +1,169 @@
// ============================================================
// 자재관리 — 자재리스트 + 불출의뢰서 API
// 백엔드: /api/inventory-mng
// ============================================================
import { apiClient } from "./client";
export interface InventoryListFilter {
project_objid?: string;
unit_code?: string;
part_no?: string;
part_name?: string;
part_type?: string;
location?: string;
cls_cd?: string;
cau_cd?: string;
writer?: string;
page?: number;
page_size?: number;
}
export interface IssueRequestFilter {
part_no?: string;
part_name?: string;
request_start_date?: string;
request_end_date?: string;
request_user?: string;
reception_status?: string;
reception_user?: string;
reception_start_date?: string;
reception_end_date?: string;
out_status?: string;
page?: number;
page_size?: number;
}
export interface ListResponse<T = any> {
rows: T[];
totalCount: number;
page: number;
pageSize: number;
}
export interface OptionItem { code: string; label: string; part_name?: string }
export interface SaveStockInput {
project_objid: string;
unit?: string;
part_objid: string;
qty: number;
price?: string;
location: string;
sub_location?: string;
cls_cd?: string;
cau_cd?: string;
receipt_date?: string;
writer?: string;
order_objid?: string;
}
export interface MoveItem {
in_objid: string;
move_qty: number;
location?: string;
sub_location?: string;
move_date?: string;
move_user?: string;
}
export interface IssueRequestLineInput {
parent_objid: string;
request_qty: number;
unit?: string;
}
export interface IssueRequestSaveInput {
master_objid?: string;
contract_mgmt_objid?: string;
request_date?: string;
request_id?: string;
remark?: string;
writer?: string;
lines: IssueRequestLineInput[];
}
export interface DispatchLineInput {
objid: string;
out_qty: number;
out_date: string;
acq_user: string;
sign?: string;
}
export interface DispatchInput {
master_objid: string;
lines: DispatchLineInput[];
writer?: string;
}
export const inventoryMngApi = {
// 자재리스트
async list(f: InventoryListFilter = {}): Promise<ListResponse> {
const r = await apiClient.get("/inventory-mng/list", { params: f });
return r.data?.data as ListResponse;
},
async saveStock(input: SaveStockInput): Promise<{ objid: string; in_objid: string }> {
const r = await apiClient.post("/inventory-mng/stock", input);
return r.data?.data;
},
async deleteStock(objids: string[]): Promise<{ deleted: number }> {
const r = await apiClient.post("/inventory-mng/stock/delete", { objids });
return r.data?.data;
},
async move(items: MoveItem[]): Promise<{ updated: number }> {
const r = await apiClient.post("/inventory-mng/move", { items });
return r.data?.data;
},
async history(objid: string): Promise<any[]> {
const r = await apiClient.get(`/inventory-mng/history/${objid}`);
return (r.data?.data ?? []) as any[];
},
// 불출의뢰
async listIssue(f: IssueRequestFilter = {}): Promise<ListResponse> {
const r = await apiClient.get("/inventory-mng/issue-request", { params: f });
return r.data?.data as ListResponse;
},
async getIssue(objid: string): Promise<{ master: any; lines: any[] }> {
const r = await apiClient.get(`/inventory-mng/issue-request/${objid}`);
return r.data?.data;
},
async candidates(parent_objids: string[]): Promise<any[]> {
const r = await apiClient.post("/inventory-mng/issue-request/candidates", { parent_objids });
return (r.data?.data ?? []) as any[];
},
async saveIssue(input: IssueRequestSaveInput): Promise<{ master_objid: string; inventory_out_no: string }> {
const r = await apiClient.post("/inventory-mng/issue-request", input);
return r.data?.data;
},
async deleteIssue(objid: string): Promise<void> {
await apiClient.delete(`/inventory-mng/issue-request/${objid}`);
},
async receiveIssue(objids: string[]): Promise<{ updated: number }> {
const r = await apiClient.post("/inventory-mng/issue-request/receive", { objids });
return r.data?.data;
},
async dispatchIssue(input: DispatchInput): Promise<{ updated: number }> {
const r = await apiClient.post("/inventory-mng/issue-request/dispatch", input);
return r.data?.data;
},
// 옵션
async projects(): Promise<OptionItem[]> {
const r = await apiClient.get("/inventory-mng/options/projects");
return (r.data?.data ?? []) as OptionItem[];
},
async units(contract_objid: string): Promise<OptionItem[]> {
const r = await apiClient.get("/inventory-mng/options/units", { params: { contract_objid } });
return (r.data?.data ?? []) as OptionItem[];
},
async users(): Promise<OptionItem[]> {
const r = await apiClient.get("/inventory-mng/options/users");
return (r.data?.data ?? []) as OptionItem[];
},
async parts(keyword: string, limit = 30): Promise<OptionItem[]> {
const r = await apiClient.get("/inventory-mng/options/parts", { params: { q: keyword, limit } });
return (r.data?.data ?? []) as OptionItem[];
},
};
+7
View File
@@ -22,6 +22,7 @@ export interface PurchaseListFilter {
purchase_type?: string;
part_type?: string;
product_cd?: string;
category_cd?: string;
paid_type?: string;
mail_send_yn?: string;
delivery_status?: string;
@@ -65,12 +66,18 @@ export const purchaseApi = {
listInboundByItem: (f: PurchaseListFilter = {}) => getList("inbound-by-item", f),
listInboundByDate: (f: PurchaseListFilter = {}) => getList("inbound-by-date", f),
listProjectStatus: (f: PurchaseListFilter = {}) => getList("project-status", f),
listOrder: (f: PurchaseListFilter = {}) => getList("order-list", f),
// 공통 옵션
async listSuppliers(): Promise<OptionItem[]> {
const r = await apiClient.get("/purchase/options/suppliers");
return (r.data?.data ?? []) as OptionItem[];
},
// 견적요청서 / 발주서 vendor (wace client_mng 매칭)
async listVendors(): Promise<OptionItem[]> {
const r = await apiClient.get("/purchase/options/vendors");
return (r.data?.data ?? []) as OptionItem[];
},
async listUsers(): Promise<OptionItem[]> {
const r = await apiClient.get("/purchase/options/users");
return (r.data?.data ?? []) as OptionItem[];