/** * 경영성과 대시보드: 엑셀 업로드 파싱·스냅샷 저장·최신 페이로드 조회 */ const fs = require("fs"); const path = require("path"); function loadXlsx() { try { return require("xlsx"); } catch { return null; } } const ROOT = path.join(__dirname, ".."); const DEFAULT_PAYLOAD_PATH = path.join(ROOT, "data", "mgmt-perf-default-payload.json"); const FILE_STATE_PATH = path.join(ROOT, "data", "mgmt-perf-last-state.json"); function loadDefaultPayload() { const raw = fs.readFileSync(DEFAULT_PAYLOAD_PATH, "utf8"); return JSON.parse(raw); } /** * 매출일보 시트를 읽어 기본 페이로드에 메타를 덧붙입니다. (집계 치환은 추후 확장) * @param {Buffer} buffer * @param {object} defaultPayload */ function buildPayloadFromWorkbook(buffer, defaultPayload) { const XLSX = loadXlsx(); if (!XLSX) { const payload = JSON.parse(JSON.stringify(defaultPayload)); payload._uploadMeta = { sheets: [], primarySheet: null, rowCount: 0, importedAt: new Date().toISOString(), note: "npm 패키지 `xlsx`가 없어 엑셀을 파싱하지 못했습니다. 프로젝트 루트에서 `npm install` 후 서버를 재시작하세요.", }; return payload; } const wb = XLSX.read(buffer, { type: "buffer", cellDates: true }); const names = wb.SheetNames || []; const sheetName = names.includes("매출일보") ? "매출일보" : names[0]; const ws = wb.Sheets[sheetName]; const matrix = XLSX.utils.sheet_to_json(ws, { header: 1, defval: "" }); const nonEmptyRows = matrix.filter((r) => Array.isArray(r) && r.some((c) => c !== "" && c != null)); const payload = JSON.parse(JSON.stringify(defaultPayload)); payload._uploadMeta = { sheets: names, primarySheet: sheetName, rowCount: nonEmptyRows.length, importedAt: new Date().toISOString(), note: "매출일보 행 수·시트명만 반영했습니다. 차트 수치를 엑셀 집계로 치환하려면 별도 매핑 로직이 필요합니다.", }; return payload; } /** * @param {import("pg").Pool | null} pgPool * @param {{ userEmail: string | null, originalFilename: string, filePath: string, fiscalYear: number, quarter: number, payload: object }} row */ async function saveUploadAndSnapshot(pgPool, row) { const { userEmail, originalFilename, filePath, fiscalYear, quarter, payload } = row; const stat = fs.statSync(filePath); if (!pgPool) { fs.writeFileSync( FILE_STATE_PATH, JSON.stringify( { payload, meta: { originalFilename, fiscalYear, quarter, savedAt: new Date().toISOString(), fileSize: stat.size, }, }, null, 2 ), "utf8" ); return { id: null, fileBacked: true }; } const ins = await pgPool.query( `INSERT INTO mgmt_perf_uploads (user_email, original_filename, fiscal_year, quarter, file_path, file_size, parse_status) VALUES ($1,$2,$3,$4,$5,$6,'ok') RETURNING id`, [userEmail || null, originalFilename, fiscalYear, quarter, filePath, stat.size] ); const uploadId = ins.rows[0].id; await pgPool.query(`INSERT INTO mgmt_perf_snapshots (upload_id, payload) VALUES ($1, $2::jsonb)`, [ uploadId, JSON.stringify(payload), ]); return { id: uploadId, fileBacked: false }; } /** * @param {import("pg").Pool | null} pgPool */ async function getLatestPayloadRow(pgPool) { if (pgPool) { const r = await pgPool.query(` SELECT s.payload, u.fiscal_year, u.quarter, u.original_filename, u.created_at FROM mgmt_perf_snapshots s JOIN mgmt_perf_uploads u ON u.id = s.upload_id ORDER BY u.created_at DESC LIMIT 1 `); if (r.rows[0]) { const row = r.rows[0]; return { payload: row.payload, fiscal_year: row.fiscal_year, quarter: row.quarter, original_filename: row.original_filename, created_at: row.created_at, }; } } try { const j = JSON.parse(fs.readFileSync(FILE_STATE_PATH, "utf8")); return { payload: j.payload, fiscal_year: j.meta?.fiscalYear, quarter: j.meta?.quarter, original_filename: j.meta?.originalFilename, created_at: j.meta?.savedAt ? new Date(j.meta.savedAt) : null, }; } catch { return { payload: loadDefaultPayload(), fiscal_year: new Date().getFullYear(), quarter: 1, original_filename: null, created_at: null, }; } } /** * DB에 저장된 연도·분기별 최신 스냅샷 1건. 해당 기간 업로드가 없으면 기본 페이로드와 `_noSnapshotForPeriod`. * @param {import("pg").Pool | null} pgPool * @param {number} fiscalYear * @param {number} quarter 1–4 */ async function getPayloadRowForPeriod(pgPool, fiscalYear, quarter) { const y = Math.min(2100, Math.max(2020, Math.floor(Number(fiscalYear)) || new Date().getFullYear())); const q = Math.min(4, Math.max(1, Math.floor(Number(quarter)) || 1)); if (pgPool) { const r = await pgPool.query( ` SELECT s.payload, u.fiscal_year, u.quarter, u.original_filename, u.created_at FROM mgmt_perf_snapshots s JOIN mgmt_perf_uploads u ON u.id = s.upload_id WHERE u.fiscal_year = $1 AND u.quarter = $2 ORDER BY u.created_at DESC LIMIT 1 `, [y, q] ); if (r.rows[0]) { const row = r.rows[0]; return { payload: row.payload, fiscal_year: row.fiscal_year, quarter: row.quarter, original_filename: row.original_filename, created_at: row.created_at, _noSnapshotForPeriod: false, }; } return { payload: loadDefaultPayload(), fiscal_year: y, quarter: q, original_filename: null, created_at: null, _noSnapshotForPeriod: true, }; } try { const j = JSON.parse(fs.readFileSync(FILE_STATE_PATH, "utf8")); const my = j.meta?.fiscalYear; const mq = j.meta?.quarter; if (my === y && mq === q) { return { payload: j.payload, fiscal_year: y, quarter: q, original_filename: j.meta?.originalFilename, created_at: j.meta?.savedAt ? new Date(j.meta.savedAt) : null, _noSnapshotForPeriod: false, }; } } catch (_) { /* no file */ } return { payload: loadDefaultPayload(), fiscal_year: y, quarter: q, original_filename: null, created_at: null, _noSnapshotForPeriod: true, }; } /** * 업로드가 존재하는 (연도, 분기) 목록 — 조회 셀렉트 옵션용 * @param {import("pg").Pool | null} pgPool */ async function listDistinctPeriods(pgPool) { if (!pgPool) { try { const j = JSON.parse(fs.readFileSync(FILE_STATE_PATH, "utf8")); if (j.meta?.fiscalYear != null && j.meta?.quarter != null) { return [{ fiscal_year: j.meta.fiscalYear, quarter: j.meta.quarter }]; } } catch (_) { /* empty */ } return []; } const r = await pgPool.query(` SELECT DISTINCT fiscal_year, quarter FROM mgmt_perf_uploads ORDER BY fiscal_year DESC, quarter DESC `); return r.rows; } /** * @param {import("pg").Pool | null} pgPool * @param {number} [limit=20] */ async function listUploads(pgPool, limit = 20) { if (!pgPool) { try { const j = JSON.parse(fs.readFileSync(FILE_STATE_PATH, "utf8")); return [ { id: "file", original_filename: j.meta?.originalFilename, fiscal_year: j.meta?.fiscalYear, quarter: j.meta?.quarter, created_at: j.meta?.savedAt, parse_status: "ok", }, ]; } catch { return []; } } const r = await pgPool.query( `SELECT id, original_filename, fiscal_year, quarter, parse_status, created_at FROM mgmt_perf_uploads ORDER BY created_at DESC LIMIT $1`, [limit] ); return r.rows; } /** * 업로드 행 삭제(PG: 스냅샷은 ON DELETE CASCADE). 디스크의 업로드 파일도 제거합니다. * 파일 전용 모드: `id === "file"` 일 때 스냅샷 JSON만 삭제합니다. * @param {import("pg").Pool | null} pgPool * @param {string} uploadId UUID 또는 `"file"` */ async function deleteUpload(pgPool, uploadId) { const id = String(uploadId || "").trim(); if (!id) return { ok: false, error: "id가 없습니다." }; if (!pgPool) { if (id !== "file") return { ok: false, error: "지원하지 않는 항목입니다." }; try { if (fs.existsSync(FILE_STATE_PATH)) fs.unlinkSync(FILE_STATE_PATH); } catch (e) { return { ok: false, error: e.message || "삭제 실패" }; } return { ok: true, fileBacked: true }; } const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; if (!uuidRe.test(id)) return { ok: false, error: "잘못된 id입니다." }; const r = await pgPool.query(`DELETE FROM mgmt_perf_uploads WHERE id = $1::uuid RETURNING file_path`, [id]); if (r.rowCount === 0) return { ok: false, error: "항목을 찾을 수 없습니다." }; const fp = r.rows[0].file_path; if (fp && fs.existsSync(fp)) { try { fs.unlinkSync(fp); } catch (_) { /* 로그만; DB는 이미 삭제됨 */ } } return { ok: true }; } module.exports = { loadDefaultPayload, buildPayloadFromWorkbook, saveUploadAndSnapshot, getLatestPayloadRow, getPayloadRowForPeriod, listDistinctPeriods, listUploads, deleteUpload, FILE_STATE_PATH, };