Files
distribution_erp/scripts/test-statement-mail.mjs
T
chpark d7cc711b93 feat(landing): 거래처 사용법 6단계 + 화면 미리보기 섹션 추가
- 가입→검색→장바구니→승인→메일→정산까지 단계별 카드
- 장바구니 미리보기 + 자동발송 메일 미리보기 추가
- 우측 상단 회원가입/로그인 버튼은 기존 유지

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:09:45 +09:00

166 lines
9.4 KiB
JavaScript

// 발주 승인 시 발송될 거래명세표 메일을 그대로 시뮬레이션
// (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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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) => `
<tr>
<td style="text-align:center;border:1px solid #cbd5e1;padding:7px">${it.seq}</td>
<td style="border:1px solid #cbd5e1;padding:7px">${escapeHtml(it.itemName)}</td>
<td style="text-align:center;color:${it.isTaxFree ? "#7c3aed" : "#e11d48"};border:1px solid #cbd5e1;padding:7px">${it.isTaxFree ? "면세" : "과세"}</td>
<td style="text-align:right;border:1px solid #cbd5e1;padding:7px">${it.qty}</td>
<td style="text-align:center;border:1px solid #cbd5e1;padding:7px">${escapeHtml(it.unit || "EA")}</td>
<td style="text-align:right;border:1px solid #cbd5e1;padding:7px">${formatNumber(fmt(it.unitPrice))}</td>
<td style="text-align:right;border:1px solid #cbd5e1;padding:7px">${formatNumber(fmt(it.supplyAmount))}</td>
<td style="text-align:right;border:1px solid #cbd5e1;padding:7px">${it.isTaxFree ? "-" : formatNumber(fmt(it.vatAmount))}</td>
<td style="text-align:right;font-weight:600;border:1px solid #cbd5e1;padding:7px">${formatNumber(fmt(it.totalAmount))}</td>
</tr>`).join("");
return `<!doctype html><html lang="ko"><body style="font-family:'Apple SD Gothic Neo','Malgun Gothic',sans-serif;color:#0f172a;padding:24px;background:#fff">
<h2 style="text-align:center;letter-spacing:8px;margin:0 0 16px">거 래 명 세 표</h2>
<div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:16px">
<div><div><b>발주번호</b> ${escapeHtml(input.orderNo)}</div><div><b>발주일자</b> ${escapeHtml(input.orderDate)}</div></div>
<div style="text-align:right"><div><b>공급자</b> ${escapeHtml(input.supplier.companyName)}</div>
<div>${escapeHtml(input.supplier.phone ?? "")} · ${escapeHtml(input.supplier.email ?? "")}</div></div>
</div>
<div style="border:1px solid #cbd5e1;padding:10px;margin-bottom:14px;font-size:13px">
<b>${escapeHtml(input.customer.companyName)}</b> 귀하
${input.customer.ceoName ? ` · 대표 ${escapeHtml(input.customer.ceoName)}` : ""}
${input.customer.bizNo ? ` · 사업자번호 ${escapeHtml(input.customer.bizNo)}` : ""}
</div>
<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead><tr style="background:#f1f5f9">
<th style="border:1px solid #cbd5e1;padding:8px">순번</th>
<th style="border:1px solid #cbd5e1;padding:8px">품명</th>
<th style="border:1px solid #cbd5e1;padding:8px">구분</th>
<th style="border:1px solid #cbd5e1;padding:8px">수량</th>
<th style="border:1px solid #cbd5e1;padding:8px">단위</th>
<th style="border:1px solid #cbd5e1;padding:8px">단가</th>
<th style="border:1px solid #cbd5e1;padding:8px">공급가액</th>
<th style="border:1px solid #cbd5e1;padding:8px">세액</th>
<th style="border:1px solid #cbd5e1;padding:8px">합계</th>
</tr></thead>
<tbody style="font-variant-numeric:tabular-nums">${rows}</tbody>
</table>
<table style="margin-top:14px;margin-left:auto;font-size:13px;font-variant-numeric:tabular-nums">
<tr><td style="padding:4px 12px;color:#7c3aed">면세 합계</td><td style="padding:4px 0;text-align:right;min-width:140px">₩ ${formatNumber(fmt(input.totals.taxFree))}</td></tr>
<tr><td style="padding:4px 12px;color:#e11d48">과세 공급가</td><td style="padding:4px 0;text-align:right">₩ ${formatNumber(fmt(input.totals.taxable))}</td></tr>
<tr><td style="padding:4px 12px">세액 합계</td><td style="padding:4px 0;text-align:right">₩ ${formatNumber(fmt(input.totals.vat))}</td></tr>
<tr><td style="padding:8px 12px;font-weight:700;border-top:2px solid #0f172a">총 합계 (VAT 포함)</td><td style="padding:8px 0;text-align:right;font-weight:700;border-top:2px solid #0f172a">₩ ${formatNumber(fmt(input.totals.total))}</td></tr>
</table>
<div style="margin-top:32px;padding-top:16px;border-top:1px solid #e2e8f0;font-size:12px;color:#475569">위와 같이 계산합니다. — 모모유통</div>
</body></html>`;
}
// ===== 가짜 발주 데이터 (스크린샷 첨부 거래명세표 그대로) =====
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 });