fix(est): preserve links and formula precision

This commit is contained in:
2026-11-05 12:51:49 +09:00
parent d922ad13f3
commit 516d2ae329
10 changed files with 215 additions and 19 deletions
File diff suppressed because one or more lines are too long
+9 -1
View File
@@ -40,7 +40,15 @@ else if (command === "text")
else if (command === "wonga")
result = vm.runInContext("EstWonga.calculate(project.tables.Wonga, project.options)", context);
else if (command === "all") result = vm.runInContext("EstNative.calculate(project)", context);
else if (command === "manual") {
else if (command === "text-layout") {
context.cases = JSON.parse(
fs.readFileSync(path.join(__dirname, "fixtures/est_text_layout_cases.json"), "utf8"),
);
result = vm.runInContext(
"cases.map(item=>({code:item.code,...EstText.calculate(item.rows)}))",
context,
);
} else if (command === "manual") {
project.options.AutoCalc = false;
project.tables.EstMainDat[1].GN += 100;
result = require(path.join(base, "server.cjs")).calculate(project);
+108
View File
@@ -281,6 +281,8 @@ def test_estx_import_reads_original_tables_options_and_templates(tmp_path):
assert len(result["wongaTemplates"]) == 4
assert result["options"]["TotalFix"] is True
assert result["options"]["GCToWonga"] is False
assert result["options"]["CostValueTrunc"] is False
assert result["options"]["MidCostDiget"] == 2
assert result["originalArchive"]
@@ -428,3 +430,109 @@ def test_text_material_only_subtotal_does_not_reuse_prior_labor():
assert result["errors"] == []
assert result["rows"][3]["TC"] == 120
assert result["rows"][6]["TC"] == 30
def test_original_sea_forest_and_temporary_works_text_layouts():
results = run("text-layout")
assert len(results) == 3
checked = 0
for result in results:
assert result["errors"] == [], result["code"]
for check in result["checks"]:
assert abs(check["stored"] - check["calculated"]) < 1e-6, (result["code"], check)
checked += 1
assert checked > 100
def test_text_preserves_braced_signs_and_comments_inside_parentheses():
result = run(
"math",
r"""EstText.calculate([
{Index:1,Code:'T'},
{LineData:' hm={-}6.76m {?} H={-}7.5m {계획수심}'},
{LineData:' d = hm-H = \x120.74\x12'},
{LineData:' L = 4인÷(4.8개{?}×8hr) = \x120.1\x12'},
{LineData:' q = 1 × 2조/회 = \x122\x12 t=15분 〔접이안시간〕'},
{LineData:' 전력비 = 0.74hr × 52.1 = \x1238.5\x12 원/본',SCheck:-3}
])""",
)
assert result["errors"] == []
assert result["rows"][2]["TC"] == 0.74
assert result["rows"][3]["TC"] == 0.1
assert result["rows"][4]["TC"] == 2
assert result["rows"][5]["TC"] == 38.5
def test_source_code_edit_updates_links_and_preserves_original_contract():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();
p.tables.StuffcDat=[{PkID:1,Code:'M',Name:'material',ValueIndex:4,InputTC:100},{PkID:2,Code:'OTHER',ValueIndex:4,InputTC:10}];
p.tables.HeadMainDat=[{PkID:1,Index:1,Code:'H',Name:'head'},{PkID:2,Index:0,Code:'M',TitleCode:'H',LinkIndex:5,GN:2}];
p.tables.HeadTitleDat=[{Code:'H',Name:'head'}];
p.tables.EstMainDat=[{PkID:1,Code:'H',LinkIndex:103,GN:10},{LinkIndex:54}];
let q=EstBusiness.baseline(EstNative.calculate(p));
const before=JSON.stringify(q.baseline);
EstModel.editCode(q,'StuffcDat',0,'M2');
EstModel.editCode(q,'HeadMainDat',0,'H2');
q.tables.StuffcDat[0].InputTC=120;
q=EstNative.calculate(q);
const original=EstBusiness.original(q,2);
let duplicate=false;try{EstModel.editCode(q,'StuffcDat',0,'OTHER')}catch{duplicate=true;}
return {q,original,preserved:before===JSON.stringify(q.baseline),duplicate};
})()""",
)
project = result["q"]
assert project["calculation"]["errors"] == []
assert project["calculation"]["direct"]["TC"] == 2400
assert project["tables"]["HeadMainDat"][1]["Code"] == "M2"
assert project["tables"]["HeadMainDat"][1]["TitleCode"] == "H2"
assert project["tables"]["EstMainDat"][0]["Code"] == "H2"
assert project["tables"]["HeadTitleDat"][0]["Code"] == "H2"
assert result["original"]["tables"]["EstMainDat"][-1]["TC"] == 2400
assert result["preserved"] and result["duplicate"]
def test_text_fraction_columns_preserve_numbered_variables_and_unmarked_result():
result = run(
"math",
r"""EstText.calculate([
{Index:1,Code:'T'},
{LineData:' L11=0.35 L21=0.35 V51=15 V52=15 N=2'},
{LineData:' L11 L21'},
{LineData:' t23=(──+──)×60 = \x122.8\x12'},
{LineData:' V51 V52'},
{LineData:' t23 + t23'},
{LineData:' Tc = ────────────'},
{LineData:' 1 · N'},
{LineData:' Q=Tc×2 = \x125.6\x12'},
{LineData:' to1 = 0.6 min/m2 to2 = 3 min/m2'},
{LineData:' T=2×30×3.5×to2/60=\x1210.5\x12'}
])""",
)
assert result["errors"] == []
assert result["rows"][3]["TC"] == 2.8
assert result["rows"][8]["TC"] == 5.6
assert result["rows"][10]["TC"] == 10.5
def test_original_cost_truncation_and_intermediate_precision_options():
result = run(
"math",
r"""(()=>{
const rows=[{Index:1,Code:'T'},
{LineData:' 노무비: \x03100.19\x03 = \x12100.1\x12',SCheck:-1}];
const intermediate=[{Index:1,Code:'I'},
{LineData:' 노무비: \x0359020\x03 / 81.8 = \x12721.51\x12 ×0.5=\x12360.7\x12',SCheck:-1}];
return {normal:EstText.calculate(rows),whole:EstText.calculate(rows,{CostValueTrunc:true}),
mid1:EstText.calculate(intermediate,{MidCostDiget:1}),mid2:EstText.calculate(intermediate,{MidCostDiget:2}),
override:EstText.calculate([rows[0],{...rows[1],Precision:2}],{CostValueTrunc:true})};
})()""",
)
assert all(item["errors"] == [] for item in result.values())
assert result["normal"]["rows"][1]["TC"] == 100.1
assert result["whole"]["rows"][1]["TC"] == 100
assert result["override"]["rows"][1]["TC"] == 100.19
assert result["mid1"]["checks"][-1]["calculated"] == 721.5
assert result["mid2"]["checks"][-1]["calculated"] == 721.51
+2
View File
@@ -18,6 +18,8 @@
실무 산불 내역 3건은 DBISAM의 재사용 BLOB 할당 공간을 논리 길이까지만 읽도록 수정한 뒤 저장 금액 8,312개 모두 일치했습니다. 다만 세 파일에 공통으로 닫히지 않은 괄호가 있는 원본 수식 한 건이 남아 해석 오류로 표시됩니다. 저장 금액 일치와 수식 오류 0을 구분합니다. 직접재료비 구획·관급 조달수수료·산업안전보건관리비 제외 옵션, 낙찰 단가를 유지한 동원 집계도 시험합니다. 자동계산을 끄면 열기·편집·저장 시 기존 금액을 보존합니다.
계산 환경에서 원본 `CostValueTrunc`(성분 금액 원 미만 절사)와 `MidCostDiget`(중간금액 소수자리)을 제어합니다. 원천·호표 코드를 바꾸면 작업본 연결도 함께 바뀌며, 당초 계약 자료는 보존됩니다. 해양·산림·가시설의 분수 배치와 구획 소계 원본 발췌를 회귀 시험에 포함합니다. 전체 해양 자료는 133,710개 저장값 중 133,635개 일치, 수식 해석 오류 99건이 남아 있습니다. 저장 결과 대조만으로 원본 실행 동등성을 판정하지 않습니다.
**전체 원본 동등성은 아직 확보하지 못했습니다.** 기성·물가변동·연차 등은 분석 분기와 합성 시나리오를 시험했으며 원본 업무 샘플 대조는 없습니다. 원본 화면 실행 비교는 못했습니다. 원본 페이지·도면 배치, CAD/VEC0650 편집, 원본 인쇄 서식, Excel/XML/JDL 변환, ESTX 수정 저장은 미구현입니다. 원본 대체품의 완료 판정으로 보지 않습니다.
검사:
+2
View File
@@ -330,6 +330,8 @@ function importEstx(buffer, name = "가져온 내역서") {
VerFormat: props.TWongaOption?.VerFormat ?? true,
TotalFix: props.TWongaOption?.TotalFix ?? tables.Wonga.some((r) => r.LinkIndex === 55),
AutoCalc: props.TEstOption?.CalcAuto ?? true,
CostValueTrunc: props.TCostOption?.CostValueTrunc ?? false,
MidCostDiget: props.TCostOption?.MidCostDiget ?? 2,
HeadDigits: props.THeadOption?.SumValueDiget || 0,
TotalFit: props.THeadOption?.CalcTrunc || false,
DollarCostCut: props.TMachOption?.DollarCostCut || false,
+6 -1
View File
@@ -162,6 +162,7 @@ const EstActions = (() => {
copies.forEach((r) => {
r.PkID = ++next;
r.NewItem = true;
delete r.PreviousCodes;
if (["StuffcDat", "LaborcDat", "SilJukDat", "EstiMateDat"].includes(state.view)) {
const base = r.Code || "U";
let suffix = next;
@@ -528,7 +529,11 @@ const EstActions = (() => {
if (key === "Precision" && (value < 0 || value > 6 || !Number.isInteger(value)))
throw Error("소수 자릿수는 0~6 정수입니다.");
EstApp.remember();
row[key] = value;
if (key === "Code") {
const previous = row.Code;
EstModel.editCode(project, node.dataset.table, Number(node.dataset.row), value);
if (state.group === previous && Number(row.Index) > 0) state.group = row.Code;
} else row[key] = value;
if (["StuffcDat", "LaborcDat", "EstiMateDat", "SilJukDat"].includes(node.dataset.table)) {
if (key === "TC") {
row.InputTC = value;
+3
View File
@@ -137,6 +137,9 @@ const EstNative = (() => {
if (!names.has(key)) names.set(key, []);
names.get(key).push(row);
}
for (const row of tables[table] || [])
for (const previous of row.PreviousCodes || [])
if (!codes.has(previous)) codes.set(previous, row);
sourceIndexes.set(num(kind), { codes, names });
}
function find(row, kind) {
+43 -1
View File
@@ -374,6 +374,48 @@ const EstModel = (() => {
});
return validate(project);
}
function editCode(project, table, index, value) {
const rows = project.tables[table],
row = rows[index];
const grouped = ["HeadMainDat", "CostMainDat", "MachMainDat"].includes(table);
const kind = Number(
Object.entries(EstNative.sourceTables).find(([, name]) => name === table)?.[0] ||
{ HeadMainDat: 103, CostMainDat: 104, MachMainDat: 105 }[table],
);
if (!kind || (grouped && !(Number(row.Index) > 0))) {
row.Code = value;
return;
}
const code = String(value || "").trim(),
old = row.Code;
if (
!code ||
rows.some(
(other, at) =>
at !== index &&
(!grouped || Number(other.Index) > 0) &&
(other.Code === code || other.PreviousCodes?.includes(code)),
)
)
throw Error("비어 있거나 중복된 원천 코드입니다.");
if (old === code) return;
row.Code = code;
row.PreviousCodes = [...new Set([...(row.PreviousCodes || []), old].filter(Boolean))];
for (const list of Object.values(project.tables))
for (const linked of list)
if (old && Number(linked.LinkIndex) === kind && linked.Code === old) linked.Code = code;
if (grouped) {
const titles = project.tables[EstNative.sourceTables[kind]] || [];
const title = titles.find((item) => item.Code === old);
if (title) Object.assign(title, { Code: code, PreviousCodes: [...row.PreviousCodes] });
for (
let at = index;
at < rows.length && (at === index || !(Number(rows[at].Index) > 0));
at++
)
if (rows[at].TitleCode === old) rows[at].TitleCode = code;
}
}
function newRow(table, rows) {
const max = Math.max(0, ...rows.map((r) => Number(r.PkID) || 0));
const row = {
@@ -397,5 +439,5 @@ const EstModel = (() => {
if (table === "CostMainDat") Object.assign(row, { LineData: "Q = 1 + 1 = \x122\x12" });
return row;
}
return { labels, numeric, columns, validate, legacy, newRow };
return { labels, numeric, columns, validate, legacy, newRow, editCode };
})();
+22 -1
View File
@@ -16,6 +16,21 @@ const EstSettings = (() => {
type: "number",
value: project.options.EstDigits || 0,
},
{
name: "costTrunc",
value: project.options.CostValueTrunc ? "1" : "0",
label: "단가산출 성분 금액 절사",
choices: [
["0", "소수 1자리"],
["1", "원 미만"],
],
},
{
name: "midCost",
label: "단가산출 중간금액 소수자리",
type: "number",
value: project.options.MidCostDiget ?? 2,
},
{
name: "total",
value: project.options.TotalFix ? "1" : "0",
@@ -72,12 +87,18 @@ const EstSettings = (() => {
},
],
(values) => {
if (![values.head, values.estimate].every((n) => Number.isInteger(n) && n >= 0 && n <= 6))
if (
![values.head, values.estimate, values.midCost].every(
(n) => Number.isInteger(n) && n >= 0 && n <= 6,
)
)
throw Error("소수 자릿수는 0~6 정수입니다.");
EstApp.change(() =>
Object.assign(project.options, {
HeadDigits: values.head,
EstDigits: values.estimate,
CostValueTrunc: values.costTrunc === "1",
MidCostDiget: values.midCost,
TotalFix: values.total === "1",
TotalFit: values.fit === "1",
DollarCostCut: values.dollar === "1",
+19 -15
View File
@@ -17,15 +17,16 @@ const EstText = (() => {
function expression(text) {
return clean(
String(text).replace(
/\x0e[^\x0e]*\x0e|\x13[^\x13]*\x13|\x15[^\x15]*\x15|\{[^}]*\}|\([가-힣][^()]*\)|\(\s*\d+(?:\.\d+)?\s*[˚°]\s*\)|<[^>]*>/g,
/\x0e[^\x0e]*\x0e|\x13[^\x13]*\x13|\x15[^\x15]*\x15|\{[^}]*\}|〔[^〕]*〕|[[^]]*]|\([가-힣][^()]*\)|\(\s*\d+(?:\.\d+)?\s*[˚°]\s*\)|<[^>]*>/g,
"",
),
)
.replace(
/(\d)\s*(?:공\/?㎥|공|단|면|겹|경간|개월|월|년|인|㎥|㎢|㎡|hr|sec|min|kwh|kw|km|ton|kg|㎏|㎞|ℓ|회|개소|개|대|분|초|본|장|매|병|조|식|일|시간|원|포|동|주|톤|μ|₩|EA|mm|cm|m3|m2|m|g|L|a)(?:\/(?:개소|경간|개|대|본|인|분|포|매|주|톤|\$|hr|일|㎥|㎡|m|a|EA))?/gi,
/(?<![A-Za-z0-9_가-힣.])(\d[\d,.]*|\.\d+)\s*(?:공\/?㎥|공|단|면|겹|경간|개월|월|년|인|㎥|㎢|㎠|㎡|hr|sec|min|kwh|kw|km|ton|kg|㎏|㎞|ℓ|회|개소|개|대|분|초|본|장|매|병|조|식|일|시간|원|포|동|주|톤|μ|₩|EA|mm|cm|m3|m2|m|g|L|a)(?:\/(?:개소|경간|개|대|본|인|분|포|매|주|톤|공|회|조|span|ton|\$|hr|일|㎥|㎡|m3|m2|m|a|EA))?/gi,
"$1",
)
.replace(/(?<=\d)\s*\/(?:일|㎥|㎡)(?=\s|$)/g, "")
.replace(/(?<=\d)\s*\/\s*(?:일|㎥|㎡|hr)(?=\s|$|[×*+)])/gi, "")
.replace(/[】]〕]\s*$/, "")
.replace(/(?<=\))\s*(?:개|인|본|면|단|회)(?:\/(?:㎡|㎥|hr))?/g, "")
.replace(/^\s*[-*•]?\s*[가-힣][가-힣\s]*\s+(?=[\d.(])/, "")
.replace(/[①-⑳]/g, "")
@@ -99,7 +100,7 @@ const EstText = (() => {
errors.push(`${head.Code}:${index + 1} ${error.message}`);
}
}
let text = original;
let text = original.replace(/\{([+-])\}/g, "$1");
if (currentSource && sc < 0 && component[sc])
text = text.replace(
/\x03([-\d.,]+)\x03/g,
@@ -134,14 +135,14 @@ const EstText = (() => {
const assignedNames = new Map();
let componentDeclaration;
try {
if (matched && /─/.test(text)) {
if ((matched || /^\s*[A-Za-z][A-Za-z0-9]*\s*=/.test(text)) && /─/.test(text)) {
const above = clean(rows[index - 1]?.LineData),
below = clean(rows[index + 1]?.LineData);
const columns = (value) =>
value
.replace(/\s*([×÷+*/−-])\s*/g, "$1")
.replace(/\s*([×÷·+*/−-])\s*/g, "$1")
.trim()
.split(/\s{2,}/);
.split(/\s+/);
const tops = columns(above),
bottoms = columns(below);
let part = 0;
@@ -187,7 +188,7 @@ const EstText = (() => {
let body = stripLabels(text.slice(0, text.indexOf("\x12"))).replace(/=\s*$/, "");
if (body.includes(":")) body = body.slice(body.indexOf(":") + 1);
const parts = body.split("=");
if (parts.length > 1 && /^[A-Za-z][A-Za-z0-9]*$/.test(parts[0].trim())) {
if (parts.length > 1 && /^[A-Za-z가-힣][A-Za-z0-9가-힣]*$/.test(parts[0].trim())) {
componentDeclaration = parts[0].trim();
body = parts[1];
} else body = parts[0];
@@ -210,7 +211,9 @@ const EstText = (() => {
end = assignments[j + 1]?.index ?? text.length;
let body = text.slice(assignment.index + assignment[0].length, end);
if (!matched && /^\s*\*.*\*\s*$/.test(text)) body = body.replace(/\s+\*\s*$/, "");
body = body.split(/\{(?!%)|\s+[::]\s*/)[0].replace(/[},,]\s*$/, "");
body = body.replace(/\{[^}]*\}/g, "");
if (!matched) body = body.split("{")[0];
body = body.split(/\s+[::]\s*/)[0].replace(/[,,]\s*$/, "");
if (body.includes("─") && calculated !== null) {
variables[assignment[1]] = floating(calculated, 2, 1);
continue;
@@ -265,7 +268,7 @@ const EstText = (() => {
.pop();
if (body.includes(":")) body = body.slice(body.indexOf(":") + 1);
body = expression(body)
.replace(/\s*([×÷+*/−-])\s*/g, "$1")
.replace(/\s*([×÷·+*/−-])\s*/g, "$1")
.trim()
.split(/\s{2,}/)
.pop();
@@ -278,7 +281,8 @@ const EstText = (() => {
explicit?.digits ??
(sc < 0 || num(row.LinkIndex) !== 0
? (num(row.Level) === finalLevel && text.includes("\x1a")) ||
index === singleComponent
index === singleComponent ||
options.CostValueTrunc
? 0
: 1
: Math.max(2, matched[1].split(".")[1]?.length || 0));
@@ -289,8 +293,8 @@ const EstText = (() => {
results.length > 1 && sc < 0 && !explicit && row.Precision === undefined;
calculated = floating(
calculated,
intermediate ? Math.max(2, matched[1].split(".")[1]?.length || 0) : digits,
intermediate ? 1 : method,
intermediate ? (options.MidCostDiget ?? 2) : digits,
intermediate ? 2 : method,
);
const firstValue = calculated;
let rendered = text.replace(marker, `\x12${calculated}\x12`);
@@ -311,9 +315,9 @@ const EstText = (() => {
),
format?.digits ??
(intermediate && part < results.length - 1
? Math.max(2, next[1].split(".")[1]?.length || 0)
? (options.MidCostDiget ?? 2)
: digits),
format?.method ?? (intermediate && part < results.length - 1 ? 1 : method),
format?.method ?? (intermediate && part < results.length - 1 ? 2 : method),
);
assignmentName = assignedNames.get(next.index) || assignmentName;
if (assignmentName) variables[assignmentName] = calculated;