667 lines
27 KiB
JavaScript
667 lines
27 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 "&";
|
|
if (c === "<") return "<";
|
|
if (c === ">") return ">";
|
|
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:'${code}';" wpsty=[[Type:3;Mode:3;Name:"${code}";]] 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("수량산출서 추가·삭제는 지원하지 않습니다.");
|
|
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 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,
|
|
};
|