Files
ai_platform/lib/mgmt-perf.js
dsyoon 3872c32a91 feat(mgmt-perf): 대시보드 연도·분기 조회(스냅샷별 로드)
- getPayloadRowForPeriod, listDistinctPeriods
- /dashboard/business-performance?year=&quarter= 및 상단 GET 폼
- 해당 기간 업로드 없을 때 샘플+안내

Made-with: Cursor
2026-04-13 20:05:35 +09:00

318 lines
9.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 경영성과 대시보드: 엑셀 업로드 파싱·스냅샷 저장·최신 페이로드 조회
*/
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 14
*/
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,
};