feat(landing): 거래처 사용법 6단계 + 화면 미리보기 섹션 추가
- 가입→검색→장바구니→승인→메일→정산까지 단계별 카드 - 장바구니 미리보기 + 자동발송 메일 미리보기 추가 - 우측 상단 회원가입/로그인 버튼은 기존 유지 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,173 @@
|
||||
// E2E 테스트: 가입 → 시드 데이터 → 발주 → 승인 + 메일 발송
|
||||
// 전제: dev 서버 실행 중 (localhost:3000), DB 마이그레이션 완료
|
||||
// 사용법: node scripts/test-e2e.mjs
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import pg from "pg";
|
||||
|
||||
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, "");
|
||||
}
|
||||
}
|
||||
|
||||
const BASE = "http://localhost:3000";
|
||||
const TEST_EMAIL = "chpark@wace.me";
|
||||
const TEST_PASSWORD = "test1234abcd";
|
||||
|
||||
let cookieJar = "";
|
||||
function setCookies(res) {
|
||||
const sc = res.headers.getSetCookie?.() || res.headers.raw?.()["set-cookie"] || [];
|
||||
for (const c of sc) {
|
||||
const kv = c.split(";")[0];
|
||||
cookieJar = cookieJar
|
||||
? cookieJar.split("; ").filter((p) => !p.startsWith(kv.split("=")[0] + "=")).concat(kv).join("; ")
|
||||
: kv;
|
||||
}
|
||||
}
|
||||
async function api(path, init = {}) {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(cookieJar ? { Cookie: cookieJar } : {}),
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
});
|
||||
setCookies(res);
|
||||
const text = await res.text();
|
||||
let json;
|
||||
try { json = JSON.parse(text); } catch { json = { raw: text }; }
|
||||
return { status: res.status, body: json };
|
||||
}
|
||||
|
||||
const log = (...a) => console.log("[e2e]", ...a);
|
||||
const fail = (msg) => { console.error("[e2e] ✖", msg); process.exit(1); };
|
||||
|
||||
// ===== 1. DB 정리 + 시드 (테스트 데이터) =====
|
||||
log("1. DB 정리 + 시드");
|
||||
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
||||
await client.connect();
|
||||
|
||||
// 기존 테스트 사용자 삭제 (재실행 가능하게)
|
||||
await client.query(`DELETE FROM momo_order_items WHERE order_objid IN (SELECT objid FROM momo_orders WHERE customer_objid IN (SELECT objid FROM momo_users WHERE email = $1))`, [TEST_EMAIL]);
|
||||
await client.query(`DELETE FROM momo_orders WHERE customer_objid IN (SELECT objid FROM momo_users WHERE email = $1)`, [TEST_EMAIL]);
|
||||
await client.query(`DELETE FROM momo_users WHERE email = $1`, [TEST_EMAIL]);
|
||||
|
||||
// 품목 / 재고 시드 (테스트 품목 3개 + 본사창고 재고 100개씩)
|
||||
const items = [
|
||||
{ code: "TEST-EGG-01", name: "M 유정란", price: 10000, taxFree: "Y" },
|
||||
{ code: "TEST-CHKN-01", name: "M 꽃계탕", price: 4500, taxFree: "Y" },
|
||||
{ code: "TEST-CLEAN-01", name: "빨강 탈취제", price: 9200, taxFree: "N" },
|
||||
];
|
||||
for (const it of items) {
|
||||
await client.query(
|
||||
`INSERT INTO momo_items (objid, item_code, item_name, unit, unit_price, is_tax_free, status, regdate)
|
||||
VALUES ($1, $2, $3, 'EA', $4, $5, 'ACTIVE', NOW())
|
||||
ON CONFLICT (item_code) DO UPDATE SET unit_price = EXCLUDED.unit_price, is_tax_free = EXCLUDED.is_tax_free, status='ACTIVE', is_del='N'`,
|
||||
[`TEST-${it.code}`, it.code, it.name, it.price, it.taxFree]
|
||||
);
|
||||
}
|
||||
const wh = await client.query(`SELECT objid FROM momo_warehouses WHERE wh_type='STOCK' LIMIT 1`);
|
||||
const whObjid = wh.rows[0].objid;
|
||||
for (const it of items) {
|
||||
await client.query(
|
||||
`INSERT INTO momo_stocks (objid, wh_objid, item_objid, qty, update_date)
|
||||
VALUES ($1, $2, (SELECT objid FROM momo_items WHERE item_code = $3), 100, NOW())
|
||||
ON CONFLICT (wh_objid, item_objid) DO UPDATE SET qty = 100, update_date = NOW()`,
|
||||
[`TEST-STK-${it.code}`, whObjid, it.code]
|
||||
);
|
||||
}
|
||||
log(" 품목 3개 + 재고 100개씩 시드 완료");
|
||||
|
||||
// ===== 2. 가입 =====
|
||||
log("2. 회원가입");
|
||||
const sup = await api("/api/auth/signup", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
email: TEST_EMAIL,
|
||||
password: TEST_PASSWORD,
|
||||
companyName: "테스트거래처(wace)",
|
||||
ceoName: "박철현",
|
||||
bizNo: "123-45-67890",
|
||||
phone: "010-1234-5678",
|
||||
}),
|
||||
});
|
||||
if (!sup.body.success) fail(`가입 실패: ${JSON.stringify(sup.body)}`);
|
||||
log(` ✔ 가입 성공 (USER 세션 발급, cookie=${cookieJar.slice(0, 30)}...)`);
|
||||
|
||||
// ===== 3. 품목 조회 (USER 세션) =====
|
||||
log("3. 품목 검색 (USER)");
|
||||
const list = await api("/api/m/items/list", { method: "POST", body: JSON.stringify({ keyword: "TEST" }) });
|
||||
const visible = (list.body.RESULTLIST || []).filter((r) => r.ITEM_CODE.startsWith("TEST-"));
|
||||
if (visible.length !== 3) fail(`품목 조회 실패: ${visible.length}개 (기대 3개)`);
|
||||
log(` ✔ ${visible.length}개 품목 노출, 재고 ${visible[0].STOCK_QTY}`);
|
||||
|
||||
// ===== 4. 발주 작성 =====
|
||||
log("4. 발주 요청");
|
||||
const order = await api("/api/m/orders/save", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
lines: [
|
||||
{ itemObjid: visible.find((x) => x.ITEM_CODE === "TEST-EGG-01").OBJID, qty: 30 },
|
||||
{ itemObjid: visible.find((x) => x.ITEM_CODE === "TEST-CHKN-01").OBJID, qty: 20 },
|
||||
{ itemObjid: visible.find((x) => x.ITEM_CODE === "TEST-CLEAN-01").OBJID, qty: 11 },
|
||||
],
|
||||
memo: "E2E 테스트 발주",
|
||||
}),
|
||||
});
|
||||
if (!order.body.success) fail(`발주 실패: ${JSON.stringify(order.body)}`);
|
||||
log(` ✔ 발주번호: ${order.body.orderNo}, objId: ${order.body.objId}`);
|
||||
|
||||
// ===== 5. 어드민 로그인 (시드 관리자) =====
|
||||
log("5. 관리자 로그인");
|
||||
cookieJar = ""; // USER 세션 클리어
|
||||
const adminLogin = await api("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ userId: "admin@momo.com", password: "admin1234" }),
|
||||
});
|
||||
if (!adminLogin.body.success) {
|
||||
// 시드 비밀번호 해시가 환경에 따라 다를 수 있어 → DB에서 비밀번호 재설정
|
||||
log(" 시드 비번 불일치 — bcrypt 재설정");
|
||||
const bcrypt = (await import("bcryptjs")).default;
|
||||
const hash = await bcrypt.hash("admin1234", 10);
|
||||
await client.query(`UPDATE momo_users SET password_hash = $1 WHERE email = 'admin@momo.com'`, [hash]);
|
||||
const retry = await api("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ userId: "admin@momo.com", password: "admin1234" }),
|
||||
});
|
||||
if (!retry.body.success) fail(`관리자 로그인 실패: ${JSON.stringify(retry.body)}`);
|
||||
}
|
||||
log(` ✔ 관리자 세션 발급`);
|
||||
|
||||
// ===== 6. 승인 + 메일 발송 =====
|
||||
log("6. 발주 승인 (재고차감 + 메일발송)");
|
||||
const approve = await api("/api/m/orders/approve", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ objid: order.body.objId }),
|
||||
});
|
||||
if (!approve.body.success) fail(`승인 실패: ${JSON.stringify(approve.body)}`);
|
||||
log(` ✔ 승인 완료, mailSent=${approve.body.mailSent}, mailError=${approve.body.mailError ?? "(none)"}`);
|
||||
|
||||
// ===== 7. 결과 검증 =====
|
||||
log("7. 후속 검증");
|
||||
const r1 = await client.query(`SELECT status, total_amount, total_taxfree, total_taxable FROM momo_orders WHERE objid = $1`, [order.body.objId]);
|
||||
log(` 주문 상태: ${r1.rows[0].status} (기대 APPROVED)`);
|
||||
log(` 금액: 면세 ${r1.rows[0].total_taxfree} / 과세 ${r1.rows[0].total_taxable} / 합계 ${r1.rows[0].total_amount}`);
|
||||
if (r1.rows[0].status !== "APPROVED") fail("주문 상태 불일치");
|
||||
|
||||
const r2 = await client.query(`SELECT qty FROM momo_stocks WHERE wh_objid = $1 AND item_objid = (SELECT objid FROM momo_items WHERE item_code = 'TEST-EGG-01')`, [whObjid]);
|
||||
log(` M 유정란 재고: ${r2.rows[0].qty} (기대 70 = 100-30)`);
|
||||
if (Number(r2.rows[0].qty) !== 70) fail("재고 차감 불일치");
|
||||
|
||||
const r3 = await client.query(`SELECT to_email, subject, status, error_msg FROM momo_mail_logs WHERE ref_objid = $1 ORDER BY regdate DESC LIMIT 1`, [order.body.objId]);
|
||||
log(` 메일 로그: to=${r3.rows[0].to_email}, status=${r3.rows[0].status}, subject=${r3.rows[0].subject}`);
|
||||
if (r3.rows[0].error_msg) log(` 메일 에러: ${r3.rows[0].error_msg}`);
|
||||
|
||||
log("✔ E2E 테스트 완료");
|
||||
await client.end();
|
||||
@@ -0,0 +1,68 @@
|
||||
// SMTP 단독 테스트 — DB 의존 없이 메일 발송 확인
|
||||
// 사용법: node scripts/test-smtp.mjs <받는사람이메일>
|
||||
import nodemailer from "nodemailer";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
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, "");
|
||||
}
|
||||
}
|
||||
|
||||
const to = process.argv[2] || "chpark@wace.me";
|
||||
const host = process.env.SMTP_HOST;
|
||||
const port = Number(process.env.SMTP_PORT || 465);
|
||||
const user = process.env.SMTP_USER;
|
||||
const pass = process.env.SMTP_PASS;
|
||||
const from = process.env.SMTP_FROM || user;
|
||||
|
||||
console.log("[smtp]", { host, port, user, from, to });
|
||||
|
||||
if (!host || !user || !pass) {
|
||||
console.error("SMTP 환경변수가 누락되었습니다.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host, port, secure: port === 465,
|
||||
auth: { user, pass },
|
||||
tls: { rejectUnauthorized: false },
|
||||
connectionTimeout: 15000,
|
||||
socketTimeout: 20000,
|
||||
});
|
||||
|
||||
try {
|
||||
console.log("[smtp] verify connection...");
|
||||
await transporter.verify();
|
||||
console.log("[smtp] ✔ verify OK");
|
||||
|
||||
console.log("[smtp] sending test mail...");
|
||||
const info = await transporter.sendMail({
|
||||
from,
|
||||
to,
|
||||
subject: "[모모유통] SMTP 연결 테스트",
|
||||
html: `
|
||||
<div style="font-family:'Apple SD Gothic Neo','Malgun Gothic',sans-serif;padding:24px;color:#0f172a">
|
||||
<h2 style="color:#0f766e">모모유통 메일 서버 테스트</h2>
|
||||
<p>이 메일이 도착했다면 SMTP 발송이 정상 작동하는 것입니다.</p>
|
||||
<ul>
|
||||
<li>발송 호스트: <b>${host}:${port}</b></li>
|
||||
<li>발송 계정: <b>${user}</b></li>
|
||||
<li>발송 시각: ${new Date().toLocaleString("ko-KR")}</li>
|
||||
</ul>
|
||||
<p style="color:#64748b;font-size:12px;margin-top:24px">실제 발주 승인 시에는 이 메일에 거래명세표 엑셀(.xlsx) 파일이 첨부됩니다.</p>
|
||||
</div>`,
|
||||
text: "모모유통 SMTP 테스트 메일입니다.",
|
||||
});
|
||||
console.log("[smtp] ✔ sent:", info.messageId);
|
||||
console.log("[smtp] response:", info.response);
|
||||
} catch (err) {
|
||||
console.error("[smtp] ✖ FAILED");
|
||||
console.error(err);
|
||||
process.exit(2);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// 발주 승인 시 발송될 거래명세표 메일을 그대로 시뮬레이션
|
||||
// (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, ">").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) => `
|
||||
<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 });
|
||||
+91
-18
@@ -1,5 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, Package, FileSpreadsheet, Mail, BarChart3, Smartphone, ShieldCheck } from "lucide-react";
|
||||
import { ArrowRight, Package, FileSpreadsheet, Mail, BarChart3, Smartphone, ShieldCheck, UserPlus, Search, ShoppingCart, CheckCircle2, Truck, Receipt } from "lucide-react";
|
||||
|
||||
export const metadata = {
|
||||
title: "모모유통 — 유통관리 시스템",
|
||||
@@ -82,33 +82,81 @@ export default function LandingPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 사용 방법 (3단계) */}
|
||||
{/* 사용 방법 (거래처 6단계) */}
|
||||
<section className="max-w-6xl mx-auto px-6 py-20">
|
||||
<div className="text-center mb-14">
|
||||
<div className="inline-flex items-center gap-2 text-emerald-700 text-xs font-bold tracking-widest mb-3">
|
||||
<span className="w-6 h-[2px] bg-emerald-600" />
|
||||
HOW IT WORKS
|
||||
HOW IT WORKS · 거래처 사용법
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-3">3단계로 끝나는 발주</h2>
|
||||
<p className="text-slate-500">엑셀 시트 여러 개를 띄울 필요 없습니다.</p>
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-3">가입부터 입금 확인까지</h2>
|
||||
<p className="text-slate-500">엑셀 시트 여러 개를 띄울 필요 없습니다. 한 화면에서 모두 처리됩니다.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
<Step
|
||||
num="1"
|
||||
title="회원가입"
|
||||
desc="이메일과 업체명만 입력하면 바로 사용 가능합니다. PC와 안드로이드 앱 모두 지원합니다."
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
<StepCard num="1" icon={<UserPlus size={20} />} title="회원가입"
|
||||
desc="우측 상단 [회원가입] 버튼 클릭 → 이메일·업체명·비밀번호 입력. 사업자번호와 대표자명은 거래명세서에 표시되니 미리 등록해 두세요."
|
||||
/>
|
||||
<Step
|
||||
num="2"
|
||||
title="품목 검색 → 발주 요청"
|
||||
desc="현재 재고가 있는 품목을 선택해 수량만 입력. 면세/과세는 시스템이 자동으로 분리 계산합니다."
|
||||
<StepCard num="2" icon={<Search size={20} />} title="품목 검색"
|
||||
desc="로그인 후 [품목 검색]에서 사진과 함께 품목을 둘러봅니다. 현재 재고가 0인 품목은 자동으로 비활성화됩니다. 면세 품목(M○○)은 보라색 뱃지로 구분됩니다."
|
||||
/>
|
||||
<Step
|
||||
num="3"
|
||||
title="모모유통 승인 → 자동 메일"
|
||||
desc="담당자 승인 한 번이면 재고 차감과 거래명세표 메일(엑셀 첨부) 발송이 동시에 처리됩니다."
|
||||
<StepCard num="3" icon={<ShoppingCart size={20} />} title="장바구니 → 발주"
|
||||
desc="[+ 담기]로 수량을 조정합니다. 우측 장바구니에 면세/과세 합계가 실시간 계산됩니다. [발주 요청] 버튼으로 모모유통에 발주서가 즉시 전송됩니다."
|
||||
/>
|
||||
<StepCard num="4" icon={<CheckCircle2 size={20} />} title="승인 대기"
|
||||
desc="모모유통 담당자가 [발주서 관리]에서 검토합니다. 재고 부족 시 자동 차단되어 잘못된 출고가 방지됩니다."
|
||||
/>
|
||||
<StepCard num="5" icon={<Mail size={20} />} title="승인 + 메일 도착"
|
||||
desc="담당자 [승인] 클릭 → 재고 차감 + 가입 이메일로 거래명세표 메일이 즉시 발송됩니다. 본문에 명세 표가 들어 있고, 엑셀(.xlsx) 파일이 첨부됩니다."
|
||||
/>
|
||||
<StepCard num="6" icon={<Truck size={20} />} title="출고·계산서·정산"
|
||||
desc="배송 완료 후 [출고완료]로 상태가 바뀌고, 월말에 [계산서 발행]이 일괄 처리됩니다. 입금 확인까지 한 화면에서 추적 가능합니다."
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 화면 미리보기 */}
|
||||
<section className="bg-gradient-to-br from-emerald-50/40 to-white border-y border-emerald-100">
|
||||
<div className="max-w-6xl mx-auto px-6 py-20">
|
||||
<div className="text-center mb-12">
|
||||
<div className="inline-flex items-center gap-2 text-emerald-700 text-xs font-bold tracking-widest mb-3">
|
||||
<span className="w-6 h-[2px] bg-emerald-600" />
|
||||
SCREEN PREVIEW
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-3">실제 동작 화면</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-5">
|
||||
<PreviewCard title="장바구니" desc="우측 패널에 면세/과세/세액이 즉시 분리 계산됩니다.">
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="flex justify-between p-2 bg-slate-50 rounded">
|
||||
<span>M 유정란 × 30</span><span className="font-bold tabular-nums">₩300,000</span>
|
||||
</div>
|
||||
<div className="flex justify-between p-2 bg-slate-50 rounded">
|
||||
<span>빨강 탈취제 × 11</span><span className="font-bold tabular-nums">₩101,200</span>
|
||||
</div>
|
||||
<div className="border-t pt-2 mt-3 space-y-1">
|
||||
<div className="flex justify-between"><span className="text-violet-700">면세 합계</span><span className="tabular-nums">₩300,000</span></div>
|
||||
<div className="flex justify-between"><span className="text-rose-700">과세 공급가</span><span className="tabular-nums">₩92,000</span></div>
|
||||
<div className="flex justify-between"><span>세액</span><span className="tabular-nums">₩9,200</span></div>
|
||||
<div className="flex justify-between font-bold text-emerald-700 pt-1 border-t"><span>총 합계</span><span className="tabular-nums">₩401,200</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</PreviewCard>
|
||||
<PreviewCard title="자동 발송 메일" desc="승인 직후 가입 이메일로 도착하는 거래명세표 본문 + 엑셀 첨부.">
|
||||
<div className="bg-slate-900 rounded-lg p-3 text-emerald-100 text-[11px] font-mono space-y-1">
|
||||
<div className="text-slate-400">받는사람: 거래처 ▸</div>
|
||||
<div className="text-slate-400 mb-2">제목: [모모유통] 발주 ORD-… 승인되었습니다</div>
|
||||
<div className="text-emerald-300">📎 거래명세표_ORD-….xlsx (12 KB)</div>
|
||||
<div className="border-t border-slate-700 pt-2 mt-2 space-y-0.5 text-slate-200">
|
||||
<div>발주번호: ORD-20260425-0007</div>
|
||||
<div>면세 합계: ₩300,000</div>
|
||||
<div>과세 공급가: ₩92,000</div>
|
||||
<div className="text-emerald-300 font-bold">총 합계: ₩401,200</div>
|
||||
</div>
|
||||
</div>
|
||||
</PreviewCard>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -222,6 +270,31 @@ function Step({ num, title, desc }: { num: string; title: string; desc: string }
|
||||
);
|
||||
}
|
||||
|
||||
function StepCard({ num, icon, title, desc }: { num: string; icon: React.ReactNode; title: string; desc: string }) {
|
||||
return (
|
||||
<div className="relative rounded-2xl border border-emerald-100 bg-white p-6 shadow-sm hover:shadow-md hover:-translate-y-0.5 transition-all">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-emerald-700 to-emerald-500 text-white flex items-center justify-center shadow">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="text-emerald-700 text-xs font-bold tracking-widest">STEP {num}</div>
|
||||
</div>
|
||||
<h3 className="text-base font-bold mb-2 text-slate-900">{title}</h3>
|
||||
<p className="text-slate-600 text-sm leading-relaxed">{desc}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewCard({ title, desc, children }: { title: string; desc: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-emerald-100 bg-white p-6 shadow-sm">
|
||||
<h3 className="font-bold mb-1">{title}</h3>
|
||||
<p className="text-xs text-slate-500 mb-4">{desc}</p>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Feature({ icon, title, desc }: { icon: React.ReactNode; title: string; desc: string }) {
|
||||
return (
|
||||
<div className="rounded-xl bg-white border border-emerald-100 p-6 hover:shadow-md transition">
|
||||
|
||||
Reference in New Issue
Block a user