!styles.has(id) || redefined.has(id)) ||
/<(?!\/?div\b|c\b|begin\b|end\b)[^>]+>/i.test(div) ||
!same(begins.slice().sort(), ends.slice().sort()) ||
begins.some((id) => tags.get(id) !== 2)
)
throw Error("공유 표지나 글자 서식이 있는 산출 행은 ESTX에서 이동할 수 없습니다.");
}
let index = 0;
const document = text.replace(/]*>[\s\S]*?<\/div>/gi, (div) => {
if (!parse(div).length) return div;
return divs[order[index++]][0];
});
return { text: document, rows: order.map((source) => sheet.rows[source]) };
}
function updateQuantities(project, base, cp949, memory) {
const sheets = project.quantitySheets || [],
originals = base.quantitySheets || [];
if (sheets.length !== originals.length) throw Error("수량산출서 추가·삭제는 지원하지 않습니다.");
if (sheets.some((sheet) => sheet.markerEdited))
throw Error("산출 줄의 수량 지정·해제는 ESTX 저장을 지원하지 않습니다. JSON으로 저장하세요.");
if (sheets.some((sheet, i) => sheet.Code !== originals[i].Code))
throw Error("수량산출서 순서 변경은 ESTX 저장을 지원하지 않습니다. JSON으로 저장하세요.");
const rows = project.tables.MulMainDat.map((r) => ({ ...r }));
const titles = (project.tables.MulTitleDat || []).map((r) => ({ ...r }));
const savedSheets = sheets.slice();
for (let i = 0; i < sheets.length; i++) {
if (same(sheets[i], originals[i])) continue;
const row = rows.find((r) => r.Code === originals[i].Code);
const oldRow = base.tables.MulMainDat.find((r) => r.Code === originals[i].Code);
if (sheets[i].Name !== originals[i].Name) {
const matches = titles.filter((title) => title.Code === originals[i].Code),
oldMatches = (base.tables.MulTitleDat || []).filter(
(title) => title.Code === originals[i].Code,
);
if (matches.length !== 1 || oldMatches.length !== 1 || matches[0].Name !== oldMatches[0].Name)
throw Error("수량산출서 원본 제목 데이터 충돌");
if (typeof sheets[i].Name !== "string" || !sheets[i].Name.trim())
throw Error("수량산출서 이름을 입력하세요.");
matches[0].Name = sheets[i].Name;
}
if (!row?.Editor?.binary || !same(row.Editor, oldRow.Editor))
throw Error("수량산출서 원본 편집기 데이터 충돌");
const resultBase = (code) => String(code ?? "").match(/^([LrG]\d{6})(?:[123]\d)?$/)?.[1];
if (
originals[i].items.some(
(item) =>
resultBase(item.Code) &&
!sheets[i].items.some((next) => next.Code === item.Code) &&
sheets[i].items.some((next) => resultBase(next.Code) === resultBase(item.Code)),
)
)
throw Error("수량 항목 끝수 처리 변경은 ESTX 저장을 지원하지 않습니다. JSON으로 저장하세요.");
const removed = new Set(
originals[i].items
.filter((item) => !sheets[i].items.some((next) => next.Code === item.Code))
.map((item) => item.Code),
);
if (removed.size)
for (const item of [
...(base.tables.EstMainDat || []),
...(project.tables.EstMainDat || []),
]) {
if (!item.MulCode?.binary) continue;
const bytes = Buffer.from(item.MulCode.binary, "base64");
let text;
try {
const wide =
bytes.length > 1 &&
bytes.length % 2 === 0 &&
[...bytes].every((byte, index) => index % 2 === 0 || byte === 0);
text = wide
? bytes.toString("utf16le")
: new TextDecoder("euc-kr", { fatal: true }).decode(bytes);
} catch {
throw Error("원본 수량 연결을 확인할 수 없어 항목을 삭제할 수 없습니다.");
}
const [sheetCode, itemCode] = text.replace(/\0+$/, "").split("\t");
if (sheetCode === originals[i].Code && removed.has(itemCode))
throw Error("내역에서 참조 중인 수량 항목은 ESTX에서 삭제할 수 없습니다.");
}
const fields = Object.keys(memoryRows(Buffer.from(oldRow.MulList.binary, "base64"))[0] || {});
const originalCodes = new Set(originals[i].items.map((item) => item.Code));
const savedItems = (items) =>
items.map((item) =>
originalCodes.has(item.Code)
? item
: Object.fromEntries(
(fields.length ? fields : quantityFields).map((field) => [
field,
item[field] ?? null,
]),
),
);
const packed = Buffer.from(row.Editor.binary, "base64"),
compressed = packed[0] === 0x78;
const bytes = compressed
? zlib.inflateSync(packed, { maxOutputLength: 256 * 1024 * 1024 })
: packed;
const text = new TextDecoder("euc-kr", { fatal: true }).decode(bytes);
if (!cp949(text).equals(bytes)) throw Error("수량산출서 원본 인코딩 보존 불가");
const presentMarkers = new Set(sheets[i].rows.flatMap((line) => line.markers || []));
const deletedRows = new Set(
originals[i].rows.flatMap((line) =>
(line.markers || []).filter((code) => removed.has(code) && !presentMarkers.has(code)),
),
);
const originalMarkers = new Set(originals[i].rows.flatMap((line) => line.markers || []));
const existingRows = [],
inserts = [];
for (const line of sheets[i].rows) {
const marker = line.markers?.[0];
if (line.markers?.length === 1 && !originalMarkers.has(marker) && !originalCodes.has(marker))
inserts.push({ row: line, at: existingRows.length });
else existingRows.push(line);
}
let retainedRows = originals[i].rows.filter(
(line) => !(line.markers || []).some((code) => deletedRows.has(code)),
);
const plainIndexes = new Set();
if (existingRows.length < retainedRows.length) {
const counts = new Map();
const oldMarkers = markerCounts(retainedRows),
newMarkers = markerCounts(existingRows);
for (const line of originals[i].rows) {
const key = JSON.stringify(line);
counts.set(key, (counts.get(key) || 0) + 1);
}
let target = 0;
for (let n = 0; n < originals[i].rows.length; n++) {
const line = originals[i].rows[n];
if ((line.markers || []).some((code) => deletedRows.has(code))) continue;
const key = JSON.stringify(line.markers);
const matchesMarker =
line.markers?.length &&
oldMarkers.get(key) === 1 &&
newMarkers.get(key) === 1 &&
key === JSON.stringify(existingRows[target]?.markers);
if (target < existingRows.length && (same(line, existingRows[target]) || matchesMarker))
target++;
else if (!line.markers?.length && counts.get(JSON.stringify(line)) === 1)
plainIndexes.add(n);
else throw Error("중복되거나 함께 수정된 산출 행은 ESTX에서 삭제할 수 없습니다.");
}
if (target !== existingRows.length) throw Error("삭제할 산출 행을 확인할 수 없습니다.");
retainedRows = originals[i].rows.filter(
(line, n) =>
!plainIndexes.has(n) && !(line.markers || []).some((code) => deletedRows.has(code)),
);
}
if (existingRows.length !== retainedRows.length)
throw Error("수량산출 행 삭제는 ESTX 저장을 지원하지 않습니다.");
const existing = { ...sheets[i], rows: existingRows };
const source =
deletedRows.size || plainIndexes.size
? removeRows(text, originals[i], deletedRows, plainIndexes)
: text;
let retained = {
...originals[i],
rows: retainedRows,
items: originals[i].items.filter((item) => !deletedRows.has(item.Code)),
};
const reordered = reorderRows(source, retained, existingRows);
retained = { ...retained, rows: reordered.rows };
let document = editDocument(reordered.text, retained, existing);
if (inserts.length) {
document = insertRows(document, sheets[i], inserts);
if (
!same(
quantity.fromWpt(
sheets[i].Code,
sheets[i].Name,
document,
sheets[i].items,
sheets[i].options,
),
sheets[i],
)
)
throw Error("추가 산출 행 재읽기 검산 실패");
}
if (same(sheets[i].rows, originals[i].rows)) {
if (!same(row.MulList, oldRow.MulList)) throw Error("수량산출서 목록 데이터 충돌");
if (!same(sheets[i].items, originals[i].items))
row.MulList = {
binary: memory(Buffer.from(row.MulList.binary, "base64"), sheets[i].items, true).toString(
"base64",
),
};
savedSheets[i] = { ...sheets[i], items: savedItems(sheets[i].items) };
continue;
}
const baseline = calculateQuantity(originals[i]),
calculated = calculateQuantity(sheets[i]);
if (calculated.errors.length || baseline.errors.length)
throw Error(`수량산출서 계산 오류: ${calculated.errors[0] || baseline.errors[0]}`);
const markerCount = originals[i].rows.reduce((total, row) => total + row.markers.length, 0),
updatedMarkerCount = sheets[i].rows.reduce((total, row) => total + row.markers.length, 0);
const results = (value) => ({
checks: value.checks.map(({ name, calculated }) => ({ name, calculated })),
items: value.items.map(({ Code, GN }) => ({ Code, GN })),
});
const resultMarkers = (sheet) =>
sheet.rows.map((row) => [...row.LineData.matchAll(/\x12[^\x12]*\x12/g)].map((m) => m[0]));
if (
baseline.checks.length === markerCount &&
calculated.checks.length === updatedMarkerCount &&
baseline.checks.every((value) => Number.isFinite(value.calculated)) &&
calculated.checks.every((value) => Number.isFinite(value.calculated)) &&
same(resultMarkers(originals[i]), resultMarkers(sheets[i])) &&
same(results(baseline), results(calculated))
) {
if (!same(row.MulList, oldRow.MulList)) throw Error("수량산출서 목록 데이터 충돌");
if (!same(sheets[i].items, originals[i].items))
row.MulList = {
binary: memory(Buffer.from(row.MulList.binary, "base64"), sheets[i].items, true).toString(
"base64",
),
};
savedSheets[i] = { ...sheets[i], items: savedItems(sheets[i].items) };
const output = cp949(document);
row.Editor = { binary: (compressed ? zlib.deflateSync(output) : output).toString("base64") };
continue;
}
if (
baseline.checks.length !== markerCount ||
calculated.checks.length !== updatedMarkerCount ||
baseline.checks.some((v) => !Number.isFinite(v.stored) || v.stored !== v.calculated) ||
baseline.items.some((v, n) => v.GN !== originals[i].items[n].GN)
)
throw Error("수량산출서 원본 결과와 재계산 값이 다릅니다. JSON으로 저장해 확인하세요.");
let current = structuredClone(sheets[i]);
for (let n = 0; n < current.rows.length; n++) {
const desired = calculated.rows[n].LineData;
const before = [...current.rows[n].LineData.matchAll(/\x12([-\d.,]+)\x12/g)];
const after = [...desired.matchAll(/\x12([-\d.,]+)\x12/g)];
if (before.length !== after.length) throw Error("수량산출 결과 표지 수 불일치");
for (let j = 0; j < after.length; j++) {
const matches = [...current.rows[n].LineData.matchAll(/\x12([-\d.,]+)\x12/g)];
if (matches[j][0] === after[j][0]) continue;
const next = structuredClone(current),
at = matches[j].index;
next.rows[n].LineData =
next.rows[n].LineData.slice(0, at) +
after[j][0] +
next.rows[n].LineData.slice(at + matches[j][0].length);
document = editDocument(document, current, next);
current = next;
}
if (current.rows[n].LineData !== desired) throw Error("수량산출 결과 재계산 검산 실패");
}
if (!same(row.MulList, oldRow.MulList)) throw Error("수량산출서 목록 데이터 충돌");
if (!same(calculated.items, originals[i].items)) {
row.MulList = {
binary: memory(Buffer.from(row.MulList.binary, "base64"), calculated.items, true).toString(
"base64",
),
};
current.items = savedItems(calculated.items);
}
savedSheets[i] = current;
const output = cp949(document);
row.Editor = { binary: (compressed ? zlib.deflateSync(output) : output).toString("base64") };
}
return {
...project,
quantitySheets: savedSheets,
tables: { ...project.tables, MulMainDat: rows, MulTitleDat: titles },
};
}
module.exports = {
editDocument,
updateQuantities,
appendRows,
insertRows,
removeRows,
reorderRows,
quantityFields,
};