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:
@@ -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")}일`;
|
||||
}
|
||||
@@ -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>
|
||||
</>}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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[];
|
||||
},
|
||||
};
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user