Files
Aislo/tools/est-test/export-quantity.cjs
eomsangdonandClaude Opus 5.5 029c7dab5a feat(est-test): 산출 줄 수량 지정과 지정 해제
- 드래그한 숫자를 결과 표지로 감싸 수량 항목 생성, 품명·규격·단위 표지 반영
- 커서 위치 결과 해제와 미사용 항목 삭제, 내역 연결 항목은 거부
- 이 편집의 ESTX 저장은 명확한 문구로 거부

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 15:15:58 +09:00

681 lines
28 KiB
JavaScript

const zlib = require("node:zlib");
const quantity = require("./native-quantity.cjs");
const { rowParser } = require("./wpt-parse.cjs");
const { calculateQuantity } = require("./quantity-calculate.cjs");
const { memoryRows } = require("./import-estx.cjs");
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const quantityFields = [
"Index",
"Code",
"LinkIndex",
"LinkCode",
"Name",
"Size",
"Unite",
"GN",
"GN_",
];
function escapeText(text) {
return [...text]
.map((c) => {
if (c === "&") return "&amp;";
if (c === "<") return "&lt;";
if (c === ">") return "&gt;";
const code = c.codePointAt(0);
return code > 126 ? `&#${code};` : c;
})
.join("");
}
function editRun(div, before, after, parse) {
for (const match of div.matchAll(/>([^<>]+)(?=<)/g)) {
const raw = match[1],
decoded = quantity.entities(raw.replace(/\r?\n/g, ""));
if (!decoded) continue;
let position = -1;
while ((position = before.LineData.indexOf(decoded, position + 1)) >= 0) {
const prefix = before.LineData.slice(0, position),
suffix = before.LineData.slice(position + decoded.length);
if (
!after.LineData.startsWith(prefix) ||
!after.LineData.endsWith(suffix) ||
after.LineData.length < prefix.length + suffix.length
)
continue;
const replacement = after.LineData.slice(
prefix.length,
after.LineData.length - suffix.length,
);
const at = match.index + 1;
const candidate = div.slice(0, at) + escapeText(replacement) + div.slice(at + raw.length);
if (same(parse(candidate)[0], after)) return candidate;
}
}
return null;
}
function editStyledRuns(div, before, after, parse) {
const a = before.LineData,
b = after.LineData;
let start = 0,
endA = a.length,
endB = b.length;
while (start < endA && start < endB && a[start] === b[start]) start++;
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
endA--;
endB--;
}
for (const block of div.matchAll(/>[^<>]+(?:<c\s+nr=\d+\s*\/>[^<>]+)+/gi)) {
const runs = [...block[0].matchAll(/>([^<>]+)(?=<|$)/g)];
const values = runs.map((run) => quantity.entities(run[1].replace(/\r?\n/g, "")));
const combined = values.join("");
let position = -1;
while ((position = a.indexOf(combined, position + 1)) >= 0) {
if (start < position || endA > position + combined.length) continue;
let patched = block[0],
inserted = false;
for (let i = runs.length - 1; i >= 0; i--) {
const begin = position + values.slice(0, i).reduce((n, value) => n + value.length, 0),
end = begin + values[i].length;
if (end < start || begin > endA) continue;
const left = values[i].slice(0, Math.max(0, start - begin)),
right = values[i].slice(Math.max(0, endA - begin));
const replacement =
begin <= start && (start < end || (i === runs.length - 1 && start === end))
? b.slice(start, endB)
: "";
if (replacement) inserted = true;
const at = runs[i].index + 1;
patched =
patched.slice(0, at) +
escapeText(left + replacement + right) +
patched.slice(at + runs[i][1].length);
}
if (!inserted && endB > start) continue;
const candidate =
div.slice(0, block.index) + patched + div.slice(block.index + block[0].length);
if (same(parse(candidate)[0], after)) return candidate;
}
}
return null;
}
// Change text inside an existing styled run, never WPTools tags or bookmarks.
function editDocument(text, oldSheet, sheet) {
const withoutLabels = (items) => items.map(({ Name, Size, Unite, ...rest }) => rest);
const oldCodes = new Set(oldSheet.items.map((item) => item.Code));
const sameCodes = same(
oldSheet.items.map((item) => item.Code),
sheet.items.map((item) => item.Code),
);
const codes = new Set();
for (const item of sheet.items) {
if (!sameCodes && (!item.Code || codes.has(item.Code)))
throw Error("수량 항목 코드가 없거나 중복됩니다.");
codes.add(item.Code);
}
for (const item of oldSheet.items) {
if (codes.has(item.Code)) continue;
if (
item.TargetCode ||
item.TargetKey ||
item.LinkCode ||
item.LinkIndex ||
oldSheet.rows.some(
(row) => (row.markers || []).includes(item.Code) || row.LineData.includes(item.Code),
)
)
throw Error("참조 중인 수량 항목은 ESTX에서 삭제할 수 없습니다.");
}
const added = sheet.items.filter((item) => !oldCodes.has(item.Code));
for (const item of added) {
if (!/^r\d{6}$/.test(item.Code || "")) throw Error("수량 항목 코드가 없거나 중복됩니다.");
if (item.TargetCode || item.TargetKey || item.LinkCode || item.LinkIndex)
throw Error("신규 수량 항목의 내역 연결은 ESTX 저장을 지원하지 않습니다.");
if (
[
[item.Name, 80],
[item.Size, 80],
[item.Unite, 8],
].some(
([value, limit]) => value != null && (typeof value !== "string" || value.length > limit),
) ||
(item.GN != null && (typeof item.GN !== "number" || !Number.isFinite(item.GN)))
)
throw Error("신규 수량 항목의 이름·규격·단위 또는 수량 형식 오류");
if (
Object.keys(item).some(
(key) => !["Code", "Name", "Size", "Unite", "GN", "TargetCode"].includes(key),
)
)
throw Error("신규 수량 항목에 저장할 수 없는 속성이 있습니다.");
}
if (
!same(
{ ...oldSheet, Name: "", rows: [], items: [] },
{ ...sheet, Name: "", rows: [], items: [] },
) ||
!same(
withoutLabels(oldSheet.items.filter((item) => codes.has(item.Code))),
withoutLabels(sheet.items.filter((item) => oldCodes.has(item.Code))),
) ||
oldSheet.rows.length !== sheet.rows.length
)
throw Error("수량산출서 목록·연결·행 구조 변경은 ESTX 저장을 지원하지 않습니다.");
const parse = rowParser(text);
let rowIndex = 0;
const result = text.replace(/<div\b[^>]*>[\s\S]*?<\/div>/gi, (div) => {
const parsed = parse(div);
if (!parsed.length) return div;
const before = oldSheet.rows[rowIndex],
after = sheet.rows[rowIndex++];
if (!before || !after || !same(parsed[0], before)) throw Error("수량산출서 원본 행 대응 오류");
if (same(before, after)) return div;
if (!same(before.markers, after.markers))
throw Error("수량산출서 표지 변경은 저장할 수 없습니다.");
const direct = editRun(div, before, after, parse);
if (direct) return direct;
const styled = editStyledRuns(div, before, after, parse);
if (styled) return styled;
const split = /(\^\([^)]+\)|[\x03\x0c\x0e\x11\x12\x13\x15\x1a])/g,
isSpecial = (s) => /^\^\([^)]+\)$/.test(s) || /[\x03\x0c\x0e\x11\x12\x13\x15\x1a]/.test(s),
previous = before.LineData.split(split),
next = after.LineData.split(split);
if (
previous.length === next.length &&
previous.every(
(v, i) =>
!isSpecial(v) ||
(isSpecial(next[i]) &&
(v === next[i] || (v.startsWith("^(") && next[i].startsWith("^(")))),
)
) {
let candidate = div,
current = before;
for (let i = 0; i < previous.length; i++) {
if (previous[i] === next[i]) continue;
previous[i] = next[i];
const updated = { ...current, LineData: previous.join("") };
const patched =
editRun(candidate, current, updated, parse) ||
editStyledRuns(candidate, current, updated, parse);
if (!patched) break;
candidate = patched;
current = updated;
}
if (same(current, after)) return candidate;
}
throw Error(
`수량산출서 ${rowIndex}행: 기존 서식 구간을 넘는 편집은 ESTX에 저장할 수 없습니다.`,
);
});
if (rowIndex !== oldSheet.rows.length) throw Error("수량산출서 행 수 불일치");
if (!same(quantity.fromWpt(sheet.Code, sheet.Name, result, sheet.items, sheet.options), sheet))
throw Error("수량산출서 문서 재읽기 검산 실패");
return result;
}
function appendRows(text, sheet, rows) {
if (!/<cs\s+nr=1\b/i.test(text)) throw Error("수량산출서 기본 글자 서식을 찾을 수 없습니다.");
const used = new Set(
[...text.matchAll(/<begin\b[^>]*>/gi)]
.map((match) => quantity.entities(match[0]).match(/Name:["']([^"']+)["']/)?.[1])
.filter(Boolean),
);
let nextTag = 1;
for (const match of text.matchAll(/\bTag=(\d+)/gi))
nextTag = Math.max(nextTag, Number(match[1]) + 1);
for (const row of rows) {
const matches = [...row.LineData.matchAll(/\x12([^\x12]*)\x12/g)];
const code = row.markers?.[0];
if (
matches.length !== 1 ||
row.markers?.length !== 1 ||
!/^r\d{6}$/.test(code || "") ||
used.has(code)
)
throw Error("추가 산출 행은 단일 결과 표지와 숫자 코드가 필요합니다.");
if (!sheet.items.some((item) => item.Code === code))
throw Error("추가 산출 행에 연결된 수량 항목이 없습니다.");
used.add(code);
const [match] = matches;
const plain =
row.LineData.slice(0, match.index) + row.LineData.slice(match.index + match[0].length);
if (/[\x00-\x1f]|\^\(/.test(plain) || /[\x00-\x1f]/.test(match[1]))
throw Error("추가 산출 행의 복합 표지나 위첨자는 ESTX 저장을 지원하지 않습니다.");
const tag = nextTag++;
text += `<div cs=1 wpsty=[[IndentLeft:0;IndentFirst:0;IndentRight:0;]]><c nr=1/>${escapeText(row.LineData.slice(0, match.index))}<begin Tag=${tag} style="Name:&#39;${code}&#39;;" wpsty=[[Type:3;Mode:3;Name:&#34;${code}&#34;;]] ch=2/>${escapeText(match[1])}<end Tag=${tag} ch=3/>${escapeText(row.LineData.slice(match.index + match[0].length))}</div>\r\n`;
}
return text;
}
function insertRows(text, sheet, inserts) {
let staged = text;
const snippets = inserts.map(({ row, at }) => {
const next = appendRows(staged, sheet, [row]);
const snippet = next.slice(staged.length);
staged = next;
return { at, snippet };
});
const parse = rowParser(text);
let index = 0;
const stylePosition = text.search(/<cs\s+nr=1\b/i);
const updated = text.replace(/<div\b[^>]*>[\s\S]*?<\/div>/gi, (div, offset) => {
if (!parse(div).length) return div;
const before = snippets
.filter((item) => item.at === index)
.map((item) => item.snippet)
.join("");
if (before && offset < stylePosition)
throw Error("추가 위치 앞에 기본 글자 서식이 없어 ESTX에 저장할 수 없습니다.");
index++;
return before + div;
});
return (
updated +
snippets
.filter((item) => item.at === index)
.map((item) => item.snippet)
.join("")
);
}
function removeRows(text, sheet, codes, plainIndexes = new Set()) {
const parse = rowParser(text);
const tags = tagCounts(text);
let index = 0;
const removed = new Set();
const removedPlain = new Set();
const document = text.replace(/<div\b[^>]*>[\s\S]*?<\/div>/gi, (div) => {
const parsed = parse(div);
if (!parsed.length) return div;
const rowIndex = index++,
row = sheet.rows[rowIndex],
code = row?.markers?.[0];
if (!row || !same(parsed[0], row)) throw Error("수량산출서 원본 행 대응 오류");
if (!codes.has(code) && !plainIndexes.has(rowIndex)) return div;
if (plainIndexes.has(rowIndex)) {
if (row.markers?.length || /<(?!\/?div\b|c\b|cs\b)[^>]+>/i.test(div))
throw Error("문서 표지나 특수 서식이 있는 산출 행은 ESTX에서 삭제할 수 없습니다.");
removedPlain.add(rowIndex);
return [...div.matchAll(/<cs\b[^>]*>/gi)].map((match) => match[0]).join("");
}
const item = sheet.items.find((value) => value.Code === code);
const begins = [...div.matchAll(/<begin\b[^>]*\bTag=(\d+)[^>]*>/gi)];
if (
row.markers.length !== 1 ||
removed.has(code) ||
!item ||
item.TargetCode ||
item.TargetKey ||
item.LinkCode ||
item.LinkIndex ||
begins.length !== 1 ||
tags.get(begins[0]?.[1]) !== 2 ||
/<(?!\/?div\b|c\b|cs\b|begin\b|end\b)[^>]+>/i.test(div)
)
throw Error("다른 표지나 연결을 포함한 산출 행은 ESTX에서 삭제할 수 없습니다.");
removed.add(code);
return [...div.matchAll(/<cs\b[^>]*>/gi)].map((match) => match[0]).join("");
});
if (
index !== sheet.rows.length ||
removed.size !== codes.size ||
removedPlain.size !== plainIndexes.size
)
throw Error("삭제할 산출 행을 원본 문서에서 찾을 수 없습니다.");
return document;
}
function tagCounts(text) {
const counts = new Map();
for (const match of text.matchAll(/\bTag=(\d+)(?=\D|$)/gi))
counts.set(match[1], (counts.get(match[1]) || 0) + 1);
return counts;
}
function markerCounts(values) {
const counts = new Map();
for (const row of values) {
if (!row.markers?.length) continue;
const key = JSON.stringify(row.markers);
counts.set(key, (counts.get(key) || 0) + 1);
}
return counts;
}
function reorderRows(text, sheet, rows) {
const before = markerCounts(sheet.rows),
after = markerCounts(rows);
const keyFor = (row) => {
const markers = JSON.stringify(row.markers);
return row.markers?.length && before.get(markers) === 1 && after.get(markers) === 1
? `marker:${markers}`
: `row:${JSON.stringify(row)}`;
};
const remaining = new Map();
sheet.rows.forEach((row, index) => {
const key = keyFor(row);
if (!remaining.has(key)) remaining.set(key, { indexes: [], next: 0 });
remaining.get(key).indexes.push(index);
});
const order = [];
for (const row of rows) {
const entry = remaining.get(keyFor(row));
if (!entry || entry.next >= entry.indexes.length) return { text, rows: sheet.rows };
order.push(entry.indexes[entry.next++]);
}
if (order.length !== sheet.rows.length || order.every((value, index) => value === index))
return { text, rows: sheet.rows };
const parse = rowParser(text);
const divs = [...text.matchAll(/<div\b[^>]*>[\s\S]*?<\/div>/gi)].filter(
(match) => parse(match[0]).length,
);
if (divs.length !== sheet.rows.length) throw Error("수량산출서 원본 행 대응 오류");
if (divs.some((match, index) => !same(parse(match[0])[0], sheet.rows[index])))
throw Error("수량산출서 원본 행 대응 오류");
const changed = order
.map((source, index) => (source === index ? -1 : index))
.filter((n) => n >= 0);
const first = divs[changed[0]].index;
const styles = new Set(
[...text.slice(0, first).matchAll(/<cs\b[^>]*\bnr=(\d+)\b/gi)].map((m) => m[1]),
);
const last = divs[changed.at(-1)];
const redefined = new Set(
[...text.slice(first, last.index + last[0].length).matchAll(/<cs\b[^>]*\bnr=(\d+)\b/gi)].map(
(m) => m[1],
),
);
const tags = tagCounts(text);
for (const index of changed) {
const div = divs[index][0];
const referenced = [
...[...div.matchAll(/<div\b[^>]*\bcs=(\d+)/gi)].map((m) => m[1]),
...[...div.matchAll(/<c\b[^>]*\bnr=(\d+)/gi)].map((m) => m[1]),
];
const begins = [...div.matchAll(/<begin\b[^>]*\bTag=(\d+)[^>]*>/gi)].map((m) => m[1]);
const ends = [...div.matchAll(/<end\b[^>]*\bTag=(\d+)[^>]*>/gi)].map((m) => m[1]);
if (
/<cs\b/i.test(div) ||
referenced.some((id) => !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(/<div\b[^>]*>[\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,
};