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