Merge pull request 'hjjeong' (#7) from hjjeong into main
Reviewed-on: https://g.wace.me/chpark/vexplor_rps/pulls/7
This commit is contained in:
@@ -15,3 +15,25 @@ DOCUMENT_DATA_SOURCE=memory
|
||||
# https://openweathermap.org/api 에서 무료 가입 후 발급
|
||||
OPENWEATHER_API_KEY=your_openweathermap_api_key_here
|
||||
|
||||
|
||||
# ==================== SMTP (견적서/발주서 등 메일 발송) ====================
|
||||
# wace Constants.Mail 1:1. 호스트는 ERP/SALES/PURCHASE 모두 동일.
|
||||
# 운영 발송 OFF: SMTP_SEND_SWITCH=N (mail_log INSERT만 수행, Transport.send 스킵)
|
||||
|
||||
SMTP_HOST=erp.rps-korea.com
|
||||
SMTP_PORT=25
|
||||
SMTP_TLS=false
|
||||
SMTP_SEND_SWITCH=Y
|
||||
|
||||
# 기본/ERP 계정
|
||||
SMTP_USER_ERP=erp@example.com
|
||||
SMTP_PW_ERP=your_erp_password
|
||||
|
||||
# 영업팀 (견적서 등) — accountType=SALES 사용 시
|
||||
SMTP_USER_SALES=sales@example.com
|
||||
SMTP_PW_SALES=your_sales_password
|
||||
|
||||
# 구매팀 (발주서 등) — accountType=PURCHASE 사용 시
|
||||
SMTP_USER_PURCHASE=purchase@example.com
|
||||
SMTP_PW_PURCHASE=your_purchase_password
|
||||
|
||||
|
||||
Generated
+37
@@ -38,6 +38,7 @@
|
||||
"node-pop3": "^0.11.0",
|
||||
"nodemailer": "^6.10.1",
|
||||
"oracledb": "^6.9.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pg": "^8.16.3",
|
||||
"quill": "^2.0.3",
|
||||
"react-quill": "^2.0.0",
|
||||
@@ -2363,6 +2364,24 @@
|
||||
"@noble/hashes": "^1.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@pdf-lib/standard-fonts": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
|
||||
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@pdf-lib/upng": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
|
||||
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^1.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@pinojs/redact": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||
@@ -9686,6 +9705,24 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pdf-lib": {
|
||||
"version": "1.17.1",
|
||||
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
|
||||
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pdf-lib/standard-fonts": "^1.0.0",
|
||||
"@pdf-lib/upng": "^1.0.1",
|
||||
"pako": "^1.0.11",
|
||||
"tslib": "^1.11.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pdf-lib/node_modules/tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/peberminta": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"node-pop3": "^0.11.0",
|
||||
"nodemailer": "^6.10.1",
|
||||
"oracledb": "^6.9.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pg": "^8.16.3",
|
||||
"quill": "^2.0.3",
|
||||
"react-quill": "^2.0.0",
|
||||
|
||||
@@ -175,6 +175,7 @@ import salesEstimateRoutes from "./routes/salesEstimateRoutes"; // 영업관리>
|
||||
import salesOrderMgmtRoutes from "./routes/salesOrderMgmtRoutes"; // 영업관리>주문서 (wace_plm 도메인 이식)
|
||||
import salesSaleRoutes from "./routes/salesSaleRoutes"; // 영업관리>판매+매출 (wace_plm 도메인)
|
||||
import salesCommonRoutes from "./routes/salesCommonRoutes"; // 영업관리 4개 메뉴 공통 옵션 (codes/parts/customers)
|
||||
import projectMgmtRoutes from "./routes/projectMgmtRoutes"; // 프로젝트관리>진행관리 (wace_plm 도메인 이식)
|
||||
import erpSyncRoutes from "./routes/erpSyncRoutes"; // ERP 마스터 동기화 (사원/부서/창고/거래처/계정과목)
|
||||
import ecrMngRoutes from "./routes/ecrMngRoutes"; // ECR(Engineering Change Request) 관리
|
||||
import customerCsRoutes from "./routes/customerCsRoutes"; // 고객 CS 관리
|
||||
@@ -419,6 +420,7 @@ app.use("/api/sales/estimate", salesEstimateRoutes); // 영업관리>견적 (wac
|
||||
app.use("/api/sales/order-mgmt", salesOrderMgmtRoutes); // 영업관리>주문서 (wace_plm 도메인)
|
||||
app.use("/api/sales", salesSaleRoutes); // 영업관리>판매+매출 (wace_plm 도메인)
|
||||
app.use("/api/sales", salesCommonRoutes); // 영업관리 공통 옵션 (codes/parts/customers)
|
||||
app.use("/api/project/progress", projectMgmtRoutes); // 프로젝트관리>진행관리 (wace_plm 도메인)
|
||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
|
||||
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import * as svc from "../services/projectMgmtService";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const filter = req.query as Record<string, string>;
|
||||
const data = await svc.listProgress(filter);
|
||||
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 getProjectNoOptions(_req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const data = await svc.listProjectNoOptions();
|
||||
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 getById(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const data = await svc.getById(id);
|
||||
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 update(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const rowCount = await svc.updateProject(id, req.body);
|
||||
if (rowCount === 0) return res.status(404).json({ success: false, message: "프로젝트를 찾을 수 없습니다." });
|
||||
return res.json({ success: true, message: "프로젝트가 수정되었습니다." });
|
||||
} catch (e: any) {
|
||||
logger.error("진행관리 수정 실패", { error: e.message });
|
||||
return res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
}
|
||||
@@ -68,35 +68,67 @@ export async function remove(req: AuthenticatedRequest, res: Response) {
|
||||
return res.json({ success: true, message: "견적이 삭제되었습니다." });
|
||||
} catch (error: any) {
|
||||
logger.error("견적 삭제 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
// AppError(statusCode) 우선 — G8 guard 409 등
|
||||
return res.status(error?.statusCode ?? 500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendMail(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const userId = req.user!.userId;
|
||||
const { contractObjid, toEmails, ccEmails, subject, contents, isSend } = req.body ?? {};
|
||||
const { contractObjid, toEmails, ccEmails, subject, contents, pdfBase64, useAddEstOnly } = req.body ?? {};
|
||||
if (!contractObjid || !toEmails || !subject) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수값 누락 (contractObjid, toEmails, subject)",
|
||||
});
|
||||
}
|
||||
const data = await salesEstimateService.sendMail(userId, {
|
||||
const result = await salesEstimateService.sendMail(userId, {
|
||||
contractObjid,
|
||||
toEmails,
|
||||
ccEmails,
|
||||
subject,
|
||||
contents: contents ?? "",
|
||||
isSend,
|
||||
pdfBase64,
|
||||
useAddEstOnly,
|
||||
});
|
||||
return res.json({ success: true, data, message: "메일 발송 이력이 등록되었습니다." });
|
||||
if (!result.success) {
|
||||
return res.status(500).json(result);
|
||||
}
|
||||
return res.json(result);
|
||||
} catch (error: any) {
|
||||
logger.error("견적 메일 발송 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /api/sales/estimate/:contractObjid/mail-info — 메일 다이얼로그 자동 채움용 (제목/수신/참조) */
|
||||
export async function getMailInfo(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const info = await salesEstimateService.getContractInfoForMail(id);
|
||||
if (!info) {
|
||||
return res.status(404).json({ success: false, message: "계약 정보를 찾을 수 없습니다." });
|
||||
}
|
||||
return res.json({ success: true, data: info });
|
||||
} catch (error: any) {
|
||||
logger.error("메일 자동채움 정보 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /api/sales/estimate/customer/:customerObjid/managers — 고객사 담당자 리스트 */
|
||||
export async function getMailManagers(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { customerObjid } = req.params;
|
||||
const managers = await salesEstimateService.getCustomerManagerList(customerObjid);
|
||||
return res.json({ success: true, data: managers });
|
||||
} catch (error: any) {
|
||||
logger.error("고객사 담당자 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// G5 견적작성 — estimate_template
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
@@ -145,3 +177,20 @@ export async function listTemplates(req: AuthenticatedRequest, res: Response) {
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// G4/G11 동일 패턴 — 견적 결재상신 (Amaranth SSO URL 발급 + amaranth_approval 매핑)
|
||||
// wace estimateList_new.jsp:887 fn_openAmaranthApproval + ApprovalService.getAmaranthSsoUrl 1:1
|
||||
export async function startApproval(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const userId = req.user!.userId;
|
||||
const { id } = req.params;
|
||||
const { approvalTitle, subjectStr } = req.body || {};
|
||||
const data = await salesEstimateService.startEstimateApproval(userId, id, {
|
||||
approvalTitle, subjectStr,
|
||||
});
|
||||
return res.json({ success: true, data, message: "결재 SSO URL이 발급되었습니다." });
|
||||
} catch (e: any) {
|
||||
logger.error("견적 결재상신 실패", { error: e.message });
|
||||
return res.status(e?.statusCode ?? 500).json({ success: false, message: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,11 @@ export async function remove(req: AuthenticatedRequest, res: Response) {
|
||||
const { id } = req.params;
|
||||
await svc.remove(id);
|
||||
return res.json({ success: true, message: "주문서가 삭제되었습니다." });
|
||||
} catch (e: any) { logger.error("주문서 삭제 실패", { error: e.message }); return res.status(500).json({ success: false, message: e.message }); }
|
||||
} catch (e: any) {
|
||||
logger.error("주문서 삭제 실패", { error: e.message });
|
||||
// AppError(statusCode) 우선 — G8 guard 409 등
|
||||
return res.status(e?.statusCode ?? 500).json({ success: false, message: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFormView(req: AuthenticatedRequest, res: Response) {
|
||||
@@ -71,6 +75,34 @@ export async function updateStatus(req: AuthenticatedRequest, res: Response) {
|
||||
} catch (e: any) { logger.error("수주 상태 변경 실패", { error: e.message }); return res.status(500).json({ success: false, message: e.message }); }
|
||||
}
|
||||
|
||||
// G4/G11 수주 결재상신 — Amaranth SSO URL 발급 + amaranth_approval 매핑
|
||||
// wace orderMgmtList.btnApproval + ApprovalService.getAmaranthSsoUrl 1:1
|
||||
export async function startApproval(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const userId = req.user!.userId;
|
||||
const { id } = req.params;
|
||||
const { approvalTitle, subjectStr } = req.body || {};
|
||||
const data = await svc.startOrderApproval(userId, id, { approvalTitle, subjectStr });
|
||||
return res.json({ success: true, data, message: "결재 SSO URL이 발급되었습니다." });
|
||||
} catch (e: any) {
|
||||
logger.error("수주 결재상신 실패", { error: e.message });
|
||||
return res.status(e?.statusCode ?? 500).json({ success: false, message: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
// G9 수주복사 (wace orderMgmtList btnCopy → copyEstimateAndOrderInfo 1:1)
|
||||
export async function copyOrder(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const userId = req.user!.userId;
|
||||
const { id } = req.params;
|
||||
const data = await svc.copyOrder(userId, id);
|
||||
return res.status(201).json({ success: true, data, message: "수주가 복사되었습니다." });
|
||||
} catch (e: any) {
|
||||
logger.error("수주복사 실패", { error: e.message });
|
||||
return res.status(e?.statusCode ?? 500).json({ success: false, message: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 수주취소: 라인별 cancel_qty 다중 UPDATE (wace saveOrderCancelQty 이식)
|
||||
export async function saveCancelQty(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import * as ctrl from "../controllers/projectMgmtController";
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticateToken);
|
||||
|
||||
router.get("/list", ctrl.getList);
|
||||
router.get("/project-no-options", ctrl.getProjectNoOptions);
|
||||
router.get("/:id", ctrl.getById);
|
||||
router.put("/:id", ctrl.update);
|
||||
|
||||
export default router;
|
||||
@@ -7,7 +7,11 @@ router.use(authenticateToken);
|
||||
|
||||
router.get("/list", salesEstimateController.getList);
|
||||
router.get("/generate-number", salesEstimateController.generateNumber);
|
||||
|
||||
// G6 메일 발송 — /:id 라우트보다 위에 (mail-info/managers는 path param 충돌 방지)
|
||||
router.post("/mail", salesEstimateController.sendMail);
|
||||
router.get("/mail-info/:id", salesEstimateController.getMailInfo);
|
||||
router.get("/customer/:customerObjid/managers", salesEstimateController.getMailManagers);
|
||||
|
||||
// G5 견적작성 (estimate_template) — /:id 라우트보다 위에
|
||||
router.post("/template1", salesEstimateController.saveTemplate1);
|
||||
@@ -15,6 +19,9 @@ router.post("/template2", salesEstimateController.saveTemplate2);
|
||||
router.get("/template/:templateObjid", salesEstimateController.getTemplate);
|
||||
router.get("/templates/:contractObjid", salesEstimateController.listTemplates);
|
||||
|
||||
// G4/G11 동일 패턴 — 견적 결재상신 (Amaranth SSO URL 발급) — /:id 라우트보다 위에
|
||||
router.post("/:id/amaranth-approval", salesEstimateController.startApproval);
|
||||
|
||||
router.get("/:id", salesEstimateController.getById);
|
||||
router.post("/", salesEstimateController.create);
|
||||
router.put("/:id", salesEstimateController.update);
|
||||
|
||||
@@ -14,5 +14,7 @@ router.put("/:id", ctrl.update);
|
||||
router.delete("/:id", ctrl.remove);
|
||||
router.patch("/:id/status", ctrl.updateStatus);
|
||||
router.post("/:id/cancel-qty", ctrl.saveCancelQty);
|
||||
router.post("/:id/copy", ctrl.copyOrder);
|
||||
router.post("/:id/amaranth-approval", ctrl.startApproval);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -89,10 +89,12 @@ const STATEMENTS: string[] = [
|
||||
|
||||
// ── AMARANTH_APPROVAL : Wehago/Amaranth SSO 매핑 ────────────────
|
||||
// 우리 시스템 결재(approval.objid) ↔ 아마란스 docId / approKey 연결
|
||||
// target_objid: wace 운영은 VARCHAR — `T.OBJID::VARCHAR = AMR_ORDER.TARGET_OBJID` (contractMgmt.xml:662)
|
||||
// vexplor_rps contract_mgmt.objid가 varchar(`CM-...`) 형식이라 cast 불가 → wace와 동일하게 VARCHAR로 유지
|
||||
`CREATE TABLE IF NOT EXISTS amaranth_approval (
|
||||
objid BIGINT PRIMARY KEY,
|
||||
approval_objid BIGINT,
|
||||
target_objid BIGINT,
|
||||
target_objid VARCHAR(80),
|
||||
target_type VARCHAR(50),
|
||||
appro_key VARCHAR(120), -- 외부시스템 연동키 ('UB_' + UUID)
|
||||
out_process_code VARCHAR(80), -- 아마란스 결재연동코드
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
// ============================================================
|
||||
// 프로젝트관리 > 진행관리 (wace_plm 도메인 이식)
|
||||
// 메인 화면: wace `/project/projectMgmtWbsList3.do` (= 운영판 RPS 진행관리)
|
||||
// 그리드 SQL: wace_plm/src/com/pms/mapper/project.xml:3854 projectMgmtWbsGridList
|
||||
// JSP 원본: wace_plm/WebContent/WEB-INF/view/project/projectMgmtWbsList3.jsp
|
||||
//
|
||||
// 1:1 이식 + RPS 매핑 변경:
|
||||
// · wace의 CLIENT_MNG/SUPPLY_MNG CASE WHEN LIKE 'C_%' 분기 → RPS는 customer_mng 단일 LEFT JOIN
|
||||
// · CODE_NAME() 함수는 RPS DB 보유 — 그대로 사용
|
||||
// · PMS_WBS_TASK/SETUP_WBS_TASK 의존 컬럼은 그리드 표시 컬럼에 없으므로 미반영 (P2)
|
||||
// ============================================================
|
||||
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
export interface ProgressListFilter {
|
||||
Year?: string; // 년도 (TO_CHAR(REGDATE,'YYYY'))
|
||||
project_nos?: string; // wace project_nos (쉼표 직렬화 OBJID — 다중 또는 단일)
|
||||
category_cd?: string;
|
||||
customer_objid?: string;
|
||||
product?: string;
|
||||
status_cd?: string;
|
||||
result_cd?: string;
|
||||
contract_start_date?: string; // 요청납기 시작
|
||||
contract_end_date?: string; // 요청납기 끝
|
||||
area_cd?: string; // '국내'/'해외' 라벨로 비교 (wace 1:1)
|
||||
free_of_charge?: string; // '유상'/'무상' 라벨로 비교
|
||||
// 영업관리 패턴(search_partObjId 단일) 통일 — wace의 product_item_code/_name LIKE 두 필드는 부재화
|
||||
search_partObjId?: string; // part_mng.objid::varchar = project_mgmt.part_objid
|
||||
serial_no?: string; // contract_item_serial EXISTS
|
||||
pm_user_id?: string; // (검색폼 주석처리되어 있으나 SQL은 받음)
|
||||
location?: string;
|
||||
setup?: string;
|
||||
}
|
||||
|
||||
// ─── 목록: 운영판 projectMgmtWbsGridList 1:1 ──────────────────
|
||||
|
||||
export async function listProgress(filter: ProgressListFilter) {
|
||||
const pool = getPool();
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
// wace L4187: Year — TO_CHAR(REGDATE,'YYYY')
|
||||
if (filter.Year) {
|
||||
conditions.push(`TO_CHAR(T.REGDATE,'YYYY') = $${idx++}`);
|
||||
params.push(filter.Year);
|
||||
}
|
||||
|
||||
// wace L4195: project_nos 쉼표 split → OBJID IN
|
||||
if (filter.project_nos) {
|
||||
const list = filter.project_nos.split(",").map(s => s.trim()).filter(Boolean);
|
||||
if (list.length > 0) {
|
||||
const placeholders = list.map(() => `$${idx++}`).join(",");
|
||||
conditions.push(`T.OBJID IN (${placeholders})`);
|
||||
params.push(...list);
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.category_cd) { conditions.push(`T.CATEGORY_CD = $${idx++}`); params.push(filter.category_cd); }
|
||||
if (filter.customer_objid) { conditions.push(`T.CUSTOMER_OBJID = $${idx++}`); params.push(filter.customer_objid); }
|
||||
if (filter.product) { conditions.push(`T.PRODUCT = $${idx++}`); params.push(filter.product); }
|
||||
if (filter.status_cd) { conditions.push(`T.STATUS_CD = $${idx++}`); params.push(filter.status_cd); }
|
||||
if (filter.result_cd) { conditions.push(`T.RESULT_CD = $${idx++}`); params.push(filter.result_cd); }
|
||||
|
||||
// wace L4224-4237: 요청납기 범위 — COALESCE(contract_item.due_date, project_mgmt.due_date, contract_mgmt.due_date)
|
||||
const dueDateCoalesce = `COALESCE(
|
||||
(SELECT CI.DUE_DATE FROM contract_item CI WHERE CI.contract_objid = T.CONTRACT_OBJID AND CI.part_objid = T.PART_OBJID AND CI.status = 'ACTIVE' ORDER BY CI.objid DESC LIMIT 1),
|
||||
T.DUE_DATE,
|
||||
(SELECT CM.due_date FROM contract_mgmt CM WHERE CM.OBJID = T.CONTRACT_OBJID LIMIT 1)
|
||||
)`;
|
||||
if (filter.contract_start_date) {
|
||||
conditions.push(`TO_DATE(${dueDateCoalesce},'YYYY-MM-DD') >= TO_DATE($${idx++},'YYYY-MM-DD')`);
|
||||
params.push(filter.contract_start_date);
|
||||
}
|
||||
if (filter.contract_end_date) {
|
||||
conditions.push(`TO_DATE(${dueDateCoalesce},'YYYY-MM-DD') <= TO_DATE($${idx++},'YYYY-MM-DD')`);
|
||||
params.push(filter.contract_end_date);
|
||||
}
|
||||
|
||||
if (filter.pm_user_id) { conditions.push(`T.PM_USER_ID = $${idx++}`); params.push(filter.pm_user_id); }
|
||||
if (filter.location) { conditions.push(`UPPER(T.LOCATION) LIKE UPPER($${idx++})`); params.push(`%${filter.location}%`); }
|
||||
if (filter.setup) { conditions.push(`UPPER(T.SETUP) LIKE UPPER($${idx++})`); params.push(`%${filter.setup}%`); }
|
||||
|
||||
// wace L4249: area_cd — CODE_NAME(area_cd) 라벨 비교 ('국내'/'해외')
|
||||
if (filter.area_cd) {
|
||||
conditions.push(`CODE_NAME(T.AREA_CD) = $${idx++}`);
|
||||
params.push(filter.area_cd);
|
||||
}
|
||||
|
||||
// wace L4253: free_of_charge — contract_mgmt.paid_type → '유상'/'무상' 매핑 비교
|
||||
if (filter.free_of_charge) {
|
||||
conditions.push(`(SELECT
|
||||
CASE
|
||||
WHEN O.PAID_TYPE = 'paid' THEN '유상'
|
||||
WHEN O.PAID_TYPE = 'free' THEN '무상'
|
||||
ELSE O.PAID_TYPE
|
||||
END
|
||||
FROM contract_mgmt AS O WHERE O.OBJID = T.CONTRACT_OBJID) = $${idx++}`);
|
||||
params.push(filter.free_of_charge);
|
||||
}
|
||||
|
||||
// 영업관리 패턴 통일: search_partObjId 단일 (PartSelect의 part_mng.objid 값 매칭)
|
||||
// wace 매퍼는 product_item_code/_name LIKE 텍스트 검색이지만 RPS는 part_objid 직접 매칭.
|
||||
if (filter.search_partObjId) {
|
||||
conditions.push(`T.PART_OBJID = $${idx++}`);
|
||||
params.push(filter.search_partObjId);
|
||||
}
|
||||
// wace L4271: serial_no — EXISTS contract_item_serial
|
||||
if (filter.serial_no) {
|
||||
conditions.push(`EXISTS (
|
||||
SELECT 1 FROM contract_item_serial CIS
|
||||
WHERE CIS.item_objid::varchar = T.CONTRACT_ITEM_OBJID
|
||||
AND UPPER(CIS.status) = 'ACTIVE'
|
||||
AND UPPER(CIS.serial_no) LIKE UPPER($${idx++})
|
||||
)`);
|
||||
params.push(`%${filter.serial_no}%`);
|
||||
}
|
||||
|
||||
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
// wace projectMgmtWbsGridList 활성 본문 1:1 (그리드 표시 18셀 + 필수 부속)
|
||||
const sql = `
|
||||
SELECT
|
||||
T.OBJID
|
||||
,T.CATEGORY_CD
|
||||
,CODE_NAME(T.CATEGORY_CD) AS CATEGORY_NAME
|
||||
,T.CUSTOMER_OBJID
|
||||
,C.customer_name AS CUSTOMER_NAME
|
||||
,T.PRODUCT
|
||||
,CODE_NAME(T.PRODUCT) AS PRODUCT_NAME
|
||||
,T.STATUS_CD
|
||||
,CODE_NAME(T.STATUS_CD) AS STATUS_NAME
|
||||
,T.RESULT_CD
|
||||
,CODE_NAME(T.RESULT_CD) AS RESULT_NAME
|
||||
,T.DUE_DATE
|
||||
,T.LOCATION
|
||||
,T.SETUP
|
||||
,T.FACILITY
|
||||
,CODE_NAME(T.FACILITY) AS FACILITY_NAME
|
||||
,T.FACILITY_QTY
|
||||
,T.FACILITY_TYPE
|
||||
,T.FACILITY_DEPTH
|
||||
,T.PROJECT_NO
|
||||
,T.PM_USER_ID
|
||||
,(SELECT user_name FROM user_info WHERE user_id = (SELECT pm_user_id FROM contract_mgmt WHERE OBJID = T.CONTRACT_OBJID)) AS PM_USER_NAME
|
||||
,T.CONTRACT_PRICE
|
||||
,T.CONTRACT_PRICE_CURRENCY
|
||||
,T.CONTRACT_CURRENCY
|
||||
,CODE_NAME(T.CONTRACT_CURRENCY) AS CONTRACT_CURRENCY_NAME
|
||||
,T.REGDATE
|
||||
,TO_CHAR(T.REGDATE,'YYYY-MM-DD') AS REG_DATE
|
||||
,T.WRITER
|
||||
,(SELECT user_name FROM user_info WHERE user_id = T.WRITER) AS WRITER_NAME
|
||||
,(SELECT COUNT(1) FROM attach_file_info WHERE target_objid = T.OBJID AND doc_type='contractMgmt01' AND UPPER(status)='ACTIVE') AS CU01_CNT
|
||||
,(SELECT COUNT(1) FROM attach_file_info WHERE target_objid = T.OBJID AND doc_type='contractMgmt02' AND UPPER(status)='ACTIVE') AS CU02_CNT
|
||||
,T.CONTRACT_NO
|
||||
,T.CUSTOMER_EQUIP_NAME
|
||||
,T.CONTRACT_DEL_DATE
|
||||
,T.CONTRACT_COMPANY
|
||||
,CODE_NAME(T.CONTRACT_COMPANY) AS CONTRACT_COMPANY_NAME
|
||||
,T.CONTRACT_DATE
|
||||
,T.PO_NO
|
||||
,T.MANUFACTURE_PLANT
|
||||
,CODE_NAME(T.MANUFACTURE_PLANT) AS MANUFACTURE_PLANT_NAME
|
||||
,T.CONTRACT_RESULT
|
||||
,CODE_NAME(T.CONTRACT_RESULT) AS CONTRACT_RESULT_NAME
|
||||
,CODE_NAME(T.AREA_CD) AS AREA_NAME
|
||||
,T.PROJECT_NAME
|
||||
|
||||
-- 운영판 그리드 핵심 컬럼들
|
||||
,(SELECT CASE
|
||||
WHEN O.PAID_TYPE = 'paid' THEN '유상'
|
||||
WHEN O.PAID_TYPE = 'free' THEN '무상'
|
||||
ELSE O.PAID_TYPE
|
||||
END
|
||||
FROM contract_mgmt AS O WHERE O.OBJID = T.CONTRACT_OBJID) AS FREE_OF_CHARGE
|
||||
,COALESCE(NULLIF(T.QUANTITY,'')::numeric, 0) AS CONTRACT_QTY
|
||||
,T.PART_NO AS PRODUCT_ITEM_CODE
|
||||
,T.PART_NAME AS PRODUCT_ITEM_NAME
|
||||
-- S/N: contract_item_serial (CIS.item_objid = T.contract_item_objid)
|
||||
,(SELECT
|
||||
CASE
|
||||
WHEN COUNT(*) = 0 THEN ''
|
||||
WHEN COUNT(*) = 1 THEN MIN(CIS.serial_no)
|
||||
ELSE MIN(CIS.serial_no) || ' 외 ' || (COUNT(*) - 1)::text || '건'
|
||||
END
|
||||
FROM contract_item_serial CIS
|
||||
WHERE CIS.item_objid::varchar = T.CONTRACT_ITEM_OBJID
|
||||
AND UPPER(CIS.status) = 'ACTIVE'
|
||||
AND CIS.serial_no IS NOT NULL
|
||||
AND CIS.serial_no != ''
|
||||
) AS SERIAL_NO
|
||||
-- 요청납기: contract_item.due_date → project_mgmt.due_date → contract_mgmt.due_date
|
||||
,COALESCE(
|
||||
(SELECT CI.due_date FROM contract_item CI
|
||||
WHERE CI.contract_objid = T.CONTRACT_OBJID
|
||||
AND CI.part_objid = T.PART_OBJID
|
||||
AND CI.status = 'ACTIVE'
|
||||
ORDER BY CI.objid DESC LIMIT 1),
|
||||
T.DUE_DATE,
|
||||
(SELECT CM.due_date FROM contract_mgmt CM WHERE CM.OBJID = T.CONTRACT_OBJID LIMIT 1)
|
||||
) AS REQ_DEL_DATE
|
||||
,T.EBOM_STATUS
|
||||
,T.MBOM_STATUS
|
||||
-- 발주일: contract_mgmt.order_date
|
||||
,(SELECT O.order_date FROM contract_mgmt AS O WHERE O.OBJID = T.CONTRACT_OBJID) AS ORDER_DATE
|
||||
,T.RECEIVING_RATE
|
||||
,T.PRODUCTION_TEAM_12
|
||||
,T.PRODUCTION_TEAM_3
|
||||
-- 출하일: sales_registration.shipping_date (project_no 매칭)
|
||||
,COALESCE(
|
||||
(SELECT TO_CHAR(SR.shipping_date, 'YYYY-MM-DD')
|
||||
FROM sales_registration SR
|
||||
WHERE SR.project_no = T.PROJECT_NO
|
||||
ORDER BY SR.sale_no DESC LIMIT 1),
|
||||
''
|
||||
) AS SHIPMENT_DATE
|
||||
,T.MECHANICAL_TYPE
|
||||
,T.OVERHAUL_ORDER
|
||||
,T.CONTRACT_OBJID
|
||||
|
||||
-- P2: PMS_WBS_TASK 의존 컬럼들은 그리드 표시엔 없으므로 자리만
|
||||
,0 AS TOTAL_RATE
|
||||
,0 AS DESIGN_RATE
|
||||
,0 AS PURCHASE_RATE
|
||||
,0 AS PRODUCE_RATE
|
||||
,0 AS SELFINS_RATE
|
||||
,0 AS FINALINS_RATE
|
||||
,0 AS SHIP_RATE
|
||||
,0 AS SETUP_RATE
|
||||
,0 AS WBS_CNT
|
||||
,NULL::text AS ASSEMBLY
|
||||
,NULL::text AS VERIFICATION
|
||||
FROM project_mgmt T
|
||||
LEFT JOIN customer_mng C
|
||||
ON C.customer_code = CASE WHEN T.CUSTOMER_OBJID LIKE 'C_%' THEN substring(T.CUSTOMER_OBJID, 3) ELSE T.CUSTOMER_OBJID END
|
||||
${where}
|
||||
ORDER BY SUBSTRING(T.PROJECT_NO, POSITION('-' IN T.PROJECT_NO)+1) DESC,
|
||||
T.OVERHAUL_ORDER DESC NULLS LAST
|
||||
`;
|
||||
|
||||
const res = await pool.query(sql, params);
|
||||
logger.info("진행관리 목록 조회", { count: res.rowCount, filter });
|
||||
return res.rows;
|
||||
}
|
||||
|
||||
// ─── 옵션: 프로젝트번호 셀렉트박스용 ──────────────────────────
|
||||
// wace `common.getCusProjectNoList` 대응 — 진행관리 메뉴 검색폼 project_no 옵션 채움.
|
||||
|
||||
export async function listProjectNoOptions() {
|
||||
const pool = getPool();
|
||||
const sql = `
|
||||
SELECT OBJID AS value, PROJECT_NO AS label
|
||||
FROM project_mgmt
|
||||
WHERE PROJECT_NO IS NOT NULL AND PROJECT_NO != ''
|
||||
ORDER BY SUBSTRING(PROJECT_NO, POSITION('-' IN PROJECT_NO)+1) DESC
|
||||
`;
|
||||
const res = await pool.query(sql);
|
||||
return res.rows;
|
||||
}
|
||||
|
||||
// ─── 단건/수정: P1.5에서 영업관리 OrderRegistDialog 재사용 결정 시 제거 가능 ──
|
||||
|
||||
export async function getById(objid: string) {
|
||||
const pool = getPool();
|
||||
const sql = `
|
||||
SELECT
|
||||
T.OBJID
|
||||
,T.PROJECT_NO
|
||||
,T.PROJECT_NAME
|
||||
,T.FACILITY
|
||||
,CODE_NAME(T.FACILITY) AS FACILITY_NAME
|
||||
,T.FACILITY_QTY
|
||||
,T.FACILITY_DEPTH
|
||||
,T.CONTRACT_DEL_DATE
|
||||
,T.CONTRACT_CURRENCY
|
||||
,CODE_NAME(T.CONTRACT_CURRENCY) AS CONTRACT_CURRENCY_NAME
|
||||
,T.CONTRACT_PRICE
|
||||
,T.CONTRACT_PRICE_CURRENCY
|
||||
FROM project_mgmt T
|
||||
WHERE T.OBJID = $1
|
||||
LIMIT 1
|
||||
`;
|
||||
const res = await pool.query(sql, [objid]);
|
||||
return res.rows[0] ?? null;
|
||||
}
|
||||
|
||||
export interface ProjectUpdateBody {
|
||||
project_no?: string;
|
||||
project_name?: string;
|
||||
facility?: string;
|
||||
facility_qty?: string;
|
||||
facility_depth?: string;
|
||||
contract_del_date?: string;
|
||||
contract_currency?: string;
|
||||
contract_price_currency?: string;
|
||||
contract_price?: string;
|
||||
}
|
||||
|
||||
export async function updateProject(objid: string, body: ProjectUpdateBody) {
|
||||
const pool = getPool();
|
||||
const sql = `
|
||||
UPDATE project_mgmt
|
||||
SET FACILITY_QTY = $1
|
||||
,PROJECT_NO = $2
|
||||
,CONTRACT_PRICE = $3
|
||||
,CONTRACT_PRICE_CURRENCY = $4
|
||||
,CONTRACT_CURRENCY = $5
|
||||
,PROJECT_NAME = $6
|
||||
,FACILITY = $7
|
||||
,FACILITY_DEPTH = $8
|
||||
,CONTRACT_DEL_DATE = $9
|
||||
WHERE OBJID = $10
|
||||
`;
|
||||
const params = [
|
||||
body.facility_qty ?? "",
|
||||
body.project_no ?? "",
|
||||
body.contract_price ?? "",
|
||||
body.contract_price_currency ?? "",
|
||||
body.contract_currency ?? "",
|
||||
body.project_name ?? "",
|
||||
body.facility ?? "",
|
||||
body.facility_depth ?? "",
|
||||
body.contract_del_date ?? "",
|
||||
objid,
|
||||
];
|
||||
const res = await pool.query(sql, params);
|
||||
logger.info("진행관리 수정", { objid, rowCount: res.rowCount });
|
||||
return res.rowCount ?? 0;
|
||||
}
|
||||
@@ -4,9 +4,15 @@
|
||||
// 헤더 컨텍스트: contract_mgmt (영업번호 = contract_no)
|
||||
// ============================================================
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { PDFDocument } from "pdf-lib";
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
import { generateContractNo } from "./salesOrderMgmtService";
|
||||
import { sendMailUTF8 } from "../utils/mailUtil";
|
||||
import { AppError } from "../middleware/errorHandler";
|
||||
import * as amaranth from "./amaranthApprovalClient";
|
||||
import { generateContractNo, assertNoProjectExists } from "./salesOrderMgmtService";
|
||||
|
||||
// ─── 타입 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -158,9 +164,15 @@ export async function getList(filter: EstimateListFilter) {
|
||||
// 견적관리는 통합 직발(IS_DIRECT_ORDER='Y') 제외
|
||||
conditions.push(`COALESCE(T.IS_DIRECT_ORDER, 'N') != 'Y'`);
|
||||
|
||||
// 결재상태 필터 (한글 라벨 매칭)
|
||||
// 결재상태 필터 (한글 라벨 매칭) — SELECT 절 APPR_STATUS와 동일 CASE.
|
||||
// AMR은 동일 LEFT JOIN이 WHERE 단계 전에 평가되므로 직접 참조 가능.
|
||||
if (filter.appr_status) {
|
||||
conditions.push(`(CASE
|
||||
WHEN AMR.status = 'complete' THEN '결재완료'
|
||||
WHEN AMR.status = 'inProcess' THEN '결재중'
|
||||
WHEN AMR.status = 'reject' THEN '반려'
|
||||
WHEN AMR.status = 'create' THEN '작성중'
|
||||
WHEN AMR.status = 'notRequired' THEN '결재불필요'
|
||||
WHEN COALESCE(T.APPROVAL_REQUIRED, 'N') = 'N' THEN '결재불필요'
|
||||
ELSE ''
|
||||
END) = $${idx++}`);
|
||||
@@ -244,15 +256,19 @@ export async function getList(filter: EstimateListFilter) {
|
||||
/* 메일 발송 정보 (mail_log: TITLE의 [OBJID:NN]에서 OBJID 매칭) */
|
||||
,ML.mail_send_status AS MAIL_SEND_STATUS
|
||||
,ML.mail_send_date AS MAIL_SEND_DATE
|
||||
/* 결재상태 (vexplor_rps에 amaranth_approval 미도입 — approval_required='N'만 우선 처리) */
|
||||
/* 결재상태 (wace contractMgmt.xml:513~522 1:1 — CONTRACT_ESTIMATE 매핑) */
|
||||
,CASE
|
||||
WHEN AMR.status = 'complete' THEN '결재완료'
|
||||
WHEN AMR.status = 'inProcess' THEN '결재중'
|
||||
WHEN AMR.status = 'reject' THEN '반려'
|
||||
WHEN AMR.status = 'create' THEN '작성중'
|
||||
WHEN AMR.status = 'notRequired' THEN '결재불필요'
|
||||
WHEN COALESCE(T.APPROVAL_REQUIRED, 'N') = 'N' THEN '결재불필요'
|
||||
ELSE ''
|
||||
END AS APPR_STATUS
|
||||
,CASE
|
||||
WHEN COALESCE(T.APPROVAL_REQUIRED, 'N') = 'N' THEN 'notRequired'
|
||||
ELSE ''
|
||||
END AS AMARANTH_STATUS
|
||||
,COALESCE(AMR.status,
|
||||
CASE WHEN COALESCE(T.APPROVAL_REQUIRED, 'N') = 'N' THEN 'notRequired' ELSE '' END
|
||||
) AS AMARANTH_STATUS
|
||||
/* 추가견적/주문서 첨부 카운트 (attach_file_info DOC_TYPE 기반) */
|
||||
,COALESCE(AF.add_est_cnt, 0) AS ADD_EST_CNT
|
||||
,COALESCE(AF.cu01_cnt, 0) AS CU01_CNT
|
||||
@@ -280,6 +296,10 @@ export async function getList(filter: EstimateListFilter) {
|
||||
ORDER BY regdate DESC
|
||||
LIMIT 1
|
||||
) ET ON true
|
||||
/* 아마란스 결재 — 견적 (target_objid = 최신 차수 estimate_template.objid) */
|
||||
LEFT JOIN amaranth_approval AMR
|
||||
ON AMR.target_objid = ET.objid::VARCHAR
|
||||
AND AMR.target_type = 'CONTRACT_ESTIMATE'
|
||||
/* 견적 라인 수량 합계 */
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(SUM(NULLIF(replace(quantity, ',', ''), '')::numeric), 0) AS estimate_quantity
|
||||
@@ -552,10 +572,13 @@ export async function update(userId: string, objid: string, body: EstimateBody)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 메일 발송 이력 등록 ──────────────────────────────────────
|
||||
// 실제 SMTP 발송은 mailSendSimpleService(별도 인프라) 연동 시 추가.
|
||||
// 1차에서는 mail_log INSERT만 수행 → 그리드 mail_send_status/date 컬럼 표시.
|
||||
// title에 [OBJID:nnn] 패턴을 포함시켜 LEFT JOIN의 SUBSTRING 매칭과 호환.
|
||||
// ─── 메일 발송 (G6) ───────────────────────────────────────────
|
||||
// wace ContractMgmtService.sendEstimateMailCustom (line 3582) 1:1.
|
||||
// - 본문(contents)는 다이얼로그 입력 그대로 사용 (HTML 견적 양식 본문 생성기는 미이식 — 양식은 PDF 첨부에 포함됨)
|
||||
// - 첨부: 기본 견적 PDF (프론트 hidden iframe에서 base64 생성 후 전달) + estimate02 N건을 pdf-lib로 합본
|
||||
// - useAddEstOnly='Y': 견적 PDF 생략, estimate02만 첨부 (수가 0이면 에러)
|
||||
// - mail_log title에 [OBJID:nnn] 토큰 부착 (그리드 LEFT JOIN의 LIKE 매칭과 호환)
|
||||
// - SMTP 계정: SALES (Constants.Mail.ACCOUNT_TYPE_SALES)
|
||||
|
||||
export interface EstimateMailBody {
|
||||
contractObjid: string;
|
||||
@@ -563,53 +586,283 @@ export interface EstimateMailBody {
|
||||
ccEmails?: string;
|
||||
subject: string;
|
||||
contents: string;
|
||||
isSend?: "Y" | "N"; // 실제 SMTP 발송 결과 (생략 시 Y로 기록 — UI 단계에서 결과 반영)
|
||||
/** 프론트에서 hidden iframe으로 template1/2 페이지 렌더 후 html2canvas+jsPDF로 생성한 base64 (data URL 또는 raw base64) */
|
||||
pdfBase64?: string;
|
||||
/** 'Y'면 견적 PDF 생성 스킵하고 estimate02만 첨부 */
|
||||
useAddEstOnly?: "Y" | "N";
|
||||
}
|
||||
|
||||
export async function sendMail(userId: string, body: EstimateMailBody) {
|
||||
// ─── 메일 발송 helper SQL ─────────────────────────────────────
|
||||
|
||||
/** wace getContractInfoForMail — 메일 다이얼로그 자동채움용 (제목/수신/참조) */
|
||||
export async function getContractInfoForMail(contractObjid: string) {
|
||||
const pool = getPool();
|
||||
const objid = genVarcharObjid("ML");
|
||||
const titleWithTag = body.subject.includes("[OBJID:")
|
||||
? body.subject
|
||||
: `${body.subject} [OBJID:${body.contractObjid}]`;
|
||||
const sql = `
|
||||
SELECT
|
||||
cm.objid AS contract_objid,
|
||||
cm.contract_no AS contract_no,
|
||||
cm.customer_objid AS customer_objid,
|
||||
cm.writer AS writer,
|
||||
c.id AS customer_pk,
|
||||
c.customer_name AS customer_name,
|
||||
c.email AS customer_email,
|
||||
uw.email AS writer_email,
|
||||
uw.user_name AS writer_name
|
||||
FROM contract_mgmt cm
|
||||
LEFT JOIN customer_mng c
|
||||
ON c.customer_code = CASE
|
||||
WHEN cm.customer_objid LIKE 'C_%' THEN substring(cm.customer_objid, 3)
|
||||
ELSE cm.customer_objid
|
||||
END
|
||||
LEFT JOIN user_info uw ON uw.user_id = cm.writer
|
||||
WHERE cm.objid = $1
|
||||
LIMIT 1`;
|
||||
const r = await pool.query(sql, [contractObjid]);
|
||||
return r.rows[0] ?? null;
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO mail_log (
|
||||
objid, system_name, send_user_id, from_addr,
|
||||
reception_user_id, receiver_to,
|
||||
title, contents, log_time, is_send, mail_type
|
||||
) VALUES (
|
||||
$1, 'vexplor_rps', $2, NULL,
|
||||
NULL, $3,
|
||||
$4, $5, NOW(), $6, 'CONTRACT_ESTIMATE'
|
||||
)`,
|
||||
[
|
||||
objid,
|
||||
userId,
|
||||
body.toEmails + (body.ccEmails ? `; cc: ${body.ccEmails}` : ""),
|
||||
titleWithTag,
|
||||
body.contents,
|
||||
body.isSend ?? "Y",
|
||||
],
|
||||
/** wace getCustomerManagerList — 메일 다이얼로그 담당자 체크박스 리스트용.
|
||||
* RPS는 wace manager1~5 슬롯 패턴이 아니라 customer_contact 테이블 N건 구조.
|
||||
*/
|
||||
export async function getCustomerManagerList(customerObjid: string) {
|
||||
const pool = getPool();
|
||||
// customer_objid (C_xxxx) → customer_mng.customer_code → customer_mng.id → customer_contact.customer_id
|
||||
const code = customerObjid.startsWith("C_") ? customerObjid.substring(2) : customerObjid;
|
||||
const sql = `
|
||||
SELECT
|
||||
cc.contact_name AS name,
|
||||
cc.contact_email AS email,
|
||||
cc.contact_phone AS phone,
|
||||
cc.department AS department,
|
||||
cc.is_main AS is_main
|
||||
FROM customer_contact cc
|
||||
JOIN customer_mng cm ON cm.id::text = cc.customer_id::text
|
||||
WHERE cm.customer_code = $1
|
||||
AND COALESCE(cc.contact_name, '') <> ''
|
||||
ORDER BY (CASE WHEN cc.is_main = 'Y' THEN 0 ELSE 1 END), cc.contact_name`;
|
||||
const r = await pool.query(sql, [code]);
|
||||
return r.rows;
|
||||
}
|
||||
|
||||
/** wace getLatestEstimateTemplate — 최신 차수 견적서 */
|
||||
export async function getLatestEstimateTemplate(contractObjid: string) {
|
||||
const pool = getPool();
|
||||
const r = await pool.query(
|
||||
`SELECT * FROM estimate_template
|
||||
WHERE contract_objid = $1
|
||||
ORDER BY regdate DESC
|
||||
LIMIT 1`,
|
||||
[contractObjid],
|
||||
);
|
||||
return r.rows[0] ?? null;
|
||||
}
|
||||
|
||||
logger.info("견적 메일 발송 이력 등록", {
|
||||
objid,
|
||||
contractObjid: body.contractObjid,
|
||||
to: body.toEmails,
|
||||
// ─── 메일 발송 본체 ───────────────────────────────────────────
|
||||
|
||||
export async function sendMail(userId: string, body: EstimateMailBody) {
|
||||
const useAddEstOnly = body.useAddEstOnly === "Y";
|
||||
|
||||
// 1. 계약 정보 조회 (수신/참조 fallback + 자동 cc: writer)
|
||||
const contractInfo = await getContractInfoForMail(body.contractObjid);
|
||||
if (!contractInfo) {
|
||||
return { success: false, message: "계약 정보를 찾을 수 없습니다." };
|
||||
}
|
||||
|
||||
// 2. 최신 차수 견적서 (useAddEstOnly='Y'면 생략 가능)
|
||||
const estimateTemplate = useAddEstOnly ? null : await getLatestEstimateTemplate(body.contractObjid);
|
||||
if (!useAddEstOnly && !estimateTemplate) {
|
||||
return { success: false, message: "견적서를 찾을 수 없습니다." };
|
||||
}
|
||||
|
||||
// 3. 수신/참조 정리 (쉼표·세미콜론 모두 split)
|
||||
const splitEmails = (s: string | undefined) => (s || "")
|
||||
.split(/[;,]/)
|
||||
.map((e) => e.trim())
|
||||
.filter((e) => e !== "");
|
||||
|
||||
const toEmails = splitEmails(body.toEmails);
|
||||
if (toEmails.length === 0) {
|
||||
return { success: false, message: "수신인 이메일이 없습니다." };
|
||||
}
|
||||
|
||||
const ccEmails = splitEmails(body.ccEmails);
|
||||
// 작성자 이메일 자동 추가 (wace sendEstimateMailCustom과 동일)
|
||||
if (contractInfo.writer_email && !ccEmails.includes(contractInfo.writer_email)) {
|
||||
ccEmails.push(contractInfo.writer_email);
|
||||
}
|
||||
|
||||
// 4. 첨부 PDF 준비
|
||||
const attachments: { filename: string; content: Buffer; contentType: string }[] = [];
|
||||
|
||||
// 4-1. estimate02 (추가견적) 파일 목록 조회
|
||||
const addEstFiles = await fetchEstimate02Files(body.contractObjid);
|
||||
|
||||
if (useAddEstOnly) {
|
||||
// 추가견적만 발송 — 견적 PDF 없이 estimate02 파일들을 그대로 첨부
|
||||
if (addEstFiles.length === 0) {
|
||||
return { success: false, message: "발송할 추가견적 PDF가 없습니다." };
|
||||
}
|
||||
for (const f of addEstFiles) {
|
||||
const buf = readUploadFileBuffer(f.file_path, f.saved_file_name);
|
||||
if (buf) {
|
||||
attachments.push({
|
||||
filename: f.real_file_name || f.saved_file_name,
|
||||
content: buf,
|
||||
contentType: "application/pdf",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (attachments.length === 0) {
|
||||
return { success: false, message: "추가견적 PDF 파일을 디스크에서 찾을 수 없습니다." };
|
||||
}
|
||||
} else {
|
||||
// 견적 PDF (프론트 base64) + estimate02 합본
|
||||
if (!body.pdfBase64) {
|
||||
return { success: false, message: "견적 PDF가 전달되지 않았습니다." };
|
||||
}
|
||||
const basePdfBuf = decodeBase64Pdf(body.pdfBase64);
|
||||
|
||||
// estimate02 파일들을 같이 받아 PDF 합본
|
||||
const addEstBufs: Buffer[] = [];
|
||||
for (const f of addEstFiles) {
|
||||
const buf = readUploadFileBuffer(f.file_path, f.saved_file_name);
|
||||
if (buf) addEstBufs.push(buf);
|
||||
}
|
||||
|
||||
let finalPdf: Buffer;
|
||||
if (addEstBufs.length > 0) {
|
||||
try {
|
||||
finalPdf = await mergePdfBuffers([basePdfBuf, ...addEstBufs]);
|
||||
logger.info("PDF 합본 완료", { contractObjid: body.contractObjid, addEstCount: addEstBufs.length });
|
||||
} catch (mergeErr) {
|
||||
logger.warn("PDF 합본 실패 — 견적서만 첨부", { error: (mergeErr as Error).message });
|
||||
finalPdf = basePdfBuf;
|
||||
}
|
||||
} else {
|
||||
finalPdf = basePdfBuf;
|
||||
}
|
||||
|
||||
const estimateNo = (estimateTemplate?.estimate_no || "견적서").toString().replace(/[^\w가-힣\-_.]/g, "_");
|
||||
const ts = new Date().toISOString().replace(/[-:.TZ]/g, "").substring(0, 14);
|
||||
attachments.push({
|
||||
filename: `${estimateNo}_${ts}.pdf`,
|
||||
content: finalPdf,
|
||||
contentType: "application/pdf",
|
||||
});
|
||||
}
|
||||
|
||||
// 5. mail_log title에 [OBJID:nnn] 토큰 부착 (그리드 LEFT JOIN과 호환)
|
||||
const subject = body.subject.trim();
|
||||
const subjectForLog = subject.includes("[OBJID:")
|
||||
? subject
|
||||
: `${subject} [OBJID:${body.contractObjid}]`;
|
||||
|
||||
// 6. HTML 본문 (다이얼로그 plain text → <br> 변환)
|
||||
const html = textToHtml(body.contents);
|
||||
|
||||
// 7. SMTP 발송 (SALES 계정)
|
||||
const result = await sendMailUTF8({
|
||||
accountType: "SALES",
|
||||
fromUserId: userId,
|
||||
toEmails,
|
||||
ccEmails: ccEmails.length > 0 ? ccEmails : undefined,
|
||||
subject,
|
||||
subjectForLog,
|
||||
html,
|
||||
attachments,
|
||||
mailType: "CONTRACT_ESTIMATE",
|
||||
});
|
||||
return { objid };
|
||||
|
||||
logger.info("견적 메일 발송 요청 완료", {
|
||||
contractObjid: body.contractObjid,
|
||||
mailLogObjid: result.objid,
|
||||
sent: result.sent,
|
||||
to: toEmails,
|
||||
cc: ccEmails,
|
||||
attachmentCount: attachments.length,
|
||||
});
|
||||
|
||||
if (!result.sent) {
|
||||
return {
|
||||
success: false,
|
||||
message: result.error || "메일 발송에 실패했습니다.",
|
||||
objid: result.objid,
|
||||
};
|
||||
}
|
||||
return { success: true, message: "견적서가 성공적으로 발송되었습니다.", objid: result.objid };
|
||||
}
|
||||
|
||||
// ─── 메일 발송 내부 helper ────────────────────────────────────
|
||||
|
||||
async function fetchEstimate02Files(contractObjid: string) {
|
||||
const pool = getPool();
|
||||
const r = await pool.query(
|
||||
`SELECT objid, real_file_name, saved_file_name, file_path
|
||||
FROM attach_file_info
|
||||
WHERE target_objid = $1
|
||||
AND doc_type = 'estimate02'
|
||||
AND UPPER(status) = 'ACTIVE'
|
||||
ORDER BY regdate ASC`,
|
||||
[contractObjid],
|
||||
);
|
||||
return r.rows;
|
||||
}
|
||||
|
||||
function decodeBase64Pdf(input: string): Buffer {
|
||||
// data URL prefix(`data:application/pdf;base64,...`) 또는 raw base64 둘 다 수용
|
||||
const comma = input.indexOf(",");
|
||||
const raw = input.startsWith("data:") && comma !== -1 ? input.substring(comma + 1) : input;
|
||||
return Buffer.from(raw, "base64");
|
||||
}
|
||||
|
||||
function readUploadFileBuffer(filePath: string | null, savedFileName: string | null): Buffer | null {
|
||||
if (!filePath || !savedFileName) return null;
|
||||
// attach_file_info.file_path는 보통 `/uploads/.../...` 형식.
|
||||
// fileController의 해석 패턴과 동일하게 `/uploads/` 이후를 cwd/uploads와 결합.
|
||||
const idx = filePath.indexOf("/uploads/");
|
||||
const relative = idx !== -1 ? filePath.substring(idx + "/uploads/".length) : filePath;
|
||||
const baseUpload = path.join(process.cwd(), "uploads");
|
||||
const fullPath = path.join(baseUpload, relative, savedFileName);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
logger.warn("첨부 파일 없음 — 스킵", { fullPath });
|
||||
return null;
|
||||
}
|
||||
return fs.readFileSync(fullPath);
|
||||
}
|
||||
|
||||
async function mergePdfBuffers(buffers: Buffer[]): Promise<Buffer> {
|
||||
const merged = await PDFDocument.create();
|
||||
for (const buf of buffers) {
|
||||
const src = await PDFDocument.load(buf, { ignoreEncryption: true });
|
||||
const pages = await merged.copyPages(src, src.getPageIndices());
|
||||
for (const p of pages) merged.addPage(p);
|
||||
}
|
||||
const bytes = await merged.save();
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
|
||||
function textToHtml(text: string): string {
|
||||
// wace는 textarea contents를 그대로 HTML body로 사용 (\n은 줄바꿈으로 보이게 <br> 변환)
|
||||
const escaped = text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
return `<html><body><div style="font-family:'Malgun Gothic','맑은 고딕',Arial,sans-serif;font-size:13px;line-height:1.6;white-space:pre-wrap;">${escaped.replace(/\n/g, "<br>")}</div></body></html>`;
|
||||
}
|
||||
|
||||
// ─── 삭제 ─────────────────────────────────────────────────────
|
||||
// 시리얼 → 라인 → 헤더(contract_mgmt) 순.
|
||||
// (G8 — 프로젝트 존재 시 삭제 방지는 별도 PR)
|
||||
// G8 — 프로젝트가 생성된 건은 삭제 차단 (wace ContractMgmtService.deleteContractMngInfo:794-808 동일).
|
||||
// helper는 project_mgmt 도메인 주인인 salesOrderMgmtService에 정의 (assertNoProjectExists).
|
||||
|
||||
export async function remove(objid: string) {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
// G8 guard
|
||||
await assertNoProjectExists(client, objid);
|
||||
await client.query(
|
||||
`UPDATE contract_item_serial SET status='INACTIVE'
|
||||
WHERE item_objid IN (SELECT objid FROM contract_item WHERE contract_objid=$1)`,
|
||||
@@ -933,6 +1186,152 @@ export async function getTemplateById(templateObjid: string) {
|
||||
return { ...header, items: itemsRes.rows };
|
||||
}
|
||||
|
||||
// ─── 결재상신 ────────────────────────────────────────────────
|
||||
//
|
||||
// G4/G11 동일 패턴 — 견적관리 결재상신 (Amaranth SSO).
|
||||
// wace estimateList_new.jsp:887 fn_openAmaranthApproval + ApprovalService.getAmaranthSsoUrl
|
||||
// target_type = 'CONTRACT_ESTIMATE'
|
||||
// target_objid = 최신 차수 estimate_template.objid (수주는 contract_mgmt.objid와 달리 차수 단위)
|
||||
// formId = '1162' (수주는 '1161')
|
||||
// compSeq = '1000'
|
||||
// amaranth_approval 신규/기존 매핑 분기:
|
||||
// - 없음: 신규 approKey 생성 + INSERT
|
||||
// - reject/delete/create: 새 approKey + UPDATE (재상신)
|
||||
// - inProcess/complete: 기존 approKey 재사용 (프론트에서 가드되지만 백엔드도 방어)
|
||||
// 첨부파일 원챔버 업로드(wace uploadEstimateFilesToOneChamber)는 미구현 — 영업 첨부 흐름 정리 후 연동.
|
||||
export async function startEstimateApproval(
|
||||
userId: string,
|
||||
contractObjid: string,
|
||||
opts: { approvalTitle?: string; subjectStr?: string } = {},
|
||||
): Promise<{ fullUrl: string; approKey: string; status: string; estObjid: string }> {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 1) 사용자 emp_seq 조회 (wace PersonBean.getEmpseq 동등)
|
||||
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 = String(u.emp_seq ?? "").trim();
|
||||
if (!empSeq) {
|
||||
throw new AppError(`empSeq 정보가 없습니다. (userId: ${userId}) 관리자에게 문의하세요.`, 400);
|
||||
}
|
||||
|
||||
// 2) contract_mgmt 헤더 + 최신 차수 estimate_template 조회
|
||||
// (wace estimateList_new.jsp:169 — estObjId 없으면 "견적서를 먼저 작성해주세요" 가드)
|
||||
const cmRes = await client.query(
|
||||
`SELECT cm.objid, cm.contract_no,
|
||||
(SELECT objid FROM estimate_template
|
||||
WHERE contract_objid = cm.objid
|
||||
ORDER BY regdate DESC LIMIT 1) AS est_objid
|
||||
FROM contract_mgmt cm
|
||||
WHERE cm.objid = $1`,
|
||||
[contractObjid],
|
||||
);
|
||||
if (cmRes.rowCount === 0) throw new AppError("견적을 찾을 수 없습니다.", 404);
|
||||
const { contract_no, est_objid } = cmRes.rows[0];
|
||||
if (!est_objid) {
|
||||
throw new AppError("견적서를 먼저 작성해주세요.", 400);
|
||||
}
|
||||
const targetObjid = String(est_objid);
|
||||
|
||||
const targetType = "CONTRACT_ESTIMATE";
|
||||
const approvalTitle = opts.approvalTitle || `견적서 결재${contract_no ? " - " + contract_no : ""}`;
|
||||
const outProcessCode =
|
||||
process.env.AMARANTH_OUT_PROCESS_CODE_CONTRACT_ESTIMATE ||
|
||||
process.env.AMARANTH_OUT_PROCESS_CODE || "";
|
||||
const formId = process.env.AMARANTH_FORM_ID_CONTRACT_ESTIMATE || "1162";
|
||||
const compSeq = process.env.AMARANTH_COMP_SEQ || "1000";
|
||||
|
||||
// 3) amaranth_approval 기존 매핑 조회 (wace selectAmaranthApprovalByTarget)
|
||||
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 (아마란스가 기존 approKey의 원챔버 첨부를 재사용하므로 수정 미반영 회피)
|
||||
approKey = "UB_" + Date.now().toString(36).toUpperCase();
|
||||
mode = "update_resubmit";
|
||||
} else {
|
||||
approKey = String(existRes.rows[0].appro_key || "");
|
||||
mode = "update_reuse";
|
||||
}
|
||||
}
|
||||
|
||||
// 4) SSO URL 발급 (chpark amaranthApprovalClient.getSsoUrl)
|
||||
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);
|
||||
}
|
||||
|
||||
// 5) amaranth_approval INSERT/UPDATE
|
||||
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 {
|
||||
// 재상신: approKey/sso_url/status 초기화, 재사용: sso_url만 갱신
|
||||
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("견적 결재상신", { contractObjid, contract_no, estObjid: targetObjid, approKey, mode });
|
||||
return { fullUrl, approKey, status: "create", estObjid: targetObjid };
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK");
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// 견적 차수 리스트 (wace estimateList.jsp fn_showEstimateList — contract_objid별 차수들)
|
||||
export async function listTemplatesByContract(contractObjid: string) {
|
||||
const pool = getPool();
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
import { AppError } from "../middleware/errorHandler";
|
||||
import * as amaranth from "./amaranthApprovalClient";
|
||||
|
||||
export interface OrderListFilter {
|
||||
category_cd?: string;
|
||||
@@ -181,8 +183,17 @@ export async function getList(filter: OrderListFilter) {
|
||||
WHEN COALESCE(SER_AGG.serial_count, 0) > 1 THEN SER_AGG.first_serial_no || ' 외 ' || (SER_AGG.serial_count - 1) || '건'
|
||||
ELSE T.SERIAL_NO
|
||||
END AS SERIAL_NO
|
||||
,NULL::text AS ORDER_APPR_STATUS
|
||||
,NULL::text AS AMARANTH_STATUS
|
||||
/* G4/G11 — 수주 결재상태 (wace contractMgmt.xml:523~530 1:1)
|
||||
target_type='CONTRACT_ORDER' amaranth_approval 매핑 결과를 한글 라벨/원본 상태로 노출 */
|
||||
,CASE
|
||||
WHEN AMR_ORDER.status = 'complete' THEN '결재완료'
|
||||
WHEN AMR_ORDER.status = 'inProcess' THEN '결재중'
|
||||
WHEN AMR_ORDER.status = 'reject' THEN '반려'
|
||||
WHEN AMR_ORDER.status = 'create' THEN '작성중'
|
||||
ELSE ''
|
||||
END AS ORDER_APPR_STATUS
|
||||
,COALESCE(AMR_ORDER.status, '') AS ORDER_AMARANTH_STATUS
|
||||
,COALESCE(AMR_ORDER.status, '') AS AMARANTH_STATUS
|
||||
,0 AS CU01_CNT
|
||||
,T.IS_DIRECT_ORDER AS IS_DIRECT_ORDER
|
||||
FROM contract_mgmt T
|
||||
@@ -224,6 +235,10 @@ export async function getList(filter: OrderListFilter) {
|
||||
WHERE CI.status='ACTIVE' AND CIS.serial_no IS NOT NULL AND CIS.serial_no != ''
|
||||
GROUP BY CI.contract_objid
|
||||
) SER_AGG ON SER_AGG.contract_objid = T.OBJID
|
||||
/* G4/G11 — 아마란스 결재(수주) — wace 1:1, target_objid는 VARCHAR */
|
||||
LEFT JOIN amaranth_approval AMR_ORDER
|
||||
ON AMR_ORDER.target_objid = T.OBJID
|
||||
AND AMR_ORDER.target_type = 'CONTRACT_ORDER'
|
||||
${where}
|
||||
ORDER BY T.regdate DESC
|
||||
`;
|
||||
@@ -519,12 +534,38 @@ export async function update(userId: string, objid: string, body: OrderBody) {
|
||||
} finally { client.release(); }
|
||||
}
|
||||
|
||||
// G8 — 프로젝트가 생성된 contract는 견적/주문 모두 삭제 차단.
|
||||
// wace deleteContractMngInfo:794-808 + mapper getProjectListBycontractObjid:3909 1:1.
|
||||
// 원본 매퍼는 status 필터 없이 단순 contract_objid 매칭 → 그대로 따름.
|
||||
// salesEstimateService.remove에서도 import해서 사용.
|
||||
export async function assertNoProjectExists(client: any, contractObjid: string) {
|
||||
const r = await client.query(
|
||||
`SELECT 1 FROM project_mgmt WHERE contract_objid=$1 LIMIT 1`,
|
||||
[contractObjid],
|
||||
);
|
||||
if (r.rowCount > 0) {
|
||||
throw new AppError("프로젝트가 생성된 건은 삭제할 수 없습니다.", 409);
|
||||
}
|
||||
}
|
||||
|
||||
export async function remove(objid: string) {
|
||||
const pool = getPool();
|
||||
await pool.query(`DELETE FROM contract_mgmt WHERE objid=$1`, [objid]);
|
||||
// contract_item / contract_item_serial은 ON DELETE CASCADE 이지만,
|
||||
// estimate_template (contract_objid 참조)는 FK가 없어 남을 수 있음 — 정책 결정 필요.
|
||||
logger.info("주문서 삭제", { objid });
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
// G8 guard
|
||||
await assertNoProjectExists(client, objid);
|
||||
await client.query(`DELETE FROM contract_mgmt WHERE objid=$1`, [objid]);
|
||||
await client.query("COMMIT");
|
||||
// contract_item / contract_item_serial은 ON DELETE CASCADE.
|
||||
// estimate_template (contract_objid 참조)는 FK 없어 남을 수 있음 — 별도 정책 결정 영역.
|
||||
logger.info("주문서 삭제", { objid });
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK");
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 수주확정 → 프로젝트 자동생성 (wace ContractMgmtService.updateOrderStatus 이식) ──
|
||||
@@ -795,6 +836,242 @@ export async function saveOrderCancelQty(userId: string, entries: CancelQtyEntry
|
||||
}
|
||||
}
|
||||
|
||||
// G9 — 수주복사 (wace copyEstimateAndOrderInfo 1:1)
|
||||
// contract_mgmt 헤더 + contract_item 라인 + contract_item_serial 통째로 새 영업번호로 복제.
|
||||
// 복사본은 contract_result='', is_direct_order='Y'로 시작 (주문관리에만 노출).
|
||||
// 매퍼 출처: contractMgmt.xml copyContractMgmt / copyContractItems / copyContractItemSerials / getNextContractNo.
|
||||
export async function copyOrder(userId: string, sourceObjid: string): Promise<{ newObjid: string; newContractNo: string }> {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 1. 새 영업번호 채번 — wace getNextContractNo 1:1 ({YY}C-{NNNN}, MAX+1)
|
||||
const numRes = await client.query(
|
||||
`SELECT TO_CHAR(NOW(),'YY') || 'C-' || LPAD((
|
||||
COALESCE(MAX(SUBSTRING(contract_no FROM 5)::integer), 0) + 1
|
||||
)::VARCHAR, 4, '0') AS contract_no
|
||||
FROM contract_mgmt
|
||||
WHERE contract_no LIKE TO_CHAR(NOW(),'YY') || 'C-%'`,
|
||||
);
|
||||
const newContractNo = numRes.rows[0].contract_no as string;
|
||||
const newObjid = genObjid("CM");
|
||||
|
||||
// 2. CONTRACT_MGMT 복사 (wace copyContractMgmt 1:1)
|
||||
const r = await client.query(
|
||||
`INSERT INTO contract_mgmt (
|
||||
objid, category_cd, customer_objid, product, area_cd,
|
||||
customer_equip_name, customer_project_name, customer_production_no, mechanical_type,
|
||||
paid_type, receipt_date, req_del_date, contract_result,
|
||||
po_no, order_date, contract_currency, exchange_rate,
|
||||
regdate, writer, contract_no, is_direct_order,
|
||||
order_supply_price, order_vat, order_total_amount
|
||||
)
|
||||
SELECT
|
||||
$1, category_cd, customer_objid, product, area_cd,
|
||||
customer_equip_name, customer_project_name, customer_production_no, mechanical_type,
|
||||
paid_type, receipt_date, req_del_date, '',
|
||||
po_no, order_date, contract_currency, exchange_rate,
|
||||
NOW(), $2, $3, 'Y',
|
||||
order_supply_price, order_vat, order_total_amount
|
||||
FROM contract_mgmt
|
||||
WHERE objid = $4`,
|
||||
[newObjid, userId, newContractNo, sourceObjid],
|
||||
);
|
||||
if (r.rowCount === 0) {
|
||||
throw new AppError("복사할 원본 주문이 없습니다.", 404);
|
||||
}
|
||||
|
||||
// 3. 원본 활성 라인 OBJID 목록 (wace getActiveItemObjIds)
|
||||
const itemsRes = await client.query(
|
||||
`SELECT objid FROM contract_item
|
||||
WHERE contract_objid=$1 AND status='ACTIVE'
|
||||
ORDER BY seq`,
|
||||
[sourceObjid],
|
||||
);
|
||||
|
||||
// 4. CONTRACT_ITEM + CONTRACT_ITEM_SERIAL 복사
|
||||
for (const row of itemsRes.rows) {
|
||||
const sourceItemObjid: string = row.objid;
|
||||
const newItemObjid = genObjid("CI");
|
||||
|
||||
// wace copyContractItems 1:1
|
||||
await client.query(
|
||||
`INSERT INTO contract_item (
|
||||
objid, contract_objid, seq, part_objid, part_no, part_name,
|
||||
quantity, due_date, customer_request, return_reason,
|
||||
regdate, writer, status,
|
||||
order_quantity, order_unit_price, order_supply_price, order_vat, order_total_amount
|
||||
)
|
||||
SELECT
|
||||
$1, $2, seq, part_objid, part_no, part_name,
|
||||
quantity, due_date, customer_request, return_reason,
|
||||
NOW(), $3, 'ACTIVE',
|
||||
order_quantity, order_unit_price, order_supply_price, order_vat, order_total_amount
|
||||
FROM contract_item
|
||||
WHERE objid=$4 AND status='ACTIVE'`,
|
||||
[newItemObjid, newObjid, userId, sourceItemObjid],
|
||||
);
|
||||
|
||||
// wace copyContractItemSerials 1:1 — objid는 prefix + seq
|
||||
const snPrefix = `${genObjid("CIS")}_`;
|
||||
await client.query(
|
||||
`INSERT INTO contract_item_serial (
|
||||
objid, item_objid, seq, serial_no, regdate, writer, status
|
||||
)
|
||||
SELECT
|
||||
$1 || seq::varchar, $2, seq, serial_no, NOW(), $3, 'ACTIVE'
|
||||
FROM contract_item_serial
|
||||
WHERE item_objid=$4 AND status='ACTIVE'`,
|
||||
[snPrefix, newItemObjid, userId, sourceItemObjid],
|
||||
);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
logger.info("수주복사 완료", { sourceObjid, newObjid, newContractNo, lines: itemsRes.rowCount });
|
||||
return { newObjid, newContractNo };
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK");
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// G4/G11 — 수주 결재상신 (wace ApprovalService.getAmaranthSsoUrl + orderMgmtList.btnApproval 1:1)
|
||||
// target_type='CONTRACT_ORDER', formId='1161', compSeq='1000' (wace orderMgmtList:554~559)
|
||||
// amaranth_approval 신규/기존 매핑 분기:
|
||||
// - 없음: 신규 approKey 생성 + INSERT
|
||||
// - reject/delete/create: 새 approKey + UPDATE (재상신)
|
||||
// - inProcess/complete: 기존 approKey 재사용 (프론트에서 가드되지만 백엔드도 방어)
|
||||
// wace 1:1 — 첨부파일 원챔버 업로드는 미구현 (영업관리 첨부 흐름 별도 작업).
|
||||
// wace ApprovalService.java:1782~1909.
|
||||
export async function startOrderApproval(userId: string, contractObjid: string, opts: {
|
||||
approvalTitle?: string;
|
||||
subjectStr?: string;
|
||||
} = {}): Promise<{ fullUrl: string; approKey: string; status: string }> {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 1) 사용자 emp_seq 조회 (wace PersonBean.getEmpseq 동등)
|
||||
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 = String(u.emp_seq ?? "").trim();
|
||||
if (!empSeq) {
|
||||
throw new AppError(`empSeq 정보가 없습니다. (userId: ${userId}) 관리자에게 문의하세요.`, 400);
|
||||
}
|
||||
|
||||
// 2) contract_mgmt 헤더 + 라인 수 확인 (hasOrderData 가드는 프론트지만 백엔드도 방어)
|
||||
const cmRes = await client.query(
|
||||
`SELECT cm.objid, cm.contract_no,
|
||||
(SELECT count(*) FROM contract_item WHERE contract_objid=cm.objid AND status='ACTIVE') AS line_cnt
|
||||
FROM contract_mgmt cm
|
||||
WHERE cm.objid=$1`,
|
||||
[contractObjid],
|
||||
);
|
||||
if (cmRes.rowCount === 0) throw new AppError("주문서를 찾을 수 없습니다.", 404);
|
||||
const { contract_no, line_cnt } = cmRes.rows[0];
|
||||
if (Number(line_cnt) === 0) {
|
||||
throw new AppError("수주 품목을 먼저 등록해주세요.", 400);
|
||||
}
|
||||
|
||||
const targetType = "CONTRACT_ORDER";
|
||||
const approvalTitle = opts.approvalTitle || `주문서 결재${contract_no ? " - " + contract_no : ""}`;
|
||||
const outProcessCode = process.env.AMARANTH_OUT_PROCESS_CODE_CONTRACT_ORDER || process.env.AMARANTH_OUT_PROCESS_CODE || "";
|
||||
const formId = process.env.AMARANTH_FORM_ID_CONTRACT_ORDER || "1161";
|
||||
const compSeq = process.env.AMARANTH_COMP_SEQ || "1000";
|
||||
|
||||
// 3) amaranth_approval 기존 매핑 조회 (wace selectAmaranthApprovalByTarget)
|
||||
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, contractObjid],
|
||||
);
|
||||
|
||||
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 (아마란스가 기존 approKey의 원챔버 첨부를 재사용하므로 수정 미반영 회피)
|
||||
approKey = "UB_" + Date.now().toString(36).toUpperCase();
|
||||
mode = "update_resubmit";
|
||||
} else {
|
||||
approKey = String(existRes.rows[0].appro_key || "");
|
||||
mode = "update_reuse";
|
||||
}
|
||||
}
|
||||
|
||||
// 4) SSO URL 발급 (chpark amaranthApprovalClient.getSsoUrl)
|
||||
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);
|
||||
}
|
||||
|
||||
// 5) amaranth_approval INSERT/UPDATE
|
||||
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, contractObjid, targetType, approKey,
|
||||
outProcessCode || null, formId, empSeq, compSeq, userId, fullUrl,
|
||||
],
|
||||
);
|
||||
} else {
|
||||
// 재상신: approKey/sso_url/status 초기화, 재사용: sso_url만 갱신
|
||||
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("수주 결재상신", { contractObjid, contract_no, approKey, mode });
|
||||
return { fullUrl, approKey, status: "create" };
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK");
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateStatus(userId: string, objid: string, contractResult: string) {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
// ============================================================
|
||||
// SMTP 발송 유틸 — wace MailUtil.sendMailWithAttachFileUTF8 1:1
|
||||
// ([MailUtil.java:810-1034](../../../../wace_plm/src/com/pms/common/utils/MailUtil.java))
|
||||
//
|
||||
// 계정 타입(ERP/SALES/PURCHASE)별 SMTP 분기 — wace Constants.Mail 동일.
|
||||
// 발송 흐름: mail_log INSERT(is_send='N') → nodemailer 발송
|
||||
// → 성공: UPDATE is_send='Y', log_time=NOW()
|
||||
// → 실패: UPDATE is_send='N', error_log
|
||||
// ============================================================
|
||||
|
||||
import nodemailer from "nodemailer";
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "./logger";
|
||||
|
||||
export type SmtpAccountType = "ERP" | "SALES" | "PURCHASE";
|
||||
|
||||
export interface MailAttachment {
|
||||
filename: string; // 표시될 파일명 (UTF-8 가능)
|
||||
path?: string; // 디스크 경로 (path 또는 content 둘 중 하나)
|
||||
content?: Buffer; // 메모리 바이너리
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
export interface SendMailUTF8Params {
|
||||
accountType?: SmtpAccountType; // 기본 ERP
|
||||
fromUserId: string; // mail_log.send_user_id
|
||||
toEmails: string[]; // 수신
|
||||
ccEmails?: string[]; // 참조
|
||||
bccEmails?: string[]; // 숨은참조
|
||||
toUserIds?: string[]; // mail_log.reception_user_id (참고용)
|
||||
subject: string; // 실제 메일 제목
|
||||
subjectForLog?: string; // mail_log.title (기본: subject 그대로). 그리드 LEFT JOIN용 [OBJID:nnn] 토큰은 호출 측에서 부착.
|
||||
html: string; // 본문 HTML (text/plain은 \n → <br>로 직접 변환)
|
||||
attachments?: MailAttachment[];
|
||||
mailType: string; // mail_log.mail_type
|
||||
important?: "High" | "Normal" | "Low";
|
||||
}
|
||||
|
||||
interface SmtpConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
pass: string;
|
||||
secure: boolean;
|
||||
}
|
||||
|
||||
function readSmtpConfig(accountType: SmtpAccountType): SmtpConfig {
|
||||
const host = process.env.SMTP_HOST || "";
|
||||
const port = parseInt(process.env.SMTP_PORT || "25", 10);
|
||||
const secure = (process.env.SMTP_TLS || "false").toLowerCase() === "true";
|
||||
|
||||
let user: string;
|
||||
let pass: string;
|
||||
switch (accountType) {
|
||||
case "SALES":
|
||||
user = process.env.SMTP_USER_SALES || "";
|
||||
pass = process.env.SMTP_PW_SALES || "";
|
||||
break;
|
||||
case "PURCHASE":
|
||||
user = process.env.SMTP_USER_PURCHASE || "";
|
||||
pass = process.env.SMTP_PW_PURCHASE || "";
|
||||
break;
|
||||
case "ERP":
|
||||
default:
|
||||
user = process.env.SMTP_USER_ERP || "";
|
||||
pass = process.env.SMTP_PW_ERP || "";
|
||||
break;
|
||||
}
|
||||
|
||||
return { host, port, user, pass, secure };
|
||||
}
|
||||
|
||||
function genMailLogObjid(): string {
|
||||
// mail_log.objid (VARCHAR). wace는 CommonUtils.createObjId() 사용 — 우리는 ML-{ms}-{rand} 패턴 통일.
|
||||
const ms = Date.now();
|
||||
const rand = Math.floor(Math.random() * 1000).toString().padStart(3, "0");
|
||||
return `ML-${ms}-${rand}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* UTF-8 HTML 본문 + 첨부파일 메일 발송.
|
||||
* wace MailUtil.sendMailWithAttachFileUTF8 1:1.
|
||||
*
|
||||
* @returns { objid: mail_log.objid, sent: 실제 발송 성공 여부 }
|
||||
* SMTP_SEND_SWITCH !== 'Y'면 INSERT만 하고 sent=false 리턴 (wace sendMailSwitch와 동일).
|
||||
*/
|
||||
export async function sendMailUTF8(params: SendMailUTF8Params): Promise<{ objid: string; sent: boolean; error?: string }> {
|
||||
const accountType: SmtpAccountType = params.accountType || "ERP";
|
||||
const cfg = readSmtpConfig(accountType);
|
||||
const sendSwitch = (process.env.SMTP_SEND_SWITCH || "Y").toUpperCase() === "Y";
|
||||
|
||||
const subjectForLog = params.subjectForLog && params.subjectForLog.trim() !== ""
|
||||
? params.subjectForLog
|
||||
: params.subject;
|
||||
|
||||
// ─── 1. mail_log INSERT (is_send 기본 NULL/N, 발송 후 갱신) ───
|
||||
const pool = getPool();
|
||||
const objid = genMailLogObjid();
|
||||
const receiverTo = params.toEmails.join(", ") + (params.ccEmails && params.ccEmails.length > 0 ? `; cc: ${params.ccEmails.join(", ")}` : "");
|
||||
|
||||
try {
|
||||
await pool.query(
|
||||
`INSERT INTO mail_log (
|
||||
objid, system_name, send_user_id, from_addr,
|
||||
reception_user_id, receiver_to,
|
||||
title, contents, log_time, is_send, mail_type
|
||||
) VALUES (
|
||||
$1, 'vexplor_rps', $2, $3,
|
||||
$4, $5,
|
||||
$6, $7, NOW(), 'N', $8
|
||||
)`,
|
||||
[
|
||||
objid,
|
||||
params.fromUserId,
|
||||
cfg.user || null,
|
||||
params.toUserIds && params.toUserIds.length > 0 ? params.toUserIds.join(", ") : null,
|
||||
receiverTo,
|
||||
subjectForLog,
|
||||
params.html,
|
||||
params.mailType,
|
||||
],
|
||||
);
|
||||
} catch (logErr) {
|
||||
logger.error("mail_log INSERT 실패", { objid, error: (logErr as Error).message });
|
||||
throw logErr;
|
||||
}
|
||||
|
||||
// ─── 2. 발송 스킵 모드: wace Constants.Mail.sendMailSwitch == false ───
|
||||
if (!sendSwitch) {
|
||||
logger.warn("SMTP_SEND_SWITCH !== 'Y' — 실제 발송 스킵, mail_log만 기록", { objid });
|
||||
return { objid, sent: false };
|
||||
}
|
||||
|
||||
// ─── 3. 실제 SMTP 발송 ───
|
||||
try {
|
||||
if (!cfg.host || !cfg.user) {
|
||||
throw new Error(`SMTP 설정 누락 (accountType=${accountType}, host=${cfg.host}, user=${cfg.user})`);
|
||||
}
|
||||
if (params.toEmails.length === 0) {
|
||||
throw new Error("수신인 이메일이 없습니다.");
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: cfg.host,
|
||||
port: cfg.port,
|
||||
secure: cfg.secure,
|
||||
auth: { user: cfg.user, pass: cfg.pass },
|
||||
// wace JavaMail: mail.smtp.starttls.enable=false, mail.smtp.ssl.enable=false, plain auth on 25
|
||||
tls: { rejectUnauthorized: false },
|
||||
logger: false,
|
||||
debug: false,
|
||||
});
|
||||
|
||||
const info = await transporter.sendMail({
|
||||
from: cfg.user,
|
||||
to: params.toEmails.join(", "),
|
||||
cc: params.ccEmails && params.ccEmails.length > 0 ? params.ccEmails.join(", ") : undefined,
|
||||
bcc: params.bccEmails && params.bccEmails.length > 0 ? params.bccEmails.join(", ") : undefined,
|
||||
subject: params.subject,
|
||||
html: params.html,
|
||||
priority: params.important === "High" ? "high" : params.important === "Low" ? "low" : "normal",
|
||||
attachments: params.attachments?.map((a) => ({
|
||||
filename: a.filename,
|
||||
path: a.path,
|
||||
content: a.content,
|
||||
contentType: a.contentType,
|
||||
})),
|
||||
});
|
||||
|
||||
logger.info("메일 발송 성공", { objid, messageId: info.messageId, accountType, to: params.toEmails });
|
||||
|
||||
// mail_log 갱신 — 성공
|
||||
await pool.query(
|
||||
`UPDATE mail_log SET is_send='Y', log_time=NOW() WHERE objid=$1`,
|
||||
[objid],
|
||||
);
|
||||
|
||||
return { objid, sent: true };
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
logger.error("메일 발송 실패", { objid, accountType, error: msg });
|
||||
|
||||
// mail_log 갱신 — 실패. 컬럼이 없으면 best-effort.
|
||||
try {
|
||||
await pool.query(
|
||||
`UPDATE mail_log SET is_send='N', error_log=$2 WHERE objid=$1`,
|
||||
[objid, msg],
|
||||
);
|
||||
} catch (updErr) {
|
||||
// error_log 컬럼이 없는 환경에서는 silently skip
|
||||
logger.warn("mail_log 실패 상태 기록 스킵 (error_log 컬럼 없을 수 있음)", { objid });
|
||||
}
|
||||
|
||||
return { objid, sent: false, error: msg };
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,26 @@ services:
|
||||
DEFAULT_COMPANY_CODE: ${COMPANY_CODE:-SPIFOX}
|
||||
# 로깅
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
# SMTP (견적서/발주서 등 메일 발송) — 호스트 .env에서 실값 주입
|
||||
SMTP_HOST: ${SMTP_HOST:-erp.rps-korea.com}
|
||||
SMTP_PORT: ${SMTP_PORT:-25}
|
||||
SMTP_TLS: ${SMTP_TLS:-false}
|
||||
SMTP_SEND_SWITCH: ${SMTP_SEND_SWITCH:-Y}
|
||||
SMTP_USER_ERP: ${SMTP_USER_ERP:-}
|
||||
SMTP_PW_ERP: ${SMTP_PW_ERP:-}
|
||||
SMTP_USER_SALES: ${SMTP_USER_SALES:-}
|
||||
SMTP_PW_SALES: ${SMTP_PW_SALES:-}
|
||||
SMTP_USER_PURCHASE: ${SMTP_USER_PURCHASE:-}
|
||||
SMTP_PW_PURCHASE: ${SMTP_PW_PURCHASE:-}
|
||||
# Amaranth 전자결재 (수주/견적 등) — 호스트 .env에서 실값 주입
|
||||
# OUT_PROCESS_CODE: wace Constants.AMARANTH_OUT_PROCESS_CODE='RPSPLM_00001' 1:1
|
||||
# 도메인별 fallback: AMARANTH_OUT_PROCESS_CODE_CONTRACT_ORDER → AMARANTH_OUT_PROCESS_CODE
|
||||
AMARANTH_OUT_PROCESS_CODE: ${AMARANTH_OUT_PROCESS_CODE:-RPSPLM_00001}
|
||||
AMARANTH_OUT_PROCESS_CODE_CONTRACT_ORDER: ${AMARANTH_OUT_PROCESS_CODE_CONTRACT_ORDER:-}
|
||||
AMARANTH_OUT_PROCESS_CODE_CONTRACT_ESTIMATE: ${AMARANTH_OUT_PROCESS_CODE_CONTRACT_ESTIMATE:-}
|
||||
AMARANTH_FORM_ID_CONTRACT_ORDER: ${AMARANTH_FORM_ID_CONTRACT_ORDER:-1161}
|
||||
AMARANTH_FORM_ID_CONTRACT_ESTIMATE: ${AMARANTH_FORM_ID_CONTRACT_ESTIMATE:-1162}
|
||||
AMARANTH_COMP_SEQ: ${AMARANTH_COMP_SEQ:-1000}
|
||||
volumes:
|
||||
- backend_uploads:/app/uploads
|
||||
- backend_data:/app/data
|
||||
|
||||
@@ -24,6 +24,24 @@ services:
|
||||
EXPRESSWAY_API_KEY: ${EXPRESSWAY_API_KEY:-}
|
||||
SMART_FACTORY_API_KEY_COMPANY_10: ${SMART_FACTORY_API_KEY_COMPANY_10:-}
|
||||
SMART_FACTORY_API_KEY_COMPANY_9: ${SMART_FACTORY_API_KEY_COMPANY_9:-}
|
||||
# SMTP (견적서/발주서 등 메일 발송)
|
||||
SMTP_HOST: ${SMTP_HOST:-erp.rps-korea.com}
|
||||
SMTP_PORT: ${SMTP_PORT:-25}
|
||||
SMTP_TLS: ${SMTP_TLS:-false}
|
||||
SMTP_SEND_SWITCH: ${SMTP_SEND_SWITCH:-Y}
|
||||
SMTP_USER_ERP: ${SMTP_USER_ERP:-}
|
||||
SMTP_PW_ERP: ${SMTP_PW_ERP:-}
|
||||
SMTP_USER_SALES: ${SMTP_USER_SALES:-}
|
||||
SMTP_PW_SALES: ${SMTP_PW_SALES:-}
|
||||
SMTP_USER_PURCHASE: ${SMTP_USER_PURCHASE:-}
|
||||
SMTP_PW_PURCHASE: ${SMTP_PW_PURCHASE:-}
|
||||
# Amaranth 전자결재 (수주/견적 등) — wace Constants.AMARANTH_OUT_PROCESS_CODE='RPSPLM_00001' 1:1
|
||||
AMARANTH_OUT_PROCESS_CODE: ${AMARANTH_OUT_PROCESS_CODE:-RPSPLM_00001}
|
||||
AMARANTH_OUT_PROCESS_CODE_CONTRACT_ORDER: ${AMARANTH_OUT_PROCESS_CODE_CONTRACT_ORDER:-}
|
||||
AMARANTH_OUT_PROCESS_CODE_CONTRACT_ESTIMATE: ${AMARANTH_OUT_PROCESS_CODE_CONTRACT_ESTIMATE:-}
|
||||
AMARANTH_FORM_ID_CONTRACT_ORDER: ${AMARANTH_FORM_ID_CONTRACT_ORDER:-1161}
|
||||
AMARANTH_FORM_ID_CONTRACT_ESTIMATE: ${AMARANTH_FORM_ID_CONTRACT_ESTIMATE:-1162}
|
||||
AMARANTH_COMP_SEQ: ${AMARANTH_COMP_SEQ:-1000}
|
||||
volumes:
|
||||
- backend_uploads:/app/uploads
|
||||
- backend_data:/app/data
|
||||
|
||||
@@ -28,6 +28,24 @@ services:
|
||||
- SMART_FACTORY_API_KEY_COMPANY_9=${SMART_FACTORY_API_KEY_COMPANY_9:-}
|
||||
- SMART_FACTORY_API_KEY_COMPANY_10=${SMART_FACTORY_API_KEY_COMPANY_10:-}
|
||||
- SMART_FACTORY_API_KEY_COMPANY_16=${SMART_FACTORY_API_KEY_COMPANY_16:-}
|
||||
# SMTP (견적서/발주서 등 메일 발송)
|
||||
- SMTP_HOST=${SMTP_HOST:-erp.rps-korea.com}
|
||||
- SMTP_PORT=${SMTP_PORT:-25}
|
||||
- SMTP_TLS=${SMTP_TLS:-false}
|
||||
- SMTP_SEND_SWITCH=${SMTP_SEND_SWITCH:-Y}
|
||||
- SMTP_USER_ERP=${SMTP_USER_ERP:-}
|
||||
- SMTP_PW_ERP=${SMTP_PW_ERP:-}
|
||||
- SMTP_USER_SALES=${SMTP_USER_SALES:-}
|
||||
- SMTP_PW_SALES=${SMTP_PW_SALES:-}
|
||||
- SMTP_USER_PURCHASE=${SMTP_USER_PURCHASE:-}
|
||||
- SMTP_PW_PURCHASE=${SMTP_PW_PURCHASE:-}
|
||||
# Amaranth 전자결재 (수주/견적 등) — wace Constants.AMARANTH_OUT_PROCESS_CODE='RPSPLM_00001' 1:1
|
||||
- AMARANTH_OUT_PROCESS_CODE=${AMARANTH_OUT_PROCESS_CODE:-RPSPLM_00001}
|
||||
- AMARANTH_OUT_PROCESS_CODE_CONTRACT_ORDER=${AMARANTH_OUT_PROCESS_CODE_CONTRACT_ORDER:-}
|
||||
- AMARANTH_OUT_PROCESS_CODE_CONTRACT_ESTIMATE=${AMARANTH_OUT_PROCESS_CODE_CONTRACT_ESTIMATE:-}
|
||||
- AMARANTH_FORM_ID_CONTRACT_ORDER=${AMARANTH_FORM_ID_CONTRACT_ORDER:-1161}
|
||||
- AMARANTH_FORM_ID_CONTRACT_ESTIMATE=${AMARANTH_FORM_ID_CONTRACT_ESTIMATE:-1162}
|
||||
- AMARANTH_COMP_SEQ=${AMARANTH_COMP_SEQ:-1000}
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3001/health"]
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
# 프로젝트관리 이식 GAP 분석 (원본 wace_plm 대비)
|
||||
|
||||
> 작성: 2026-05-11 / 작성자: hjjeong
|
||||
> **개정 2026-05-11**: 운영판 실 화면은 `projectMgmtList.jsp`(옛 화면)가 **아니라** `projectMgmtWbsList3.jsp` + 매퍼 `projectMgmtWbsGridList`인 것이 확인되어 GAP 전면 개정.
|
||||
|
||||
---
|
||||
|
||||
## 0. 한 문장 요약
|
||||
|
||||
운영판 진행관리는 **`/project/projectMgmtWbsList3.do`** endpoint가 반환하는 화면이고, 그리드 SQL은 매퍼 `projectMgmtWbsGridList`(project.xml:3854~4280). 그리드 컬럼은 8그룹/18셀 + 검색폼은 11필드. 의존 테이블 대부분 RPS에 보유 — `project_mgmt`/`contract_mgmt`/`contract_item`/`contract_item_serial`/`customer_mng`/`user_info`/`comm_code`/`attach_file_info`/`sales_registration`/`part_mng`. `pms_wbs_task`/`setup_wbs_task`만 부재(P2 의존, 그리드 표시 컬럼엔 영향 거의 없음). **P1에서 그리드 18셀 전부 실데이터로 채울 수 있음.**
|
||||
|
||||
## 0.1 이식 원칙
|
||||
|
||||
> JSP/Java/매퍼XML 안의 `/* */`, `<!-- -->`, `//` 주석 블록은 비활성. 활성 코드만 이식.
|
||||
|
||||
- 운영 화면(waceplm.esgrin.com) = 진실의 기준.
|
||||
- wace 컨트롤러에 `projectMgmtList.do`(옛) + `projectMgmtList1.do` + **`projectMgmtWbsList3.do`(운영판)** 셋이 동시 존재 — 메뉴가 가리키는 것이 무엇인지 확인 필수.
|
||||
- `client_mng`/`supply_mng` → RPS는 `customer_mng`로 통합. CASE WHEN LIKE 'C_%' 분기는 모두 customer_mng 단일 LEFT JOIN으로 변환.
|
||||
- `CODE_NAME()` 함수 RPS DB에 존재. 다만 영업관리 패턴(`LEFT JOIN comm_code CC_X ON CC_X.code_id=...`)으로 통일.
|
||||
- 금액 1,234.00 / 수량 1,234 / 모든 숫자 right-align.
|
||||
|
||||
---
|
||||
|
||||
## 1. 운영판 진행관리 흐름
|
||||
|
||||
### 1.1 endpoint
|
||||
|
||||
| URL | Controller | view / 결과 |
|
||||
|---|---|---|
|
||||
| `/project/projectMgmtWbsList3.do` | `projectMgmtWbsList3` (L3243) | view 반환 + 코드맵(`category_cd`/`customer_cd`/`project_no`/`product_cd`/`status_cd`/`result_cd`/`pm_user_id`) |
|
||||
| `/project/projectMgmtWbsGridList.do` | (`projectMgmtWbsGridList` 매퍼 호출) | 그리드 JSON |
|
||||
| `fn_openSaleRegPopup(orderNo)` → `/salesMgmt/salesRegForm.do?orderNo={PROJECT_NO}` | (영업관리 주문서 등록 화면) | 행 클릭 → 영업관리 주문서 등록 폼 오픈. **진행관리 자체 수정 폼 없음.** |
|
||||
|
||||
→ 본 이식의 RPS 측 라우트:
|
||||
- `GET /api/project/progress/list` — 그리드
|
||||
- 행 클릭 동작은 **P1.5에서 결정** (영업관리 `OrderRegistDialog` 재사용 vs 미연결)
|
||||
|
||||
### 1.2 그리드 컬럼 (운영판 그대로)
|
||||
|
||||
8그룹 / 18셀 (frozen 1개 포함):
|
||||
|
||||
| # | dataField | 라벨 | 의존 | P1 채움 |
|
||||
|---|---|---|---|---|
|
||||
| 1 | **PROJECT_NO** (frozen) | 프로젝트번호 | project_mgmt | ✅ |
|
||||
| 2 | CATEGORY_NAME | 주문유형 | comm_code(0000167) | ✅ |
|
||||
| 3 | PRODUCT_NAME | 제품구분 | comm_code(0000001) | ✅ |
|
||||
| 4 | AREA_NAME | 국내/해외 | comm_code(area_cd) | ✅ |
|
||||
| 5 | REG_DATE | 접수일 | TO_CHAR(regdate,'YYYY-MM-DD') | ✅ |
|
||||
| 6 | CUSTOMER_NAME | 고객사 | customer_mng | ✅ |
|
||||
| 7 | FREE_OF_CHARGE | 유/무상 | contract_mgmt.paid_type → '유상'/'무상' | ✅ |
|
||||
| 8 | PRODUCT_ITEM_CODE | 품번 | project_mgmt.part_no | ✅ |
|
||||
| 9 | PRODUCT_ITEM_NAME | 품명 | project_mgmt.part_name | ✅ |
|
||||
| 10 | SERIAL_NO | S/N | contract_item_serial (CIS.item_objid=T.contract_item_objid) | ✅ |
|
||||
| 11 | CONTRACT_QTY | 수주수량 | project_mgmt.quantity | ✅ |
|
||||
| 12 | REQ_DEL_DATE | 요청납기 | COALESCE(contract_item.due_date, project_mgmt.due_date, contract_mgmt.due_date) | ✅ |
|
||||
| 13 | EBOM_STATUS | E-BOM | project_mgmt.ebom_status | ✅ (대부분 빈값, 운영DB도 빈값) |
|
||||
| 14 | MBOM_STATUS | M-BOM | project_mgmt.mbom_status | ✅ (대부분 빈값) |
|
||||
| 15 | ORDER_DATE | 발주일 | contract_mgmt.order_date | ✅ |
|
||||
| 16 | RECEIVING_RATE | 입고율 | project_mgmt.receiving_rate | ✅ |
|
||||
| 17 | PRODUCTION_TEAM_12 | 제조1,2팀 | project_mgmt.production_team_12 | ✅ |
|
||||
| 18 | PRODUCTION_TEAM_3 | 제조3팀 | project_mgmt.production_team_3 | ✅ |
|
||||
| 19 | ASSEMBLY | 조립 | (wace SQL에 없음 — 빈값) | (빈값) |
|
||||
| 20 | VERIFICATION | 검증 | (wace SQL에 없음 — 빈값) | (빈값) |
|
||||
| 21 | SHIPMENT_DATE | 출하일 | sales_registration.shipping_date (project_no 매칭) | ✅ |
|
||||
|
||||
→ **18셀 모두 P1에서 채움 가능** (장비 조립/검증 2개는 wace 원본도 빈값). PMS_WBS_TASK 의존 컬럼들은 그리드 표시에 없음.
|
||||
|
||||
### 1.3 검색 폼 (11필드)
|
||||
|
||||
| # | name | 라벨 | 입력 | 매핑 |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Year | 년도 | select(sysYear±4) | TO_CHAR(REGDATE,'YYYY') |
|
||||
| 2 | project_nos | 프로젝트번호 | multi-select | OBJID IN (쉼표 split) |
|
||||
| 3 | category_cd | 주문유형 | select(0000167) | T.CATEGORY_CD |
|
||||
| 4 | customer_objid | 고객사 | select | T.CUSTOMER_OBJID |
|
||||
| 5 | product | 제품구분 | select(0000001) | T.PRODUCT |
|
||||
| 6 | contract_start_date / contract_end_date | 요청납기일 | date range | COALESCE(contract_item.due_date, project_mgmt.due_date, contract_mgmt.due_date) |
|
||||
| 7 | area_cd | 국내/해외 | select(국내/해외 라벨) | CODE_NAME(AREA_CD) = '국내'/'해외' |
|
||||
| 8 | free_of_charge | 유/무상 | select(유상/무상 라벨) | contract_mgmt.paid_type 매핑 비교 |
|
||||
| 9 | product_item_code | 품번 | autocomplete | T.PART_NO ILIKE '%?%' |
|
||||
| 10 | product_item_name | 품명 | autocomplete | T.PART_NAME ILIKE '%?%' |
|
||||
| 11 | serial_no | S/N | text | EXISTS contract_item_serial ILIKE |
|
||||
|
||||
비활성(주석블록): location / setup / pm_user_id — 무시.
|
||||
|
||||
### 1.4 ORDER BY
|
||||
|
||||
```sql
|
||||
ORDER BY SUBSTRING(PROJECT_NO,POSITION('-' IN PROJECT_NO)+1) DESC,
|
||||
OVERHAUL_ORDER DESC NULLS LAST
|
||||
```
|
||||
|
||||
### 1.5 행 클릭 동작
|
||||
|
||||
`fn_openSaleRegPopup(PROJECT_NO)` → `/salesMgmt/salesRegForm.do?orderNo={PROJECT_NO}` (영업관리 주문서 등록 폼). **진행관리 자체 수정 폼 없음.** RPS 이식 시 P1.5 별도 결정.
|
||||
|
||||
---
|
||||
|
||||
## 2. RPS DB 보유 매트릭스 (개정)
|
||||
|
||||
| 테이블 | 보유 | 진행관리에서의 용도 | P1 |
|
||||
|---|---|---|---|
|
||||
| project_mgmt | ✅ | 메인 (90건) | ✅ |
|
||||
| contract_mgmt | ✅ | paid_type / order_date / due_date / pm_user_id | ✅ |
|
||||
| **contract_item** | ✅ | 요청납기 COALESCE 1순위 | ✅ |
|
||||
| **contract_item_serial** | ✅ | S/N 집계 | ✅ |
|
||||
| **sales_registration** | ✅ | 출하일 COALESCE | ✅ |
|
||||
| customer_mng | ✅ | 고객사명 (wace의 client_mng/supply_mng 분기 흡수) | ✅ |
|
||||
| user_info | ✅ | PM명 / writer명 / chg_user 등 | ✅ |
|
||||
| comm_code + CODE_NAME() | ✅ | 카테고리/제품/지역/통화 등 라벨 | ✅ |
|
||||
| attach_file_info | ✅ | contractMgmt01/02 카운트 (CU01_CNT/CU02_CNT) | ✅ |
|
||||
| part_mng | ✅ | (이번 P1에서는 직접 사용 안 함 — project_mgmt.part_no/part_name 직접 사용) | — |
|
||||
| purchase_order_master | ✅ | (이번 P1에서는 사용 안 함 — P2의 투입원가 컬럼에서만 사용) | — |
|
||||
| pms_wbs_task | ❌ | 진척율/연체 카운트 — 그리드 표시 컬럼엔 없음 | (P2) |
|
||||
| setup_wbs_task | ❌ | 셋업 진척율 — 그리드 표시 컬럼엔 없음 | (P2) |
|
||||
| client_mng / supply_mng | ➖ | wace 전용 (customer_mng로 흡수) | — |
|
||||
|
||||
→ **P1에서 18셀 전부 실데이터 채울 수 있음.**
|
||||
|
||||
---
|
||||
|
||||
## 3. GAP 매트릭스 (개정)
|
||||
|
||||
| # | 우선 | 항목 | 권장 작업 |
|
||||
|---|---|---|---|
|
||||
| **PRJ-1** | 🔴 | 진행관리 메뉴 자체 부재 → 운영판 화면 1:1 이식 | **본 PR (P1)** — 18셀 + 검색 11필드 |
|
||||
| **PRJ-2** | 🟠 | 행 클릭 → 영업관리 주문서 등록 폼 오픈 (`fn_openSaleRegPopup`) | **P1.5** — `OrderRegistDialog` 재사용 vs 미연결. 사용자 결정 |
|
||||
| **PRJ-3** | 🟠 | 다중 선택 프로젝트번호 검색 (multi-select) | **본 PR** — frontend SmartSelect multi 모드, backend는 `project_nos` 쉼표 split |
|
||||
| **PRJ-4** | 🟡 | 검색 코드맵 옵션 (`project_no` 다중 옵션 리스트) | wace는 코드맵에서 동적 생성. RPS는 grid 데이터 기반 옵션 또는 별도 API. **P1 보수적 처리**: 사용자가 OBJID 또는 PROJECT_NO 직접 입력 |
|
||||
| **PRJ-5** | 🟢 | 엑셀 다운로드 | wace에 없음 — 본 PR 제외 |
|
||||
| **PRJ-6** | 🟢 | 진척율(PMS_WBS_TASK 의존) — 그리드 표시 컬럼엔 영향 없으나 SQL은 계산 | **P2 (WBS 메뉴 이식 후)**. 본 P1은 진척율 컬럼 자체가 그리드에 없으므로 미반영 |
|
||||
| **PRJ-7** | 🟢 | 설계/생산 ASSEMBLY/VERIFICATION 컬럼 (운영판 빈값) | 자리만, 빈값 |
|
||||
|
||||
---
|
||||
|
||||
## 4. P1 스코프 — 본 GAP 결정사항
|
||||
|
||||
### 4.1 백엔드 갱신 (`backend-node/src/`)
|
||||
|
||||
| 파일 | 동작 |
|
||||
|---|---|
|
||||
| `services/projectMgmtService.ts` | **전면 교체** — `listProgress` SQL을 `projectMgmtWbsGridList` 1:1로 재작성. `getById`/`updateProject`는 P1.5에서 결정될 때까지 일단 유지 (사용 안 됨) |
|
||||
| `controllers/projectMgmtController.ts` | 현행 유지 |
|
||||
| `routes/projectMgmtRoutes.ts` | 현행 유지 (`GET /list` + 향후 추가 endpoint 자리) |
|
||||
| `app.ts` | 현행 유지 (이미 마운트됨) |
|
||||
|
||||
### 4.2 프론트엔드 갱신 (`frontend/`)
|
||||
|
||||
| 파일 | 동작 |
|
||||
|---|---|
|
||||
| `app/(main)/COMPANY_16/project/progress/page.tsx` | **전면 교체** — 검색폼 11필드(2행) + 그리드 18셀(8그룹 평탄화) |
|
||||
| `components/project/ProjectProgressEditDialog.tsx` | **폐기** (옛 jsp 기반). 행 클릭은 P1.5에서 결정 |
|
||||
| `lib/api/projectMgmt.ts` | 검색 타입/결과 행 타입 갱신 |
|
||||
| `components/layout/AdminPageRenderer.tsx` | 현행 유지 |
|
||||
|
||||
### 4.3 검색 옵션 컴포넌트 매핑
|
||||
|
||||
| 검색 필드 | RPS 컴포넌트 |
|
||||
|---|---|
|
||||
| Year | `<select>` (native, sysYear±4 동적) |
|
||||
| project_nos | `<Input>` (쉼표 직렬화 텍스트) — 다중 SmartSelect는 P1.5에서 보강 가능 |
|
||||
| category_cd / product | `<CommCodeSelect groupId="0000167"/"0000001">` |
|
||||
| customer_objid | `<CustomerSelect>` |
|
||||
| 요청납기 범위 | `<Input type="date">` × 2 |
|
||||
| area_cd | `<select>` 정적 옵션 ('국내'/'해외') |
|
||||
| free_of_charge | `<select>` 정적 옵션 ('유상'/'무상') |
|
||||
| product_item_code / product_item_name | `<Input>` (자동완성은 P1.5 보강) |
|
||||
| serial_no | `<Input>` |
|
||||
|
||||
### 4.4 본 PR에서 **하지 않을 것**
|
||||
- WBS관리 메뉴(별도 PR P2)
|
||||
- PMS_WBS_TASK / SETUP_WBS_TASK 의존 컬럼 (그리드 표시 컬럼엔 없으므로 영향 없음)
|
||||
- 행 클릭 → 영업관리 주문서 등록 폼 라우팅 (P1.5 별도)
|
||||
- 다중 select / 품번품명 자동완성 (P1.5 보강)
|
||||
- 엑셀 / 결재 (운영판 없음)
|
||||
|
||||
---
|
||||
|
||||
## 5. 사용자 결정 사항 (2026-05-11)
|
||||
|
||||
| # | 항목 | 결정 |
|
||||
|---|---|---|
|
||||
| 1 | URL 경로 | `/COMPANY_16/project/progress` |
|
||||
| 2 | 차종/OEM 마일스톤 | 영구 제외 |
|
||||
| 3 | 운영판 1:1 재작성 범위 | **그리드 + 검색폼 모두 운영판(projectMgmtWbsList3) 기준 재작성** |
|
||||
| 4 | 행 클릭 다이얼로그 | P1.5 — `OrderRegistDialog` 재사용 검토. P1에서는 미연결 |
|
||||
| 5 | 진척율(WBS) 컬럼 | P2에서 — 그리드 표시 컬럼엔 없으므로 P1 영향 없음 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 다음 단계
|
||||
|
||||
1. P1 운영판 1:1 재작성 → 사용자 동작 확인 (그리드 18셀이 실데이터로 채워지는지)
|
||||
2. P1.5: 행 클릭 동작 결정 (영업관리 OrderRegistDialog 재사용 / 미연결 / 신규 다이얼로그)
|
||||
3. P2: WBS관리 메뉴(`/COMPANY_16/project/wbs-template`) — PMS_WBS_* 운영DB DDL 추출 + 02-wbs.md
|
||||
@@ -47,14 +47,15 @@
|
||||
| G1 | 🔴 | **수주확정 시 프로젝트 자동생성** | `ContractMgmtService.updateOrderStatus` (라인 2987~3113) + `project.xml` (7518~7581) | [salesOrderMgmtService.ts:521](../../../backend-node/src/services/salesOrderMgmtService.ts#L521) `updateStatus`는 `CONTRACT_RESULT` UPDATE만 — 프로젝트 생성 호출 없음 | `updateStatus` 트랜잭션 내에서: contract_item 루프 → PRODUCT='0000928'(Machine)이면 quantity만큼 N회, 아니면 1회 → `project_no` 채번 (`{주문유형}-{제품구분}-{YYMMDD}-{순번3자리}`) → `project_mgmt` INSERT |
|
||||
| G2 | 🔴 | **직접등록 통합폼** (`estimateAndOrderRegistFormPopup`) | `ContractMgmtService.saveEstimateAndOrderInfo` (라인 2664) | endpoint 자체 부재. 주문관리 화면 "신규" 버튼이 견적 없이 주문 등록하는 흐름 미지원 | `POST /api/sales/order/direct` 신설 — `IS_DIRECT_ORDER='Y'` 강제, `contract_mgmt` UPSERT + `contract_item` UPSERT + `contract_item_serial` 다중 INSERT |
|
||||
| G3 | 🟠 | 견적요청등록 시 contract_item 다중 INSERT | `ContractMgmtService.saveContractMgmtInfo` (라인 544) | [salesEstimateService.ts](../../../backend-node/src/services/salesEstimateService.ts)는 헤더만 INSERT, 라인 입력 누락 | save 트랜잭션에 `contract_item` 다중 UPSERT + `contract_item_serial` 처리 추가 |
|
||||
| G4 | 🟠 | 결재 자동판정 (`checkApprovalRequired`) | (별도 컨트롤러, 신규수주/가격인하 룰) | 미구현. APPROVAL_REQUIRED='N' 라벨 표시만 | 룰 분석 후 endpoint 신설. 외부 amaranth SSO는 RPS 결재 모듈 결정 후 |
|
||||
| G4 | 🟡 | 결재 자동판정 (`checkApprovalRequired`) | (별도 컨트롤러, 신규수주/가격인하 룰) | 미구현. APPROVAL_REQUIRED='N' 라벨 표시만 | 룰 분석 후 endpoint 신설 (사전판정 룰만 — SSO 흐름은 G11에서 처리됨) |
|
||||
| G5 | 🟠 | 견적템플릿 일반/장비 분기 + PDF | `ContractMgmtService.saveEstimateTemplate/2` (라인 1501/1591) + SmartEditor `uploadPdfChunk` | 미이식. 추가견적 카운트(시연 시드)만 표시 | template1/template2 popup 라우트 + `puppeteer` 또는 `react-pdf` PDF 생성 → `attach_file_info doc_type='estimate02'` INSERT |
|
||||
| G6 | 🟠 | SMTP 실제 발송 | `ContractMgmtService.sendEstimateMail` (라인 1774-1968), `MailUtil.sendMailWithAttachFileUTF8` (라인 1925) | [salesEstimateService.ts:618](../../../backend-node/src/services/salesEstimateService.ts#L618)는 mail_log INSERT만 | `mailSendSimpleService`(nodemailer) 통합 + HTML 본문 생성기(`makeEstimateMailContents`) 포팅 + 첨부 결합 |
|
||||
| G7 | 🟡 | 주문서 수정 시 contract_item UPSERT (OBJID 유지) | `mapper.upsertContractItemWithOrder` (UPSERT 패턴) | 이식본은 단순 UPDATE — 라인 변경 시 OBJID 유지 보장 안 됨 | UPSERT(ON CONFLICT) 패턴 적용 + 삭제된 라인 처리 분기 |
|
||||
| G8 | 🟡 | 프로젝트 존재 시 견적·주문 삭제 방지 | `ContractMgmtService.deleteContractMngInfo` (라인 794~808) | [salesOrderMgmtService.ts](../../../backend-node/src/services/salesOrderMgmtService.ts) delete는 사전 체크 없음 | delete 전에 `project_mgmt WHERE contract_objid=$1 LIMIT 1` 체크 → 있으면 거부 |
|
||||
| G9 | 🟡 | 견적요청 → 견적작성 라인 자동 복제 UI | (원본은 사용자가 contract_item에서 수동 선택) | 미구현 — 견적 작성 시 매번 라인 재입력 | "이전 라인 복제" 버튼 + contract_item → estimate_template_item 일괄 복사 |
|
||||
| G10 | 🟢 | 환율 마스터 + EXCHANGE_RATE 자동 변환 | `contractBase` SQL EST_TOTAL_AMOUNT_KRW 환산식 | 환산식만 있고 환율 마스터 미구축 | 환율 테이블 신설(또는 ECOS API 동기화) |
|
||||
| G11 | 🟢 | 결재 모듈 (amaranth_approval / 자체) | 외부 amaranth + APPR_STATUS 라벨 | RPS 결재 정책 미정 | vexplor `approvalController` 매핑 vs `amaranth_approval` 도입 결정 |
|
||||
| G9 | ✅ | **수주복사** (헤더 + contract_item + 시리얼 통째로 복제, 새 영업번호 채번) | `ContractMgmtService.copyEstimateAndOrderInfo` (라인 2601) + 매퍼 `copyContractMgmt`/`copyContractItems`/`copyContractItemSerials`/`getNextContractNo` | **완료 (2026-05-11)** — `salesOrderMgmtService.copyOrder` + `POST /sales/order-mgmt/:id/copy` + 주문관리 그리드 "수주복사" 버튼. 검증: [06-copy-order-verify.md](./06-copy-order-verify.md) |
|
||||
| ~~G10~~ | ❌ | ~~환율 마스터 + EXCHANGE_RATE 자동 변환~~ | (영업관리 GAP 아님 — Admin 도메인) | wace 영업관리 화면도 `exchange_rate`는 사용자 직접 입력. `COMM_EXCHANGE_RATE` 테이블·환율관리 화면은 wace AdminController(`4898~4993`) + `admin.xml(8191~8336)` 소속 | 영업관리 GAP에서 제외. Admin 메뉴 이식 시점에 별도로 다룸 |
|
||||
| G11 | ✅ | **수주 결재상신 (Amaranth 직행)** | wace `ApprovalService.getAmaranthSsoUrl` (라인 1782~1909) + `orderMgmtList.btnApproval` (132~175) | chpark의 `amaranthApprovalClient`(HMAC-SHA256 + AES-128-CBC) 기반. `amaranth_approval` 테이블만 사용, 자체 approval 미경유 (wace 패턴 동일). target_type=`CONTRACT_ORDER`, formId=`1161`, compSeq=`1000` | **완료 (2026-05-11)** — `salesOrderMgmtService.startOrderApproval` + `POST /sales/order-mgmt/:id/amaranth-approval` + 주문관리 "결재상신" 버튼 + 결재상태 컬럼(작성중/결재중/결재완료/반려). DB ALTER: `amaranth_approval.target_objid` → VARCHAR. 검증: [07-amaranth-approval-verify.md](./07-amaranth-approval-verify.md). 백로그: 첨부파일 원챔버 업로드 |
|
||||
| G11E | ✅ | **견적 결재상신 (Amaranth 직행, G11 동일 패턴)** | wace `estimateList_new.jsp:154~270, 868~916` + `ApprovalService.getAmaranthSsoUrl` CONTRACT_ESTIMATE 분기 | G11과 동일 흐름. 차이점: target_type=`CONTRACT_ESTIMATE`, target_objid=`estimate_template.objid`(최신 차수), formId=`1162`. 사전판정(`checkApprovalRequired`)은 G4 영역으로 분리 | **완료 (2026-05-11)** — `salesEstimateService.startEstimateApproval` + `POST /sales/estimate/:id/amaranth-approval` + 견적관리 "결재상신" 버튼 + 견적 list SQL `LEFT JOIN amaranth_approval` + 결재상태 4단계 라벨 + `notRequired`/`approval_required='N'` fallback. 검증: [08-estimate-approval-verify.md](./08-estimate-approval-verify.md). 백로그: 첨부파일 원챔버 업로드, 사전판정(G4) |
|
||||
|
||||
## 3. 코드/SQL 정합성 메모
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
# 영업관리 G9 — 수주복사 검증 (BEGIN/ROLLBACK)
|
||||
|
||||
> 작성: 2026-05-11 / 작성자: hjjeong
|
||||
> 목적: wace `copyEstimateAndOrderInfo` 1:1 이식한 vexplor_rps의 수주복사가 contract_mgmt 헤더 + contract_item 라인 + contract_item_serial 시리얼을 정확히 복제하는지 확인.
|
||||
|
||||
## 원본 매퍼 출처
|
||||
`wace_plm/src/com/pms/salesmgmt/mapper/contractMgmt.xml`
|
||||
- `getNextContractNo` (6118)
|
||||
- `copyContractMgmt` (6127)
|
||||
- `copyContractItems` (6184)
|
||||
- `copyContractItemSerials` (6230)
|
||||
- `getActiveItemObjIds` (6254)
|
||||
|
||||
## 이식 위치
|
||||
- 서비스: `backend-node/src/services/salesOrderMgmtService.ts:copyOrder`
|
||||
- 컨트롤러: `backend-node/src/controllers/salesOrderMgmtController.ts:copyOrder`
|
||||
- 라우트: `POST /api/sales/order-mgmt/:id/copy`
|
||||
- 프론트 API: `frontend/lib/api/salesOrderMgmt.ts:copyOrder`
|
||||
- 프론트 UI: `frontend/app/(main)/COMPANY_16/sales/order/page.tsx` (수주복사 버튼 + handleCopyOrder)
|
||||
|
||||
## 복사 정책 (wace 1:1)
|
||||
- 새 영업번호: `{YY}C-{NNNN}` (MAX+1)
|
||||
- 헤더 복사 시 갱신: `objid`, `contract_no`, `writer`, `regdate(NOW())`, `contract_result=''`, `is_direct_order='Y'`
|
||||
- 라인 복사 시 갱신: `objid`, `contract_objid`, `writer`, `regdate(NOW())`, `status='ACTIVE'`
|
||||
- 시리얼 복사: `objid = prefix || seq`, 나머지 컬럼 그대로
|
||||
|
||||
> 복사본은 항상 `is_direct_order='Y'`로 들어오므로 견적관리에 노출되지 않고 주문관리에만 노출됨 (wace orderMgmtList 동작).
|
||||
|
||||
## 검증 SQL (수동 BEGIN/ROLLBACK)
|
||||
|
||||
```sql
|
||||
BEGIN;
|
||||
|
||||
-- 1. 새 영업번호 채번
|
||||
SELECT TO_CHAR(NOW(),'YY') || 'C-' || LPAD((
|
||||
COALESCE(MAX(SUBSTRING(contract_no FROM 5)::integer), 0) + 1
|
||||
)::VARCHAR, 4, '0') AS new_contract_no
|
||||
FROM contract_mgmt
|
||||
WHERE contract_no LIKE TO_CHAR(NOW(),'YY') || 'C-%';
|
||||
|
||||
-- 2. 헤더 복사 (수동 테스트 키)
|
||||
INSERT INTO contract_mgmt (
|
||||
objid, category_cd, customer_objid, product, area_cd,
|
||||
customer_equip_name, customer_project_name, customer_production_no, mechanical_type,
|
||||
paid_type, receipt_date, req_del_date, contract_result,
|
||||
po_no, order_date, contract_currency, exchange_rate,
|
||||
regdate, writer, contract_no, is_direct_order,
|
||||
order_supply_price, order_vat, order_total_amount
|
||||
)
|
||||
SELECT
|
||||
'CM-TEST-COPY-001', category_cd, customer_objid, product, area_cd,
|
||||
customer_equip_name, customer_project_name, customer_production_no, mechanical_type,
|
||||
paid_type, receipt_date, req_del_date, '',
|
||||
po_no, order_date, contract_currency, exchange_rate,
|
||||
NOW(), 'TEST_USER', '26C-9999', 'Y',
|
||||
order_supply_price, order_vat, order_total_amount
|
||||
FROM contract_mgmt
|
||||
WHERE objid = '<SOURCE_OBJID>';
|
||||
|
||||
-- 3. 활성 라인 복사
|
||||
INSERT INTO contract_item (
|
||||
objid, contract_objid, seq, part_objid, part_no, part_name,
|
||||
quantity, due_date, customer_request, return_reason,
|
||||
regdate, writer, status,
|
||||
order_quantity, order_unit_price, order_supply_price, order_vat, order_total_amount
|
||||
)
|
||||
SELECT
|
||||
'CI-TEST-COPY-' || seq::varchar, 'CM-TEST-COPY-001', seq, part_objid, part_no, part_name,
|
||||
quantity, due_date, customer_request, return_reason,
|
||||
NOW(), 'TEST_USER', 'ACTIVE',
|
||||
order_quantity, order_unit_price, order_supply_price, order_vat, order_total_amount
|
||||
FROM contract_item
|
||||
WHERE contract_objid='<SOURCE_OBJID>' AND status='ACTIVE';
|
||||
|
||||
-- 4. 검증: BEFORE/AFTER 비교
|
||||
SELECT '원본 라인' AS what, count(*) FROM contract_item WHERE contract_objid='<SOURCE_OBJID>' AND status='ACTIVE';
|
||||
SELECT '복사 라인' AS what, count(*) FROM contract_item WHERE contract_objid='CM-TEST-COPY-001' AND status='ACTIVE';
|
||||
|
||||
ROLLBACK;
|
||||
```
|
||||
|
||||
## 검증 결과 (2026-05-11, 26C-0800 → 시뮬레이션)
|
||||
|
||||
| 단계 | 원본 (26C-0800) | 복사본 (26C-9999) | 비고 |
|
||||
|---|---|---|---|
|
||||
| 새 영업번호 | — | `26C-0803` | 현재 MAX=`26C-0802` → +1 ✓ |
|
||||
| 헤더 컬럼 (category_cd/customer_objid/product/paid_type/receipt_date/contract_currency 등) | 동일 | 동일 | wace 24개 컬럼 1:1 복사 확인 |
|
||||
| contract_result | (빈값) | `''` | 강제 빈문자열 ✓ |
|
||||
| is_direct_order | (빈값) | `Y` | 강제 'Y' ✓ |
|
||||
| writer | `khy1022` | `TEST_USER` | 현재 사용자로 갱신 ✓ |
|
||||
| 활성 라인 수 | 3 | 3 | seq 보존 ✓ |
|
||||
| 시리얼 | — | — | (해당 행에는 시리얼 없음 — 별도 시리얼 보유 행으로 추가 검증 필요 시) |
|
||||
|
||||
## API 호출 (인증 토큰 필요)
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://localhost:8080/api/sales/order-mgmt/<SOURCE_OBJID>/copy' \
|
||||
-H 'Authorization: Bearer <TOKEN>' \
|
||||
-H 'Content-Type: application/json'
|
||||
|
||||
# 응답: {"success": true, "data": {"newObjid": "CM-...", "newContractNo": "26C-0803"}, "message": "수주가 복사되었습니다."}
|
||||
```
|
||||
|
||||
## UI 동작 (wace 1:1)
|
||||
1. 주문관리 그리드에서 행 1개 선택
|
||||
2. "수주복사" 버튼 클릭
|
||||
3. 확인 다이얼로그: `[26C-0800] 수주를 복사하시겠습니까?`
|
||||
4. 확인 → API 호출
|
||||
5. 성공 토스트: `복사가 완료되었습니다. (영업번호: 26C-0803)`
|
||||
6. 목록 새로고침 → 복사본 그리드 상단에 표시
|
||||
|
||||
## 미선택 시 분기 (wace 1:1)
|
||||
- 0건 선택: `복사할 행을 선택해주십시오.`
|
||||
- 2건 이상 선택: (현 vexplor UI는 단일선택만 지원 — wace의 "한번에 한개의 수주만 복사 가능합니다." 분기는 불필요)
|
||||
@@ -0,0 +1,138 @@
|
||||
# 영업관리 G4/G11 — 수주 결재상신 (Amaranth 직행) 검증
|
||||
|
||||
> 작성: 2026-05-11 / 작성자: hjjeong
|
||||
> 목적: wace 영업관리 결재 흐름(외부 Amaranth SSO 직행)을 vexplor_rps 주문관리에 1:1 이식. chpark의 `amaranthApprovalClient`(자바 `AmaranthApprovalApiClient` 포팅) 재사용.
|
||||
|
||||
## 원본 출처
|
||||
- 프론트: `wace_plm/WebContent/WEB-INF/view/contractMgmt/orderMgmtList.jsp:132~175` (btnApproval 가드)
|
||||
- 프론트: 같은 파일 `:547~576` (`fn_openAmaranthApproval` → SSO URL 호출 → window.open)
|
||||
- 백엔드: `wace_plm/src/com/pms/service/ApprovalService.java:1782~1909` (`getAmaranthSsoUrl`)
|
||||
- 매퍼: `wace_plm/src/com/pms/salesmgmt/mapper/contractMgmt.xml:523~530, 661~663` (ORDER_APPR_STATUS 라벨 + LEFT JOIN AMARANTH_APPROVAL)
|
||||
|
||||
## 이식 위치
|
||||
- 백엔드 서비스: `backend-node/src/services/salesOrderMgmtService.ts:startOrderApproval`
|
||||
- 백엔드 컨트롤러: `backend-node/src/controllers/salesOrderMgmtController.ts:startApproval`
|
||||
- 라우트: `POST /api/sales/order-mgmt/:id/amaranth-approval`
|
||||
- 프론트 API: `frontend/lib/api/salesOrderMgmt.ts:startApproval`
|
||||
- 프론트 UI: `frontend/app/(main)/COMPANY_16/sales/order/page.tsx:handleAmaranthApproval` + "결재상신" 버튼 + `order_appr_status` 컬럼
|
||||
- 재사용: `backend-node/src/services/amaranthApprovalClient.ts:getSsoUrl` (chpark)
|
||||
|
||||
## DB 스키마 변경
|
||||
- `amaranth_approval.target_objid` BIGINT → **VARCHAR(80)** (wace 운영 패턴 1:1)
|
||||
- 출처: wace 매퍼 `T.OBJID::VARCHAR = AMR_ORDER.TARGET_OBJID`
|
||||
- 이유: vexplor_rps `contract_mgmt.objid`가 varchar(`CM-...` prefix 형식)라 bigint cast 불가
|
||||
- 영향: 해당 테이블 데이터 0건이라 무손실. ECR/CS는 bigint값을 varchar에 INSERT해도 자동 cast → 무영향
|
||||
- 마이그레이션 파일 `approvalTableMigration.ts`도 동기화
|
||||
|
||||
## 결재 정책 (wace 1:1)
|
||||
- target_type: `CONTRACT_ORDER` (영업관리 주문서)
|
||||
- formId: `1161` (운영 amaranth 양식 ID)
|
||||
- compSeq: `1000` (운영 회사 시퀀스)
|
||||
- mod: `W` (Write)
|
||||
- empSeq 출처: `user_info.emp_seq` (PersonBean.getEmpseq 동등) — 미설정 시 명시적 에러
|
||||
- approKey 분기:
|
||||
- 신규: `UB_` + Date.now().toString(36).toUpperCase()
|
||||
- 기존 reject/delete/create: 새 approKey + amaranth_approval UPDATE (재상신)
|
||||
- 기존 inProcess/complete: 기존 approKey 재사용 (프론트에서 차단되지만 백엔드 방어)
|
||||
|
||||
## 환경변수 (운영 배포 시 주입)
|
||||
| 변수 | 기본값 | 비고 |
|
||||
|---|---|---|
|
||||
| `AMARANTH_OUT_PROCESS_CODE_CONTRACT_ORDER` | (없음) | 수주 결재 전용 코드. 미설정 시 `AMARANTH_OUT_PROCESS_CODE` fallback |
|
||||
| `AMARANTH_FORM_ID_CONTRACT_ORDER` | `1161` | wace 운영값 |
|
||||
| `AMARANTH_COMP_SEQ` | `1000` | wace 운영값 |
|
||||
|
||||
> Amaranth 외부 커넥션의 인증 정보(`baseUrl`/`groupSeq`/`callerName`/`accessToken`/`hashKey`/`aesKey`)는 chpark이 'Amaranth - 결재' 외부 커넥션 시드로 자동 주입 (별도 환경변수 불필요).
|
||||
|
||||
## 가드 (프론트 + 백엔드 동시)
|
||||
| 조건 | 메시지 | 처리 |
|
||||
|---|---|---|
|
||||
| 행 미선택 | "결재상신할 행을 선택해주십시오." | 프론트 toast |
|
||||
| `has_order_data === 0` | "수주 품목을 먼저 등록해주세요." | 프론트 toast + 백엔드 400 |
|
||||
| `order_amaranth_status === 'inProcess'` | "결재 진행중인 건은 상신할 수 없습니다." | 프론트 toast |
|
||||
| `order_amaranth_status === 'complete'` | "결재 완료된 건은 상신할 수 없습니다." | 프론트 toast |
|
||||
| 사용자 emp_seq 미설정 | "empSeq 정보가 없습니다." | 백엔드 400 |
|
||||
| 원본 contract_mgmt 부재 | "주문서를 찾을 수 없습니다." | 백엔드 404 |
|
||||
| SSO API resultCode != 0 | "결재 연동 오류: ..." | 백엔드 502 |
|
||||
|
||||
## 검증 SQL (BEGIN/ROLLBACK)
|
||||
```sql
|
||||
BEGIN;
|
||||
INSERT INTO amaranth_approval
|
||||
(objid, target_objid, target_type, appro_key, status, form_id, comp_seq, emp_seq, writer, sso_url, regdate)
|
||||
VALUES (9999999999, '1256462102', 'CONTRACT_ORDER', 'UB_TEST', 'create', '1161', '1000', '999', 'test', 'http://test', NOW());
|
||||
|
||||
SELECT T.objid, T.contract_no,
|
||||
CASE WHEN AMR.status='complete' THEN '결재완료'
|
||||
WHEN AMR.status='inProcess' THEN '결재중'
|
||||
WHEN AMR.status='reject' THEN '반려'
|
||||
WHEN AMR.status='create' THEN '작성중'
|
||||
ELSE '' END AS order_appr_status
|
||||
FROM contract_mgmt T
|
||||
LEFT JOIN amaranth_approval AMR
|
||||
ON AMR.target_objid = T.objid AND AMR.target_type='CONTRACT_ORDER'
|
||||
WHERE T.objid='1256462102';
|
||||
ROLLBACK;
|
||||
```
|
||||
|
||||
### 검증 결과 (2026-05-11)
|
||||
| target_objid | status | order_appr_status (한글) |
|
||||
|---|---|---|
|
||||
| `1256462102` | create | 작성중 |
|
||||
| `1256462102` | inProcess | 결재중 |
|
||||
| `1256462102` | complete | 결재완료 |
|
||||
| `1256462102` | reject | 반려 |
|
||||
| `CM-1778464096341-756` (varchar PK) | inProcess | 결재중 ✓ |
|
||||
|
||||
- VARCHAR PK 호환 확인 — ALTER 효과 정상.
|
||||
- ROLLBACK 후 운영 데이터 영향 없음.
|
||||
|
||||
## API 호출
|
||||
```bash
|
||||
curl -X POST 'http://localhost:8080/api/sales/order-mgmt/<CONTRACT_OBJID>/amaranth-approval' \
|
||||
-H 'Authorization: Bearer <TOKEN>' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"approvalTitle":"주문서 결재 - 26C-0800"}'
|
||||
|
||||
# 성공: {"success":true,"data":{"fullUrl":"https://...","approKey":"UB_...","status":"create"}}
|
||||
# 실패: {"success":false,"message":"empSeq 정보가 없습니다. ..."}
|
||||
```
|
||||
|
||||
## UI 동작
|
||||
1. 주문관리 그리드에서 행 1개 선택
|
||||
2. "결재상신" 버튼 클릭 (수주복사 옆, 하늘색 sky-600)
|
||||
3. 가드 통과 → 확인 다이얼로그: "결재상신 하시겠습니까?"
|
||||
4. 확인 → API 호출 → `window.open(fullUrl, "amaranthApproval", "width=1200,height=900,...")`
|
||||
5. 외부 Amaranth 결재 페이지에서 사용자가 양식 작성 + 상신
|
||||
6. 목록 새로고침 → "결재상태" 컬럼이 '작성중' → '결재중' → '결재완료' 순으로 변화
|
||||
|
||||
## 미구현 (백로그)
|
||||
- **첨부파일 원챔버 업로드** — wace `uploadOrderFilesToOneChamber` (영업관리 첨부 흐름 별도 작업 후 연동)
|
||||
- **견적 결재** (target_type=`CONTRACT_ESTIMATE`) — 같은 패턴, 견적관리 페이지에 추가만 하면 됨 (이번 PR 범위 외 — 현재 [estimate/page.tsx:474](../../../frontend/app/(main)/COMPANY_16/sales/estimate/page.tsx#L474) placeholder 토스트만 있음)
|
||||
- **결재 콜백** — amaranth가 우리 시스템에 결재 결과를 통보하는 webhook (운영에서는 폴링 또는 amaranth_approval 수동 갱신)
|
||||
|
||||
## 트러블슈팅 — Amaranth 운영 측 토큰 등록 (2026-05-11 확인)
|
||||
|
||||
dev 환경에서 wace 계정(emp_seq=379) + 코드/HMAC 서명 모두 정상이지만 amaranth 서버가 다음 메시지로 거부:
|
||||
|
||||
```
|
||||
인증 토큰 발급 실패: API Proxy 호출 시 유효한 레디스 값이 존재하지 않습니다.
|
||||
```
|
||||
|
||||
**진단**:
|
||||
- 우리 코드 흐름 정상 (`getAuthToken` → `api99u01A01` 호출까지 도달)
|
||||
- amaranth 서버가 `{resultCode:비-0, resultMsg:"...레디스..."}` 응답 → 토큰을 Redis 캐시에서 찾지 못함
|
||||
- 7개 amaranth 커넥션 모두 같은 callerName(`API_gcmsAmaranth40578`)/groupSeq(`gcmsAmaranth40578`) 공유, accessToken만 도메인별로 다름
|
||||
- 'Amaranth - 결재' 커넥션 시드 시점: 2026-05-08 12:16 (chpark 시드). 마지막 테스트: 2026-05-08 16:44
|
||||
|
||||
**가능한 원인 (운영 측 조치 필요)**:
|
||||
1. 'Amaranth - 결재' 토큰만 amaranth 측 Redis 캐시에 등록 안 됨 (cron 배치로 매일 호출되는 다른 7개 커넥션은 정상 동작 가능성)
|
||||
2. callerName이 wace_plm 운영과 공유되어 동시 사용 시 한쪽이 무효화
|
||||
3. 결재 전용 토큰의 별도 갱신 주기
|
||||
|
||||
**대응**:
|
||||
- chpark에게 5/8 시드 시 amaranth 운영 측에 결재 토큰 등록을 마저 요청했는지 확인
|
||||
- 또는 RPS ERP 담당자에게 결재 토큰 Redis 재등록 요청
|
||||
- 우회 검증: 다른 amaranth cron 배치(예: 매일 03:10 사원 동기화)가 잘 도는지 확인 → 다른 건 성공이면 결재 토큰만 별도 등록 필요 확정
|
||||
|
||||
**코드 변경 없음** — 운영 협조로 해결되는 영역. 토큰 등록 완료되면 같은 흐름이 그대로 동작.
|
||||
@@ -0,0 +1,162 @@
|
||||
# 영업관리 G4/G11 — 견적 결재상신 (Amaranth 직행) 검증
|
||||
|
||||
> 작성: 2026-05-11 / 작성자: hjjeong
|
||||
> 목적: wace 영업관리 견적 결재 흐름(외부 Amaranth SSO 직행)을 vexplor_rps 견적관리에 1:1 이식. G11 수주 결재상신과 동일 패턴, `target_type='CONTRACT_ESTIMATE'` + `target_objid=estimate_template.objid` 차이.
|
||||
|
||||
## 원본 출처
|
||||
- 프론트: `wace_plm/WebContent/WEB-INF/view/contractMgmt/estimateList_new.jsp:154~270` (btnApproval 가드 + checkApprovalRequired 사전판정)
|
||||
- 프론트: 같은 파일 `:868~916` (`fn_showApprovalConfirmSimple` + `fn_openAmaranthApproval` → SSO URL 호출 → window.open)
|
||||
- 백엔드: `wace_plm/src/com/pms/service/ApprovalService.java:1782~1909` (`getAmaranthSsoUrl` — CONTRACT_ESTIMATE 분기는 1853~1854)
|
||||
- 매퍼: `wace_plm/src/com/pms/salesmgmt/mapper/contractMgmt.xml:513~522, 656~659` (APPR_STATUS 라벨 + LEFT JOIN AMARANTH_APPROVAL CONTRACT_ESTIMATE)
|
||||
|
||||
## G11(수주) 대비 차이점
|
||||
|
||||
| 항목 | 수주 (G11) | 견적 (이번 작업) |
|
||||
|---|---|---|
|
||||
| target_type | `CONTRACT_ORDER` | `CONTRACT_ESTIMATE` |
|
||||
| target_objid | `contract_mgmt.objid` (헤더 1개) | `estimate_template.objid` (최신 차수) |
|
||||
| formId | `1161` | `1162` |
|
||||
| 가드 | `has_order_data === 0` | `est_objid` 없음(견적서 미작성) |
|
||||
| 사전판정 | 없음 | wace는 `checkApprovalRequired` 분기 — **G4 영역**이라 이번 작업에서 제외 |
|
||||
|
||||
견적은 차수마다 별도 결재. 같은 영업번호의 차수1 결재 후 차수2 만들면 차수2는 다시 신규 amaranth_approval 매핑.
|
||||
|
||||
## 이식 위치
|
||||
- 백엔드 서비스: `backend-node/src/services/salesEstimateService.ts:startEstimateApproval`
|
||||
- 백엔드 컨트롤러: `backend-node/src/controllers/salesEstimateController.ts:startApproval`
|
||||
- 라우트: `POST /api/sales/estimate/:id/amaranth-approval`
|
||||
- 프론트 API: `frontend/lib/api/salesEstimate.ts:startApproval`
|
||||
- 프론트 UI: `frontend/app/(main)/COMPANY_16/sales/estimate/page.tsx:handleAmaranthApproval` + "결재상신" 버튼
|
||||
- 견적 list SQL 보강: 같은 파일 `salesEstimateService.getList` — `LEFT JOIN amaranth_approval AMR ON ET.objid::VARCHAR=AMR.target_objid AND AMR.target_type='CONTRACT_ESTIMATE'` + APPR_STATUS/AMARANTH_STATUS CASE 분기
|
||||
- 재사용: `backend-node/src/services/amaranthApprovalClient.ts:getSsoUrl` (chpark, G11에서 도입)
|
||||
|
||||
## 결재 정책 (wace 1:1)
|
||||
- target_type: `CONTRACT_ESTIMATE`
|
||||
- formId: `1162` (운영 amaranth 견적 양식 ID — 수주 1161과 별도)
|
||||
- compSeq: `1000`
|
||||
- mod: `W`
|
||||
- empSeq 출처: `user_info.emp_seq`
|
||||
- approKey 분기 (G11 동일):
|
||||
- 신규: `UB_` + Date.now().toString(36).toUpperCase()
|
||||
- 기존 reject/delete/create: 새 approKey + amaranth_approval UPDATE (재상신)
|
||||
- 기존 inProcess/complete: 기존 approKey 재사용 (프론트에서 차단되지만 백엔드 방어)
|
||||
|
||||
## 환경변수 (운영 배포 시 주입)
|
||||
| 변수 | 기본값 | 비고 |
|
||||
|---|---|---|
|
||||
| `AMARANTH_OUT_PROCESS_CODE_CONTRACT_ESTIMATE` | (없음) | 견적 결재 전용 코드. 미설정 시 `AMARANTH_OUT_PROCESS_CODE` fallback |
|
||||
| `AMARANTH_FORM_ID_CONTRACT_ESTIMATE` | `1162` | wace 운영값 |
|
||||
| `AMARANTH_COMP_SEQ` | `1000` | wace 운영값 (수주와 공유) |
|
||||
|
||||
3개 docker-compose(`deploy/onpremise`, `docker/deploy`, `docker/prod/docker-compose.backend.prod.yml`) 모두 `${VAR:-default}` 형식으로 매핑됨 — 호스트 .env에 아무것도 안 넣어도 wace 운영값 그대로 동작.
|
||||
|
||||
## 가드 (프론트 + 백엔드 동시)
|
||||
| 조건 | 메시지 | 처리 |
|
||||
|---|---|---|
|
||||
| 행 미선택 | "결재상신할 행을 선택해주십시오." | 프론트 toast |
|
||||
| `est_objid` 없음 | "견적서를 먼저 작성해주세요." | 프론트 toast + 백엔드 400 |
|
||||
| `amaranth_status === 'inProcess'` | "결재 진행중인 건은 상신할 수 없습니다." | 프론트 toast |
|
||||
| `amaranth_status === 'complete'` | "결재 완료된 건은 상신할 수 없습니다." | 프론트 toast |
|
||||
| `amaranth_status === 'notRequired'` 또는 `approval_required === 'N'` | "결재불필요로 처리된 건입니다." | 프론트 toast |
|
||||
| 사용자 emp_seq 미설정 | "empSeq 정보가 없습니다." | 백엔드 400 |
|
||||
| 원본 contract_mgmt 부재 | "견적을 찾을 수 없습니다." | 백엔드 404 |
|
||||
| SSO API resultCode != 0 | "결재 연동 오류: ..." | 백엔드 502 |
|
||||
|
||||
## 검증 SQL (BEGIN/ROLLBACK)
|
||||
|
||||
```sql
|
||||
BEGIN;
|
||||
|
||||
-- 가짜 amaranth_approval 매핑 INSERT (CONTRACT_ESTIMATE, target_objid = est_objid)
|
||||
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
|
||||
(9999999998, '-452406811', 'CONTRACT_ESTIMATE', 'UB_TEST_EST', 'RPSPLM_00001', '1162',
|
||||
'create', '999', '1000', '', 'test', 'http://test', NOW());
|
||||
|
||||
-- 4단계 상태 라벨 확인 (UPDATE를 반복)
|
||||
SELECT CASE
|
||||
WHEN AMR.status = 'complete' THEN '결재완료'
|
||||
WHEN AMR.status = 'inProcess' THEN '결재중'
|
||||
WHEN AMR.status = 'reject' THEN '반려'
|
||||
WHEN AMR.status = 'create' THEN '작성중'
|
||||
WHEN AMR.status = 'notRequired' THEN '결재불필요'
|
||||
WHEN COALESCE(T.APPROVAL_REQUIRED, 'N') = 'N' THEN '결재불필요'
|
||||
ELSE '' END AS appr_status, AMR.status, T.contract_no
|
||||
FROM contract_mgmt T
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT objid FROM estimate_template
|
||||
WHERE contract_objid = T.objid
|
||||
ORDER BY regdate DESC LIMIT 1
|
||||
) ET ON true
|
||||
LEFT JOIN amaranth_approval AMR
|
||||
ON AMR.target_objid = ET.objid::VARCHAR
|
||||
AND AMR.target_type = 'CONTRACT_ESTIMATE'
|
||||
WHERE T.objid = '-912974684';
|
||||
|
||||
UPDATE amaranth_approval SET status='inProcess' WHERE objid=9999999998;
|
||||
-- 위 SELECT 재실행 → '결재중'
|
||||
UPDATE amaranth_approval SET status='complete' WHERE objid=9999999998;
|
||||
-- → '결재완료'
|
||||
UPDATE amaranth_approval SET status='reject' WHERE objid=9999999998;
|
||||
-- → '반려'
|
||||
DELETE FROM amaranth_approval WHERE objid=9999999998;
|
||||
-- → '결재불필요' (approval_required='N' fallback)
|
||||
|
||||
ROLLBACK;
|
||||
```
|
||||
|
||||
### 검증 결과 (2026-05-11)
|
||||
|
||||
샘플 contract: `26C-0712` (contract_objid=`-912974684`, est_objid=`-452406811`, approval_required='N')
|
||||
|
||||
| AMR.status | appr_status (한글) |
|
||||
|---|---|
|
||||
| `create` | 작성중 |
|
||||
| `inProcess` | 결재중 |
|
||||
| `complete` | 결재완료 |
|
||||
| `reject` | 반려 |
|
||||
| (AMR row 삭제, approval_required='N') | 결재불필요 |
|
||||
|
||||
- AMR row가 있으면 status 우선, 없으면 approval_required fallback — wace 1:1.
|
||||
- ROLLBACK 후 운영 데이터 영향 없음.
|
||||
- 견적 list SQL의 LEFT JOIN AMR 동작 확인 — 차수가 늘어나도 최신 차수만 매핑.
|
||||
|
||||
## API 호출
|
||||
|
||||
```bash
|
||||
curl -X POST 'http://localhost:8080/api/sales/estimate/<CONTRACT_OBJID>/amaranth-approval' \
|
||||
-H 'Authorization: Bearer <TOKEN>' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"approvalTitle":"견적서 결재 - 26C-0712"}'
|
||||
|
||||
# 성공: {"success":true,"data":{"fullUrl":"https://...","approKey":"UB_...","status":"create","estObjid":"-452406811"}}
|
||||
# 실패: {"success":false,"message":"견적서를 먼저 작성해주세요."}
|
||||
```
|
||||
|
||||
## UI 동작
|
||||
|
||||
1. 견적관리 그리드에서 행 1개 선택
|
||||
2. "결재상신" 버튼 클릭 (메일발송 옆, sky-600)
|
||||
3. 가드 통과 → 확인 다이얼로그: "결재상신 하시겠습니까?"
|
||||
4. 확인 → API 호출 → `window.open(fullUrl, "amaranthApproval", "width=1200,height=900,...")`
|
||||
5. 외부 Amaranth 결재 페이지에서 사용자가 양식 작성 + 상신
|
||||
6. 목록 새로고침 → "결재상태" 컬럼이 '작성중' → '결재중' → '결재완료' 순으로 변화
|
||||
|
||||
## 미구현 (백로그)
|
||||
- **첨부파일 원챔버 업로드** — wace `uploadEstimateFilesToOneChamber` (영업관리 첨부 흐름 별도 작업 후 연동)
|
||||
- **사전판정 (`checkApprovalRequired`)** — G4 영역. wace는 결재상신 클릭 → 재오더/신규수주/가격인하 룰 판정 → 재오더면 "결재불필요" 자동 처리, 신규수주/가격인하면 사유 안내 후 결재상신. 이번 작업은 G11 동일 흐름(단순 SSO)만.
|
||||
- **결재 콜백** — amaranth가 우리 시스템에 결재 결과를 통보하는 webhook (운영에서는 폴링 또는 amaranth_approval 수동 갱신)
|
||||
|
||||
## 운영 트러블슈팅 (G11과 공유)
|
||||
|
||||
dev에서 wace 계정으로 결재상신 클릭 시 amaranth가 다음 메시지로 거부 가능:
|
||||
|
||||
```
|
||||
인증 토큰 발급 실패: API Proxy 호출 시 유효한 레디스 값이 존재하지 않습니다.
|
||||
```
|
||||
|
||||
원인은 'Amaranth - 결재' 커넥션 accessToken이 amaranth 측 Redis 캐시에 등록 안 된 상태(7개 amaranth 커넥션 중 결재 토큰만 별도). G11 작업 시 동일 현상 확인됨. 대응 = chpark 또는 RPS ERP 담당자에게 결재 토큰 Redis 등록 요청 (자세한 진단: `07-amaranth-approval-verify.md` 트러블슈팅 섹션).
|
||||
|
||||
**코드 변경 없음** — 운영 협조로 해결되는 영역.
|
||||
@@ -0,0 +1,238 @@
|
||||
"use client";
|
||||
|
||||
// 진행관리 (wace projectMgmtWbsList3.jsp + projectMgmtWbsGridList 1:1 이식)
|
||||
// 원본:
|
||||
// - JSP: /Users/jhj/wace_plm/WebContent/WEB-INF/view/project/projectMgmtWbsList3.jsp (349줄)
|
||||
// - 매퍼: wace_plm/src/com/pms/mapper/project.xml:3854 projectMgmtWbsGridList
|
||||
// GAP: docs/migration/project/00-gap.md
|
||||
//
|
||||
// 그리드: 8그룹 18셀 평탄화 (DataGrid가 그룹 헤더 미지원 → 라벨 prefix로 표현)
|
||||
// 검색폼: 11필드 (1행 6 + 2행 5)
|
||||
// 행 클릭: P1.5에서 영업관리 OrderRegistDialog 재사용 검토 — 현재 미연결
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Search, Loader2, RotateCcw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
||||
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
|
||||
import { CustomerSelect } from "@/components/common/CustomerSelect";
|
||||
import { PartSelect } from "@/components/common/PartSelect";
|
||||
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
|
||||
import { projectMgmtApi, ProgressListFilter, ProgressRow } from "@/lib/api/projectMgmt";
|
||||
|
||||
// wace projectMgmtWbsList3.jsp 컬럼 정의 1:1 (8그룹 → 평탄화, 그룹명은 라벨 prefix)
|
||||
const GRID_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "project_no", label: "프로젝트번호", width: "w-[160px]", frozen: true },
|
||||
// 프로젝트정보 그룹
|
||||
{ key: "category_name", label: "주문유형", width: "w-[90px]", align: "center" },
|
||||
{ key: "product_name", label: "제품구분", width: "w-[100px]", align: "left" },
|
||||
{ key: "area_name", label: "국내/해외", width: "w-[90px]", align: "center" },
|
||||
{ key: "reg_date", label: "접수일", width: "w-[110px]", align: "center" },
|
||||
{ key: "customer_name", label: "고객사", width: "w-[140px]" },
|
||||
{ key: "free_of_charge", label: "유/무상", width: "w-[80px]", align: "center" },
|
||||
{ key: "product_item_code", label: "품번", width: "w-[150px]" },
|
||||
{ key: "product_item_name", label: "품명", width: "w-[180px]" },
|
||||
{ key: "serial_no", label: "S/N", width: "w-[150px]" },
|
||||
{ key: "contract_qty", label: "수주수량", width: "w-[90px]", align: "right", formatNumber: true },
|
||||
{ key: "req_del_date", label: "요청납기", width: "w-[110px]", align: "center" },
|
||||
// 설계
|
||||
{ key: "ebom_status", label: "E-BOM", width: "w-[100px]", align: "center" },
|
||||
// 생산관리
|
||||
{ key: "mbom_status", label: "M-BOM", width: "w-[100px]", align: "center" },
|
||||
// 구매
|
||||
{ key: "order_date", label: "발주일", width: "w-[110px]", align: "center" },
|
||||
{ key: "receiving_rate", label: "입고율", width: "w-[90px]", align: "right" },
|
||||
// 생산
|
||||
{ key: "production_team_12", label: "제조1,2팀", width: "w-[100px]", align: "center" },
|
||||
{ key: "production_team_3", label: "제조3팀", width: "w-[100px]", align: "center" },
|
||||
// 장비
|
||||
{ key: "assembly", label: "조립", width: "w-[90px]", align: "center" },
|
||||
{ key: "verification", label: "검증", width: "w-[90px]", align: "center" },
|
||||
// 출하
|
||||
{ key: "shipment_date", label: "출하일", width: "w-[110px]", align: "center" },
|
||||
];
|
||||
|
||||
const CATEGORY_GROUP = "0000167"; // 주문유형
|
||||
const PRODUCT_GROUP = "0000001"; // 제품구분
|
||||
|
||||
// wace L229: 시스템년도 ±4 (운영판은 sysYear-4 ~ sysYear). RPS는 sysYear±4로 여유.
|
||||
const YEAR_OPTIONS = (() => {
|
||||
const cur = new Date().getFullYear();
|
||||
const arr: string[] = [];
|
||||
for (let y = cur + 4; y >= cur - 4; y--) arr.push(String(y));
|
||||
return arr;
|
||||
})();
|
||||
|
||||
const EMPTY_FILTER: ProgressListFilter = {
|
||||
Year: "", project_nos: "", category_cd: "", customer_objid: "", product: "",
|
||||
contract_start_date: "", contract_end_date: "",
|
||||
area_cd: "", free_of_charge: "",
|
||||
search_partObjId: "", serial_no: "",
|
||||
};
|
||||
|
||||
export default function ProjectProgressPage() {
|
||||
const [rows, setRows] = useState<ProgressRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filter, setFilter] = useState<ProgressListFilter>(EMPTY_FILTER);
|
||||
const [projectNoOptions, setProjectNoOptions] = useState<SmartSelectOption[]>([]);
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await projectMgmtApi.list(filter);
|
||||
setRows(data);
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
|
||||
} finally { setLoading(false); }
|
||||
}, [filter]);
|
||||
|
||||
useEffect(() => { fetchList(); /* eslint-disable-next-line */ }, []);
|
||||
|
||||
// 프로젝트번호 셀렉트 옵션 (wace common.getCusProjectNoList 대응)
|
||||
useEffect(() => {
|
||||
projectMgmtApi.projectNoOptions()
|
||||
.then((opts) => setProjectNoOptions(opts.map((o) => ({ code: o.value, label: o.label }))))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleReset = () => {
|
||||
setFilter(EMPTY_FILTER);
|
||||
setTimeout(() => fetchList(), 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 검색폼 — wace projectMgmtWbsList3.jsp:222-313 활성 11필드 */}
|
||||
<div className="border-b bg-card px-4 py-3">
|
||||
<div className="grid grid-cols-6 gap-3 text-sm">
|
||||
{/* 1행 */}
|
||||
<Field label="년도">
|
||||
<select
|
||||
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
|
||||
value={filter.Year ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, Year: e.target.value })}
|
||||
>
|
||||
<option value="">전체</option>
|
||||
{YEAR_OPTIONS.map((y) => <option key={y} value={y}>{y}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="프로젝트번호">
|
||||
<SmartSelect
|
||||
options={projectNoOptions}
|
||||
value={filter.project_nos ?? ""}
|
||||
onValueChange={(v) => setFilter({ ...filter, project_nos: v })}
|
||||
placeholder="전체"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="주문유형">
|
||||
<CommCodeSelect
|
||||
groupId={CATEGORY_GROUP}
|
||||
value={filter.category_cd ?? ""}
|
||||
onValueChange={(v) => setFilter({ ...filter, category_cd: v })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="고객사">
|
||||
<CustomerSelect
|
||||
value={filter.customer_objid ?? ""}
|
||||
onValueChange={(v) => setFilter({ ...filter, customer_objid: v })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="제품구분">
|
||||
<CommCodeSelect
|
||||
groupId={PRODUCT_GROUP}
|
||||
value={filter.product ?? ""}
|
||||
onValueChange={(v) => setFilter({ ...filter, product: v })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="요청납기일">
|
||||
<div className="flex items-center gap-1">
|
||||
<Input type="date" value={filter.contract_start_date ?? ""} onChange={(e) => setFilter({ ...filter, contract_start_date: e.target.value })} />
|
||||
<span className="text-xs text-muted-foreground">~</span>
|
||||
<Input type="date" value={filter.contract_end_date ?? ""} onChange={(e) => setFilter({ ...filter, contract_end_date: e.target.value })} />
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{/* 2행 */}
|
||||
<Field label="국내/해외">
|
||||
<select
|
||||
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
|
||||
value={filter.area_cd ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, area_cd: e.target.value })}
|
||||
>
|
||||
<option value="">전체</option>
|
||||
<option value="국내">국내</option>
|
||||
<option value="해외">해외</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="유/무상">
|
||||
<select
|
||||
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
|
||||
value={filter.free_of_charge ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, free_of_charge: e.target.value })}
|
||||
>
|
||||
<option value="">전체</option>
|
||||
<option value="유상">유상</option>
|
||||
<option value="무상">무상</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="품번">
|
||||
<PartSelect
|
||||
mode="partNo"
|
||||
value={filter.search_partObjId ?? ""}
|
||||
onValueChange={(v) => setFilter({ ...filter, search_partObjId: v })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="품명">
|
||||
<PartSelect
|
||||
mode="partName"
|
||||
value={filter.search_partObjId ?? ""}
|
||||
onValueChange={(v) => setFilter({ ...filter, search_partObjId: v })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="S/N">
|
||||
<Input
|
||||
value={filter.serial_no ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, serial_no: e.target.value })}
|
||||
placeholder="S/N LIKE"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* 액션 */}
|
||||
<div className="flex items-end justify-end gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleReset}>
|
||||
<RotateCcw className="h-4 w-4" /><span className="ml-1">초기화</span>
|
||||
</Button>
|
||||
<Button size="sm" onClick={fetchList} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
|
||||
<span className="ml-1">조회</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 그리드 (8그룹 18셀 평탄화) */}
|
||||
<div className="flex-1 min-h-0 p-2">
|
||||
<DataGrid
|
||||
columns={GRID_COLUMNS}
|
||||
data={rows}
|
||||
loading={loading}
|
||||
showRowNumber
|
||||
emptyMessage="조건에 맞는 프로젝트가 없습니다."
|
||||
gridId="project-progress-wbslist3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Plus, Save, Trash2, Loader2, Search, Pencil } from "lucide-react";
|
||||
import { Plus, Save, Trash2, Loader2, Search, Pencil, Send } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
@@ -21,6 +21,7 @@ import { PartSelect } from "@/components/common/PartSelect";
|
||||
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
|
||||
import { ItemSearchDialog, ItemRow } from "@/components/common/ItemSearchDialog";
|
||||
import { AttachmentDialog } from "@/components/common/AttachmentDialog";
|
||||
import { EstimateMailDialog } from "@/components/sales/EstimateMailDialog";
|
||||
import { salesEstimateApi, EstimateRow, EstimateBody, EstimateItem } from "@/lib/api/salesEstimate";
|
||||
|
||||
// ─── 컬럼 ─────────────────────────────────────────────────────
|
||||
@@ -131,15 +132,8 @@ export default function SalesEstimatePage() {
|
||||
const [seqStartNo, setSeqStartNo] = useState("");
|
||||
const [seqCount, setSeqCount] = useState("");
|
||||
|
||||
// 메일 발송 다이얼로그
|
||||
// 메일 발송 다이얼로그 — wace estimateMailFormPopup.jsp 1:1
|
||||
const [mailDialogOpen, setMailDialogOpen] = useState(false);
|
||||
const [mailSending, setMailSending] = useState(false);
|
||||
const [mailForm, setMailForm] = useState({
|
||||
toEmails: "",
|
||||
ccEmails: "",
|
||||
subject: "",
|
||||
contents: "",
|
||||
});
|
||||
|
||||
// 첨부파일 다이얼로그 (추가견적 클립 컬럼 클릭 시)
|
||||
// G5 견적작성 — 일반/장비 선택 다이얼로그
|
||||
@@ -359,56 +353,47 @@ export default function SalesEstimatePage() {
|
||||
}
|
||||
};
|
||||
|
||||
// ─── 결재상신 (G4/G11 동일 패턴 — 견적, target_type='CONTRACT_ESTIMATE') ─────
|
||||
// wace estimateList_new.jsp:155 btnApproval + :887 fn_openAmaranthApproval 1:1.
|
||||
// 단순 SSO 흐름만 — 사전판정(checkApprovalRequired) 분기는 G4 영역으로 분리.
|
||||
// 가드: 행 미선택 / est_objid 없음(견적서 미작성) / inProcess·complete / 결재불필요
|
||||
const handleAmaranthApproval = async () => {
|
||||
if (!selected) { toast.warning("결재상신할 행을 선택해주십시오."); return; }
|
||||
if (!selected.est_objid) { toast.warning("견적서를 먼저 작성해주세요."); return; }
|
||||
const amaranthStatus = String(selected.amaranth_status ?? "");
|
||||
if (amaranthStatus === "inProcess") { toast.warning("결재 진행중인 건은 상신할 수 없습니다."); return; }
|
||||
if (amaranthStatus === "complete") { toast.warning("결재 완료된 건은 상신할 수 없습니다."); return; }
|
||||
if (amaranthStatus === "notRequired" || selected.approval_required === "N") {
|
||||
toast.warning("결재불필요로 처리된 건입니다."); return;
|
||||
}
|
||||
const ok = await confirm("결재상신", { description: "결재상신 하시겠습니까?" });
|
||||
if (!ok) return;
|
||||
try {
|
||||
const { fullUrl } = await salesEstimateApi.startApproval(selected.objid, {
|
||||
approvalTitle: `견적서 결재${selected.contract_no ? " - " + selected.contract_no : ""}`,
|
||||
});
|
||||
if (!fullUrl) { toast.error("결재 SSO URL을 받지 못했습니다."); return; }
|
||||
window.open(fullUrl, "amaranthApproval", "width=1200,height=900,scrollbars=yes,resizable=yes");
|
||||
await fetchList();
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.message ?? "결재 시스템 연동 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// ─── 메일 발송 ──────────────────────────────────────────────
|
||||
// 1차에서는 mail_log INSERT만 (이력만 등록 → 그리드 메일발송 컬럼 표시).
|
||||
// 실제 SMTP 발송은 mailSendSimple 인프라 통합 시 추가 예정.
|
||||
// 실제 SMTP는 backend-node SMTP_SEND_SWITCH='Y'일 때 sales 계정(sales@rps-korea.com)으로 발송.
|
||||
// PDF 첨부: 견적관리 → "메일발송" 클릭 → EstimateMailDialog가
|
||||
// 최신 차수 template1/template2 페이지를 hidden iframe에 렌더 →
|
||||
// fn_generateAndUploadPdf로 base64 추출 → backend가 estimate02 N건과 합본 첨부.
|
||||
|
||||
const openMailDialog = () => {
|
||||
if (!selected) {
|
||||
toast.warning("메일 발송할 견적을 선택하세요.");
|
||||
return;
|
||||
}
|
||||
const customer = selected.customer_name ?? "";
|
||||
const estNo = selected.estimate_no ?? selected.contract_no ?? "";
|
||||
setMailForm({
|
||||
toEmails: "",
|
||||
ccEmails: "",
|
||||
subject: `[${customer}] 견적서 발송 - ${estNo}`,
|
||||
contents: `안녕하세요, ${customer} 담당자님.\n\n요청하신 견적서(${estNo})를 발송드립니다.\n검토 후 회신 부탁드립니다.\n\n감사합니다.`,
|
||||
});
|
||||
setMailDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSendMail = async () => {
|
||||
if (!selected) return;
|
||||
if (!mailForm.toEmails.trim()) {
|
||||
toast.error("받는 사람을 입력하세요.");
|
||||
return;
|
||||
}
|
||||
if (!mailForm.subject.trim()) {
|
||||
toast.error("제목을 입력하세요.");
|
||||
return;
|
||||
}
|
||||
setMailSending(true);
|
||||
try {
|
||||
await salesEstimateApi.sendMail({
|
||||
contractObjid: selected.objid,
|
||||
toEmails: mailForm.toEmails.trim(),
|
||||
ccEmails: mailForm.ccEmails.trim() || undefined,
|
||||
subject: mailForm.subject,
|
||||
contents: mailForm.contents,
|
||||
isSend: "Y",
|
||||
});
|
||||
toast.success("메일 발송 이력이 등록되었습니다.");
|
||||
setMailDialogOpen(false);
|
||||
await fetchList();
|
||||
} catch (err: any) {
|
||||
toast.error(`메일 발송 실패: ${err?.response?.data?.message ?? err.message}`);
|
||||
} finally {
|
||||
setMailSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── 라인 편집 ──────────────────────────────────────────────
|
||||
|
||||
const updateItem = (idx: number, key: keyof EstimateItem, val: any) => {
|
||||
@@ -512,9 +497,9 @@ export default function SalesEstimatePage() {
|
||||
<Button size="sm" variant="outline" onClick={openTemplateChoice} disabled={!selected}>
|
||||
<Pencil className="w-4 h-4 mr-1" />견적작성
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={!selected}
|
||||
onClick={() => toast.info("결재 모듈 연동 예정 — approval_required='N'은 자동 '결재불필요'로 표시됩니다.")}>
|
||||
결재상신
|
||||
<Button size="sm" className="bg-sky-600 hover:bg-sky-700 text-white"
|
||||
onClick={handleAmaranthApproval} disabled={!selected}>
|
||||
<Send className="w-4 h-4 mr-1" />결재상신
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={!selected}
|
||||
onClick={openMailDialog}>
|
||||
@@ -824,54 +809,15 @@ export default function SalesEstimatePage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 메일 발송 Dialog */}
|
||||
<Dialog open={mailDialogOpen} onOpenChange={setMailDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>견적 메일 발송</DialogTitle>
|
||||
<DialogDescription>
|
||||
발송 이력이 mail_log에 기록되어 그리드 메일발송 컬럼에 표시됩니다.
|
||||
{selected && <> · 영업번호: <strong>{selected.contract_no ?? selected.objid}</strong></>}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs">받는 사람 (To) *</Label>
|
||||
<Input value={mailForm.toEmails}
|
||||
onChange={(e) => setMailForm({ ...mailForm, toEmails: e.target.value })}
|
||||
placeholder="example@company.com (여러 명은 ; 또는 , 로 구분)" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">참조 (CC)</Label>
|
||||
<Input value={mailForm.ccEmails}
|
||||
onChange={(e) => setMailForm({ ...mailForm, ccEmails: e.target.value })}
|
||||
placeholder="(선택)" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">제목 *</Label>
|
||||
<Input value={mailForm.subject}
|
||||
onChange={(e) => setMailForm({ ...mailForm, subject: e.target.value })} />
|
||||
<p className="text-[11px] text-muted-foreground mt-1">
|
||||
저장 시 [OBJID:{selected?.objid ?? ""}] 태그가 자동으로 추가됩니다.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">본문</Label>
|
||||
<Textarea rows={8} value={mailForm.contents}
|
||||
onChange={(e) => setMailForm({ ...mailForm, contents: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setMailDialogOpen(false)} disabled={mailSending}>취소</Button>
|
||||
<Button onClick={handleSendMail} disabled={mailSending}>
|
||||
{mailSending ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : <Save className="w-4 h-4 mr-1" />}
|
||||
발송 (이력 등록)
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* 메일 발송 Dialog — wace estimateMailFormPopup.jsp 1:1 */}
|
||||
<EstimateMailDialog
|
||||
open={mailDialogOpen}
|
||||
onOpenChange={setMailDialogOpen}
|
||||
contractObjid={selected?.objid ?? null}
|
||||
addEstCount={Number((selected as any)?.add_est_cnt ?? 0)}
|
||||
estStatusCount={Number(selected?.est_status ?? 0)}
|
||||
onSent={() => { fetchList(); }}
|
||||
/>
|
||||
|
||||
{/* 첨부파일 다이얼로그 — 추가견적 등 클립 컬럼 클릭 시 */}
|
||||
{attachContext && (
|
||||
|
||||
+103
-50
@@ -9,7 +9,7 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useParams, useSearchParams, useRouter } from "next/navigation";
|
||||
import { salesEstimateApi, EstimateTemplateItemRow } from "@/lib/api/salesEstimate";
|
||||
import { CustomerSelect } from "@/components/common/CustomerSelect";
|
||||
import { CustomerSelect, fetchCustomers } from "@/components/common/CustomerSelect";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
// ─── 포맷 헬퍼 (wace addComma / getCurrencySymbol 1:1) ────────
|
||||
@@ -224,6 +224,10 @@ export default function EstimateTemplate1Page() {
|
||||
|
||||
// 4) 로그인 사용자 정보 — 담당자 이름/연락처 자동 채움 (기존 값 없을 때만)
|
||||
// (백엔드에 별도 user 조회 API가 있으면 사용; 일단 비워두고 수동 입력 허용)
|
||||
|
||||
// 5) 수신처 CustomerSelect 옵션 미리 로드 — PDF 캡처 시 selectedLabel이 비어있는 현상 방지
|
||||
// (hidden iframe 메일 발송 흐름에서 dataLoaded 트리거 시점에 옵션 fetch가 안 끝나면 빈 셀렉트로 잡힘)
|
||||
try { await fetchCustomers(); } catch {}
|
||||
} finally {
|
||||
if (!cancel) setLoading(false);
|
||||
}
|
||||
@@ -296,58 +300,79 @@ export default function EstimateTemplate1Page() {
|
||||
window.print();
|
||||
}
|
||||
|
||||
// PDF 다운로드 — wace fn_generatePdf 1:1 (클라이언트 html2canvas + jsPDF)
|
||||
// ─── 견적서 PDF 생성 (공용) — wace fn_generatePdf 1:1 ─────────────
|
||||
// 다운로드/메일발송 모두 같은 캡처 로직 사용. 반환값은 jsPDF 인스턴스.
|
||||
async function generatePdfDocument(): Promise<any> {
|
||||
const html2canvas = (await import("html2canvas-pro")).default;
|
||||
const { jsPDF } = await import("jspdf");
|
||||
|
||||
const container = document.querySelector(".estimate-container") as HTMLElement | null;
|
||||
if (!container) throw new Error("견적서 컨테이너(.estimate-container)를 찾을 수 없습니다.");
|
||||
|
||||
const canvas = await html2canvas(container, {
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
logging: false,
|
||||
backgroundColor: "#ffffff",
|
||||
// 클론된 DOM에서 input/textarea/select → 텍스트 노드로 교체 (글자 잘림 방지)
|
||||
onclone: (doc) => {
|
||||
const replaceWithText = (el: HTMLElement, text: string) => {
|
||||
const span = doc.createElement("span");
|
||||
span.textContent = text;
|
||||
const style = el.getAttribute("style") || "";
|
||||
span.setAttribute("style", style + ";display:inline-block;white-space:pre-wrap;");
|
||||
el.parentNode?.replaceChild(span, el);
|
||||
};
|
||||
doc.querySelectorAll<HTMLInputElement>('input[type="text"],input[type="date"]').forEach(el => replaceWithText(el, el.value || ""));
|
||||
doc.querySelectorAll<HTMLTextAreaElement>("textarea").forEach(el => replaceWithText(el, el.value || ""));
|
||||
doc.querySelectorAll<HTMLSelectElement>("select").forEach(el => {
|
||||
const opt = el.options[el.selectedIndex];
|
||||
replaceWithText(el as unknown as HTMLElement, opt?.text || "");
|
||||
});
|
||||
// SmartSelect/CustomerSelect (role="combobox") → 선택된 라벨만 텍스트로
|
||||
// (button 안 span.truncate가 selectedLabel — 옵션 fetch 미완 등 엣지케이스에서 텍스트가 비어 캡처 누락되는 현상 방지)
|
||||
doc.querySelectorAll<HTMLElement>('[role="combobox"]').forEach((el) => {
|
||||
const labelSpan = el.querySelector(".truncate");
|
||||
// 실제 렌더 텍스트는 라이브 DOM에서 가져오기 (clone된 doc에서는 빈 경우 있음)
|
||||
const liveBtn = document.querySelectorAll<HTMLElement>('[role="combobox"]');
|
||||
let text = (labelSpan?.textContent ?? "").trim();
|
||||
if (text === "" || /^거래처\s*선택$|^고객사\s*선택$|^선택$/i.test(text)) {
|
||||
// placeholder만 잡혔으면 빈 문자열로
|
||||
const idx = Array.from(doc.querySelectorAll('[role="combobox"]')).indexOf(el);
|
||||
const live = liveBtn[idx];
|
||||
const liveLabel = live?.querySelector(".truncate")?.textContent?.trim() ?? "";
|
||||
text = /^거래처\s*선택$|^고객사\s*선택$|^선택$/i.test(liveLabel) ? "" : liveLabel;
|
||||
}
|
||||
replaceWithText(el, text);
|
||||
});
|
||||
// 인쇄 비대상 요소 숨김
|
||||
doc.querySelectorAll<HTMLElement>(".no-print, .btn-area, .delete-btn-cell button").forEach(el => { el.style.display = "none"; });
|
||||
},
|
||||
});
|
||||
|
||||
const imgData = canvas.toDataURL("image/jpeg", 0.85);
|
||||
const pdf = new jsPDF("p", "mm", "a4");
|
||||
const imgWidth = 210, pageHeight = 297;
|
||||
const imgHeight = canvas.height * imgWidth / canvas.width;
|
||||
let heightLeft = imgHeight;
|
||||
let position = 0;
|
||||
|
||||
pdf.addImage(imgData, "JPEG", 0, position, imgWidth, imgHeight, undefined, "FAST");
|
||||
heightLeft -= pageHeight;
|
||||
while (heightLeft > 1) {
|
||||
position = heightLeft - imgHeight;
|
||||
pdf.addPage();
|
||||
pdf.addImage(imgData, "JPEG", 0, position, imgWidth, imgHeight, undefined, "FAST");
|
||||
heightLeft -= pageHeight;
|
||||
}
|
||||
return pdf;
|
||||
}
|
||||
|
||||
// PDF 다운로드 — wace fn_generatePdf 1:1
|
||||
async function handleDownloadPdf() {
|
||||
if (!confirm("PDF로 다운로드 하시겠습니까?")) return;
|
||||
try {
|
||||
// html2canvas-pro: oklab/oklch 등 모던 CSS color function 지원 (Tailwind 4 호환)
|
||||
const html2canvas = (await import("html2canvas-pro")).default;
|
||||
const { jsPDF } = await import("jspdf");
|
||||
|
||||
const container = document.querySelector(".estimate-container") as HTMLElement | null;
|
||||
if (!container) return;
|
||||
|
||||
const canvas = await html2canvas(container, {
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
logging: false,
|
||||
backgroundColor: "#ffffff",
|
||||
// 클론된 DOM에서 input/textarea/select → 텍스트 노드로 교체 (글자 잘림 방지)
|
||||
onclone: (doc) => {
|
||||
const replaceWithText = (el: HTMLElement, text: string) => {
|
||||
const span = doc.createElement("span");
|
||||
span.textContent = text;
|
||||
const style = el.getAttribute("style") || "";
|
||||
span.setAttribute("style", style + ";display:inline-block;white-space:pre-wrap;");
|
||||
el.parentNode?.replaceChild(span, el);
|
||||
};
|
||||
doc.querySelectorAll<HTMLInputElement>('input[type="text"],input[type="date"]').forEach(el => replaceWithText(el, el.value || ""));
|
||||
doc.querySelectorAll<HTMLTextAreaElement>("textarea").forEach(el => replaceWithText(el, el.value || ""));
|
||||
doc.querySelectorAll<HTMLSelectElement>("select").forEach(el => {
|
||||
const opt = el.options[el.selectedIndex];
|
||||
replaceWithText(el as unknown as HTMLElement, opt?.text || "");
|
||||
});
|
||||
// 인쇄 비대상 요소 숨김
|
||||
doc.querySelectorAll<HTMLElement>(".no-print, .btn-area, .delete-btn-cell button").forEach(el => { el.style.display = "none"; });
|
||||
},
|
||||
});
|
||||
|
||||
const imgData = canvas.toDataURL("image/jpeg", 0.85);
|
||||
const pdf = new jsPDF("p", "mm", "a4");
|
||||
const imgWidth = 210, pageHeight = 297;
|
||||
const imgHeight = canvas.height * imgWidth / canvas.width;
|
||||
let heightLeft = imgHeight;
|
||||
let position = 0;
|
||||
|
||||
pdf.addImage(imgData, "JPEG", 0, position, imgWidth, imgHeight, undefined, "FAST");
|
||||
heightLeft -= pageHeight;
|
||||
while (heightLeft > 1) {
|
||||
position = heightLeft - imgHeight;
|
||||
pdf.addPage();
|
||||
pdf.addImage(imgData, "JPEG", 0, position, imgWidth, imgHeight, undefined, "FAST");
|
||||
heightLeft -= pageHeight;
|
||||
}
|
||||
|
||||
const pdf = await generatePdfDocument();
|
||||
const fileName = (estimateNo || "견적서") + ".pdf";
|
||||
pdf.save(fileName);
|
||||
} catch (e: any) {
|
||||
@@ -356,6 +381,34 @@ export default function EstimateTemplate1Page() {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 부모 창의 메일 다이얼로그용 글로벌 노출 ──────────────────────────
|
||||
// wace estimateMailFormPopup.jsp가 hidden iframe으로 이 페이지를 열고
|
||||
// iframe.contentWindow.fn_generateAndUploadPdf(cb)를 호출한다.
|
||||
// 데이터 로딩 완료 후 dataLoaded=true 세팅 → 부모가 폴링으로 감지.
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (typeof window === "undefined") return;
|
||||
const w = window as any;
|
||||
w.dataLoaded = true;
|
||||
w.fn_generateAndUploadPdf = async (callback: (pdfBase64: string) => void) => {
|
||||
try {
|
||||
const pdf = await generatePdfDocument();
|
||||
// jsPDF.output('datauristring') → data:application/pdf;base64,...
|
||||
const dataUri: string = pdf.output("datauristring");
|
||||
callback(dataUri);
|
||||
} catch (e) {
|
||||
console.error("fn_generateAndUploadPdf error", e);
|
||||
callback("");
|
||||
}
|
||||
};
|
||||
return () => {
|
||||
try {
|
||||
w.dataLoaded = false;
|
||||
delete w.fn_generateAndUploadPdf;
|
||||
} catch {}
|
||||
};
|
||||
}, [loading]);
|
||||
|
||||
function handleClose() {
|
||||
// 새 탭으로 열린 경우 닫기 시도 + 안되면 router back
|
||||
if (window.opener) {
|
||||
|
||||
+81
-1
@@ -10,7 +10,7 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useParams, useSearchParams, useRouter } from "next/navigation";
|
||||
import { salesEstimateApi } from "@/lib/api/salesEstimate";
|
||||
import { CustomerSelect } from "@/components/common/CustomerSelect";
|
||||
import { CustomerSelect, fetchCustomers } from "@/components/common/CustomerSelect";
|
||||
|
||||
// ─── 포맷 헬퍼 ────────────────────────────────────────────────
|
||||
function addComma(num: number | string): string {
|
||||
@@ -276,6 +276,8 @@ export default function EstimateTemplate2Page() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 수신처 CustomerSelect 옵션 미리 로드 — PDF 캡처 selectedLabel 누락 방지
|
||||
try { await fetchCustomers(); } catch {}
|
||||
} finally {
|
||||
if (!cancel) setLoading(false);
|
||||
}
|
||||
@@ -359,6 +361,84 @@ export default function EstimateTemplate2Page() {
|
||||
else router.back();
|
||||
}
|
||||
|
||||
// ─── 메일 다이얼로그용 PDF 생성 노출 (template1과 동일 패턴) ─────────────
|
||||
// wace estimateMailFormPopup.jsp가 hidden iframe으로 이 페이지를 열고
|
||||
// iframe.contentWindow.fn_generateAndUploadPdf(cb)를 호출.
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (typeof window === "undefined") return;
|
||||
const w = window as any;
|
||||
w.dataLoaded = true;
|
||||
w.fn_generateAndUploadPdf = async (callback: (pdfBase64: string) => void) => {
|
||||
try {
|
||||
const html2canvas = (await import("html2canvas-pro")).default;
|
||||
const { jsPDF } = await import("jspdf");
|
||||
|
||||
const container = document.querySelector(".estimate-container") as HTMLElement | null;
|
||||
if (!container) throw new Error("견적서 컨테이너(.estimate-container)를 찾을 수 없습니다.");
|
||||
|
||||
const canvas = await html2canvas(container, {
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
logging: false,
|
||||
backgroundColor: "#ffffff",
|
||||
onclone: (doc) => {
|
||||
const replaceWithText = (el: HTMLElement, text: string) => {
|
||||
const span = doc.createElement("span");
|
||||
span.textContent = text;
|
||||
const style = el.getAttribute("style") || "";
|
||||
span.setAttribute("style", style + ";display:inline-block;white-space:pre-wrap;");
|
||||
el.parentNode?.replaceChild(span, el);
|
||||
};
|
||||
doc.querySelectorAll<HTMLInputElement>('input[type="text"],input[type="date"]').forEach(el => replaceWithText(el, el.value || ""));
|
||||
doc.querySelectorAll<HTMLTextAreaElement>("textarea").forEach(el => replaceWithText(el, el.value || ""));
|
||||
doc.querySelectorAll<HTMLSelectElement>("select").forEach(el => {
|
||||
const opt = el.options[el.selectedIndex];
|
||||
replaceWithText(el as unknown as HTMLElement, opt?.text || "");
|
||||
});
|
||||
// SmartSelect/CustomerSelect → 선택된 라벨만 텍스트로 (placeholder는 무시)
|
||||
doc.querySelectorAll<HTMLElement>('[role="combobox"]').forEach((el) => {
|
||||
const idx = Array.from(doc.querySelectorAll('[role="combobox"]')).indexOf(el);
|
||||
const live = document.querySelectorAll<HTMLElement>('[role="combobox"]')[idx];
|
||||
const liveLabel = live?.querySelector(".truncate")?.textContent?.trim() ?? "";
|
||||
const text = /^거래처\s*선택$|^고객사\s*선택$|^선택$/i.test(liveLabel) ? "" : liveLabel;
|
||||
replaceWithText(el, text);
|
||||
});
|
||||
// contenteditable 영역도 보존 (template2의 비고)
|
||||
doc.querySelectorAll<HTMLElement>(".no-print, .btn-area, button").forEach(el => { el.style.display = "none"; });
|
||||
},
|
||||
});
|
||||
|
||||
const imgData = canvas.toDataURL("image/jpeg", 0.85);
|
||||
const pdf = new jsPDF("p", "mm", "a4");
|
||||
const imgWidth = 210, pageHeight = 297;
|
||||
const imgHeight = canvas.height * imgWidth / canvas.width;
|
||||
let heightLeft = imgHeight;
|
||||
let position = 0;
|
||||
|
||||
pdf.addImage(imgData, "JPEG", 0, position, imgWidth, imgHeight, undefined, "FAST");
|
||||
heightLeft -= pageHeight;
|
||||
while (heightLeft > 1) {
|
||||
position = heightLeft - imgHeight;
|
||||
pdf.addPage();
|
||||
pdf.addImage(imgData, "JPEG", 0, position, imgWidth, imgHeight, undefined, "FAST");
|
||||
heightLeft -= pageHeight;
|
||||
}
|
||||
|
||||
callback(pdf.output("datauristring"));
|
||||
} catch (e) {
|
||||
console.error("fn_generateAndUploadPdf (template2) error", e);
|
||||
callback("");
|
||||
}
|
||||
};
|
||||
return () => {
|
||||
try {
|
||||
w.dataLoaded = false;
|
||||
delete w.fn_generateAndUploadPdf;
|
||||
} catch {}
|
||||
};
|
||||
}, [loading]);
|
||||
|
||||
if (loading) return <div style={{ padding: 40 }}>견적서를 불러오는 중...</div>;
|
||||
|
||||
// ─── 렌더링 (wace 마크업 1:1) ────────────────────────────────
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Plus, Save, Trash2, Loader2, Search, Pencil, CheckCircle2, XCircle } from "lucide-react";
|
||||
import { Plus, Save, Trash2, Loader2, Search, Pencil, CheckCircle2, XCircle, Copy, Send } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
@@ -332,6 +332,49 @@ export default function SalesOrderPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// G4/G11 결재상신 — Amaranth SSO URL 발급 후 새 창 열기 (wace orderMgmtList.btnApproval 1:1)
|
||||
// 가드: 행 1건 선택 / 라인 0건 (hasOrderData) / 진행중·완료 상태 차단
|
||||
const handleAmaranthApproval = async () => {
|
||||
if (!selected) { toast.warning("결재상신할 행을 선택해주십시오."); return; }
|
||||
const hasOrderData = Number(selected.has_order_data ?? 0);
|
||||
if (hasOrderData === 0) { toast.warning("수주 품목을 먼저 등록해주세요."); return; }
|
||||
const amaranthStatus = String(selected.order_amaranth_status ?? "");
|
||||
if (amaranthStatus === "inProcess") { toast.warning("결재 진행중인 건은 상신할 수 없습니다."); return; }
|
||||
if (amaranthStatus === "complete") { toast.warning("결재 완료된 건은 상신할 수 없습니다."); return; }
|
||||
const ok = await confirm("결재상신", { description: "결재상신 하시겠습니까?" });
|
||||
if (!ok) return;
|
||||
try {
|
||||
const { fullUrl } = await salesOrderMgmtApi.startApproval(selected.objid, {
|
||||
approvalTitle: `주문서 결재${selected.contract_no ? " - " + selected.contract_no : ""}`,
|
||||
});
|
||||
if (!fullUrl) { toast.error("결재 SSO URL을 받지 못했습니다."); return; }
|
||||
window.open(fullUrl, "amaranthApproval", "width=1200,height=900,scrollbars=yes,resizable=yes");
|
||||
await fetchList();
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.message ?? "결재 시스템 연동 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// G9 수주복사 (wace orderMgmtList btnCopy 1:1)
|
||||
// - 한 행만 선택 가능
|
||||
// - 확인 다이얼로그 → POST /sales/order-mgmt/:id/copy
|
||||
// - 성공 시 새 영업번호 안내 + 목록 새로고침 (복사본은 is_direct_order='Y'로 들어와 주문관리에 노출)
|
||||
const handleCopyOrder = async () => {
|
||||
if (!selected) { toast.warning("복사할 행을 선택해주십시오."); return; }
|
||||
const ok = await confirm("수주복사", {
|
||||
description: `[${selected.contract_no ?? selected.objid}] 수주를 복사하시겠습니까?`,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
const { newContractNo } = await salesOrderMgmtApi.copyOrder(selected.objid);
|
||||
toast.success(`복사가 완료되었습니다. (영업번호: ${newContractNo})`);
|
||||
setSelected(null);
|
||||
await fetchList();
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.message ?? "복사 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 수주취소 — wace 패턴: 라인 조회 → 라인별 취소수량 input → 일괄 저장
|
||||
const handleCancelOrder = async () => {
|
||||
if (!selected) { toast.warning("수주취소할 행을 선택해주십시오."); return; }
|
||||
@@ -504,6 +547,12 @@ export default function SalesOrderPage() {
|
||||
<Button size="sm" className="bg-rose-600 hover:bg-rose-700 text-white" onClick={handleCancelOrder} disabled={!selected}>
|
||||
<XCircle className="w-4 h-4 mr-1" />수주취소
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={handleCopyOrder} disabled={!selected}>
|
||||
<Copy className="w-4 h-4 mr-1" />수주복사
|
||||
</Button>
|
||||
<Button size="sm" className="bg-sky-600 hover:bg-sky-700 text-white" onClick={handleAmaranthApproval} disabled={!selected}>
|
||||
<Send className="w-4 h-4 mr-1" />결재상신
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={handleDelete} disabled={!selected}>
|
||||
<Trash2 className="w-4 h-4 mr-1" />삭제
|
||||
</Button>
|
||||
|
||||
@@ -18,7 +18,7 @@ let inflight: Promise<SmartSelectOption[]> | null = null;
|
||||
|
||||
// 운영 wace 데이터: contract_mgmt.customer_objid = 'C_' + customer_mng.customer_code
|
||||
// (이전엔 customer_mng.id padded로 매핑했으나 운영 데이터와 어긋났음 — 26C-0801 라온기술/정림유리 미스매치 사례)
|
||||
const fetchCustomers = async (): Promise<SmartSelectOption[]> => {
|
||||
export const fetchCustomers = async (): Promise<SmartSelectOption[]> => {
|
||||
if (cached) return cached;
|
||||
if (inflight) return inflight;
|
||||
inflight = (async () => {
|
||||
|
||||
@@ -104,6 +104,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
"/COMPANY_16/sales/shipping-plan": dynamic(() => import("@/app/(main)/COMPANY_16/sales/shipping-plan/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/sales/claim": dynamic(() => import("@/app/(main)/COMPANY_16/sales/claim/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/sales/quote": dynamic(() => import("@/app/(main)/COMPANY_16/sales/quote/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/project/progress": dynamic(() => import("@/app/(main)/COMPANY_16/project/progress/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/production/process-info": dynamic(() => import("@/app/(main)/COMPANY_16/production/process-info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/production/result": dynamic(() => import("@/app/(main)/COMPANY_16/production/result/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/production/work-instruction": dynamic(() => import("@/app/(main)/COMPANY_16/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }),
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* EstimateMailDialog — wace estimateMailFormPopup.jsp 1:1 이식
|
||||
*
|
||||
* 견적관리 그리드 "메일발송" 버튼에서 호출. 발송 흐름:
|
||||
* 1. open 시 mail-info API로 고객사/작성자 자동 채움 (제목/contents 템플릿/cc=writer_email)
|
||||
* 2. 고객사 담당자 체크박스 리스트 표시 → 체크 시 toEmails에 자동 추가
|
||||
* 3. "발송" 클릭 →
|
||||
* - hasBaseEst='N' && hasAddEst='Y': useAddEstOnly='Y'로 API 호출 (PDF 생성 스킵)
|
||||
* - 그 외: 최신 차수 template1/template2 페이지를 hidden iframe으로 렌더 → fn_generateAndUploadPdf로 base64 추출 → API 호출
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Loader2, Send } from "lucide-react";
|
||||
import { salesEstimateApi, EstimateTemplateRow } from "@/lib/api/salesEstimate";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export interface EstimateMailDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** contract_mgmt.objid (영업번호의 헤더 ID) */
|
||||
contractObjid: string | null;
|
||||
/** 그리드의 추가견적(estimate02) 카운트 — hasAddEst 분기에 사용 */
|
||||
addEstCount?: number;
|
||||
/** 그리드의 견적 차수 카운트 — hasBaseEst 분기에 사용 (0이면 견적서 없음) */
|
||||
estStatusCount?: number;
|
||||
/** 발송 완료 후 그리드 갱신 콜백 */
|
||||
onSent?: () => void;
|
||||
}
|
||||
|
||||
interface Manager {
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
department: string;
|
||||
is_main: string;
|
||||
}
|
||||
|
||||
export function EstimateMailDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
contractObjid,
|
||||
addEstCount = 0,
|
||||
estStatusCount = 0,
|
||||
onSent,
|
||||
}: EstimateMailDialogProps) {
|
||||
const hasAddEst = addEstCount > 0;
|
||||
const hasBaseEst = estStatusCount > 0;
|
||||
// 추가견적만 있는 경우: PDF 생성 스킵
|
||||
const useAddEstOnly = !hasBaseEst && hasAddEst;
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [progress, setProgress] = useState("");
|
||||
const [managers, setManagers] = useState<Manager[]>([]);
|
||||
const [checkedEmails, setCheckedEmails] = useState<Record<string, boolean>>({});
|
||||
const [form, setForm] = useState({
|
||||
toEmails: "",
|
||||
ccEmails: "",
|
||||
subject: "",
|
||||
contents: "",
|
||||
});
|
||||
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
|
||||
// ─── open 시 mail-info + 담당자 자동 로드 ──────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!open || !contractObjid) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
setManagers([]);
|
||||
setCheckedEmails({});
|
||||
setForm({ toEmails: "", ccEmails: "", subject: "", contents: "" });
|
||||
try {
|
||||
const info = await salesEstimateApi.getMailInfo(contractObjid);
|
||||
if (cancelled || !info) return;
|
||||
|
||||
const customerName = info.customer_name ?? "";
|
||||
const contractNo = info.contract_no ?? "";
|
||||
// wace estimateMailFormPopup.jsp fn_generateMailTemplate 1:1
|
||||
const template =
|
||||
`안녕하세요.\n\n` +
|
||||
`${customerName} 귀하께서 요청하신 견적서를 첨부파일로 송부드립니다.\n\n` +
|
||||
`영업번호: ${contractNo}\n\n` +
|
||||
`첨부된 견적서를 검토하신 후 문의사항이 있으시면 연락 주시기 바랍니다.\n\n` +
|
||||
`감사합니다.\n`;
|
||||
|
||||
setForm({
|
||||
toEmails: info.customer_email ?? "",
|
||||
ccEmails: info.writer_email ?? "",
|
||||
subject: `[${customerName}] ${contractNo} 견적서`,
|
||||
contents: template,
|
||||
});
|
||||
|
||||
// 고객사 담당자 리스트 (별도 API)
|
||||
if (info.customer_objid) {
|
||||
try {
|
||||
const mgrs = await salesEstimateApi.getMailManagers(info.customer_objid);
|
||||
if (!cancelled) setManagers(mgrs);
|
||||
} catch {
|
||||
// 담당자 조회 실패는 무시 — 수신인 직접 입력 가능
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error("계약 정보를 불러올 수 없습니다: " + (e?.message ?? ""));
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, contractObjid]);
|
||||
|
||||
// ─── 담당자 체크박스 → toEmails 자동 추가 ─────────────────────────────────
|
||||
function toggleManager(email: string, checked: boolean) {
|
||||
setCheckedEmails((prev) => ({ ...prev, [email]: checked }));
|
||||
setForm((prev) => {
|
||||
const current = prev.toEmails
|
||||
.split(/[,;]/)
|
||||
.map((e) => e.trim())
|
||||
.filter(Boolean);
|
||||
if (checked && !current.includes(email)) {
|
||||
return { ...prev, toEmails: current.concat(email).join(", ") };
|
||||
}
|
||||
if (!checked && current.includes(email)) {
|
||||
return { ...prev, toEmails: current.filter((e) => e !== email).join(", ") };
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 최신 차수 견적서를 hidden iframe으로 렌더 → PDF base64 추출 ───────────
|
||||
const generatePdfBase64 = useCallback(async (latest: EstimateTemplateRow): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const templateType = latest.template_type === "2" ? "2" : "1";
|
||||
const url = `/COMPANY_16/sales/estimate/template${templateType}/pop/${encodeURIComponent(
|
||||
contractObjid!,
|
||||
)}?templateObjid=${encodeURIComponent(latest.objid)}`;
|
||||
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.style.cssText =
|
||||
"position:absolute;left:-9999px;top:-9999px;width:900px;height:1200px;border:none;";
|
||||
iframe.src = url;
|
||||
iframeRef.current = iframe;
|
||||
document.body.appendChild(iframe);
|
||||
|
||||
let attempts = 0;
|
||||
const maxAttempts = 1200; // 100ms × 1200 = 120초
|
||||
|
||||
const cleanup = () => {
|
||||
try {
|
||||
if (iframe.parentNode) iframe.parentNode.removeChild(iframe);
|
||||
} catch {}
|
||||
iframeRef.current = null;
|
||||
};
|
||||
|
||||
// 데이터 로딩 완료 대기 (wace estimateMailFormPopup.jsp의 dataLoaded 폴링과 동일)
|
||||
const checkDataLoaded = () => {
|
||||
attempts++;
|
||||
if (attempts > maxAttempts) {
|
||||
cleanup();
|
||||
reject(new Error("견적서 데이터 로딩 시간이 초과되었습니다. (120초)"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const w = iframe.contentWindow as any;
|
||||
if (w && w.dataLoaded === true && typeof w.fn_generateAndUploadPdf === "function") {
|
||||
w.fn_generateAndUploadPdf((pdfBase64: string) => {
|
||||
cleanup();
|
||||
if (pdfBase64) resolve(pdfBase64);
|
||||
else reject(new Error("PDF 생성에 실패했습니다."));
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// cross-origin은 아니지만, 초기에는 contentWindow가 빈 상태일 수 있음
|
||||
}
|
||||
setTimeout(checkDataLoaded, 100);
|
||||
};
|
||||
|
||||
iframe.addEventListener("load", () => checkDataLoaded());
|
||||
|
||||
// 전체 타임아웃 (180초) — wace와 동일
|
||||
setTimeout(() => {
|
||||
if (iframeRef.current) {
|
||||
cleanup();
|
||||
reject(new Error("PDF 생성 시간이 초과되었습니다. (180초)"));
|
||||
}
|
||||
}, 180_000);
|
||||
});
|
||||
}, [contractObjid]);
|
||||
|
||||
// ─── 발송 ────────────────────────────────────────────────────────────────
|
||||
async function handleSend() {
|
||||
if (!contractObjid) return;
|
||||
const toEmails = form.toEmails.trim();
|
||||
const subject = form.subject.trim();
|
||||
const contents = form.contents.trim();
|
||||
|
||||
if (toEmails === "") { toast.error("수신인을 입력해주세요."); return; }
|
||||
if (subject === "") { toast.error("제목을 입력해주세요."); return; }
|
||||
if (contents === "") { toast.error("내용을 입력해주세요."); return; }
|
||||
|
||||
// 이메일 형식 검증
|
||||
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const emails = toEmails.split(/[,;]/).map((e) => e.trim()).filter(Boolean);
|
||||
for (const e of emails) {
|
||||
if (!emailPattern.test(e)) {
|
||||
toast.error(`올바른 이메일 형식이 아닙니다: ${e}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!confirm("견적서를 발송하시겠습니까?")) return;
|
||||
|
||||
setSending(true);
|
||||
setProgress("발송 준비 중...");
|
||||
try {
|
||||
let pdfBase64: string | undefined;
|
||||
|
||||
if (!useAddEstOnly) {
|
||||
// 견적 PDF 생성 필요 — 최신 차수 조회 후 iframe으로 렌더
|
||||
setProgress("최신 차수 견적서 조회 중...");
|
||||
const list = await salesEstimateApi.listTemplates(contractObjid);
|
||||
if (!list || list.length === 0) {
|
||||
toast.error("견적서를 찾을 수 없습니다.");
|
||||
setSending(false);
|
||||
setProgress("");
|
||||
return;
|
||||
}
|
||||
const latest = list[0];
|
||||
|
||||
setProgress("견적서 PDF 생성 중... (최대 3분)");
|
||||
pdfBase64 = await generatePdfBase64(latest);
|
||||
}
|
||||
|
||||
setProgress("메일 발송 중...");
|
||||
const result = await salesEstimateApi.sendMail({
|
||||
contractObjid,
|
||||
toEmails,
|
||||
ccEmails: form.ccEmails.trim() || undefined,
|
||||
subject,
|
||||
contents,
|
||||
pdfBase64,
|
||||
useAddEstOnly: useAddEstOnly ? "Y" : "N",
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(`발송 실패: ${result.message}`);
|
||||
} else {
|
||||
toast.success(result.message || "견적서가 성공적으로 발송되었습니다.");
|
||||
onOpenChange(false);
|
||||
onSent?.();
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(`발송 실패: ${e?.response?.data?.message ?? e?.message ?? "알 수 없는 오류"}`);
|
||||
} finally {
|
||||
setSending(false);
|
||||
setProgress("");
|
||||
}
|
||||
}
|
||||
|
||||
// 다이얼로그 닫힐 때 iframe 정리
|
||||
useEffect(() => {
|
||||
if (!open && iframeRef.current) {
|
||||
try { iframeRef.current.parentNode?.removeChild(iframeRef.current); } catch {}
|
||||
iframeRef.current = null;
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const pdfNoticeText = useAddEstOnly
|
||||
? "PDF 첨부: 추가견적 PDF가 첨부됩니다."
|
||||
: hasAddEst
|
||||
? "PDF 첨부: 최종 차수 견적서 + 추가견적 PDF가 합본으로 첨부됩니다."
|
||||
: "PDF 첨부: 최종 차수 견적서가 자동으로 첨부됩니다.";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!sending) onOpenChange(o); }}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>견적서 메일 발송</DialogTitle>
|
||||
<DialogDescription>{pdfNoticeText}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 mr-2 animate-spin" />
|
||||
계약 정보를 불러오는 중...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* 고객사 담당자 체크박스 리스트 */}
|
||||
<div>
|
||||
<Label className="text-xs">고객사 담당자 선택</Label>
|
||||
<div className="border rounded-md p-2 bg-muted/30 max-h-[150px] overflow-y-auto text-sm">
|
||||
{managers.length === 0 ? (
|
||||
<div className="text-muted-foreground text-center py-2">
|
||||
등록된 담당자가 없습니다. 수신인을 직접 입력해주세요.
|
||||
</div>
|
||||
) : (
|
||||
managers.map((m, i) => {
|
||||
const email = m.email ?? "";
|
||||
const id = `manager_${i}_${email}`;
|
||||
return (
|
||||
<label key={id} htmlFor={id} className="flex items-center gap-2 py-1 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={id}
|
||||
checked={!!checkedEmails[email]}
|
||||
disabled={email === ""}
|
||||
onChange={(e) => toggleManager(email, e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
{m.name}
|
||||
{email && <span className="text-muted-foreground"> ({email})</span>}
|
||||
{m.is_main === "Y" && <span className="ml-1 text-[10px] px-1 bg-blue-100 dark:bg-blue-900 rounded">대표</span>}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* To */}
|
||||
<div>
|
||||
<Label className="text-xs">수신인 (To) <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
value={form.toEmails}
|
||||
onChange={(e) => setForm({ ...form, toEmails: e.target.value })}
|
||||
placeholder="email1@example.com, email2@example.com"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">여러 개는 쉼표(,) 또는 세미콜론(;)으로 구분</p>
|
||||
</div>
|
||||
|
||||
{/* CC */}
|
||||
<div>
|
||||
<Label className="text-xs">참조 (CC)</Label>
|
||||
<Input
|
||||
value={form.ccEmails}
|
||||
onChange={(e) => setForm({ ...form, ccEmails: e.target.value })}
|
||||
placeholder="참조 이메일 주소 (선택사항)"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">작성자 이메일이 자동으로 참조에 추가됩니다.</p>
|
||||
</div>
|
||||
|
||||
{/* 제목 */}
|
||||
<div>
|
||||
<Label className="text-xs">제목 <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
value={form.subject}
|
||||
onChange={(e) => setForm({ ...form, subject: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 내용 */}
|
||||
<div>
|
||||
<Label className="text-xs">내용 <span className="text-red-500">*</span></Label>
|
||||
<Textarea
|
||||
rows={8}
|
||||
value={form.contents}
|
||||
onChange={(e) => setForm({ ...form, contents: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{sending && progress && (
|
||||
<div className="text-sm text-blue-600 dark:text-blue-400 flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{progress}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={sending}>취소</Button>
|
||||
<Button onClick={handleSend} disabled={loading || sending}>
|
||||
{sending ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : <Send className="w-4 h-4 mr-1" />}
|
||||
발송
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { apiClient } from "./client";
|
||||
|
||||
// wace projectMgmtWbsGridList 매퍼 + 영업관리 패턴 통일
|
||||
export interface ProgressListFilter {
|
||||
Year?: string;
|
||||
project_nos?: string; // OBJID (단일 또는 쉼표 다중)
|
||||
category_cd?: string;
|
||||
customer_objid?: string;
|
||||
product?: string;
|
||||
status_cd?: string;
|
||||
result_cd?: string;
|
||||
contract_start_date?: string;
|
||||
contract_end_date?: string;
|
||||
area_cd?: string; // '국내'/'해외'
|
||||
free_of_charge?: string; // '유상'/'무상'
|
||||
// 영업관리 패턴 통일 — PartSelect의 value (part_mng.objid::varchar)
|
||||
search_partObjId?: string;
|
||||
serial_no?: string;
|
||||
pm_user_id?: string;
|
||||
location?: string;
|
||||
setup?: string;
|
||||
}
|
||||
|
||||
export interface ProjectNoOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// 운영판 그리드 18셀 + 부속 필드
|
||||
export interface ProgressRow {
|
||||
objid: string;
|
||||
project_no: string | null;
|
||||
category_cd: string | null;
|
||||
category_name: string | null;
|
||||
customer_objid: string | null;
|
||||
customer_name: string | null;
|
||||
product: string | null;
|
||||
product_name: string | null;
|
||||
area_name: string | null;
|
||||
reg_date: string | null;
|
||||
free_of_charge: string | null;
|
||||
product_item_code: string | null;
|
||||
product_item_name: string | null;
|
||||
serial_no: string | null;
|
||||
contract_qty: number | string | null;
|
||||
req_del_date: string | null;
|
||||
ebom_status: string | null;
|
||||
mbom_status: string | null;
|
||||
order_date: string | null;
|
||||
receiving_rate: string | null;
|
||||
production_team_12: string | null;
|
||||
production_team_3: string | null;
|
||||
assembly: string | null;
|
||||
verification: string | null;
|
||||
shipment_date: string | null;
|
||||
// 부속
|
||||
contract_objid: string | null;
|
||||
contract_no: string | null;
|
||||
overhaul_order: string | null;
|
||||
pm_user_name: string | null;
|
||||
writer_name: string | null;
|
||||
cu01_cnt: number | null;
|
||||
cu02_cnt: number | null;
|
||||
}
|
||||
|
||||
export interface ProgressDetail {
|
||||
objid: string;
|
||||
project_no: string;
|
||||
project_name: string;
|
||||
facility: string;
|
||||
facility_name: string;
|
||||
facility_qty: string;
|
||||
facility_depth: string;
|
||||
contract_del_date: string;
|
||||
contract_currency: string;
|
||||
contract_currency_name: string;
|
||||
contract_price: string;
|
||||
contract_price_currency: string;
|
||||
}
|
||||
|
||||
export interface ProgressUpdateBody {
|
||||
project_no?: string;
|
||||
project_name?: string;
|
||||
facility?: string;
|
||||
facility_qty?: string;
|
||||
facility_depth?: string;
|
||||
contract_del_date?: string;
|
||||
contract_currency?: string;
|
||||
contract_price_currency?: string;
|
||||
contract_price?: string;
|
||||
}
|
||||
|
||||
export const projectMgmtApi = {
|
||||
async list(filter: ProgressListFilter = {}) {
|
||||
const res = await apiClient.get("/project/progress/list", { params: filter });
|
||||
return (res.data?.data ?? []) as ProgressRow[];
|
||||
},
|
||||
async projectNoOptions(): Promise<ProjectNoOption[]> {
|
||||
const res = await apiClient.get("/project/progress/project-no-options");
|
||||
return (res.data?.data ?? []) as ProjectNoOption[];
|
||||
},
|
||||
async detail(objid: string): Promise<ProgressDetail | null> {
|
||||
const res = await apiClient.get(`/project/progress/${objid}`);
|
||||
return res.data?.data ?? null;
|
||||
},
|
||||
async update(objid: string, body: ProgressUpdateBody) {
|
||||
return (await apiClient.put(`/project/progress/${objid}`, body)).data;
|
||||
},
|
||||
};
|
||||
@@ -135,10 +135,41 @@ export const salesEstimateApi = {
|
||||
ccEmails?: string;
|
||||
subject: string;
|
||||
contents: string;
|
||||
isSend?: "Y" | "N";
|
||||
pdfBase64?: string;
|
||||
useAddEstOnly?: "Y" | "N";
|
||||
}) {
|
||||
const res = await apiClient.post("/sales/estimate/mail", body);
|
||||
return res.data?.data as { objid: string };
|
||||
const res = await apiClient.post("/sales/estimate/mail", body, {
|
||||
// PDF base64 포함 시 페이로드 큼 — 충분한 타임아웃
|
||||
timeout: 120_000,
|
||||
});
|
||||
return res.data as { success: boolean; message: string; objid?: string };
|
||||
},
|
||||
|
||||
/** 메일 다이얼로그 자동 채움 (제목/수신/참조용 고객·작성자 정보) */
|
||||
async getMailInfo(contractObjid: string) {
|
||||
const res = await apiClient.get(`/sales/estimate/mail-info/${contractObjid}`);
|
||||
return res.data?.data as {
|
||||
contract_objid: string;
|
||||
contract_no: string | null;
|
||||
customer_objid: string | null;
|
||||
customer_name: string | null;
|
||||
customer_email: string | null;
|
||||
writer: string | null;
|
||||
writer_email: string | null;
|
||||
writer_name: string | null;
|
||||
};
|
||||
},
|
||||
|
||||
/** 메일 다이얼로그 고객사 담당자 체크박스 리스트용 */
|
||||
async getMailManagers(customerObjid: string) {
|
||||
const res = await apiClient.get(`/sales/estimate/customer/${customerObjid}/managers`);
|
||||
return (res.data?.data ?? []) as {
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
department: string;
|
||||
is_main: string;
|
||||
}[];
|
||||
},
|
||||
|
||||
// ─── G5 견적작성 (estimate_template) ──────────────────────────
|
||||
@@ -161,6 +192,14 @@ export const salesEstimateApi = {
|
||||
const res = await apiClient.get(`/sales/estimate/templates/${contractObjid}`);
|
||||
return (res.data?.data ?? []) as EstimateTemplateRow[];
|
||||
},
|
||||
|
||||
// G4/G11 동일 패턴 — 견적 결재상신 (Amaranth SSO URL 발급)
|
||||
// wace estimateList_new.jsp:887 fn_openAmaranthApproval 1:1
|
||||
async startApproval(objid: string, body: { approvalTitle?: string; subjectStr?: string } = {})
|
||||
: Promise<{ fullUrl: string; approKey: string; status: string; estObjid: string }> {
|
||||
const res = await apiClient.post(`/sales/estimate/${objid}/amaranth-approval`, body);
|
||||
return res.data?.data;
|
||||
},
|
||||
};
|
||||
|
||||
// ─── G5 견적작성 타입 ───────────────────────────────────────────
|
||||
|
||||
@@ -52,8 +52,9 @@ export interface OrderRow {
|
||||
item_summary: string | null;
|
||||
part_no: string | null;
|
||||
serial_no: string | null;
|
||||
order_appr_status: string | null;
|
||||
amaranth_status: string | null;
|
||||
order_appr_status: string | null; // 한글 라벨 ('결재완료'/'결재중'/'반려'/'작성중'/'')
|
||||
amaranth_status: string | null; // 원본 상태 (호환용)
|
||||
order_amaranth_status: string | null; // 원본 상태 ('complete'/'inProcess'/'reject'/'create'/'')
|
||||
cu01_cnt: number | null;
|
||||
is_direct_order: string | null;
|
||||
}
|
||||
@@ -136,4 +137,16 @@ export const salesOrderMgmtApi = {
|
||||
const res = await apiClient.get(`/sales/order-mgmt/${objid}/form-view`);
|
||||
return res.data?.data ?? { info: null, items: [] };
|
||||
},
|
||||
// G9 수주복사 (wace btnCopy → copyEstimateAndOrderInfo 1:1)
|
||||
async copyOrder(objid: string): Promise<{ newObjid: string; newContractNo: string }> {
|
||||
const res = await apiClient.post(`/sales/order-mgmt/${objid}/copy`);
|
||||
return res.data?.data;
|
||||
},
|
||||
// G4/G11 수주 결재상신 — Amaranth SSO URL 발급
|
||||
// wace orderMgmtList.btnApproval → ApprovalService.getAmaranthSsoUrl 1:1
|
||||
async startApproval(objid: string, body: { approvalTitle?: string; subjectStr?: string } = {})
|
||||
: Promise<{ fullUrl: string; approKey: string; status: string }> {
|
||||
const res = await apiClient.post(`/sales/order-mgmt/${objid}/amaranth-approval`, body);
|
||||
return res.data?.data;
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user