From 17b08c7a091e7d90ec89bf70875928f057525e2d Mon Sep 17 00:00:00 2001 From: hjjeong Date: Tue, 19 May 2026 14:57:47 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B5=AC=EB=A7=A4=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=9C=EC=A3=BC=EC=84=9C=20=EB=A9=94=EC=9D=BC=20=EB=B0=9C?= =?UTF-8?q?=EC=86=A1=20+=20PDF=20=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C=20+?= =?UTF-8?q?=20=ED=96=89=EC=B6=94=EA=B0=80/=EC=82=AD=EC=A0=9C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend purchaseOrderMailService 신설 — getOrderMailInfo / getPartnerManagerList / sendOrderMail (SMTP PURCHASE, 발송 성공 시 mail_send_yn='Y'/mail_send_date 갱신) - backend routes — GET /order-form/mail-info/:objid, POST /order-form/mail, GET /options/partner-managers/:partnerObjid - frontend lib/utils/purchaseOrderPdf — html2canvas-pro + jsPDF (A4, scale=2, input/textarea → 텍스트 변환). download:true 면 파일 저장, 아니면 base64 반환 - PurchaseOrderMailDialog 신설 — EstimateMailDialog 패턴 단순화 (한글/영문 본문 분기, 공급업체 단일 email 자동 채움) - 3개 양식 다이얼로그 — 읽기전용 + 저장된 발주서일 때 "메일 발송" + "PDF 다운로드" 버튼 노출. window.print 간이판 제거 - 3개 양식 다이얼로그 — "행 추가"/"선택 행 삭제" 버튼 + 그리드 체크박스 컬럼 제거 (wace 운영판은 모두 주석 처리/부재. 발주서는 품의서에서 자동 채움된 품목 그대로 사용) --- .../src/controllers/purchaseController.ts | 49 +++ backend-node/src/routes/purchaseRoutes.ts | 13 +- .../src/services/purchaseOrderMailService.ts | 158 +++++++++ .../PurchaseOrderEnglishFormDialog.tsx | 141 ++++---- .../PurchaseOrderGeneralFormDialog.tsx | 144 ++++----- .../purchase/PurchaseOrderMailDialog.tsx | 305 ++++++++++++++++++ .../PurchaseOrderOutsourcingFormDialog.tsx | 140 ++++---- frontend/lib/api/purchase.ts | 47 +++ frontend/lib/utils/purchaseOrderPdf.ts | 59 ++++ 9 files changed, 789 insertions(+), 267 deletions(-) create mode 100644 backend-node/src/services/purchaseOrderMailService.ts create mode 100644 frontend/components/purchase/PurchaseOrderMailDialog.tsx create mode 100644 frontend/lib/utils/purchaseOrderPdf.ts diff --git a/backend-node/src/controllers/purchaseController.ts b/backend-node/src/controllers/purchaseController.ts index 579f25a2..7cb6b9cc 100644 --- a/backend-node/src/controllers/purchaseController.ts +++ b/backend-node/src/controllers/purchaseController.ts @@ -6,6 +6,7 @@ import { Response } from "express"; import { AuthenticatedRequest } from "../types/auth"; import * as svc from "../services/purchaseService"; import * as formSvc from "../services/purchaseOrderFormService"; +import * as mailSvc from "../services/purchaseOrderMailService"; import { logger } from "../utils/logger"; function parseFilter(q: Record): svc.PurchaseListFilter { @@ -107,6 +108,54 @@ export async function deletePurchaseOrderForm(req: AuthenticatedRequest, res: Re } } +// ─── 발주서 메일 발송 ───────────────────────────────────────── + +/** GET /api/purchase/order-form/mail-info/:objid */ +export async function getPurchaseOrderMailInfo(req: AuthenticatedRequest, res: Response) { + try { + const objid = String(req.params.objid ?? "").trim(); + if (!objid) return res.status(400).json({ success: false, message: "objid required" }); + const data = await mailSvc.getOrderMailInfo(objid); + if (!data) return res.status(404).json({ success: false, message: "발주서를 찾을 수 없어요" }); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("발주서 메일 정보 조회 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +/** GET /api/purchase/options/partner-managers/:partnerObjid */ +export async function getPartnerManagers(req: AuthenticatedRequest, res: Response) { + try { + const partnerObjid = String(req.params.partnerObjid ?? "").trim(); + if (!partnerObjid) return res.json({ success: true, data: [] }); + const data = await mailSvc.getPartnerManagerList(partnerObjid); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("공급업체 담당자 조회 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +/** POST /api/purchase/order-form/mail */ +export async function sendPurchaseOrderMail(req: AuthenticatedRequest, res: Response) { + try { + const body = req.body as mailSvc.PurchaseOrderMailBody; + if (!body || !body.pomObjid) { + return res.status(400).json({ success: false, message: "pomObjid 가 필요해요" }); + } + const userId = String(req.user?.userId ?? ""); + const result = await mailSvc.sendOrderMail(userId, body); + if (!result.success) { + return res.status(400).json(result); + } + return res.json(result); + } catch (e: any) { + logger.error("발주서 메일 발송 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + export async function getSuppliers(_req: AuthenticatedRequest, res: Response) { try { const data = await svc.listSupplierOptions(); diff --git a/backend-node/src/routes/purchaseRoutes.ts b/backend-node/src/routes/purchaseRoutes.ts index fcfef1cb..fb9b6482 100644 --- a/backend-node/src/routes/purchaseRoutes.ts +++ b/backend-node/src/routes/purchaseRoutes.ts @@ -20,16 +20,19 @@ router.get("/inbound-by-date", ctrl.getInboundByDate); // 입고일별 입 router.get("/project-status", ctrl.getProjectStatus); // 프로젝트별 발주/입고 현황 router.get("/order-list", ctrl.getPurchaseOrderList); // 발주서관리 (wace purchaseOrderMasterList_new 1:1) -// 발주서 폼 (general 양식, wace purchaseOrderFormPopup_general.do 1:1) -router.get ("/order-form/init", ctrl.getPurchaseOrderFormInit); // 품의서에서 자동 채움 -router.post ("/order-form/save", ctrl.savePurchaseOrderForm); // 마스터+파트 UPSERT -router.get ("/order-form/:objid", ctrl.getPurchaseOrderForm); // 수정/조회 -router.delete("/order-form/:objid", ctrl.deletePurchaseOrderForm); // 삭제 cascade +// 발주서 폼 (general / outsourcing / english 양식, wace purchaseOrderFormPopup_*.do 1:1) +router.get ("/order-form/init", ctrl.getPurchaseOrderFormInit); // 품의서에서 자동 채움 +router.post ("/order-form/save", ctrl.savePurchaseOrderForm); // 마스터+파트 UPSERT +router.post ("/order-form/mail", ctrl.sendPurchaseOrderMail); // 메일 발송 (PDF 첨부) +router.get ("/order-form/mail-info/:objid", ctrl.getPurchaseOrderMailInfo); // 메일 다이얼로그 자동 채움 +router.get ("/order-form/:objid", ctrl.getPurchaseOrderForm); // 수정/조회 +router.delete("/order-form/:objid", ctrl.deletePurchaseOrderForm); // 삭제 cascade // 공통 옵션 router.get("/options/suppliers", ctrl.getSuppliers); router.get("/options/vendors", ctrl.getVendors); // wace client_mng 기반 router.get("/options/users", ctrl.getUsers); router.get("/options/projects", ctrl.getProjects); +router.get("/options/partner-managers/:partnerObjid", ctrl.getPartnerManagers); // 발주서 메일 담당자 export default router; diff --git a/backend-node/src/services/purchaseOrderMailService.ts b/backend-node/src/services/purchaseOrderMailService.ts new file mode 100644 index 00000000..34359d1d --- /dev/null +++ b/backend-node/src/services/purchaseOrderMailService.ts @@ -0,0 +1,158 @@ +// ============================================================ +// 구매관리 > 발주서관리 — 메일 발송 서비스 +// +// wace ContractMgmtService.sendEstimateMailCustom 패턴(영업관리) 재사용: +// - 본문(contents): 다이얼로그 입력 그대로 (HTML 변환은 textToHtml) +// - 첨부: 프론트가 html2canvas + jsPDF 로 만든 base64 PDF 1장 +// - SMTP 계정: PURCHASE (mailUtil SmtpAccountType) +// - mail_log title 에 [OBJID:nnn] 토큰 부착 — 그리드 LIKE 매칭 호환 +// - 발송 성공 시 purchase_order_master.mail_send_yn='Y', mail_send_date=NOW() +// ============================================================ + +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; +import { sendMailUTF8 } from "../utils/mailUtil"; + +export interface PurchaseOrderMailBody { + pomObjid: string; + toEmails: string; // ; 또는 , 로 구분 + ccEmails?: string; + subject: string; + contents: string; + /** 프론트 html2canvas + jsPDF base64 (data URL 또는 raw base64) */ + pdfBase64: string; +} + +/** 메일 다이얼로그 자동채움 — 공급업체/작성자/발주번호 */ +export async function getOrderMailInfo(pomObjid: string) { + const pool = getPool(); + const sql = ` + SELECT + POM.OBJID AS pom_objid, + POM.PURCHASE_ORDER_NO AS purchase_order_no, + POM.PARTNER_OBJID AS partner_objid, + POM.MANAGER_EMAIL AS writer_email, + POM.MANAGER_NAME AS writer_name, + POM.FORM_TYPE AS form_type, + CM.CLIENT_NM AS partner_name, + CM.EMAIL AS partner_email + FROM PURCHASE_ORDER_MASTER POM + LEFT JOIN CLIENT_MNG CM + ON CM.OBJID = POM.PARTNER_OBJID + WHERE POM.OBJID = $1 + LIMIT 1`; + const r = await pool.query(sql, [pomObjid]); + return r.rows[0] ?? null; +} + +/** 공급업체 담당자 리스트 — RPS client_mng 는 단일 email 만 보관 (별도 contact 테이블 없음) */ +export async function getPartnerManagerList(partnerObjid: string) { + const pool = getPool(); + const r = await pool.query( + `SELECT CLIENT_NM AS name, EMAIL AS email, TEL_NO AS phone, '' AS department, 'Y' AS is_main + FROM CLIENT_MNG + WHERE OBJID = $1 + AND COALESCE(EMAIL, '') <> ''`, + [partnerObjid], + ); + return r.rows; +} + +function decodeBase64Pdf(input: string): Buffer { + const m = /^data:application\/pdf;base64,(.*)$/i.exec(input); + const b64 = m ? m[1] : input; + return Buffer.from(b64, "base64"); +} + +function textToHtml(text: string): string { + return (text || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\n/g, "
"); +} + +export async function sendOrderMail(userId: string, body: PurchaseOrderMailBody) { + const info = await getOrderMailInfo(body.pomObjid); + if (!info) { + return { success: false, message: "발주서 정보를 찾을 수 없습니다." }; + } + + 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); + // 작성자 이메일 자동 cc + if (info.writer_email && !ccEmails.includes(info.writer_email)) { + ccEmails.push(info.writer_email); + } + + if (!body.pdfBase64) { + return { success: false, message: "발주서 PDF 가 전달되지 않았습니다." }; + } + + const pdfBuf = decodeBase64Pdf(body.pdfBase64); + const safeNo = (info.purchase_order_no || "발주서").toString().replace(/[^\w가-힣\-_.]/g, "_"); + const ts = new Date().toISOString().replace(/[-:.TZ]/g, "").substring(0, 14); + const attachment = { + filename: `${safeNo}_${ts}.pdf`, + content: pdfBuf, + contentType: "application/pdf", + }; + + const subject = body.subject.trim(); + const subjectForLog = subject.includes("[OBJID:") + ? subject + : `${subject} [OBJID:${body.pomObjid}]`; + const html = textToHtml(body.contents); + + const result = await sendMailUTF8({ + accountType: "PURCHASE", + fromUserId: userId, + toEmails, + ccEmails: ccEmails.length > 0 ? ccEmails : undefined, + subject, + subjectForLog, + html, + attachments: [attachment], + mailType: "PURCHASE_ORDER", + }); + + logger.info("발주서 메일 발송 완료", { + pomObjid: body.pomObjid, + mailLogObjid: result.objid, + sent: result.sent, + to: toEmails, + cc: ccEmails, + }); + + if (!result.sent) { + return { + success: false, + message: result.error || "메일 발송에 실패했습니다.", + objid: result.objid, + }; + } + + // 발송 성공 시 mail_send_yn / mail_send_date 갱신 + try { + await getPool().query( + `UPDATE PURCHASE_ORDER_MASTER + SET MAIL_SEND_YN = 'Y', + MAIL_SEND_DATE = NOW() + WHERE OBJID = $1`, + [body.pomObjid], + ); + } catch (e: any) { + logger.warn("mail_send_yn 갱신 실패", { error: e.message, pomObjid: body.pomObjid }); + } + + return { success: true, message: "발주서가 성공적으로 발송되었습니다.", objid: result.objid }; +} diff --git a/frontend/components/purchase/PurchaseOrderEnglishFormDialog.tsx b/frontend/components/purchase/PurchaseOrderEnglishFormDialog.tsx index 0d0332b1..7928f9f1 100644 --- a/frontend/components/purchase/PurchaseOrderEnglishFormDialog.tsx +++ b/frontend/components/purchase/PurchaseOrderEnglishFormDialog.tsx @@ -8,17 +8,19 @@ // - 그리드 8 visible: Item No. / Commodity & Description / Unit / Q'ty / Currency / Unit Price / Amount / Delivery // - TOTAL 한 행 + 하단 "Look forward to your soonest delivery..." + 서명영역 (stamp_seal.png 65x65) -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Plus, Trash2 } from "lucide-react"; +import { Download, Mail } from "lucide-react"; import { toast } from "sonner"; import { CommCodeSelect } from "@/components/common/CommCodeSelect"; import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect"; import { DateInput } from "@/components/common/DateInput"; import { NumberInput } from "@/components/common/NumberInput"; import { purchaseApi, OptionItem } from "@/lib/api/purchase"; +import { generatePurchaseOrderPdf } from "@/lib/utils/purchaseOrderPdf"; +import { PurchaseOrderMailDialog } from "./PurchaseOrderMailDialog"; interface Props { open: boolean; @@ -106,8 +108,6 @@ export function PurchaseOrderEnglishFormDialog({ const isEdit = !!pomObjid; const [master, setMaster] = useState(EMPTY_MASTER); const [parts, setParts] = useState([]); - const [deletedPartIds, setDeletedPartIds] = useState([]); - const [checkedRowKeys, setCheckedRowKeys] = useState([]); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); @@ -118,8 +118,6 @@ export function PurchaseOrderEnglishFormDialog({ if (!open) return; setMaster({ ...EMPTY_MASTER }); setParts([]); - setDeletedPartIds([]); - setCheckedRowKeys([]); (async () => { try { @@ -204,7 +202,27 @@ export function PurchaseOrderEnglishFormDialog({ return a === "결재중" || a === "결재완료" || master.status === "cancel"; }, [master.appr_status, master.status]); - const handleDownload = () => window.print(); + const pdfContainerRef = useRef(null); + const [mailOpen, setMailOpen] = useState(false); + const [generatingPdf, setGeneratingPdf] = useState(false); + + const handleDownload = async () => { + if (!pdfContainerRef.current) return; + setGeneratingPdf(true); + try { + const filename = `${master.purchase_order_no || "purchase_order_english"}.pdf`; + await generatePurchaseOrderPdf(pdfContainerRef.current, { download: true, filename }); + } catch (e: any) { + toast.error("PDF generation failed: " + (e?.message ?? "")); + } finally { + setGeneratingPdf(false); + } + }; + + const handleRequestPdf = async (): Promise => { + if (!pdfContainerRef.current) throw new Error("Purchase order container not found"); + return generatePurchaseOrderPdf(pdfContainerRef.current); + }; const updateRow = (rowKey: string, patch: Partial) => { setParts((prev) => prev.map((r) => { @@ -218,51 +236,6 @@ export function PurchaseOrderEnglishFormDialog({ })); }; - const handleAddRow = () => { - setParts((prev) => [...prev, { - rowKey: nextKey(), - objid: "", part_objid: "", - part_no: "", part_name: "", spec: "", - order_qty: "", qty: "", - unit: "0001400", - currency: DEFAULT_CURRENCY, - partner_price: "", - supply_unit_price: 0, - delivery_request_date: "", - }]); - }; - - const handleDeleteSelectedRows = () => { - if (checkedRowKeys.length === 0) { - toast.info("Select rows to delete"); - return; - } - setParts((prev) => { - const remaining: PartRow[] = []; - const deletedObjids: string[] = []; - for (const r of prev) { - if (checkedRowKeys.includes(r.rowKey)) { - if (r.objid) deletedObjids.push(r.objid); - } else { - remaining.push(r); - } - } - if (deletedObjids.length > 0) { - setDeletedPartIds((d) => Array.from(new Set([...d, ...deletedObjids]))); - } - return remaining; - }); - setCheckedRowKeys([]); - }; - - const toggleRowCheck = (rowKey: string, checked: boolean) => { - setCheckedRowKeys((prev) => - checked ? [...prev, rowKey] : prev.filter((k) => k !== rowKey)); - }; - const toggleAllCheck = (checked: boolean) => { - setCheckedRowKeys(checked ? parts.map((p) => p.rowKey) : []); - }; - const handleSave = async () => { if (!master.partner_objid) { toast.warning("Select supplier (Messrs.)"); return; } if (parts.length === 0) { toast.warning("No items"); return; } @@ -290,7 +263,7 @@ export function PurchaseOrderEnglishFormDialog({ supply_unit_price: toNum(p.supply_unit_price), delivery_request_date: p.delivery_request_date, })), - deletedPartObjids: deletedPartIds, + deletedPartObjids: [], }; const res = await purchaseApi.saveOrderForm(payload); toast.success(`Saved (${res.purchase_order_no})`); @@ -308,7 +281,7 @@ export function PurchaseOrderEnglishFormDialog({ Purchase Order (English) wace English Purchase Order PDF form -
+
{/* 헤더 */}
@@ -420,29 +393,26 @@ export function PurchaseOrderEnglishFormDialog({
{!isReadOnly && ( - <> - - - - - )} - {isReadOnly && ( )} + {isReadOnly && master.objid && ( + <> + + + + )}