sebit-mcp-public
Version:
> 한국어 설명은 아래 링크에서 확인할 수 있습니다. > 👉 [README.ko.md](./README.ko.md)
624 lines (623 loc) • 25.6 kB
JavaScript
;
// =============================
// FILE: src/models/journal.ts
// Journal book writer (ko/en)
// - 벤더(거래처)별 파일명: <vendor>_<year>.xlsx
// - 월별 시트("01"~"12") 자동 생성
// - 연도 폴더에 audit.log 기록
// - 구(설명기반) 파일을 벤더기반 파일로 자동 이관
// =============================
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.runJournal = runJournal;
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
const XLSX = __importStar(require("xlsx"));
// ---------------------------
// 경로/유틸
// ---------------------------
// 기본 루트: C:\Users\user\Desktop\journal_book
const DEFAULT_ROOT = (() => {
const home = process.env.USERPROFILE || "C:/Users/user";
return path.join(home, "Desktop", "journal_book");
})();
const ROOT = process.env.JOURNAL_ROOT || DEFAULT_ROOT;
const ILLEGAL = /[\\/:*?"<>|]/g;
const squash = (s) => (s ?? "").toString().replace(ILLEGAL, "_").trim();
const ensureDir = (p) => fs.mkdirSync(p, { recursive: true });
function loadOrCreateBook(file, language = "ko") {
if (fs.existsSync(file)) {
return XLSX.readFile(file);
}
// 새 북 생성 시 모든 월 시트 + 요약 시트 미리 생성
const wb = XLSX.utils.book_new();
const HEADERS = language === "en"
? ["Date", "Vendor", "Description", "Account", "Debit", "Credit", "Amount", "Currency"]
: ["날짜", "거래처", "설명", "계정", "차변", "대변", "금액", "통화"];
// 1월~12월 시트 생성
for (let month = 1; month <= 12; month++) {
const sheetName = month < 10 ? `0${month}` : String(month);
const ws = XLSX.utils.aoa_to_sheet([HEADERS]);
XLSX.utils.book_append_sheet(wb, ws, sheetName);
}
// 요약 시트 생성 (13번째)
const summaryHeaders = language === "en"
? ["Month", "Total Debit", "Total Credit", "Balance", "Transaction Count"]
: ["월", "총 차변", "총 대변", "잔액", "거래건수"];
const summaryWs = XLSX.utils.aoa_to_sheet([summaryHeaders]);
XLSX.utils.book_append_sheet(wb, summaryWs, language === "en" ? "Summary" : "요약");
return wb;
}
function saveBook(wb, file) {
ensureDir(path.dirname(file));
XLSX.writeFile(wb, file, { bookType: "xlsx" });
}
function monthSheetName(isoDate) {
const m = new Date(isoDate).getMonth() + 1;
return m < 10 ? `0${m}` : String(m);
}
function safeVendor(vendor, language) {
const v = (vendor ?? "").trim();
if (v)
return v;
return language === "en" ? "UnknownVendor" : "미지정거래처";
}
// ---------------------------
// 자연어 파서 (강화버전)
// ---------------------------
// 스마트 계정 분류 함수
function classifyAccountKo(text, paymentMethod) {
const t = text.toLowerCase();
// 결제수단 우선 확인
if (paymentMethod) {
if (/카드|신용|체크|visa|master|삼성페이|애플페이/.test(paymentMethod))
return "카드";
if (/현금|cash/.test(paymentMethod))
return "현금";
if (/계좌이체|이체|송금/.test(paymentMethod))
return "계좌이체";
}
// 내용 기반 분류
if (/식비|커피|음식|식사|밥|점심|저녁|아침|카페|레스토랑|치킨|피자|햄버거|떡볶이/.test(t))
return "식비";
if (/교통|버스|지하철|택시|기차|비행기|주유|기름|연료/.test(t))
return "교통비";
if (/쇼핑|옷|신발|가방|화장품|액세서리|의류/.test(t))
return "쇼핑";
if (/의료|병원|약국|치료|진료|검진/.test(t))
return "의료비";
if (/교육|학원|책|강의|수업|학습/.test(t))
return "교육비";
if (/통신|핸드폰|인터넷|휴대폰|전화/.test(t))
return "통신비";
if (/공과금|전기|가스|수도|관리비|아파트/.test(t))
return "공과금";
if (/엔터|영화|게임|놀이|여행|호텔|숙박/.test(t))
return "엔터테인먼트";
// 기본값
return paymentMethod || "기타";
}
function parseRelativeDate(dateStr) {
const today = new Date();
const year = today.getFullYear();
const month = today.getMonth() + 1;
const day = today.getDate();
if (/오늘|today/.test(dateStr)) {
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
if (/어제|yesterday/.test(dateStr)) {
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
return `${yesterday.getFullYear()}-${String(yesterday.getMonth() + 1).padStart(2, "0")}-${String(yesterday.getDate()).padStart(2, "0")}`;
}
if (/그저께|일어제/.test(dateStr)) {
const dayBefore = new Date(today);
dayBefore.setDate(dayBefore.getDate() - 2);
return `${dayBefore.getFullYear()}-${String(dayBefore.getMonth() + 1).padStart(2, "0")}-${String(dayBefore.getDate()).padStart(2, "0")}`;
}
return "";
}
function parseKoText(text, company) {
const t = text.replace(/\s+/g, " ").trim();
const today = new Date();
// 날짜 파싱 (다양한 형식 지원)
let date = "";
// 1. 상대적 날짜 ("오늘", "어제" 등)
const relativeDate = parseRelativeDate(t);
if (relativeDate)
date = relativeDate;
// 2. 완전한 날짜 (2025년 3월 15일)
if (!date) {
const mFullDate = t.match(/(?<y>\d{4})\s*년\s*(?<m>\d{1,2})\s*월\s*(?<d>\d{1,2})\s*일/);
const y = mFullDate?.groups?.y, m = mFullDate?.groups?.m, d = mFullDate?.groups?.d;
if (y && m && d)
date = `${y}-${String(+m).padStart(2, "0")}-${String(+d).padStart(2, "0")}`;
}
// 3. 월/일만 (올해로 가정)
if (!date) {
const mMonthDay = t.match(/(?<m>\d{1,2})\s*월\s*(?<d>\d{1,2})\s*일/);
const m = mMonthDay?.groups?.m, d = mMonthDay?.groups?.d;
if (m && d)
date = `${today.getFullYear()}-${String(+m).padStart(2, "0")}-${String(+d).padStart(2, "0")}`;
}
// 4. 숫자 날짜 (3/15, 03-15 등)
if (!date) {
const mNumDate = t.match(/(?<m>\d{1,2})[\/\-](?<d>\d{1,2})/);
const m = mNumDate?.groups?.m, d = mNumDate?.groups?.d;
if (m && d)
date = `${today.getFullYear()}-${String(+m).padStart(2, "0")}-${String(+d).padStart(2, "0")}`;
}
// 기본값: 오늘
if (!date) {
date = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
}
// 벤더 파싱 (다양한 패턴)
let vendor = "";
// "XXX에서" 패턴
const mVendorFrom = t.match(/([가-힣A-Za-z0-9 _\-.&]+?)\s*에서/);
if (mVendorFrom)
vendor = mVendorFrom[1].trim();
// "XXX에" 패턴
if (!vendor) {
const mVendorTo = t.match(/([가-힣A-Za-z0-9 _\-.&]+?)\s*에\s/);
if (mVendorTo)
vendor = mVendorTo[1].trim();
}
// "XXX 구매" 패턴
if (!vendor) {
const mVendorBuy = t.match(/([가-힣A-Za-z0-9 _\-.&]+?)\s*(구매|결제|지불)/);
if (mVendorBuy)
vendor = mVendorBuy[1].trim();
}
// 브랜드명 자동 인식
const brands = ['스타벅스', '맥도날드', '버거킹', 'KFC', '롯데리아', '쿠팡', '마켓컬리', '이마트', '홈플러스',
'GS25', 'CU', '세븐일레븐', '카카오택시', '우버', '배달의민족', '요기요'];
if (!vendor) {
for (const brand of brands) {
if (t.includes(brand)) {
vendor = brand;
break;
}
}
}
// 금액 파싱 (다양한 패턴)
let amount = 0;
let currency = "KRW";
// 한국어 금액 (6,000원, 6000원, 6천원 등)
const mWon = t.match(/([\d,]+)\s*원/) || t.match(/([\d]+)\s*천\s*원/);
if (mWon) {
let amountStr = mWon[1];
if (mWon[0].includes('천원')) {
amount = parseInt(amountStr) * 1000;
}
else {
amount = parseInt(amountStr.replace(/,/g, ""), 10);
}
currency = "KRW";
}
// 외화 (100 USD, $100 등)
if (amount === 0) {
const mDollar = t.match(/\$\s*([\d,]+)/) || t.match(/([\d,]+)\s*USD/i);
const mEuro = t.match(/€\s*([\d,]+)/) || t.match(/([\d,]+)\s*EUR/i);
if (mDollar) {
amount = parseInt(mDollar[1].replace(/,/g, ""), 10);
currency = "USD";
}
else if (mEuro) {
amount = parseInt(mEuro[1].replace(/,/g, ""), 10);
currency = "EUR";
}
}
// 결제수단 파싱
let paymentMethod = "";
if (/카드|신용카드|체크카드|visa|master|카드로/.test(t))
paymentMethod = "카드";
else if (/현금|cash|현금으로/.test(t))
paymentMethod = "현금";
else if (/페이|pay|삼성페이|애플페이|카카오페이/.test(t))
paymentMethod = "모바일페이";
else if (/이체|송금|계좌/.test(t))
paymentMethod = "계좌이체";
// 스마트 계정 분류
const account = classifyAccountKo(t, paymentMethod);
// 설명 추출 (더 똑똑하게)
let description = "지출";
// 상품명/서비스명 추출
const products = ['커피', '아메리카노', '라떼', '빵', '샌드위치', '햄버거', '피자', '치킨',
'택시', '버스', '지하철', '주유', '쇼핑', '옷', '신발', '책', '영화'];
for (const product of products) {
if (t.includes(product)) {
description = product;
break;
}
}
// 벤더 뒤의 단어 추출
if (description === "지출" && vendor) {
const afterVendor = t.split(vendor)[1] || "";
const descMatch = afterVendor.match(/([가-힣A-Za-z0-9]+)(?:\s|,|\.|$|원)/);
if (descMatch && !['에서', '에', '로', '으로', '결제', '지불', '구매'].includes(descMatch[1])) {
description = descMatch[1];
}
}
return {
date, vendor, description, account,
debit: amount, credit: 0, currency,
language: "ko"
};
}
// 영어용 스마트 계정 분류
function classifyAccountEn(text, paymentMethod) {
const t = text.toLowerCase();
// 결제수단 우선 확인
if (paymentMethod) {
if (/card|credit|debit|visa|master|amex|apple\s*pay|samsung\s*pay/.test(paymentMethod))
return "Card";
if (/cash|bill/.test(paymentMethod))
return "Cash";
if (/transfer|wire|bank/.test(paymentMethod))
return "Bank Transfer";
}
// 내용 기반 분류
if (/food|coffee|restaurant|lunch|dinner|breakfast|cafe|pizza|burger|meal/.test(t))
return "Food & Dining";
if (/transport|bus|subway|taxi|train|flight|uber|lyft|fuel|gas|gasoline/.test(t))
return "Transportation";
if (/shopping|clothes|shoes|bag|cosmetics|apparel|retail/.test(t))
return "Shopping";
if (/medical|hospital|pharmacy|doctor|treatment|health/.test(t))
return "Healthcare";
if (/education|school|book|course|lesson|learning|tuition/.test(t))
return "Education";
if (/phone|internet|mobile|telecom|communication/.test(t))
return "Communication";
if (/utility|electric|gas|water|rent|apartment|utilities/.test(t))
return "Utilities";
if (/entertainment|movie|game|travel|hotel|vacation/.test(t))
return "Entertainment";
return paymentMethod || "Other";
}
function parseRelativeDateEn(dateStr) {
const today = new Date();
const year = today.getFullYear();
const month = today.getMonth() + 1;
const day = today.getDate();
if (/today|오늘/.test(dateStr)) {
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
if (/yesterday|어제/.test(dateStr)) {
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
return `${yesterday.getFullYear()}-${String(yesterday.getMonth() + 1).padStart(2, "0")}-${String(yesterday.getDate()).padStart(2, "0")}`;
}
return "";
}
function parseEnText(text, company) {
const t = text.replace(/\s+/g, " ").trim();
const today = new Date();
// 날짜 파싱 (다양한 형식 지원)
let date = "";
// 1. 상대적 날짜
const relativeDate = parseRelativeDateEn(t);
if (relativeDate)
date = relativeDate;
// 2. 미국식 날짜 (Mar 15, 2025 / March 15, 2025)
if (!date) {
const mUSDate = t.match(/(Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:t|tember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)\s+(\d{1,2}),?\s*(\d{4})/i);
if (mUSDate) {
const monthMap = {
jan: 1, january: 1, feb: 2, february: 2, mar: 3, march: 3,
apr: 4, april: 4, may: 5, jun: 6, june: 6,
jul: 7, july: 7, aug: 8, august: 8, sep: 9, sept: 9, september: 9,
oct: 10, october: 10, nov: 11, november: 11, dec: 12, december: 12
};
const monthNum = monthMap[mUSDate[1].toLowerCase()];
date = `${mUSDate[3]}-${String(monthNum).padStart(2, "0")}-${String(+mUSDate[2]).padStart(2, "0")}`;
}
}
// 3. 영국식 날짜 (15 Mar 2025)
if (!date) {
const mUKDate = t.match(/(\d{1,2})\s+(Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:t|tember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)\s*(\d{4})/i);
if (mUKDate) {
const monthMap = {
jan: 1, january: 1, feb: 2, february: 2, mar: 3, march: 3,
apr: 4, april: 4, may: 5, jun: 6, june: 6,
jul: 7, july: 7, aug: 8, august: 8, sep: 9, sept: 9, september: 9,
oct: 10, october: 10, nov: 11, november: 11, dec: 12, december: 12
};
const monthNum = monthMap[mUKDate[2].toLowerCase()];
date = `${mUKDate[3]}-${String(monthNum).padStart(2, "0")}-${String(+mUKDate[1]).padStart(2, "0")}`;
}
}
// 4. ISO 날짜 (2025-03-15)
if (!date) {
const mISO = t.match(/(\d{4})-(\d{2})-(\d{2})/);
if (mISO)
date = `${mISO[1]}-${mISO[2]}-${mISO[3]}`;
}
// 5. 숫자 날짜 (3/15, 03/15, 15/3 등)
if (!date) {
const mSlash = t.match(/(\d{1,2})\/(\d{1,2})(?:\/(\d{4}))?/);
if (mSlash) {
const year = mSlash[3] || today.getFullYear();
// 미국식 가정 (MM/DD)
date = `${year}-${String(+mSlash[1]).padStart(2, "0")}-${String(+mSlash[2]).padStart(2, "0")}`;
}
}
// 기본값: 오늘
if (!date) {
date = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
}
// 벤더 파싱 (다양한 패턴)
let vendor = "";
// "at XXX" 패턴
const mVendorAt = t.match(/\s+at\s+([A-Za-z0-9 _\-.'&]+?)(?:\s|,|\.|$|\d)/);
if (mVendorAt)
vendor = mVendorAt[1].trim();
// "from XXX" 패턴
if (!vendor) {
const mVendorFrom = t.match(/\s+from\s+([A-Za-z0-9 _\-.'&]+?)(?:\s|,|\.|$|\d)/);
if (mVendorFrom)
vendor = mVendorFrom[1].trim();
}
// "XXX purchase" 패턴
if (!vendor) {
const mVendorPurchase = t.match(/([A-Za-z0-9 _\-.'&]+?)\s+(purchase|payment|paid)/i);
if (mVendorPurchase)
vendor = mVendorPurchase[1].trim();
}
// 유명 브랜드 자동 인식
const brands = ['Starbucks', 'McDonald\'s', 'Burger King', 'KFC', 'Subway', 'Amazon', 'Apple', 'Google',
'Walmart', 'Target', 'Costco', 'Uber', 'Lyft', 'Netflix', 'Spotify', 'PayPal'];
if (!vendor) {
for (const brand of brands) {
if (new RegExp(`\\b${brand}\\b`, 'i').test(t)) {
vendor = brand;
break;
}
}
}
// 금액 파싱 (다양한 통화)
let amount = 0;
let currency = "USD";
// 달러 ($100, 100 USD, $1,000.50)
const mDollar = t.match(/\$\s*([\d,]+(?:\.\d{2})?)/) || t.match(/([\d,]+(?:\.\d{2})?)\s*USD/i);
if (mDollar) {
amount = parseFloat(mDollar[1].replace(/,/g, ""));
currency = "USD";
}
// 유로 (€100, 100 EUR)
if (amount === 0) {
const mEuro = t.match(/€\s*([\d,]+(?:\.\d{2})?)/) || t.match(/([\d,]+(?:\.\d{2})?)\s*EUR/i);
if (mEuro) {
amount = parseFloat(mEuro[1].replace(/,/g, ""));
currency = "EUR";
}
}
// 영국 파운드 (£100, 100 GBP)
if (amount === 0) {
const mPound = t.match(/£\s*([\d,]+(?:\.\d{2})?)/) || t.match(/([\d,]+(?:\.\d{2})?)\s*GBP/i);
if (mPound) {
amount = parseFloat(mPound[1].replace(/,/g, ""));
currency = "GBP";
}
}
// 원화 (1000 KRW, 1,000원)
if (amount === 0) {
const mKRW = t.match(/([\d,]+)\s*KRW/i) || t.match(/([\d,]+)\s*원/);
if (mKRW) {
amount = parseInt(mKRW[1].replace(/,/g, ""), 10);
currency = "KRW";
}
}
// 결제수단 파싱
let paymentMethod = "";
if (/card|credit|debit|visa|master|amex/i.test(t))
paymentMethod = "Card";
else if (/cash|bill/i.test(t))
paymentMethod = "Cash";
else if (/pay|apple\s*pay|samsung\s*pay|paypal/i.test(t))
paymentMethod = "Mobile Pay";
else if (/transfer|wire|bank/i.test(t))
paymentMethod = "Bank Transfer";
// 스마트 계정 분류
const account = classifyAccountEn(t, paymentMethod);
// 설명 추출
let description = "Expense";
// 상품명/서비스명 추출
const products = ['coffee', 'americano', 'latte', 'bread', 'sandwich', 'burger', 'pizza', 'chicken',
'taxi', 'bus', 'subway', 'fuel', 'shopping', 'clothes', 'shoes', 'book', 'movie'];
for (const product of products) {
if (new RegExp(`\\b${product}\\b`, 'i').test(t)) {
description = product.charAt(0).toUpperCase() + product.slice(1);
break;
}
}
// 벤더 뒤의 단어 추출
if (description === "Expense" && vendor) {
const afterVendor = t.split(vendor)[1] || "";
const descMatch = afterVendor.match(/\b([A-Za-z0-9]+)\b/);
if (descMatch && !['at', 'from', 'for', 'with', 'payment', 'purchase', 'paid'].includes(descMatch[1].toLowerCase())) {
description = descMatch[1].charAt(0).toUpperCase() + descMatch[1].slice(1);
}
}
return {
date, vendor, description, account,
debit: amount, credit: 0, currency,
language: "en"
};
}
// ---------------------------
// 파일 경로 & 구파일 이관
// ---------------------------
function vendorFilePath(company, year, vendor) {
const dir = path.join(ROOT, squash(company), String(year));
ensureDir(dir);
return path.join(dir, `${squash(vendor)}_${year}.xlsx`);
}
function migrateLegacyDescBook(company, year, description, vendorFile) {
if (!description)
return;
const dir = path.join(ROOT, squash(company), String(year));
const legacy = path.join(dir, `${squash(description)}_${year}.xlsx`);
if (!fs.existsSync(legacy) || legacy === vendorFile)
return;
const oldWb = XLSX.readFile(legacy);
const newWb = fs.existsSync(vendorFile) ? XLSX.readFile(vendorFile) : XLSX.utils.book_new();
for (const s of oldWb.SheetNames) {
const os = oldWb.Sheets[s];
const oa = XLSX.utils.sheet_to_json(os, { header: 1 });
if (!oa?.length)
continue;
const vs = newWb.Sheets[s];
if (vs) {
const va = XLSX.utils.sheet_to_json(vs, { header: 1 });
const merged = (va || []).concat(oa.slice(va?.length ? 1 : 0)); // 헤더 중복 방지
newWb.Sheets[s] = XLSX.utils.aoa_to_sheet(merged);
}
else {
newWb.Sheets[s] = XLSX.utils.aoa_to_sheet(oa);
if (!newWb.SheetNames.includes(s))
newWb.SheetNames.push(s);
}
}
saveBook(newWb, vendorFile);
fs.unlinkSync(legacy);
}
// ---------------------------
// 메인: runJournal
// ---------------------------
function runJournal(input) {
let payload;
let company = "";
if ("text" in input) {
const lang = (input.language || "ko");
company = (input.company || "").trim();
payload = lang === "en" ? parseEnText(input.text, company) : parseKoText(input.text, company);
}
else {
company = (input.company || "").trim();
const language = (input.language || "ko");
payload = {
date: input.date,
vendor: input.vendor ?? "",
description: input.description ?? "",
account: input.account,
debit: Math.max(0, +input.debit || 0),
credit: Math.max(0, +input.credit || 0),
currency: input.currency || (language === "en" ? "USD" : "KRW"),
language,
};
}
// 2) 밸리데이션
if (!company)
throw new Error("company is required.");
if (!payload.date)
throw new Error("date is required.");
if (!payload.account)
throw new Error("account is required.");
if (!(payload.debit > 0 || payload.credit > 0))
throw new Error("one of debit or credit must be positive.");
if (payload.debit < 0 || payload.credit < 0)
throw new Error("amount cannot be negative.");
// 3) 대상 파일/시트
const year = new Date(payload.date).getFullYear();
const vendor = safeVendor(payload.vendor, payload.language);
const file = vendorFilePath(company, year, vendor);
migrateLegacyDescBook(company, year, payload.description, file);
const wb = loadOrCreateBook(file, payload.language);
const sheetName = monthSheetName(payload.date);
const HEADERS = payload.language === "en"
? ["Date", "Vendor", "Description", "Account", "Debit", "Credit", "Amount", "Currency"]
: ["날짜", "거래처", "설명", "계정", "차변", "대변", "금액", "통화"];
let ws = wb.Sheets[sheetName];
if (!ws) {
ws = XLSX.utils.aoa_to_sheet([HEADERS]);
XLSX.utils.book_append_sheet(wb, ws, sheetName);
}
const row = [
payload.date,
vendor,
payload.description || (payload.language === "en" ? "Expense" : "지출"),
payload.account,
Number(payload.debit || 0),
Number(payload.credit || 0),
Number((payload.debit || 0) + (payload.credit || 0)),
payload.currency || (payload.language === "en" ? "USD" : "KRW"),
];
// 5) 중복 검사: 날짜+거래처+금액
const rowsAoa = XLSX.utils.sheet_to_json(ws, { header: 1 });
const duplicated = rowsAoa.some((r) => String(r?.[0] ?? "") === row[0] &&
String(r?.[1] ?? "") === row[1] &&
Number(r?.[6] ?? 0) === row[6]);
// 6) 중복 아니면 추가
if (!duplicated) {
XLSX.utils.sheet_add_aoa(ws, [row], { origin: -1 });
}
// 7) 저장 및 로우 인덱스
const rowIndex = XLSX.utils.sheet_to_json(ws, { header: 1 }).length;
saveBook(wb, file);
// 8) 감사 로그
const yearDir = path.dirname(file);
const logPath = path.join(yearDir, "audit.log");
const stamp = new Date().toISOString();
fs.appendFileSync(logPath, JSON.stringify({
ts: stamp,
company,
year,
sheet: sheetName,
rowIndex,
action: duplicated ? "skip-duplicate" : "append-row",
row,
}) + "\n", "utf8");
// 9) 반환
const [dateStr, vendorStr, descriptionStr, accountStr, debitNum, creditNum, _amountNum, currencyStr] = row;
return {
ok: true,
duplicated,
filePath: file,
sheet: sheetName,
rowIndex,
entry: {
date: dateStr,
vendor: vendorStr,
description: descriptionStr,
account: accountStr,
debit: debitNum,
credit: creditNum,
currency: currencyStr,
language: payload.language,
},
};
}