- 대시보드 조회를 위·엑셀 업로드를 아래로 재배치 - DELETE /api/mgmt-perf/upload/:id 및 최근 업로드 행 삭제 버튼 - dashboard.css 전역 body 어두운 배경을 body.mgmt-perf-standalone로 한정, 임베드는 투명 - mgmt_perf_embed에 standalone 클래스 유지 Made-with: Cursor
222 lines
6.7 KiB
JavaScript
222 lines
6.7 KiB
JavaScript
/**
|
|
* 경영성과 대시보드: 엑셀 업로드 파싱·스냅샷 저장·최신 페이로드 조회
|
|
*/
|
|
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,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @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,
|
|
listUploads,
|
|
deleteUpload,
|
|
FILE_STATE_PATH,
|
|
};
|