Files
Aislo/tools/est-test/native-exchange.js

652 lines
26 KiB
JavaScript

const EstExchange = (() => {
const decodeExcelText = (value) =>
value.replace(/_x([\da-f]{4})_/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
function xmlText(buffer) {
const bytes = new Uint8Array(buffer);
if (bytes[0] === 0xff && bytes[1] === 0xfe)
return new TextDecoder("utf-16le", { fatal: true }).decode(bytes);
if (bytes[0] === 0xfe && bytes[1] === 0xff)
return new TextDecoder("utf-16be", { fatal: true }).decode(bytes);
const header = new TextDecoder("ascii").decode(bytes.slice(0, 250)),
declared = header.match(/encoding\s*=\s*["']([^"']+)["']/i)?.[1]?.toLowerCase() || "utf-8",
encoding = ["cp949", "ks_c_5601-1987", "euc-kr", "ms949"].includes(declared)
? "euc-kr"
: declared;
if (!["utf-8", "utf8", "euc-kr", "utf-16", "utf-16le", "utf-16be"].includes(encoding))
throw Error("지원하지 않는 XML 인코딩: " + encoding);
return new TextDecoder(encoding, { fatal: true }).decode(bytes);
}
function parse(source) {
if (/<!DOCTYPE|<!ENTITY/i.test(source)) throw Error("XML 외부 선언은 허용하지 않습니다.");
const doc = new DOMParser().parseFromString(source, "application/xml");
if (doc.querySelector("parsererror")) throw Error("XML 구문 오류");
return doc;
}
function read(files) {
const workbook = parse(files["xl/workbook.xml"]),
rels = parse(files["xl/_rels/workbook.xml.rels"]);
const shared = files["xl/sharedStrings.xml"]
? [...parse(files["xl/sharedStrings.xml"]).getElementsByTagNameNS("*", "si")].map((si) =>
[...si.getElementsByTagNameNS("*", "t")].map((t) => t.textContent).join(""),
)
: [];
const tables = {};
for (const sheet of workbook.getElementsByTagNameNS("*", "sheet")) {
const name = sheet.getAttribute("name");
if (
(!EstNative.empty().tables[name] &&
![
"Quantities",
"Schedule",
"ExcelQuantityLinks",
"QDocuments",
"QLines",
"QItems",
"Review",
"Annual",
"ProgressDrafts",
"ProgressInputs",
"Escalation",
"EscalationSettings",
].includes(name)) ||
name.startsWith("Cad")
)
continue;
const id = sheet.getAttributeNS(
"http://schemas.openxmlformats.org/officeDocument/2006/relationships",
"id",
),
rel = [...rels.getElementsByTagNameNS("*", "Relationship")].find(
(r) => r.getAttribute("Id") === id,
),
target = rel?.getAttribute("Target") || "";
const path = target.startsWith("/") ? target.slice(1) : "xl/" + target;
if (!files[path]) throw Error(`Excel 시트 파일 없음: ${name}`);
const rows = [...parse(files[path]).getElementsByTagNameNS("*", "row")]
.map((row) => {
const values = [];
for (const cell of row.getElementsByTagNameNS("*", "c")) {
const address = cell.getAttribute("r") || "",
letters = address.match(/^[A-Z]+/)?.[0];
if (!letters) throw Error("Excel 셀 주소 오류");
const at = [...letters].reduce((n, c) => n * 26 + c.charCodeAt(0) - 64, 0) - 1;
if (at >= 16384) throw Error("Excel 열 범위 오류");
if (cell.getElementsByTagNameNS("*", "f").length)
throw Error(`${name} ${address}: 수식은 Excel에서 값으로 붙여넣은 뒤 가져오세요.`);
const type = cell.getAttribute("t"),
v = cell.getElementsByTagNameNS("*", "v")[0]?.textContent;
let value;
if (type === "s") {
value = shared[Number(v)];
if (value == null) throw Error("Excel 공유 문자열 오류");
} else if (type === "inlineStr")
value = [...cell.getElementsByTagNameNS("*", "t")].map((t) => t.textContent).join("");
else if (type === "b") value = v === "1";
else if (type === "e") throw Error(`${name} ${address}: Excel 오류값`);
else if (type === "str") value = v || "";
else if (v != null) {
value = Number(v);
if (!Number.isFinite(value)) throw Error("Excel 숫자 오류");
}
values[at] = typeof value === "string" ? decodeExcelText(value) : value;
}
return values;
})
.filter((row) => row.some((v) => v != null));
const headers = rows.shift() || [];
if (
!headers.length ||
headers.some(
(k) =>
typeof k !== "string" ||
!/^[A-Za-z][A-Za-z0-9_]*$/.test(k) ||
["constructor", "prototype"].includes(k),
) ||
new Set(headers).size !== headers.length
)
throw Error(`${name}: 필드명 행이 올바르지 않습니다.`);
tables[name] = rows.map((values) =>
Object.fromEntries(headers.map((k, i) => [k, values[i] ?? null])),
);
}
if (!Object.keys(tables).length)
throw Error("가져올 EST 표 이름이 없습니다. 이 앱에서 내보낸 Excel 표를 사용하세요.");
return tables;
}
function quantitySheets(project, tables) {
const names = ["QDocuments", "QLines", "QItems"];
if (!names.some((name) => Object.hasOwn(tables, name))) return null;
if (!names.every((name) => Object.hasOwn(tables, name)))
throw Error("수량산출서는 QDocuments·QLines·QItems 시트가 모두 필요합니다.");
const old = new Map((project.quantitySheets || []).map((sheet) => [sheet.Code, sheet]));
const docs = new Map();
for (const row of tables.QDocuments) {
if (typeof row.Code !== "string" || !row.Code || docs.has(row.Code))
throw Error("수량산출서 코드가 비어 있거나 중복됩니다.");
if (row.Name != null && typeof row.Name !== "string") throw Error("수량산출서 이름 오류");
const digits = row.digits ?? 2,
roundMode = row.roundMode ?? 2;
if (
!Number.isInteger(digits) ||
digits < 0 ||
digits > 12 ||
!Number.isInteger(roundMode) ||
roundMode < 0 ||
roundMode > 4
)
throw Error("수량산출서 자릿수·반올림 방식 오류");
docs.set(row.Code, {
...old.get(row.Code),
Code: row.Code,
Name: row.Name ?? "",
rows: [],
items: [],
options: { ...old.get(row.Code)?.options, digits, roundMode },
});
}
function grouped(rows) {
const groups = new Map(),
positions = new Set();
for (const row of rows) {
if (!docs.has(row.DocumentCode)) throw Error(`수량산출서 코드 없음: ${row.DocumentCode}`);
if (!Number.isInteger(row.Position) || row.Position < 1)
throw Error("수량산출서 행 순서 오류");
const key = JSON.stringify([row.DocumentCode, row.Position]);
if (positions.has(key)) throw Error("수량산출서 행 순서 중복");
positions.add(key);
if (!groups.has(row.DocumentCode)) groups.set(row.DocumentCode, []);
groups.get(row.DocumentCode).push(row);
}
for (const group of groups.values()) group.sort((a, b) => a.Position - b.Position);
return groups;
}
const lines = grouped(tables.QLines),
items = grouped(tables.QItems);
for (const [code, doc] of docs) {
doc.rows = (lines.get(code) || []).map((row) => {
if (row.LineData != null && typeof row.LineData !== "string")
throw Error("산출근거 문자 오류");
let markers;
try {
markers = JSON.parse(row.Markers || "[]");
} catch {
throw Error("수량 결과 표지 형식 오류");
}
if (!Array.isArray(markers) || markers.some((value) => typeof value !== "string"))
throw Error("수량 결과 표지 형식 오류");
return { LineData: row.LineData ?? "", markers };
});
const prior = new Map((old.get(code)?.items || []).map((item) => [item.Code, item]));
const seen = new Set();
doc.items = (items.get(code) || []).map((row) => {
if (typeof row.Code !== "string" || !row.Code || seen.has(row.Code))
throw Error("수량 항목 코드가 비어 있거나 중복됩니다.");
seen.add(row.Code);
for (const key of ["Name", "Size", "Unite", "TargetCode", "TargetKey"])
if (row[key] != null && typeof row[key] !== "string")
throw Error(`수량 항목 ${key} 형식 오류`);
const previous = prior.get(row.Code),
next = { ...previous, Code: row.Code };
for (const key of ["Name", "Size", "Unite"]) {
if (row[key] != null) next[key] = row[key];
else if (!previous || previous[key] != null) next[key] = "";
}
if (row.GN != null) {
if (typeof row.GN !== "number" || !Number.isFinite(row.GN))
throw Error("수량 항목 수치 오류");
next.GN = row.GN;
} else if (!previous || previous.GN != null) next.GN = 0;
if (row.TargetCode != null) {
if (
!previous ||
Object.hasOwn(previous, "TargetCode") ||
row.TargetCode !== previous.LinkCode
)
next.TargetCode = row.TargetCode;
} else if (previous && (previous.TargetCode ?? previous.LinkCode)) next.TargetCode = "";
if (row.TargetKey != null) next.TargetKey = row.TargetKey;
else if (previous?.TargetKey) next.TargetKey = "";
return next;
});
}
return [...docs.values()];
}
function apply(project, tables) {
const next = structuredClone(project);
const sheets = quantitySheets(next, tables);
if (sheets) next.quantitySheets = sheets;
if (Object.hasOwn(tables, "ProgressDrafts") || Object.hasOwn(tables, "ProgressInputs")) {
if (!Object.hasOwn(tables, "ProgressDrafts") || !Object.hasOwn(tables, "ProgressInputs"))
throw Error("기성 Excel에는 ProgressDrafts와 ProgressInputs 시트가 모두 필요합니다.");
const old = next.progress || [],
locked = old.filter((round) => round.locked);
if (old.slice(0, locked.length).some((round) => !round.locked))
throw Error("확정 기성 회차 순서 오류");
const previous = new Map(
old.filter((round) => !round.locked).map((round) => [round.id, round]),
);
const ids = new Set(locked.map((round) => round.id)),
drafts = new Map();
for (const row of tables.ProgressDrafts) {
if (typeof row.id !== "string" || !row.id || ids.has(row.id))
throw Error("기성 회차 ID가 없거나 중복됩니다.");
ids.add(row.id);
const round = {
...previous.get(row.id),
id: row.id,
name: row.name ?? "",
locked: false,
approved: row.approved ?? false,
separateComponents: row.separateComponents ?? true,
inputs: Object.create(null),
};
delete round.contract;
delete round.result;
if (row.amountDigits == null) delete round.amountDigits;
else round.amountDigits = row.amountDigits;
drafts.set(row.id, round);
}
const inputFields = [
"GumMode",
"Gum",
"GumNC",
"GumGC",
"GumJC",
"GumSC",
"SaMode",
"Sa",
"SaNC",
"SaGC",
"SaJC",
"SaSC",
"QuantityDigits",
"AmountDigits",
];
for (const row of tables.ProgressInputs) {
const round = drafts.get(row.RoundId);
if (
!round ||
typeof row.RowKey !== "string" ||
!row.RowKey ||
Object.hasOwn(round.inputs, row.RowKey)
)
throw Error("기성 입력의 회차·행 식별자가 없거나 중복됩니다.");
const prior = previous.get(row.RoundId)?.inputs?.[row.RowKey] || {};
const input = Object.fromEntries(
Object.entries(prior).filter(([key]) => !inputFields.includes(key)),
);
for (const field of inputFields) if (row[field] != null) input[field] = row[field];
round.inputs[row.RowKey] = input;
}
next.progress = [...locked, ...drafts.values()];
}
for (const [table, rows] of Object.entries(tables)) {
if (
[
"QDocuments",
"QLines",
"QItems",
"ProgressDrafts",
"ProgressInputs",
"Escalation",
"EscalationSettings",
"ExcelQuantityLinks",
].includes(table)
)
continue;
if (table === "Annual") {
next.annual = rows.map((row) => {
if (typeof row.name !== "string" || typeof row.formula !== "string")
throw Error("연차 이름·수식 형식 오류");
return { name: row.name, formula: row.formula };
});
continue;
}
if (table === "Review") {
const edits = {};
for (const row of rows) {
if (
typeof row.RowKey !== "string" ||
!row.RowKey.startsWith("row:") ||
Object.hasOwn(edits, row.RowKey)
)
throw Error("계약심사 내역 식별자가 없거나 중복됩니다.");
const edit = {};
for (const field of ["GN", "NU", "GU", "JU", "SU", "NC", "GC", "JC", "SC", "Reason"])
if (row[field] != null) edit[field] = row[field];
edits[row.RowKey] = edit;
}
next.contractReview = { edits };
EstReview.validate(next);
continue;
}
if (table === "Quantities") {
const seen = new Set();
next.quantities = rows.map((row) => {
if (typeof row.Code !== "string" || !row.Code || seen.has(row.Code))
throw Error("수식표 코드가 비어 있거나 중복됩니다.");
seen.add(row.Code);
for (const key of ["Name", "TargetCode", "TargetKey", "formula"])
if (row[key] != null && typeof row[key] !== "string")
throw Error(`수식표 ${key} 형식 오류`);
const digits = row.digits ?? 2;
if (!Number.isInteger(digits) || digits < 0 || digits > 6)
throw Error("수식표 소수자리는 0~6입니다.");
return {
Code: row.Code,
Name: row.Name ?? "",
TargetCode: row.TargetCode ?? "",
TargetKey: row.TargetKey ?? "",
formula: row.formula ?? "0",
digits,
};
});
continue;
}
if (table === "Schedule") {
next.schedule = rows.map((row) => ({
id: row.id,
SourceKey: row.SourceKey ?? "",
Code: row.Code ?? "",
Name: row.Name ?? "",
start: row.start ?? "",
end: row.end ?? "",
weight: row.weight ?? 0,
progress: row.progress ?? 0,
}));
EstSchedule.validate(next);
continue;
}
const old = next.tables[table],
byId = new Map(old.filter((r) => r.PkID != null).map((r) => [r.PkID, r]));
const keys = rows[0] ? Object.keys(rows[0]) : [];
if (
!keys.includes("PkID") &&
old.some((r) => Object.values(r).some((v) => v && typeof v === "object"))
)
throw Error(`${table}: 바이너리를 보존하려면 PkID 열이 필요합니다.`);
const seen = new Set();
next.tables[table] = rows.map((row) => {
if (row.PkID != null) {
if (seen.has(row.PkID)) throw Error(`${table}: PkID 중복`);
seen.add(row.PkID);
}
const previous = byId.get(row.PkID);
// Keep original blobs and fields deliberately omitted from the workbook.
const merged = { ...previous, ...row };
if (["StuffcDat", "LaborcDat", "SilJukDat", "EstiMateDat"].includes(table)) {
if (
previous &&
row.TC !== previous.TC &&
(row.InputTC ?? previous.InputTC) === previous.InputTC
) {
merged.InputTC = row.TC;
merged.ValueIndex = 4;
}
for (const component of ["NC", "GC", "JC", "SC"])
if (
previous &&
row[component] !== previous[component] &&
(row["Input" + component] ?? previous["Input" + component]) ===
previous["Input" + component]
)
merged["Input" + component] = row[component];
if (
!previous ||
Object.keys(row).some((key) => (row[key] ?? null) !== (previous[key] ?? null))
)
next.options.SourceRefresh = true;
}
for (const key of keys)
if (previous?.[key] && typeof previous[key] === "object") merged[key] = previous[key];
if (Object.hasOwn(row, "Code") && (previous?.Code ?? "") !== (row.Code ?? "") && previous)
throw Error("Excel에서 기존 코드를 바꿀 수 없습니다. 앱의 코드 편집을 사용하세요.");
return merged;
});
}
if (Object.hasOwn(tables, "Escalation")) {
const targets = new Map();
next.tables.EstMainDat.forEach((row, index) => {
if (
Number(row.Level || 0) > 0 ||
[54, 55, 57, 58, 59, 62, 67].includes(Number(row.LinkIndex || 0))
)
return;
const key = EstBusiness.rowKey(row, index),
matches = targets.get(key) || [];
matches.push(row);
targets.set(key, matches);
});
const seen = new Set();
const fields = [
"ANU",
"AGU",
"AJU",
"ASU",
"EscPlanned",
"EscActual",
"EscProgress",
"EscAuto",
"EscManualQuantity",
"EscConstruction",
"EscPS",
"EscBefore",
];
for (const row of tables.Escalation) {
const matches = targets.get(row.RowKey) || [];
if (typeof row.RowKey !== "string" || seen.has(row.RowKey) || matches.length !== 1)
throw Error(`물가변동 내역이 없거나 중복됩니다: ${row.RowKey}`);
seen.add(row.RowKey);
for (const field of fields) {
if (!Object.hasOwn(row, field)) continue;
if (row[field] == null) delete matches[0][field];
else {
if (
field === "EscAuto"
? typeof row[field] !== "boolean"
: typeof row[field] !== "number" || !Number.isFinite(row[field])
)
throw Error(`물가변동 ${field} 형식 오류`);
matches[0][field] = row[field];
}
}
}
}
if (Object.hasOwn(tables, "ExcelQuantityLinks")) {
next.excelQuantityLinks = tables.ExcelQuantityLinks.map((row) => ({
target: row.target,
file: row.file,
sheet: row.sheet,
cell: row.cell,
}));
EstModel.validate(next);
for (const link of next.excelQuantityLinks) {
const rows = next.tables.EstMainDat.filter((row) => row.PkID === link.target);
if (rows.length !== 1 || !EstQuantity.excelTarget(rows[0]))
throw Error(`Excel 연결 내역이 없거나 중복되었거나 상세 행이 아닙니다: ${link.target}`);
}
}
if (Object.hasOwn(tables, "EstMainDat"))
for (const link of project.excelQuantityLinks || [])
if (
(next.excelQuantityLinks || []).some((item) => item.target === link.target) &&
!project.tables.EstMainDat.some((row) => row.PkID === link.target) &&
next.tables.EstMainDat.some((row) => row.PkID === link.target)
)
throw Error(`삭제된 Excel 연결 내역의 식별자를 다시 사용할 수 없습니다: ${link.target}`);
if (Object.hasOwn(tables, "EscalationSettings")) {
const rows = tables.EscalationSettings;
if (
rows.length !== 1 ||
typeof rows[0].EscDesignTotal !== "number" ||
!Number.isFinite(rows[0].EscDesignTotal) ||
rows[0].EscDesignTotal < 0
)
throw Error("물가변동 조사 도급액 형식 오류");
next.options.EscDesignTotal = rows[0].EscDesignTotal;
}
if (Object.hasOwn(tables, "Review")) {
const targets = new Map();
next.tables.EstMainDat.forEach((row, index) => {
if (!EstReview.editable(row)) return;
const key = EstReview.rowKey(row, index),
matches = targets.get(key) || [];
matches.push(row);
targets.set(key, matches);
});
for (const [key, edit] of Object.entries(next.contractReview.edits)) {
const matches = targets.get(key) || [];
if (matches.length !== 1) throw Error(`심사 내역이 없거나 중복됩니다: ${key}`);
if (
Object.keys(edit).some(
(field) => field !== "Reason" && !EstReview.allowed(matches[0]).includes(field),
)
)
throw Error(`해당 내역에 사용할 수 없는 심사 입력: ${key}`);
}
}
if (Object.hasOwn(tables, "ProgressDrafts")) {
const counts = new Map();
next.tables.EstMainDat.forEach((row, index) => {
const key = EstBusiness.rowKey(row, index);
counts.set(key, (counts.get(key) || 0) + 1);
});
for (const round of next.progress.filter((item) => !item.locked))
for (const key of Object.keys(round.inputs))
if (counts.get(key) !== 1) throw Error(`기성 입력 내역이 없거나 중복됩니다: ${key}`);
}
return EstModel.validate(next);
}
async function exportExcel() {
document.activeElement?.blur();
const response = await fetch("/api/export-xlsx", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(EstApp.state.project),
});
if (!response.ok) throw Error((await response.json()).error);
EstApp.download(
`${EstApp.state.project.name}.xlsx`,
await response.blob(),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
);
}
async function exportNative() {
document.activeElement?.blur();
const response = await fetch("/api/export-estx", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(EstApp.state.project),
});
if (!response.ok) throw Error((await response.json()).error);
EstApp.download(
`${EstApp.state.project.name}_edited.estx`,
await response.blob(),
"application/octet-stream",
);
}
async function exportJdl() {
document.activeElement?.blur();
const project = EstApp.state.project,
out = await EstApp.api("/api/export-jdl", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(project),
});
if (!confirm(out.warnings.slice(0, 15).join("\n") + "\nJDL 기본 표로 내보낼까요?")) return;
EstApp.download(
`${project.name}.jdl`,
Uint8Array.from(atob(out.data), (c) => c.charCodeAt(0)),
"application/octet-stream",
);
}
function install() {
const jdlInput = document.createElement("input");
jdlInput.type = "file";
jdlInput.accept = ".jdl";
jdlInput.hidden = true;
jdlInput.id = "jdl-file";
document.body.append(jdlInput);
jdlInput.addEventListener("change", async () => {
const file = jdlInput.files[0],
key = EstApp.state.documentKey,
revision = EstApp.state.revision;
if (!file) return;
try {
const next = await EstApp.api("/api/import-jdl", {
method: "POST",
body: await file.arrayBuffer(),
});
if (key !== EstApp.state.documentKey || revision !== EstApp.state.revision)
throw Error("작업이 변경되었습니다. 다시 불러오세요.");
if (confirm("현재 작업 대신 JDL 내역서를 여시겠습니까?")) EstApp.setProject(next);
} catch (error) {
EstApp.state.notice = error.message;
EstApp.render();
} finally {
jdlInput.value = "";
}
});
const xmlInput = document.createElement("input");
xmlInput.type = "file";
xmlInput.accept = ".xml";
xmlInput.hidden = true;
xmlInput.id = "xml-file";
document.body.append(xmlInput);
xmlInput.addEventListener("change", async () => {
const file = xmlInput.files[0],
key = EstApp.state.documentKey,
revision = EstApp.state.revision;
if (!file) return;
try {
const next = EstC3r.read(xmlText(await file.arrayBuffer()));
if (key !== EstApp.state.documentKey || revision !== EstApp.state.revision)
throw Error("작업이 변경되었습니다. 다시 불러오세요.");
if (confirm("현재 작업 대신 XML 내역서를 여시겠습니까?")) EstApp.setProject(next);
} catch (error) {
EstApp.state.notice = error.message;
EstApp.render();
} finally {
xmlInput.value = "";
}
});
const input = document.createElement("input");
input.type = "file";
input.accept = ".xlsx";
input.hidden = true;
input.id = "excel-file";
document.body.append(input);
input.addEventListener("change", async () => {
const file = input.files[0];
if (!file) return;
const key = EstApp.state.documentKey,
revision = EstApp.state.revision;
try {
const files = await EstApp.api("/api/import-xlsx", {
method: "POST",
body: await file.arrayBuffer(),
}),
tables = read(files);
if (key !== EstApp.state.documentKey || revision !== EstApp.state.revision)
throw Error("작업이 변경되었습니다. Excel을 다시 불러오세요.");
const next = apply(EstApp.state.project, tables);
if (
!confirm(
`${Object.entries(tables)
.map(([name, rows]) => `${EstApp.titles[name] || name}: ${rows.length}행`)
.join("\n")}\n현재 작업의 해당 표를 교체합니다.`,
)
)
return;
EstApp.change(() => (EstApp.state.project = next));
} catch (error) {
EstApp.state.notice = error.message;
EstApp.render();
} finally {
input.value = "";
}
});
}
return { read, apply, decodeExcelText, exportExcel, exportNative, exportJdl, install };
})();