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:
hjjeong
2026-05-11 09:26:35 +00:00
34 changed files with 3229 additions and 216 deletions
+22
View File
@@ -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
+37
View File
@@ -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",
+1
View File
@@ -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",
+2
View File
@@ -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;
}
+440 -41
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
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();
+196
View File
@@ -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 };
}
}