From 902118d46e0789d818c655753187b575d641571f Mon Sep 17 00:00:00 2001 From: hjjeong Date: Mon, 11 May 2026 16:09:10 +0900 Subject: [PATCH 1/5] =?UTF-8?q?PR-C=20G6=20=EA=B2=AC=EC=A0=81=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20SMTP=20=EB=A9=94=EC=9D=BC=20=EB=B0=9C=EC=86=A1=20(w?= =?UTF-8?q?ace=20sendEstimateMailCustom=201:1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - nodemailer + pdf-lib로 실제 SMTP 발송. mail_log INSERT(is_send='N') → 발송 → 성공 시 UPDATE(is_send='Y'), 실패 시 UPDATE(error_log). SMTP_SEND_SWITCH='N'면 발송 스킵. - SMTP 3계정(ERP/SALES/PURCHASE) host/user/pw 환경변수 분리. 견적서는 SALES. dev는 backend-node/.env, 운영은 deploy/onpremise + docker/prod + docker/deploy 3개 compose에 environment 매핑(호스트 .env에서 실값 주입). - 다이얼로그(EstimateMailDialog): wace estimateMailFormPopup.jsp 1:1. 고객사 담당자 체크박스 + To/CC/제목/내용 자동채움(GET /sales/estimate/mail-info/:id + .../customer/:id/managers). hasBaseEst/hasAddEst 분기로 PDF 첨부 안내. 본문은 다이얼로그 plain text 입력 →
변환. - PDF 첨부: 메일 다이얼로그가 hidden iframe으로 최신 차수 template1/2 페이지를 렌더 → window.fn_generateAndUploadPdf(cb) 글로벌 → jsPDF.output('datauristring') base64 추출 → 한 요청에 전달. backend가 견적 PDF + estimate02 N건 pdf-lib로 합본 첨부. - PDF 캡처 수신처 누락 픽스: CustomerSelect의 /sales/customers 옵션 fetch가 iframe에서 dataLoaded=true 뒤에 끝나 셀렉트 라벨이 빈 상태로 캡처되던 현상. fetchCustomers export + template1/2 setLoading(false) 직전 await + onclone에서 [role="combobox"] 라이브 DOM 텍스트 fallback. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend-node/.env.example | 22 + backend-node/package-lock.json | 37 ++ backend-node/package.json | 1 + .../controllers/salesEstimateController.ts | 39 +- .../src/routes/salesEstimateRoutes.ts | 4 + .../src/services/salesEstimateService.ts | 300 +++++++++++-- backend-node/src/utils/mailUtil.ts | 196 +++++++++ deploy/onpremise/docker-compose.yml | 11 + docker/deploy/docker-compose.yml | 11 + docker/prod/docker-compose.backend.prod.yml | 11 + .../(main)/COMPANY_16/sales/estimate/page.tsx | 111 +---- .../template1/pop/[contractObjid]/page.tsx | 153 ++++--- .../template2/pop/[contractObjid]/page.tsx | 82 +++- frontend/components/common/CustomerSelect.tsx | 2 +- .../components/sales/EstimateMailDialog.tsx | 400 ++++++++++++++++++ frontend/lib/api/salesEstimate.ts | 37 +- 16 files changed, 1229 insertions(+), 188 deletions(-) create mode 100644 backend-node/src/utils/mailUtil.ts create mode 100644 frontend/components/sales/EstimateMailDialog.tsx diff --git a/backend-node/.env.example b/backend-node/.env.example index 807ae916..ba41810f 100644 --- a/backend-node/.env.example +++ b/backend-node/.env.example @@ -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 + diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index c68b1172..c4708087 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -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", diff --git a/backend-node/package.json b/backend-node/package.json index e827da0c..d9410550 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -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", diff --git a/backend-node/src/controllers/salesEstimateController.ts b/backend-node/src/controllers/salesEstimateController.ts index 4f8b7fb5..6467ebd2 100644 --- a/backend-node/src/controllers/salesEstimateController.ts +++ b/backend-node/src/controllers/salesEstimateController.ts @@ -75,28 +75,59 @@ export async function remove(req: AuthenticatedRequest, res: Response) { 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 // ────────────────────────────────────────────────────────────── diff --git a/backend-node/src/routes/salesEstimateRoutes.ts b/backend-node/src/routes/salesEstimateRoutes.ts index 4fcbcda2..5a69e52b 100644 --- a/backend-node/src/routes/salesEstimateRoutes.ts +++ b/backend-node/src/routes/salesEstimateRoutes.ts @@ -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); diff --git a/backend-node/src/services/salesEstimateService.ts b/backend-node/src/services/salesEstimateService.ts index bb094edc..7d630439 100644 --- a/backend-node/src/services/salesEstimateService.ts +++ b/backend-node/src/services/salesEstimateService.ts @@ -4,8 +4,12 @@ // 헤더 컨텍스트: 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 { sendMailUTF8 } from "../utils/mailUtil"; import { generateContractNo } from "./salesOrderMgmtService"; // ─── 타입 ───────────────────────────────────────────────────── @@ -552,10 +556,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,42 +570,269 @@ 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 →
변환) + 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 { + 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은 줄바꿈으로 보이게
변환) + const escaped = text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + return `
${escaped.replace(/\n/g, "
")}
`; } // ─── 삭제 ───────────────────────────────────────────────────── diff --git a/backend-node/src/utils/mailUtil.ts b/backend-node/src/utils/mailUtil.ts new file mode 100644 index 00000000..53ab5787 --- /dev/null +++ b/backend-node/src/utils/mailUtil.ts @@ -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 →
로 직접 변환) + 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 }; + } +} diff --git a/deploy/onpremise/docker-compose.yml b/deploy/onpremise/docker-compose.yml index a779cad7..2ae61dec 100644 --- a/deploy/onpremise/docker-compose.yml +++ b/deploy/onpremise/docker-compose.yml @@ -53,6 +53,17 @@ 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:-} volumes: - backend_uploads:/app/uploads - backend_data:/app/data diff --git a/docker/deploy/docker-compose.yml b/docker/deploy/docker-compose.yml index 115aef91..891f6ce2 100644 --- a/docker/deploy/docker-compose.yml +++ b/docker/deploy/docker-compose.yml @@ -24,6 +24,17 @@ 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:-} volumes: - backend_uploads:/app/uploads - backend_data:/app/data diff --git a/docker/prod/docker-compose.backend.prod.yml b/docker/prod/docker-compose.backend.prod.yml index 8ab60253..da884600 100644 --- a/docker/prod/docker-compose.backend.prod.yml +++ b/docker/prod/docker-compose.backend.prod.yml @@ -28,6 +28,17 @@ 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:-} restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3001/health"] diff --git a/frontend/app/(main)/COMPANY_16/sales/estimate/page.tsx b/frontend/app/(main)/COMPANY_16/sales/estimate/page.tsx index 4ecf2c4c..070d4c67 100644 --- a/frontend/app/(main)/COMPANY_16/sales/estimate/page.tsx +++ b/frontend/app/(main)/COMPANY_16/sales/estimate/page.tsx @@ -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 견적작성 — 일반/장비 선택 다이얼로그 @@ -360,55 +354,19 @@ export default function SalesEstimatePage() { }; // ─── 메일 발송 ────────────────────────────────────────────── - // 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) => { @@ -824,54 +782,15 @@ export default function SalesEstimatePage() { - {/* 메일 발송 Dialog */} - - - - 견적 메일 발송 - - 발송 이력이 mail_log에 기록되어 그리드 메일발송 컬럼에 표시됩니다. - {selected && <> · 영업번호: {selected.contract_no ?? selected.objid}} - - - -
-
- - setMailForm({ ...mailForm, toEmails: e.target.value })} - placeholder="example@company.com (여러 명은 ; 또는 , 로 구분)" /> -
-
- - setMailForm({ ...mailForm, ccEmails: e.target.value })} - placeholder="(선택)" /> -
-
- - setMailForm({ ...mailForm, subject: e.target.value })} /> -

- 저장 시 [OBJID:{selected?.objid ?? ""}] 태그가 자동으로 추가됩니다. -

-
-
- -