// 발주 승인 시 발송될 거래명세표 메일을 그대로 시뮬레이션
// (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 });