2 Commits
6 changed files with 290 additions and 27 deletions
@@ -8,6 +8,63 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def test_quantity_document_edits_keep_styles_and_bookmarks_and_block_cross_style_edits():
script = r"""const q=require('./tools/est-test/native-quantity.cjs'),w=require('./tools/est-test/export-quantity.cjs');
const doc='<cs nr=1/><div><c nr=1/>폭= <begin Tag=2 wpsty=[[Name:&#34;r000001&#34;;]]/>6<end Tag=2/> m</div>';
const old=q.fromWpt('A','문서',doc,[],{}),edited=structuredClone(old);
edited.rows[0].LineData=edited.rows[0].LineData.replace('6','12');
const next=w.editDocument(doc,old,edited),again=q.fromWpt('A','문서',next,[],{});
const bad=structuredClone(old);bad.rows[0].LineData='완전히 다른 줄';
let rejected=false;try{w.editDocument(doc,old,bad)}catch{rejected=true}
process.stdout.write(JSON.stringify({same:JSON.stringify(again)===JSON.stringify(edited),tags:next.includes('<begin Tag=2')&&next.includes('<end Tag=2'),rejected}));"""
result = json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT).decode("utf-8"))
assert result == {"same": True, "tags": True, "rejected": True}
def test_estx_quantity_edit_preserves_other_documents_and_indexes_when_sample_exists():
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
if not sample.exists():
pytest.skip("installed EST main sample unavailable")
script = """const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs');
const bytes=fs.readFileSync(process.argv[1]),p=m.importEstx(bytes),first=p.quantitySheets[0];
first.rows[0].LineData=first.rows[0].LineData.replace('표준','시험 표준');
const out=e.exportEstx(p),q=m.importEstx(out),a=m.unzip(bytes),b=m.unzip(out);
const unchanged=[...a].filter(([k])=>k.endsWith('.idx')||k.startsWith('cad')).every(([k,v])=>v.equals(b.get(k)));
process.stdout.write(JSON.stringify({line:q.quantitySheets[0].rows[0].LineData,others:JSON.stringify(q.quantitySheets.slice(1))===JSON.stringify(p.quantitySheets.slice(1)),unchanged}));"""
result = json.loads(
subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT).decode("utf-8")
)
assert "시험 표준" in result["line"]
assert result["others"] and result["unchanged"]
def test_estx_options_and_expanded_blob_preserve_unrelated_data_when_sample_exists():
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/sample.estx")
if not sample.exists():
pytest.skip("installed EST sample unavailable")
script = """const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs');
const bytes=fs.readFileSync(process.argv[1]),p=m.importEstx(bytes),before=structuredClone(p);
p.name='길이가 달라진 새 공사명';p.options.AutoCalc=!p.options.AutoCalc;
p.options.CostValueTrunc=!p.options.CostValueTrunc;p.options.MidCostDiget=3;
const row=p.tables.EstMainDat.find(r=>Object.values(r).some(v=>v&&v.binary));
const field=Object.keys(row).find(k=>row[k]&&row[k].binary);
const content=Buffer.alloc(12000, 65);row[field]={binary:content.toString('base64')};
const out=e.exportEstx(p),q=m.importEstx(out),a=m.unzip(bytes),b=m.unzip(out);
const unchanged=[...a].filter(([k])=>k.endsWith('.idx')||k.startsWith('cad')).every(([k,v])=>v.equals(b.get(k)));
const other=p.tables.EstMainDat.every((r,i)=>Object.keys(r).every(k=>JSON.stringify(r[k])===JSON.stringify(q.tables.EstMainDat[i][k])));
const options=before.originalOptions.every(x=>{const y=q.originalOptions.find(z=>z.className===x.className);
return Object.entries(x.properties).every(([k,v])=>['ProjectName','CalcAuto','CostValueTrunc','MidCostDiget'].includes(k)||JSON.stringify(v)===JSON.stringify(y.properties[k]));});
process.stdout.write(JSON.stringify({unchanged,other,options,name:q.name,auto:q.options.AutoCalc,cost:q.options.CostValueTrunc,digits:q.options.MidCostDiget,expected:p.options,content:Buffer.from(q.tables.EstMainDat.find(r=>r.PkID===row.PkID)[field].binary,'base64').equals(content)}));"""
result = json.loads(
subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT).decode("utf-8")
)
assert result["unchanged"] and result["other"] and result["options"] and result["content"]
assert result["name"] == "길이가 달라진 새 공사명"
assert result["auto"] == result["expected"]["AutoCalc"]
assert result["cost"] == result["expected"]["CostValueTrunc"]
assert result["digits"] == 3
def test_recalculation_and_native_export_keep_original_title_identities_when_sample_exists():
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/sample.estx")
if not sample.exists():
+1 -1
View File
@@ -13,7 +13,7 @@
- **Excel `.xlsx`**: CAD를 제외한 전체 표의 스칼라 필드를 숫자·문자·불리언으로 내보냅니다. 첫 행 필드명과 시트 이름을 유지한 파일을 다시 읽어 해당 표를 교체합니다. 기존 PkID의 바이너리는 보존하며 코드 변경·중복과 미계산 수식/오류 셀을 거부합니다. 출처 단가 수정은 연결 내역에 전파합니다. 파일에 바이너리·업무 회차·당초 사본을 넣지는 않으므로 전체 백업은 JSON을 사용합니다.
- **조달청 XML**: DB에 보관된 공사원가호환규정 XSD 1.0.5의 요소 순서와 필드를 사용합니다. 작성업체·공사구분을 입력하며 코드/문구 길이와 연결 자료를 검사합니다. 내역·일위·산근·기계·기초 단가·원가를 내보내고 다시 읽습니다. 연결 없는 고정 금액·기계 상세는 독립 단가로 전개하며 변환 전에 알립니다. 가져온 저장 금액과 원문을 보존하고 자동계산을 끕니다. 이는 제출기관 인증/등록 검사나 최신 규정 적합 판정을 뜻하지 않습니다.
- **JDL 기본 표**: 원본 검사 DLL의 필드 정의와 EST 처리기의 TAB/ENQ·CP949 표 구조에 따라 ZIP 안의 Global.ini·Main.txt·자원/공종/내역/원가 표를 생성·읽습니다. 복잡한 일위·산근·기계는 저장 금액의 고정 단가로 전개하며 제외 행은 생략 사실을 알립니다. 원본 JDL도 보존합니다. 확장 산식·기성/물가 파일의 완전 변환과 원본 검사 DLL 인증은 미확인입니다.
- **원본 구조 유지 ESTX 수정 저장**: 가져온 파일의 행 수와 인덱스 키를 유지하는 단가·수량·문구·금액 및 할당 공간 안의 BLOB/원가 서식을 기록하고 재읽기로 검산합니다. 원본 파일을 덮어쓰지 않으며 `.idx`와 CAD 바이너리를 보존합니다. 새 문서, 공사명/계산 옵션 변경, 행 추가·삭제·인덱스 키 변경, BLOB 공간 확장, 수정한 WPTools 수량 문서와 웹 기성·연차·당초 사본은 지원하지 않아 저장을 거부합니다. 이 범위는 JSON으로 보관합니다. 원본 EST 실행 검증은 못했습니다.
- **원본 구조 유지 ESTX 수정 저장**: 가져온 파일의 공사명·계산 옵션과 행 수 및 인덱스 키가 유지된 단가·수량·문구·금액, 기존 BLOB과 원가 서식을 기록하고 재읽기로 검산합니다. 수량산출서는 기존 서식 구간 안의 텍스트 편집을 저장하며 표지와 문서 구조는 보존합니다. 편집 문서의 계산 결과 표지와 원본 수량 목록은 자동 갱신하지 않습니다. 기존 BLOB은 공간이 부족하면 블록을 추가합니다. 원본 파일을 덮어쓰지 않으며 `.idx`와 CAD 바이너리를 보존합니다. 새 문서, 행 추가·삭제·인덱스 키 변경, 서식 구간을 가로지르는 수량산출서 편집과 목록·연결 변경, 웹 기성·연차·당초 사본은 지원하지 않아 저장을 거부합니다. 이 범위는 JSON으로 보관합니다. 원본 EST 실행 검증은 못했습니다.
- **예정공정표**: 공정별 기간·진도와 가중치를 입력하고 기간 막대 및 입력 진도의 가중평균을 표시합니다. 금액 가중치를 수정할 수 있으며 날짜 역전·잘못된 진도를 차단합니다. 공휴일/공기 산정 기준을 임의 적용하지 않습니다.
- **공종별 동원 출력**: 한 번의 원천 계산에서 공종별 인원·자재·장비·실적·견적을 나누며 전체 합계와 금액/수량을 대조합니다. 문서 본문과 인쇄 설정도 저장·복원·취소 대상입니다.
+81 -24
View File
@@ -1,6 +1,8 @@
const zlib = require("node:zlib");
const { importEstx, unzip, Values, memoryRows } = require("./import-estx.cjs");
const { pack } = require("./archive.cjs");
const { updateOptions } = require("./export-options.cjs");
const { updateQuantities } = require("./export-quantity.cjs");
const signature = Buffer.from("068abe8e592364cb403d71d2e3bc64d0", "hex");
const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
let characters;
@@ -100,11 +102,23 @@ function updateBlob(file, start, id, bytes) {
blocks.push(block);
block = file.readUInt32LE(block * 512 + 4);
}
if (bytes.length > blocks.length * 494)
throw Error(
"변경 BLOB이 원본 할당 공간을 초과합니다. 원본 공간 확장은 아직 지원하지 않습니다.",
);
if (file.readUInt32LE(start * 512 + 10) !== id) throw Error("BLOB ID 오류");
const extra = Math.max(0, Math.ceil(bytes.length / 494) - blocks.length);
if (extra) {
const oldCount = file.length / 512;
if (!Number.isInteger(oldCount) || file.readUInt32LE(29) !== oldCount)
throw Error("BLOB 블록 수 헤더가 원본 형식과 다릅니다.");
if ((oldCount + extra) * 512 > 256 * 1024 * 1024) throw Error("BLOB 크기 한도 초과");
file = Buffer.concat([file, Buffer.alloc(extra * 512)]);
for (let i = 0; i < extra; i++) {
const previous = blocks.at(-1),
number = oldCount + i;
file.writeUInt32LE(number, previous * 512 + 4);
file.writeUInt32LE(previous, number * 512);
blocks.push(number);
}
file.writeUInt32LE(oldCount + extra, 29);
}
let at = 0;
for (const [i, number] of blocks.entries()) {
const offset = number * 512,
@@ -115,6 +129,7 @@ function updateBlob(file, start, id, bytes) {
at += take;
if (i === 0) file.writeUInt32LE(bytes.length, offset + 14);
}
return file;
}
function exportEstx(project) {
if (!project.originalArchive)
@@ -122,21 +137,44 @@ function exportEstx(project) {
const original = Buffer.from(project.originalArchive, "base64"),
base = importEstx(original),
files = unzip(original);
if (project.name !== base.name)
throw Error("공사명 변경의 원본 옵션 저장 구조가 미확인입니다. JSON으로 보관하세요.");
const optionFields = {
GCToWonga: ["TWongaOption", "GCToWonga"],
GangubExceptJodalRatio: ["TWongaOption", "GangubExceptJodalRatio"],
EYunUp: ["TWongaOption", "EYunUp"],
VerFormat: ["TWongaOption", "VerFormat"],
TotalFix: ["TWongaOption", "TotalFix"],
AutoCalc: ["TEstOption", "CalcAuto"],
CostValueTrunc: ["TCostOption", "CostValueTrunc"],
MidCostDiget: ["TCostOption", "MidCostDiget"],
HeadDigits: ["THeadOption", "SumValueDiget"],
TotalFit: ["THeadOption", "CalcTrunc"],
DollarCostCut: ["TMachOption", "DollarCostCut"],
JDLCalcMethod: ["TCommonOption", "JDLCalcMethod"],
OldRecalcMethod: ["TChangeOption", "OldRecalcMethod"],
};
const optionChanges = {};
const setOption = (cls, name, data) => {
(optionChanges[cls] ||= {})[name] = data;
};
if (project.name !== base.name) setOption("TCommonOption", "ProjectName", project.name);
for (const key of Object.keys(base.options))
if (
!["SourceRefresh", "AutoCalc"].includes(key) &&
!same(project.options[key], base.options[key])
)
throw Error(`계산 옵션 ${key} 변경은 ESTX에 저장할 수 없습니다.`);
if (key !== "SourceRefresh" && !same(project.options[key], base.options[key])) {
if (!optionFields[key]) throw Error(`계산 옵션 ${key} 저장 구조 미확인`);
setOption(...optionFields[key], project.options[key]);
}
if (project.progress?.length || project.annual?.length || project.baseline)
throw Error(
"웹 기성/연차/당초 사본은 원본 별도 업무 파일 구조가 필요합니다. JSON 저장을 사용하세요.",
);
if (!same(project.quantitySheets || [], base.quantitySheets || []))
throw Error("편집한 수량산출서의 원본 WPTools 저장은 아직 지원하지 않습니다.");
project = updateQuantities(project, base, cp949);
let changed = 0;
if (Object.keys(optionChanges).length) {
const options = files.get("datestoption.opt");
if (!options) throw Error("ESTX 원본 옵션 파일 없음");
files.set("datestoption.opt", updateOptions(options, optionChanges, value));
changed++;
}
const oldByLower = new Map(Object.keys(base.tables).map((key) => [key.toLowerCase(), key]));
for (const [filename, data] of files) {
if (!filename.endsWith(".dat") || !data.subarray(8, 24).equals(signature)) continue;
@@ -149,7 +187,7 @@ function exportEstx(project) {
const stride = data.readUInt16LE(45),
count = data.readUInt16LE(47),
start = 512 + 768 * count;
const blobFile = files.get(filename.replace(/\.dat$/, ".blb"));
const blobName = filename.replace(/\.dat$/, ".blb");
for (let i = 0; i < rows.length; i++)
for (let f = 0; f < count; f++) {
const desc = 512 + 768 * f,
@@ -185,12 +223,22 @@ function exportEstx(project) {
} else if (kind === 4 && width === 2) {
if (!Number.isInteger(next)) throw Error("ESTX 정수 오류");
data.writeInt16LE(next, at + 1);
} else if (kind === 3 && width === 8 && next?.binary && old?.binary && blobFile && data[at])
updateBlob(
blobFile,
data.readUInt32LE(at + 1),
data.readUInt32LE(at + 5),
Buffer.from(next.binary, "base64"),
} else if (
kind === 3 &&
width === 8 &&
next?.binary &&
old?.binary &&
files.has(blobName) &&
data[at]
)
files.set(
blobName,
updateBlob(
files.get(blobName),
data.readUInt32LE(at + 1),
data.readUInt32LE(at + 5),
Buffer.from(next.binary, "base64"),
),
);
else throw Error(`${key}.${name}: 미확인 필드 저장형 ${kind}`);
data[at] = 1;
@@ -215,17 +263,24 @@ function exportEstx(project) {
if (field == null) throw Error("원가 BLOB 필드 없음");
const originalBlob = Object.values(base.tables.wongadat[0]).find((v) => v?.binary);
const bytes = memory(Buffer.from(originalBlob.binary, "base64"), project.tables.Wonga);
updateBlob(
blb,
dat.readUInt32LE(start + field + 1),
dat.readUInt32LE(start + field + 5),
bytes,
files.set(
"wongadat.blb",
updateBlob(
blb,
dat.readUInt32LE(start + field + 1),
dat.readUInt32LE(start + field + 5),
bytes,
),
);
changed++;
}
if (!changed) return original;
const result = pack(files),
check = importEstx(result);
if (check.name !== project.name) throw Error("공사명 저장 후 재읽기 검산 실패");
for (const key of Object.keys(base.options))
if (key !== "SourceRefresh" && !same(check.options[key], project.options[key]))
throw Error(`${key}: 옵션 저장 후 재읽기 검산 실패`);
for (const [key, rows] of Object.entries(base.tables)) {
if (key === "Wonga" || key === "wongadat") continue;
for (let i = 0; i < rows.length; i++)
@@ -234,6 +289,8 @@ function exportEstx(project) {
throw Error(`${key}.${name}: 저장 후 재읽기 검산 실패`);
}
if (!same(check.tables.Wonga, project.tables.Wonga)) throw Error("원가 저장 후 재읽기 검산 실패");
if (!same(check.quantitySheets, project.quantitySheets))
throw Error("수량산출서 저장 후 재읽기 검산 실패");
return result;
}
module.exports = { exportEstx, memory, cp949 };
+57
View File
@@ -0,0 +1,57 @@
const { Values } = require("./import-estx.cjs");
// Preserve every unedited property byte and the surrounding memory-table records.
function updateOptions(buffer, changes, encode) {
const pieces = [];
let cursor = 0,
offset = 0;
const remaining = new Map(Object.entries(changes));
while ((offset = buffer.indexOf("TPF0", offset)) >= 0) {
const length = offset >= 4 ? buffer.readUInt32LE(offset - 4) : 0;
if (length < 6 || offset + length > buffer.length) {
offset += 4;
continue;
}
const reader = new Values(buffer.subarray(offset, offset + length));
const className = reader.short();
reader.short();
const changesForClass = remaining.get(className);
if (!changesForClass) {
offset += length;
continue;
}
const edits = [];
while (reader.buffer[reader.position] !== 0) {
const name = reader.short(),
start = reader.position;
reader.value();
if (Object.hasOwn(changesForClass, name)) {
edits.push([start, reader.position, encode(changesForClass[name])]);
delete changesForClass[name];
}
}
const end = reader.position;
for (const [name, value] of Object.entries(changesForClass)) {
const key = Buffer.from(name, "ascii");
edits.push([end, end, Buffer.concat([Buffer.from([key.length]), key, encode(value)])]);
}
const parts = [];
let position = 0;
for (const [start, stop, data] of edits) {
parts.push(reader.buffer.subarray(position, start), data);
position = stop;
}
parts.push(reader.buffer.subarray(position));
const object = Buffer.concat(parts),
size = Buffer.alloc(4);
size.writeUInt32LE(object.length);
pieces.push(buffer.subarray(cursor, offset - 4), size, object);
cursor = offset + length;
offset = cursor;
remaining.delete(className);
}
if (remaining.size) throw Error(`ESTX 옵션 객체 없음: ${[...remaining.keys()].join(", ")}`);
pieces.push(buffer.subarray(cursor));
return Buffer.concat(pieces);
}
module.exports = { updateOptions };
+92
View File
@@ -0,0 +1,92 @@
const zlib = require("node:zlib");
const quantity = require("./native-quantity.cjs");
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
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("");
}
// Change text inside an existing styled run, never WPTools tags or bookmarks.
function editDocument(text, oldSheet, sheet) {
if (
!same({ ...oldSheet, rows: [] }, { ...sheet, rows: [] }) ||
oldSheet.rows.length !== sheet.rows.length
)
throw Error("수량산출서 목록·연결·행 구조 변경은 ESTX 저장을 지원하지 않습니다.");
const declarations = [...text.matchAll(/<(?:cs|begin)\b[^>]*>/gi)].map((m) => m[0]).join("");
const parse = (div) => quantity.fromWpt("", "", declarations + div, [], {}).rows;
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("수량산출서 표지 변경은 저장할 수 없습니다.");
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;
}
}
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 updateQuantities(project, base, cp949) {
const sheets = project.quantitySheets || [],
originals = base.quantitySheets || [];
if (sheets.length !== originals.length) throw Error("수량산출서 추가·삭제는 지원하지 않습니다.");
const rows = project.tables.MulMainDat.map((r) => ({ ...r }));
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 (!row?.Editor?.binary || !same(row.Editor, oldRow.Editor))
throw Error("수량산출서 원본 편집기 데이터 충돌");
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 output = cp949(editDocument(text, originals[i], sheets[i]));
row.Editor = { binary: (compressed ? zlib.deflateSync(output) : output).toString("base64") };
}
return { ...project, tables: { ...project.tables, MulMainDat: rows } };
}
module.exports = { editDocument, updateQuantities };
+2 -2
View File
@@ -128,7 +128,7 @@ const EstQuantity = (() => {
{ esc, number, byId } = EstApp;
byId("work-content").innerHTML =
selector(state) +
`<div class="business-tools"><b>${esc(sheet.Name)}</b><button data-quantity-apply>내역 수량 반영</button></div><table class="sheet"><thead><tr><th>산출코드</th><th>품명</th><th>규격</th><th>원본 저장 수량</th><th>재계산 수량</th><th>연결 내역코드</th></tr></thead><tbody>${output.items.map((item, index) => `<tr><td>${esc(item.Code)}</td><td>${esc(item.Name)}</td><td>${esc(item.Size)}</td><td>${number(sheet.items[index].GN)}</td><td>${number(item.GN)}</td><td><input data-quantity-target="${index}" value="${esc(item.TargetCode || item.LinkCode || "")}" aria-label="연결 내역코드"></td></tr>`).join("")}</tbody></table><p class="business-note">${esc(output.errors.join(" / "))} 원본 결과 표지의 이름을 유지해 수량을 연결합니다. 원본 ESTX의 편집기·도면 바이너리는 별도로 보존합니다.</p><table class="sheet"><thead><tr><th>줄</th><th>산출근거 편집</th></tr></thead><tbody>${sheet.rows.map((row, index) => `<tr><td>${index + 1}</td><td><textarea data-quantity-line="${index}" rows="1" style="min-width:750px" aria-label="산출근거 ${index + 1}">${esc(EstText.display(output.rows[index].LineData))}</textarea></td></tr>`).join("")}</tbody></table>`;
`<div class="business-tools"><b>${esc(sheet.Name)}</b><button data-quantity-apply>내역 수량 반영</button></div><table class="sheet"><thead><tr><th>산출코드</th><th>품명</th><th>규격</th><th>원본 저장 수량</th><th>재계산 수량</th><th>연결 내역코드</th></tr></thead><tbody>${output.items.map((item, index) => `<tr><td>${esc(item.Code)}</td><td>${esc(item.Name)}</td><td>${esc(item.Size)}</td><td>${number(sheet.items[index].GN)}</td><td>${number(item.GN)}</td><td><input data-quantity-target="${index}" value="${esc(item.TargetCode || item.LinkCode || "")}" aria-label="연결 내역코드"></td></tr>`).join("")}</tbody></table><p class="business-note">${esc(output.errors.join(" / "))} 원본 결과 표지의 이름을 유지해 수량을 연결합니다. ESTX 저장 시 편집 문서의 계산 결과와 원본 수량 목록은 자동 갱신되지 않습니다.</p><table class="sheet"><thead><tr><th>줄</th><th>산출근거 편집</th></tr></thead><tbody>${sheet.rows.map((row, index) => `<tr><td>${index + 1}</td><td><textarea data-quantity-line="${index}" rows="1" style="min-width:750px" aria-label="산출근거 ${index + 1}">${esc(EstText.display(output.rows[index].LineData))}</textarea></td></tr>`).join("")}</tbody></table>`;
}
function install() {
document.addEventListener("change", (event) => {
@@ -179,6 +179,6 @@ const EstQuantity = (() => {
}
});
}
return { fromWpt, calculate, selector, render, install };
return { entities, fromWpt, calculate, selector, render, install };
})();
if (typeof module !== "undefined" && module.exports) module.exports = EstQuantity;