433 lines
15 KiB
JavaScript
433 lines
15 KiB
JavaScript
// 독립 로컬 서버. Aislo의 DB·API·설정·패키지에 의존하지 않는다.
|
|
const http = require("node:http"),
|
|
fs = require("node:fs"),
|
|
path = require("node:path"),
|
|
vm = require("node:vm"),
|
|
crypto = require("node:crypto");
|
|
const { importEstx, table: dbisamTable } = require("./import-estx.cjs");
|
|
const { readXls } = require("./xls-read.cjs");
|
|
const bookStore = require("./book-store.cjs");
|
|
const mainDataPath =
|
|
process.env.EST_MAIN_DATA || "C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx";
|
|
let mainData = null;
|
|
const exchange = require("./exchange.cjs");
|
|
const { exportEstx } = require("./export-estx.cjs");
|
|
const jdl = require("./jdl.cjs");
|
|
const { inspectAccom } = require("./inspect-accom.cjs");
|
|
const forms = require("./forms-store.cjs");
|
|
const searchStore = require("./search-store.cjs");
|
|
const base = __dirname,
|
|
port = Number(process.env.EST_TEST_PORT || 5175),
|
|
host = process.env.EST_TEST_HOST || "100.65.141.15";
|
|
const dataDirectory = process.env.EST_TEST_DATA_DIR || path.join(base, "data");
|
|
const scripts = [
|
|
"native-math.js",
|
|
"native-formula.js",
|
|
"native-machine.js",
|
|
"native-rows.js",
|
|
"native-text.js",
|
|
"native-excel-sheet.cjs",
|
|
"native-quantity-total.js",
|
|
"native-quantity.cjs",
|
|
"native-wonga.js",
|
|
"native-jungsang.js",
|
|
"native-sources.js",
|
|
"native-engine.js",
|
|
"native-row-match.js",
|
|
"native-escalation.js",
|
|
"native-progress.js",
|
|
"native-annual-load.js",
|
|
"native-business.js",
|
|
"native-review.js",
|
|
"native-resources.js",
|
|
"native-schedule.js",
|
|
"native-model.js",
|
|
"native-verify.js",
|
|
];
|
|
const engine = vm.createContext({ structuredClone, console });
|
|
for (const file of scripts)
|
|
vm.runInContext(fs.readFileSync(path.join(base, file), "utf8"), engine, { filename: file });
|
|
function calculate(project) {
|
|
engine.input = project;
|
|
try {
|
|
return vm.runInContext(
|
|
"EstProgress.rebuild(input.options?.AutoCalc === false ? EstModel.validate(input) : EstNative.calculate(EstModel.validate(input)))",
|
|
engine,
|
|
{
|
|
timeout: 20000,
|
|
},
|
|
);
|
|
} finally {
|
|
delete engine.input;
|
|
}
|
|
}
|
|
const allowed = new Set([
|
|
"index.html",
|
|
"native.css",
|
|
"native-app.js",
|
|
"native-views.js",
|
|
"native-actions.js",
|
|
"native-business-actions.js",
|
|
"native-block-actions.js",
|
|
"native-pick.js",
|
|
"native-find.js",
|
|
"native-blank.js",
|
|
"native-quantity-sheets.js",
|
|
"native-excel-map.js",
|
|
"native-transfer.js",
|
|
"native-bid.js",
|
|
"native-forms-library.js",
|
|
"native-cleanup.js",
|
|
"native-name-reg.js",
|
|
"native-tidy.js",
|
|
"native-multi-search.js",
|
|
"native-doc-editor.js",
|
|
"native-docs.js",
|
|
"native-doc-forms.js",
|
|
"native-block-file.js",
|
|
"native-display.js",
|
|
"native-cell-clip.js",
|
|
"native-row-links.js",
|
|
"native-es-info.js",
|
|
"native-jdl-compare.js",
|
|
"native-global-vars.js",
|
|
"native-new-estimate.js",
|
|
"native-book.js",
|
|
"native-data-update.js",
|
|
"native-wonga-tools.js",
|
|
"native-estimate-print.js",
|
|
"native-xml-check.js",
|
|
"native-review-auto.js",
|
|
"native-autolink.js",
|
|
"native-est-tools.js",
|
|
"native-print-batch.js",
|
|
"native-mach-formula.js",
|
|
"native-defaults.js",
|
|
"native-excel-view.js",
|
|
"native-excel-edit.cjs",
|
|
"native-code-change.js",
|
|
"native-code-change-view.js",
|
|
"native-progress-wonga.js",
|
|
"native-haljung.js",
|
|
"native-vat.js",
|
|
"native-issues.js",
|
|
"native-attachments.js",
|
|
"native-standard.js",
|
|
"native-estimate.js",
|
|
"native-basic-prices.js",
|
|
"native-extra.js",
|
|
"native-accom-view.js",
|
|
"native-settings.js",
|
|
"native-report.js",
|
|
"native-forms.js",
|
|
"native-c3r.js",
|
|
"native-exchange.js",
|
|
...scripts,
|
|
]);
|
|
function reply(res, status, data, type = "application/json; charset=utf-8") {
|
|
res.writeHead(status, {
|
|
"Content-Type": type,
|
|
"Cache-Control": "no-store",
|
|
"X-Content-Type-Options": "nosniff",
|
|
});
|
|
res.end(type.startsWith("application/json") ? JSON.stringify(data) : data);
|
|
}
|
|
async function body(req) {
|
|
const chunks = [];
|
|
let length = 0;
|
|
for await (const chunk of req) {
|
|
length += chunk.length;
|
|
if (length > 256 * 1024 * 1024) throw Error("파일은 256MB 이하만 열 수 있습니다.");
|
|
chunks.push(chunk);
|
|
}
|
|
return Buffer.concat(chunks);
|
|
}
|
|
function list() {
|
|
if (!fs.existsSync(dataDirectory)) return [];
|
|
return fs
|
|
.readdirSync(dataDirectory)
|
|
.filter((name) => /^[a-f0-9]{24}\.json$/.test(name))
|
|
.map((name) => {
|
|
const file = path.join(dataDirectory, name),
|
|
record = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
return {
|
|
id: name.slice(0, -5),
|
|
name: record.name,
|
|
modified: fs.statSync(file).mtime.toISOString(),
|
|
};
|
|
})
|
|
.sort((a, b) => b.modified.localeCompare(a.modified));
|
|
}
|
|
const server = http.createServer(async (req, res) => {
|
|
try {
|
|
const origin = req.headers.origin;
|
|
if (
|
|
origin &&
|
|
origin !== `http://127.0.0.1:${port}` &&
|
|
origin !== `http://localhost:${port}` &&
|
|
origin !== `http://${host}:${port}`
|
|
) {
|
|
reply(res, 403, { error: "다른 사이트의 요청은 허용하지 않습니다." });
|
|
return;
|
|
}
|
|
const url = new URL(req.url, "http://127.0.0.1");
|
|
if (req.method === "GET" && url.pathname === "/api/health") {
|
|
reply(res, 200, { app: "est-independent", schema: "est-web-2" });
|
|
return;
|
|
}
|
|
if (req.method === "GET" && url.pathname === "/api/sample") {
|
|
reply(res, 200, JSON.parse(fs.readFileSync(path.join(base, "sample.json"), "utf8")));
|
|
return;
|
|
}
|
|
if (req.method === "GET" && url.pathname === "/api/print-catalog") {
|
|
reply(res, 200, JSON.parse(fs.readFileSync(path.join(base, "print-catalog.json"), "utf8")));
|
|
return;
|
|
}
|
|
if (req.method === "GET" && url.pathname === "/api/parity") {
|
|
engine.sample = JSON.parse(fs.readFileSync(path.join(base, "sample.json"), "utf8"));
|
|
engine.expected = JSON.parse(fs.readFileSync(path.join(base, "parity.json"), "utf8"));
|
|
try {
|
|
reply(
|
|
res,
|
|
200,
|
|
vm.runInContext("EstVerification.run(sample,expected)", engine, { timeout: 20000 }),
|
|
);
|
|
} finally {
|
|
delete engine.sample;
|
|
delete engine.expected;
|
|
}
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/forms" && ["GET", "POST"].includes(req.method)) {
|
|
const input = req.method === "POST" ? JSON.parse((await body(req)).toString("utf8")) : null;
|
|
reply(res, 200, input ? forms.save(dataDirectory, input) : forms.read(dataDirectory));
|
|
return;
|
|
}
|
|
if (req.method === "GET" && url.pathname === "/api/search") {
|
|
const kinds = (url.searchParams.get("kinds") || "").split(",").filter(Boolean);
|
|
reply(
|
|
res,
|
|
200,
|
|
searchStore.search(
|
|
dataDirectory,
|
|
url.searchParams.get("q"),
|
|
kinds,
|
|
url.searchParams.get("exclude") || "",
|
|
url.searchParams.get("only") ? url.searchParams.get("only").split(",") : null,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
if (req.method === "POST" && url.pathname === "/api/forms/delete") {
|
|
const input = JSON.parse((await body(req)).toString("utf8"));
|
|
reply(res, 200, forms.remove(dataDirectory, input.kind, input.name));
|
|
return;
|
|
}
|
|
if (req.method === "POST" && /^\/api\/projects\/[a-f0-9]{24}\/hide$/.test(url.pathname)) {
|
|
const id = url.pathname.split("/")[3],
|
|
file = path.join(dataDirectory, `${id}.json`),
|
|
removed = path.join(dataDirectory, "removed");
|
|
if (!fs.existsSync(file)) {
|
|
reply(res, 404, { error: "저장된 작업 없음" });
|
|
return;
|
|
}
|
|
fs.mkdirSync(removed, { recursive: true });
|
|
fs.renameSync(file, path.join(removed, `${id}.json`));
|
|
reply(res, 200, { id });
|
|
return;
|
|
}
|
|
if (req.method === "GET" && url.pathname === "/api/projects") {
|
|
reply(res, 200, list());
|
|
return;
|
|
}
|
|
if (req.method === "GET" && /^\/api\/projects\/[a-f0-9]{24}$/.test(url.pathname)) {
|
|
const id = url.pathname.split("/").pop(),
|
|
file = path.join(dataDirectory, `${id}.json`);
|
|
if (!fs.existsSync(file)) {
|
|
reply(res, 404, { error: "저장된 작업 없음" });
|
|
return;
|
|
}
|
|
reply(res, 200, JSON.parse(fs.readFileSync(file, "utf8")));
|
|
return;
|
|
}
|
|
if (req.method === "POST" && url.pathname === "/api/import") {
|
|
const bytes = await body(req),
|
|
name = String(url.searchParams.get("name") || "ESTX 가져오기").slice(0, 200);
|
|
reply(res, 200, importEstx(bytes, name));
|
|
return;
|
|
}
|
|
if (req.method === "GET" && url.pathname === "/api/main-data") {
|
|
// 현재데이타 갱신 원천 Main Data(원본 설치 main.estx = BufDat\Main\Main_1) — 갱신에 쓰는 표만
|
|
if (!mainData) {
|
|
const project = importEstx(fs.readFileSync(mainDataPath), "Main");
|
|
const keys = [
|
|
"LaborcDat",
|
|
"StuffcDat",
|
|
"SilJukDat",
|
|
"MachTitleDat",
|
|
"MachMainDat",
|
|
"MachGijunDat",
|
|
];
|
|
mainData = {
|
|
name: "Main",
|
|
tables: Object.fromEntries(keys.map((k) => [k, project.tables[k] || []])),
|
|
};
|
|
}
|
|
reply(res, 200, mainData);
|
|
return;
|
|
}
|
|
if (req.method === "GET" && url.pathname.startsWith("/api/book")) {
|
|
// 품셈 보기(원본 BookData) — /api/book · /api/book/<책>/tree · /text/<pkid> · /search?q=&mode=title|body|keyword
|
|
const [, book, what, id] = url.pathname.slice(1).split("/").slice(1);
|
|
reply(
|
|
res,
|
|
200,
|
|
!book
|
|
? bookStore.list()
|
|
: what === "tree"
|
|
? bookStore.tree(book)
|
|
: what === "text"
|
|
? bookStore.text(book, id)
|
|
: what === "search"
|
|
? bookStore.search(book, url.searchParams.get("q"), url.searchParams.get("mode"))
|
|
: (() => {
|
|
throw Error("품셈 요청 경로 없음");
|
|
})(),
|
|
);
|
|
return;
|
|
}
|
|
if (req.method === "POST" && url.pathname === "/api/read-xls") {
|
|
reply(res, 200, readXls(await body(req)));
|
|
return;
|
|
}
|
|
if (req.method === "POST" && url.pathname === "/api/pps-table") {
|
|
// 조달청 코드 목록(원본 System\MainSys\PPS_STUFFC.dat · PPS_HEAD.dat, DBISAM) — 코드·명칭·규격·단위만
|
|
const rows = dbisamTable(await body(req), null, []);
|
|
if (!rows.length || !("Code" in rows[0]) || !("Name" in rows[0]))
|
|
throw Error("조달청 코드 목록(PPS_STUFFC.dat · PPS_HEAD.dat)이 아닙니다.");
|
|
reply(
|
|
res,
|
|
200,
|
|
rows.map(({ Gubun, Code, Name, Size, Unit }) => ({ Gubun, Code, Name, Size, Unit })),
|
|
);
|
|
return;
|
|
}
|
|
if (req.method === "POST" && url.pathname === "/api/inspect-accom") {
|
|
reply(res, 200, inspectAccom(await body(req)));
|
|
return;
|
|
}
|
|
if (req.method === "POST" && url.pathname === "/api/import-xlsx") {
|
|
reply(res, 200, exchange.readWorkbook(await body(req)));
|
|
return;
|
|
}
|
|
if (req.method === "POST" && url.pathname === "/api/import-jdl") {
|
|
const empty = vm.runInContext("EstNative.empty()", engine);
|
|
reply(res, 200, jdl.markOriginal(calculate(jdl.read(await body(req), empty))));
|
|
return;
|
|
}
|
|
if (req.method === "POST" && url.pathname === "/api/export-jdl") {
|
|
const project = calculate(JSON.parse((await body(req)).toString("utf8"))),
|
|
out = jdl.output(project);
|
|
reply(res, 200, { data: out.bytes.toString("base64"), warnings: out.warnings });
|
|
return;
|
|
}
|
|
if (req.method === "POST" && url.pathname === "/api/export-report-xlsx") {
|
|
const input = JSON.parse((await body(req)).toString("utf8"));
|
|
if (
|
|
!Array.isArray(input.columns) ||
|
|
!input.columns.length ||
|
|
input.columns.some((x) => typeof x !== "string") ||
|
|
new Set(input.columns).size !== input.columns.length ||
|
|
!Array.isArray(input.rows) ||
|
|
input.rows.some(
|
|
(row) =>
|
|
!Array.isArray(row) ||
|
|
row.length !== input.columns.length ||
|
|
row.some((value) => !["string", "number", "boolean"].includes(typeof value)),
|
|
) ||
|
|
(input.detail != null && typeof input.detail !== "string") ||
|
|
!Array.isArray(input.errors) ||
|
|
input.errors.some((x) => typeof x !== "string")
|
|
)
|
|
throw Error("Excel 출력 데이터 형식 오류");
|
|
const rows = input.rows.map((row) =>
|
|
Object.fromEntries(input.columns.map((key, index) => [key, row[index]])),
|
|
);
|
|
const tables = { Report: rows };
|
|
if (input.detail) tables.Summary = [{ "결과 요약": input.detail }];
|
|
if (input.errors.length)
|
|
tables.Errors = input.errors.map((message) => ({ "계산 오류": message }));
|
|
reply(
|
|
res,
|
|
200,
|
|
exchange.workbook({ name: String(input.name || ""), tables }),
|
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
);
|
|
return;
|
|
}
|
|
if (req.method === "POST" && url.pathname === "/api/export-xlsx") {
|
|
const project = calculate(JSON.parse((await body(req)).toString("utf8")));
|
|
reply(
|
|
res,
|
|
200,
|
|
exchange.workbook(project),
|
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
);
|
|
return;
|
|
}
|
|
if (req.method === "POST" && url.pathname === "/api/export-estx") {
|
|
const project = calculate(JSON.parse((await body(req)).toString("utf8")));
|
|
reply(res, 200, exportEstx(project), "application/octet-stream");
|
|
return;
|
|
}
|
|
if (req.method === "POST" && ["/api/calculate", "/api/save"].includes(url.pathname)) {
|
|
if (!req.headers["content-type"]?.startsWith("application/json")) {
|
|
reply(res, 415, { error: "JSON 요청이 필요합니다." });
|
|
return;
|
|
}
|
|
const project = JSON.parse((await body(req)).toString("utf8"));
|
|
const result = calculate(project);
|
|
if (url.pathname === "/api/save") {
|
|
fs.mkdirSync(dataDirectory, { recursive: true });
|
|
result.id = /^[a-f0-9]{24}$/.test(project.id || "")
|
|
? project.id
|
|
: crypto.randomBytes(12).toString("hex");
|
|
const target = path.join(dataDirectory, `${result.id}.json`),
|
|
temporary = `${target}.tmp`;
|
|
fs.writeFileSync(temporary, JSON.stringify(result), "utf8");
|
|
fs.renameSync(temporary, target);
|
|
}
|
|
reply(res, 200, result);
|
|
return;
|
|
}
|
|
if (req.method === "GET") {
|
|
const file = url.pathname === "/" ? "index.html" : decodeURIComponent(url.pathname.slice(1));
|
|
if (!allowed.has(file)) {
|
|
reply(res, 404, { error: "파일 없음" });
|
|
return;
|
|
}
|
|
const type = file.endsWith(".html")
|
|
? "text/html; charset=utf-8"
|
|
: file.endsWith(".css")
|
|
? "text/css; charset=utf-8"
|
|
: "text/javascript; charset=utf-8";
|
|
reply(res, 200, fs.readFileSync(path.join(base, file)), type);
|
|
return;
|
|
}
|
|
reply(res, 404, { error: "요청 경로 없음" });
|
|
} catch (error) {
|
|
reply(res, 400, { error: error.message });
|
|
}
|
|
});
|
|
server.on("error", (error) => {
|
|
console.error(
|
|
error.code === "EADDRINUSE"
|
|
? `포트 ${port}가 이미 사용 중입니다. 기존 서버를 확인하세요.`
|
|
: error.message,
|
|
);
|
|
process.exitCode = 1;
|
|
});
|
|
if (require.main === module)
|
|
server.listen(port, host, () => console.log(`EST 독립 웹앱: http://${host}:${port}`));
|
|
module.exports = { server, calculate };
|