// 발주 승인 시 발송될 거래명세표 메일을 그대로 시뮬레이션 // (DB 없이 실제 운영 코드와 동일한 buildStatementHtml/Xlsx 사용) import nodemailer from "nodemailer"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import * as XLSX from "xlsx"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const envPath = path.join(__dirname, "..", ".env.development"); if (fs.existsSync(envPath)) { for (const line of fs.readFileSync(envPath, "utf-8").split(/\r?\n/)) { const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/); if (m) process.env[m[1]] ??= m[2].replace(/^["']|["']$/g, ""); } } // ===== 운영 코드와 동일한 함수 (excel-statement.ts 미니 포팅) ===== const fmt = (n) => Math.round(n); const escapeHtml = (s) => String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); const formatNumber = (n) => n.toLocaleString("ko-KR"); function buildStatementXlsx(input) { const wb = XLSX.utils.book_new(); const aoa = []; aoa.push(["거 래 명 세 표"]); aoa.push([]); aoa.push(["발주번호", input.orderNo, "", "발주일자", input.orderDate]); aoa.push([]); aoa.push(["[공급받는자]"]); aoa.push(["업체명", input.customer.companyName, "대표자", input.customer.ceoName ?? "-"]); aoa.push(["사업자번호", input.customer.bizNo ?? "-", "전화번호", input.customer.phone ?? "-"]); aoa.push([]); aoa.push(["[공급자]"]); aoa.push(["업체명", input.supplier.companyName, "계좌번호", input.supplier.bankAccount ?? "-"]); aoa.push(["전화번호", input.supplier.phone ?? "-", "이메일", input.supplier.email ?? "-"]); aoa.push([]); aoa.push(["순번", "품명", "구분", "수량", "단위", "단가", "공급가액", "세액", "합계"]); for (const it of input.items) { aoa.push([it.seq, it.itemName, it.isTaxFree ? "면세" : "과세", it.qty, it.unit || "EA", fmt(it.unitPrice), fmt(it.supplyAmount), fmt(it.vatAmount), fmt(it.totalAmount)]); } aoa.push([]); aoa.push(["", "", "", "", "", "면세 합계", fmt(input.totals.taxFree)]); aoa.push(["", "", "", "", "", "과세 공급가", fmt(input.totals.taxable)]); aoa.push(["", "", "", "", "", "세액 합계", fmt(input.totals.vat)]); aoa.push(["", "", "", "", "", "총 합계 (VAT포함)", fmt(input.totals.total)]); const ws = XLSX.utils.aoa_to_sheet(aoa); ws["!cols"] = [{ wch: 6 }, { wch: 28 }, { wch: 6 }, { wch: 8 }, { wch: 6 }, { wch: 12 }, { wch: 14 }, { wch: 12 }, { wch: 14 }]; ws["!merges"] = [{ s: { r: 0, c: 0 }, e: { r: 0, c: 8 } }]; XLSX.utils.book_append_sheet(wb, ws, "거래명세표"); return XLSX.write(wb, { type: "buffer", bookType: "xlsx" }); } function buildStatementHtml(input) { const rows = input.items.map((it) => ` ${it.seq} ${escapeHtml(it.itemName)} ${it.isTaxFree ? "면세" : "과세"} ${it.qty} ${escapeHtml(it.unit || "EA")} ${formatNumber(fmt(it.unitPrice))} ${formatNumber(fmt(it.supplyAmount))} ${it.isTaxFree ? "-" : formatNumber(fmt(it.vatAmount))} ${formatNumber(fmt(it.totalAmount))} `).join(""); return `

거 래 명 세 표

발주번호 ${escapeHtml(input.orderNo)}
발주일자 ${escapeHtml(input.orderDate)}
공급자 ${escapeHtml(input.supplier.companyName)}
${escapeHtml(input.supplier.phone ?? "")} · ${escapeHtml(input.supplier.email ?? "")}
${escapeHtml(input.customer.companyName)} 귀하 ${input.customer.ceoName ? ` · 대표 ${escapeHtml(input.customer.ceoName)}` : ""} ${input.customer.bizNo ? ` · 사업자번호 ${escapeHtml(input.customer.bizNo)}` : ""}
${rows}
순번 품명 구분 수량 단위 단가 공급가액 세액 합계
면세 합계₩ ${formatNumber(fmt(input.totals.taxFree))}
과세 공급가₩ ${formatNumber(fmt(input.totals.taxable))}
세액 합계₩ ${formatNumber(fmt(input.totals.vat))}
총 합계 (VAT 포함)₩ ${formatNumber(fmt(input.totals.total))}
위와 같이 계산합니다. — 모모유통
`; } // ===== 가짜 발주 데이터 (스크린샷 첨부 거래명세표 그대로) ===== const items = [ { seq: 1, itemName: "M 유정란", unit: "EA", qty: 30, unitPrice: 10000, isTaxFree: true }, { seq: 2, itemName: "M 꽃계탕", unit: "EA", qty: 20, unitPrice: 4500, isTaxFree: true }, { seq: 3, itemName: "빨강 탈취제", unit: "EA", qty: 11, unitPrice: 9200, isTaxFree: false }, { seq: 4, itemName: "파랑 탈취제", unit: "EA", qty: 11, unitPrice: 9200, isTaxFree: false }, { seq: 5, itemName: "초록 탈취제", unit: "EA", qty: 3, unitPrice: 9200, isTaxFree: false }, ]; for (const it of items) { const total = Math.round(it.unitPrice * it.qty); if (it.isTaxFree) { it.supplyAmount = total; it.vatAmount = 0; it.totalAmount = total; } else { const s = Math.round(total / 1.1); it.supplyAmount = s; it.vatAmount = total - s; it.totalAmount = total; } } const totals = items.reduce((a, it) => ({ supply: a.supply + it.supplyAmount, vat: a.vat + it.vatAmount, total: a.total + it.totalAmount, taxFree: a.taxFree + (it.isTaxFree ? it.supplyAmount : 0), taxable: a.taxable + (it.isTaxFree ? 0 : it.supplyAmount), }), { supply: 0, vat: 0, total: 0, taxFree: 0, taxable: 0 }); const today = new Date().toISOString().slice(0, 10); const stmt = { orderNo: `ORD-${today.replace(/-/g, "")}-TEST`, orderDate: today, customer: { companyName: "수원거래처(테스트)", ceoName: "박철현", bizNo: "123-45-67890", phone: "010-1234-5678" }, supplier: { companyName: "모모유통", bankAccount: process.env.MOMO_BANK_ACCOUNT, phone: process.env.MOMO_PHONE, email: process.env.SMTP_FROM ?? "chpark@coa-soft.com", }, items, totals, }; const to = process.argv[2] || "chpark@wace.me"; const port = Number(process.env.SMTP_PORT || 465); const transporter = nodemailer.createTransport({ host: process.env.SMTP_HOST, port, secure: port === 465, auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }, tls: { rejectUnauthorized: false }, }); console.log("[stmt-mail] sending to:", to); const html = buildStatementHtml(stmt); const xlsx = buildStatementXlsx(stmt); const info = await transporter.sendMail({ from: process.env.SMTP_FROM, to, subject: `[모모유통] 발주 ${stmt.orderNo} 승인되었습니다 (테스트)`, html, attachments: [{ filename: `거래명세표_${stmt.orderNo}.xlsx`, content: xlsx, contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", }], }); console.log("[stmt-mail] ✔ sent:", info.messageId); console.log("[stmt-mail] response:", info.response); console.log("[stmt-mail] totals:", { taxFree: totals.taxFree, taxable: totals.taxable, vat: totals.vat, total: totals.total });