diff --git a/resources/tester/helper_est_archive.cjs b/resources/tester/helper_est_archive.cjs
index d4076ca83..9400e156b 100644
--- a/resources/tester/helper_est_archive.cjs
+++ b/resources/tester/helper_est_archive.cjs
@@ -12,6 +12,7 @@ for (const table of [
"HeadTitleDat",
"MachTitleDat",
"CostTitleDat",
+ "Wonga",
]) {
let checked = 0,
matched = 0,
@@ -41,7 +42,7 @@ for (const table of [
});
}
} else
- for (const key of ["NC", "GC", "JC", "SC", "TC"]) {
+ for (const key of table === "Wonga" ? ["Cost"] : ["NC", "GC", "JC", "SC", "TC"]) {
if (a[key] === null || a[key] === undefined) continue;
checked++;
if (b && Math.abs(Number(a[key]) - Number(b[key] || 0)) < 1e-6) matched++;
diff --git a/resources/tester/helper_est_native.cjs b/resources/tester/helper_est_native.cjs
index efa963256..22ceb96bd 100644
--- a/resources/tester/helper_est_native.cjs
+++ b/resources/tester/helper_est_native.cjs
@@ -40,7 +40,11 @@ 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 === "refresh")
+else if (command === "manual") {
+ project.options.AutoCalc = false;
+ project.tables.EstMainDat[1].GN += 100;
+ result = require(path.join(base, "server.cjs")).calculate(project);
+} else if (command === "refresh")
result = vm.runInContext(
"project.options.SourceRefresh=true; EstNative.calculate(project)",
context,
@@ -52,7 +56,28 @@ else if (command === "repeat")
);
else if (command === "import")
result = require(path.join(base, "import-estx.cjs")).importEstx(fs.readFileSync(process.argv[3]));
-else if (command === "extended") {
+else if (command === "blob-tail") {
+ const { blob } = require(path.join(base, "import-estx.cjs")),
+ bytes = Buffer.alloc(1536);
+ bytes.writeUInt32LE(2, 516);
+ bytes.writeUInt16LE(3, 520);
+ bytes.writeUInt32LE(7, 522);
+ bytes.writeUInt32LE(3, 526);
+ bytes.write("abc", 530);
+ bytes.writeUInt32LE(1, 1024);
+ bytes.writeUInt16LE(3, 1032);
+ bytes.write("xyz", 1042);
+ const value = blob(bytes, 1, 7).toString();
+ bytes.writeUInt32LE(4, 526);
+ bytes.writeUInt32LE(0, 516);
+ let truncated = "";
+ try {
+ blob(bytes, 1, 7);
+ } catch (error) {
+ truncated = error.message;
+ }
+ result = { value, truncated };
+} else if (command === "extended") {
context.extended = JSON.parse(
fs.readFileSync(path.join(__dirname, "fixtures/est_native_extended.json"), "utf8"),
);
@@ -96,7 +121,15 @@ else if (command === "extended") {
changed.tables.EstMainDat.splice(1,0,{PkID:3,Code:'M',Name:'new',GN:1,LinkIndex:5,NewItem:true});
const originals=[0,1,2].map(method=>EstBusiness.original(changed,method));
const bid=EstBusiness.bid(q,[90]);const bidAgain=EstBusiness.bid(bid,[90]);
- ({first,second,years,resources,escalation,originals,bid,bidAgain,project:q});
+ const bidResources=EstResources.calculate(bid);
+ const converted=structuredClone(q);converted.tables.HeadMainDat.push({Index:0,GN:0.5,LinkIndex:68,Size:'unit conversion'});
+ const convertedResources=EstResources.calculate(converted);
+ const equipment=EstNative.empty();
+ equipment.tables.MachMainDat=[{Index:1,Code:'E',Name:'equipment',Unite:'hr'},{Index:-2,Name:'주연료',GN:1,TU:100}];
+ equipment.tables.HeadMainDat=[{Index:1,Code:'H',Name:'head'},{Index:0,LinkIndex:105,Code:'E',GN:2}];
+ equipment.tables.HeadTitleDat=[{Code:'H'}];equipment.tables.EstMainDat=[{PkID:1,LinkIndex:103,Code:'H',GN:10},{LinkIndex:54}];
+ const equipmentResources=EstResources.calculate(equipment);
+ ({first,second,years,resources,escalation,originals,bid,bidAgain,bidResources,convertedResources,equipmentResources,project:q});
`,
context,
);
diff --git a/resources/tester/test_est_rea_parity.py b/resources/tester/test_est_rea_parity.py
index 073edd8cc..9f5bb6205 100644
--- a/resources/tester/test_est_rea_parity.py
+++ b/resources/tester/test_est_rea_parity.py
@@ -53,6 +53,91 @@ def test_numeric_helpers_distinguish_rounding_modes():
) == [2, 4, -1.3, 1.24]
+def test_large_currency_boundary_and_unit_conversion_caption():
+ assert run("math", "EstMath.floating(172068*0.1*756,1,2)") == 13008340.8
+ result = run(
+ "math",
+ """EstRows.head([
+ {Index:1,Code:'H'},
+ {Index:0,GN:1,NU:14341},
+ {Index:0,GN:0.1,LinkIndex:68,Size:'소계 / 10단자'}
+ ],{HeadDigits:0})""",
+ )
+ assert result["rows"][0]["NC"] == 1434
+ assert result["rows"][2]["NU"] == 14341
+ assert result["rows"][2]["NC"] == 1434
+
+
+def test_procurement_zero_place_keeps_integer_total():
+ row = run(
+ "math",
+ "EstRows.procurement({Index:3,LinkIndex:57,GN:0.54,Gangub:0,OCheck:1,SCheck:3},[{LinkIndex:5,GC:30029967,TC:30029967}])",
+ )
+ assert row["JC"] == row["TC"] == 162161
+
+
+def test_blob_reused_capacity_and_incomplete_payload_are_distinct():
+ result = run("blob-tail")
+ assert result["value"] == "abc"
+ assert "BLOB 총 길이 오류" in result["truncated"]
+
+
+def test_procurement_exclusion_only_changes_industrial_safety_base():
+ result = run(
+ "math",
+ """EstWonga.calculate([
+ {Gubun:'J',Name:'관급자재대',Cost:1000,NC:100,VSusic:'원금액:1,000, {조달수수료:100}'},
+ {Gubun:'S',Name:'산업안전보건관리비',VSusic:'J*0.1'},
+ {Gubun:'P',Name:'other',VSusic:'J*0.1'}
+ ],{GangubExceptJodalRatio:true})""",
+ )
+ assert result["errors"] == []
+ assert [row["Cost"] for row in result["rows"]] == [1000, 90, 100]
+
+
+def test_resource_factor_includes_trailing_multiplier():
+ row = run(
+ "math",
+ r"""EstText.calculate([
+ {Index:1,Code:'C',LineData:''},
+ {Index:0,LinkIndex:5,SCheck:-2,LineData:'\x0eM\x0e : \x03100\x03 * 2 = \x12200\x12 * 0.5 = \x12100\x12'}
+ ],{},()=>({Code:'M',Name:'M',SourceKind:5,GC:100,TC:100})).rows[1]""",
+ )
+ assert row["ResourceFactor"] == 1
+ assert row["TC"] == 100
+
+
+def test_direct_material_scope_is_the_marked_section():
+ result = run(
+ "math",
+ """(() => {
+ const rows=[{Level:2,Name:'순직접공사비'},{Level:0,LinkIndex:5,GC:100},
+ {Level:2,Name:'사급자재대*'},{Level:0,LinkIndex:5,GC:200},{LinkIndex:54}];
+ return [EstNative.materialBase(rows,{GCToWonga:false}),EstNative.materialBase(rows,{GCToWonga:true})];
+ })()""",
+ )
+ assert result == [200, 300]
+
+
+def test_practical_fire_projects_keep_stored_values_and_report_bad_source_formula():
+ files = list((ROOT / "resources/knowledge/original/실무문서").rglob("*.estx"))
+ assert len(files) >= 3
+ for file in files:
+ output = subprocess.check_output(
+ ["node", str(HELPER.with_name("helper_est_archive.cjs")), str(file)],
+ text=True,
+ encoding="utf-8",
+ timeout=120,
+ )
+ result = json.loads(output)
+ assert result["differences"] == [], file.name
+ # The original text has an unclosed parenthesis in this transport
+ # calculation; preserve its stored price and show the source error.
+ assert all(
+ error.startswith("D0000000194:") and "괄호" in error for error in result["errors"]
+ ), (file.name, result["errors"])
+
+
def test_head_41_stored_checks():
result = run("head")
assert result["errors"] == []
@@ -169,6 +254,15 @@ def test_progress_accepted_rounds_annual_allocation_and_resources():
] == [2120, 2520, 2520]
assert result["bid"]["calculation"]["direct"]["TC"] == 1800
assert result["bidAgain"]["tables"]["EstMainDat"] == result["bid"]["tables"]["EstMainDat"]
+ assert result["bidResources"]["errors"] == []
+ assert result["bidResources"]["rows"][0]["quantity"] == 18
+ assert result["bidResources"]["rows"][0]["amount"] == 1800
+ assert result["convertedResources"]["errors"] == []
+ assert result["convertedResources"]["rows"][0]["quantity"] == 10
+ assert result["convertedResources"]["rows"][0]["amount"] == 1000
+ assert result["equipmentResources"]["errors"] == []
+ assert result["equipmentResources"]["rows"][0]["quantity"] == 20
+ assert result["equipmentResources"]["rows"][0]["amount"] == 2000
def test_estx_import_reads_original_tables_options_and_templates(tmp_path):
@@ -265,3 +359,72 @@ def test_original_quantity_sheets_and_three_saved_formula_disagreements():
assert abs((before.get("GN") or 0) - (after.get("GN") or 0)) < 1e-6, key
assert checked == 449
assert differences == set(expected)
+
+
+def test_legacy_project_preserves_running_cost_basis_and_final_total():
+ result = run(
+ "math",
+ """EstNative.calculate(EstModel.legacy({
+ sources:[{id:'s',kind:'재료비',price:10.129}],
+ units:[{id:'u',lines:[{sourceId:'s',quantity:2,digits:2}]}],
+ estimate:[{unitId:'u',quantity:3}],
+ costs:[{base:'직접공사비',rate:10},{base:'누계',rate:5}]
+ })).calculation""",
+ )
+ assert result["errors"] == []
+ assert result["direct"]["TC"] == 60
+ assert result["total"] == 69
+
+
+def test_manual_calculation_server_preserves_stored_amounts():
+ result = run("manual")
+ assert result["tables"]["EstMainDat"][1]["GN"] == SAMPLE["tables"]["EstMainDat"][1]["GN"] + 100
+ for table in ["EstMainDat", "HeadMainDat", "Wonga"]:
+ for original, saved in zip(SAMPLE["tables"][table], result["tables"][table]):
+ for key in ["TC", "NC", "GC", "JC", "SC", "Cost"]:
+ assert saved.get(key) == original.get(key)
+
+
+def test_text_fraction_spacing_ascii_multiplication_and_tree_units():
+ result = run(
+ "math",
+ r"""EstText.calculate([
+ {Index:1,Code:'T'},
+ {LineData:' n = 6.55 Cms = 37.4 A = 1 N = 1'},
+ {LineData:' L = 50m 25kg/1회'},
+ {LineData:' ○ 거리 Z=17m, 7m마다 1인으로 하면'},
+ {LineData:' n × Cms 6.55 × 37.4'},
+ {LineData:' t1 = ────── = ─────── = \x1211.67\x12'},
+ {LineData:' 60×0.35 60×0.35'},
+ {LineData:' D=0.2xAxN+2 = \x122.2\x12'},
+ {LineData:' 길이 = L + Z = \x1267\x12'},
+ {LineData:' * 윤 주 : 0.94m (0.65×0.65×3.14÷4)÷2 = \x120.17\x12㎥'},
+ {LineData:' 노무비: \x03235204\x03 × 3인 / 125주 = \x125644.8\x12',SCheck:-1}
+ ])""",
+ )
+ assert result["errors"] == []
+ assert [row["TC"] for row in result["rows"] if "TC" in row][1:] == [
+ 11.67,
+ 2.2,
+ 67,
+ 0.17,
+ 5644.8,
+ ]
+
+
+def test_text_material_only_subtotal_does_not_reuse_prior_labor():
+ result = run(
+ "math",
+ r"""EstText.calculate([
+ {Index:1,Code:'T'},
+ {LineData:' \x1a노무비: 100\x1a',SCheck:-1,Level:-1},
+ {LineData:' \x1a재료비: 20\x1a',SCheck:-2,Level:-1},
+ {LineData:' \x1a계:\x1a \x12120\x12',SCheck:-5,Level:-1},
+ {LineData:' 다음 재료 구획',Level:1},
+ {LineData:' \x1a재료비: 30\x1a',SCheck:-2,Level:-1},
+ {LineData:' \x1a계:\x1a \x1230\x12',SCheck:-5,Level:-1}
+ ])""",
+ )
+ assert result["errors"] == []
+ assert result["rows"][3]["TC"] == 120
+ assert result["rows"][6]["TC"] == 30
diff --git a/tools/est-test/README.md b/tools/est-test/README.md
index e9696da64..7b0ed7522 100644
--- a/tools/est-test/README.md
+++ b/tools/est-test/README.md
@@ -16,6 +16,8 @@
원본 수량산출서 75개의 WPTools 글·결과 표지·위첨자를 읽어 편집하고 내역 수량에 연결합니다. 저장 수량 449개 중 446개가 재계산 결과와 일치합니다. 나머지 3개는 원본 글의 산식과 저장 수량이 서로 다릅니다(예: `0.35×1.3×220`의 저장값 286, 계산값 100.1). 화면에 원본 저장 수량과 재계산 수량을 함께 표시합니다. 원본 수치를 맞추려고 산식을 변경하지 않았습니다.
+실무 산불 내역 3건은 DBISAM의 재사용 BLOB 할당 공간을 논리 길이까지만 읽도록 수정한 뒤 저장 금액 8,312개 모두 일치했습니다. 다만 세 파일에 공통으로 닫히지 않은 괄호가 있는 원본 수식 한 건이 남아 해석 오류로 표시됩니다. 저장 금액 일치와 수식 오류 0을 구분합니다. 직접재료비 구획·관급 조달수수료·산업안전보건관리비 제외 옵션, 낙찰 단가를 유지한 동원 집계도 시험합니다. 자동계산을 끄면 열기·편집·저장 시 기존 금액을 보존합니다.
+
**전체 원본 동등성은 아직 확보하지 못했습니다.** 기성·물가변동·연차 등은 분석 분기와 합성 시나리오를 시험했으며 원본 업무 샘플 대조는 없습니다. 원본 화면 실행 비교는 못했습니다. 원본 페이지·도면 배치, CAD/VEC0650 편집, 원본 인쇄 서식, Excel/XML/JDL 변환, ESTX 수정 저장은 미구현입니다. 원본 대체품의 완료 판정으로 보지 않습니다.
검사:
diff --git a/tools/est-test/import-estx.cjs b/tools/est-test/import-estx.cjs
index ae6d0d1de..ab8ad2f29 100644
--- a/tools/est-test/import-estx.cjs
+++ b/tools/est-test/import-estx.cjs
@@ -65,7 +65,8 @@ function blob(file, start, id) {
seen = new Set();
let block = start,
previous = 0,
- expected = 0;
+ expected = 0,
+ received = 0;
while (block) {
const offset = block * 512;
if (seen.has(block) || offset + 512 > file.length) throw Error("BLOB 체인 범위/순환 오류");
@@ -78,12 +79,20 @@ function blob(file, start, id) {
expected = file.readUInt32LE(offset + 14);
if (expected > LIMIT) throw Error("BLOB 크기 한도 초과");
}
- parts.push(file.subarray(offset + 18, offset + 18 + length));
+ const take = Math.min(length, expected - received);
+ parts.push(file.subarray(offset + 18, offset + 18 + take));
+ received += take;
+ // Reused DBISAM BLOB allocations may retain linked capacity after the
+ // logical end. The first block's declared length owns the payload boundary.
+ if (received === expected) break;
previous = block;
block = file.readUInt32LE(offset + 4);
}
const result = Buffer.concat(parts);
- if (result.length !== expected) throw Error("BLOB 총 길이 오류");
+ if (result.length !== expected)
+ throw Error(
+ `BLOB 총 길이 오류: 블록 ${start}, ID ${id}, 선언 ${expected}, 읽음 ${result.length}`,
+ );
return result;
}
function table(buffer, blobs, warnings) {
@@ -265,7 +274,12 @@ function importEstx(buffer, name = "가져온 내역서") {
!data.subarray(8, 24).equals(Buffer.from("068abe8e592364cb403d71d2e3bc64d0", "hex"))
)
continue;
- const rows = table(data, files.get(filename.replace(/\.dat$/, ".blb")), warnings);
+ let rows;
+ try {
+ rows = table(data, files.get(filename.replace(/\.dat$/, ".blb")), warnings);
+ } catch (error) {
+ throw Error(`${filename}: ${error.message}`);
+ }
const key = filename.replace(/\.dat$/, "");
const canonical =
{
@@ -310,6 +324,8 @@ function importEstx(buffer, name = "가져온 내역서") {
const props = Object.fromEntries(objects.map((o) => [o.className, o.properties]));
const options = {
GCToWonga: props.TWongaOption?.GCToWonga ?? false,
+ GangubExceptJodalRatio: props.TWongaOption?.GangubExceptJodalRatio ?? false,
+ EYunUp: props.TWongaOption?.EYunUp ?? false,
SourceRefresh: false,
VerFormat: props.TWongaOption?.VerFormat ?? true,
TotalFix: props.TWongaOption?.TotalFix ?? tables.Wonga.some((r) => r.LinkIndex === 55),
@@ -356,4 +372,4 @@ function importEstx(buffer, name = "가져온 내역서") {
originalArchive: buffer.toString("base64"),
};
}
-module.exports = { importEstx, unzip, table, memoryRows, optionObjects, crc32 };
+module.exports = { importEstx, unzip, table, blob, memoryRows, optionObjects, crc32 };
diff --git a/tools/est-test/index.html b/tools/est-test/index.html
index a5a5451c8..eb1c51b4f 100644
--- a/tools/est-test/index.html
+++ b/tools/est-test/index.html
@@ -18,6 +18,7 @@
+
diff --git a/tools/est-test/native-actions.js b/tools/est-test/native-actions.js
index ccc278742..eeabb1356 100644
--- a/tools/est-test/native-actions.js
+++ b/tools/est-test/native-actions.js
@@ -162,6 +162,16 @@ const EstActions = (() => {
copies.forEach((r) => {
r.PkID = ++next;
r.NewItem = true;
+ if (["StuffcDat", "LaborcDat", "SilJukDat", "EstiMateDat"].includes(state.view)) {
+ const base = r.Code || "U";
+ let suffix = next;
+ do {
+ r.Code = `${base}_${suffix++}`;
+ } while (
+ rows.some((row) => row.Code === r.Code) ||
+ copies.some((row) => row !== r && row.Code === r.Code)
+ );
+ }
if (grouped && Number(r.Index) > 0 && r.Code) {
r.Code = `${r.Code}_${next}`;
r.Index = ++nextIndex;
@@ -230,74 +240,7 @@ const EstActions = (() => {
"REA 샘플 대조",
`
${result.checked}개 중 ${result.passed}개 일치 · 불일치 ${result.failed}개
보관된 EST 샘플의 저장값과 계산 결과를 비교했습니다. 원본 실행 비교는 포함하지 않습니다.
${result.errors.length ? `${esc(result.errors.join(" / "))}
` : ""}| 영역 | 검사 수 | 일치 |
${[...new Set(result.results.map((r) => r.group))].map((group) => `| ${esc(group)} | ${result.results.filter((r) => r.group === group).length} | ${result.results.filter((r) => r.group === group && r.pass).length} |
`).join("")}
`,
);
- } else if (name === "settings")
- form(
- "계산 환경 설정",
- [
- {
- name: "head",
- label: "일위대가 성분 소수자리",
- type: "number",
- value: project.options.HeadDigits || 0,
- },
- {
- name: "estimate",
- label: "내역 성분 소수자리",
- type: "number",
- value: project.options.EstDigits || 0,
- },
- {
- name: "total",
- value: project.options.TotalFix ? "1" : "0",
- label: "도급액 고정",
- choices: [
- ["0", "해제"],
- ["1", "원가 LinkIndex 55행 금액에 고정"],
- ],
- },
- {
- name: "fit",
- value: project.options.TotalFit ? "1" : "0",
- label: "성분 합계 맞춤",
- choices: [
- ["0", "해제"],
- ["1", "최대 나머지 배분"],
- ],
- },
- {
- name: "dollar",
- value: project.options.DollarCostCut ? "1" : "0",
- label: "외화 원화환산 천원 절사",
- choices: [
- ["0", "원 단위"],
- ["1", "천원 단위"],
- ],
- },
- {
- name: "jdl",
- value: project.options.JDLCalcMethod ? "1" : "0",
- label: "운전사 중간금액 소수1자리 절사",
- choices: [
- ["0", "해제"],
- ["1", "사용"],
- ],
- },
- ],
- (values) => {
- if (![values.head, values.estimate].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,
- TotalFix: values.total === "1",
- TotalFit: values.fit === "1",
- DollarCostCut: values.dollar === "1",
- JDLCalcMethod: values.jdl === "1",
- }),
- );
- },
- );
+ } else if (name === "settings") EstSettings.show();
else if (name === "raw-line") {
const row = activeRows()[Number(node.dataset.row)];
showDialog(
@@ -652,5 +595,5 @@ const EstActions = (() => {
}
});
}
- return { install };
+ return { install, form };
})();
diff --git a/tools/est-test/native-app.js b/tools/est-test/native-app.js
index 9a600c99a..1e53ba50b 100644
--- a/tools/est-test/native-app.js
+++ b/tools/est-test/native-app.js
@@ -81,7 +81,14 @@ const EstApp = (() => {
state.selection.clear();
state.group = "";
state.page = 0;
- calculate();
+ if (state.project.options.AutoCalc !== false) calculate();
+ else
+ state.project.calculation ||= {
+ errors: [],
+ warnings: [],
+ direct: state.project.tables.EstMainDat.find((row) => Number(row.LinkIndex) === 54) || {},
+ total: state.project.tables.Wonga.find((row) => Number(row.LinkIndex) === 55)?.Cost || 0,
+ };
cache();
render();
}
diff --git a/tools/est-test/native-business.js b/tools/est-test/native-business.js
index e202ae80f..2096a7365 100644
--- a/tools/est-test/native-business.js
+++ b/tools/est-test/native-business.js
@@ -201,11 +201,7 @@ const EstBusiness = (() => {
direct.reduce((n, r) => n + num(r[`${prefix}${key}`]), 0),
]),
);
- const material = project.options.GCToWonga
- ? bases.GC
- : direct
- .filter((r) => num(r.LinkIndex) === 5)
- .reduce((n, r) => n + num(r[`${prefix}GC`]), 0);
+ const material = EstNative.materialBase(rows, project.options, `${prefix}GC`);
const wonga = copy(project.tables.Wonga);
for (const row of wonga) {
if (row.Youl && !String(row.Name).replace(/\s/g, "").includes("부가")) {
diff --git a/tools/est-test/native-engine.js b/tools/est-test/native-engine.js
index 0830aa4d4..b213cc45c 100644
--- a/tools/est-test/native-engine.js
+++ b/tools/est-test/native-engine.js
@@ -88,6 +88,28 @@ const EstNative = (() => {
});
return result;
}
+ function materialBase(rows, options = {}, field = "GC") {
+ const levels = [];
+ let amount = 0;
+ for (const row of rows) {
+ if (num(row.LinkIndex) === 54) break;
+ const level = num(row.Level);
+ if (level > 0) {
+ while (levels.length && levels.at(-1).level <= level) levels.pop();
+ levels.push({
+ level,
+ direct: String(row.Name || "")
+ .trim()
+ .endsWith("*"),
+ });
+ } else if (
+ num(row.CalcIndex) < 2 &&
+ (options.GCToWonga || levels.some((group) => group.direct))
+ )
+ amount += num(row[field]);
+ }
+ return amount;
+ }
function calculate(input) {
const project = clone(input),
tables = project.tables,
@@ -139,7 +161,11 @@ const EstNative = (() => {
const result =
kind === 103
? EstRows.head(group.rows, options, resolve)
- : EstText.calculate(group.rows, options, options.SourceRefresh ? resolve : () => null);
+ : EstText.calculate(
+ group.rows,
+ options,
+ options.SourceRefresh || options.ResourceTracing ? resolve : () => null,
+ );
const title = {
...tables[sourceTables[kind]].find((row) => row.Code === code),
...result.titles[0],
@@ -208,36 +234,46 @@ const EstNative = (() => {
errors.push(...estimate.errors);
const direct = estimate.direct;
const boundary = tables.EstMainDat.findIndex((row) => num(row.LinkIndex) === 54);
- const leaves = tables.EstMainDat.slice(
- 0,
- boundary < 0 ? tables.EstMainDat.length : boundary,
- ).filter((row) => !num(row.Level) && num(row.CalcIndex) < 2);
- const directMaterial = options.GCToWonga
- ? num(direct.GC)
- : leaves.filter((row) => num(row.LinkIndex) === 5).reduce((n, row) => n + num(row.GC), 0);
+ const directMaterial = materialBase(tables.EstMainDat, options);
const governmentHeading = tables.EstMainDat.findIndex(
(row, i) => i > boundary && num(row.Level) > 0 && normalize(row.Name).includes("관급"),
);
- let government = null;
+ let government = null,
+ procurementFees = 0;
if (governmentHeading >= 0) {
- government = 0;
+ government = num(tables.EstMainDat[governmentHeading].TC);
const level = num(tables.EstMainDat[governmentHeading].Level);
for (
let i = governmentHeading + 1;
i < tables.EstMainDat.length && num(tables.EstMainDat[i].Level) < level;
i++
)
- if (!num(tables.EstMainDat[i].Level)) government += num(tables.EstMainDat[i].TC);
+ if (num(tables.EstMainDat[i].LinkIndex) === 57)
+ procurementFees += num(tables.EstMainDat[i].TC);
}
if (options.AutoWongaBase !== false)
for (const row of tables.Wonga) {
- if (row.VSusic || [1, 3].includes(num(row.CalcIndex)) || row.BaseField) continue;
+ if (
+ (row.VSusic && !/^원금액\s*:/.test(row.VSusic)) ||
+ [1, 3].includes(num(row.CalcIndex)) ||
+ row.BaseField
+ )
+ continue;
const name = normalize(row.Name);
if (name.includes("직접재료")) row.Cost = directMaterial;
else if (name.includes("간접재료")) row.Cost = num(direct.GC) - directMaterial;
else if (name.includes("직접노무")) row.Cost = num(direct.NC);
else if (name.includes("산출경비")) row.Cost = num(direct.JC);
- else if (name.includes("관급자재") && government !== null) row.Cost = government;
+ else if (name.includes("관급자재") && government !== null) {
+ row.Cost = government;
+ row.TC = government;
+ row.NC = procurementFees;
+ if (/^원금액\s*:/.test(row.VSusic))
+ row.VSusic = row.VSusic.replace(
+ /(원금액\s*:)[\d,.]+/,
+ `$1${government.toLocaleString("en-US")}`,
+ ).replace(/(조달수수료\s*:)[\d,.]+/, `$1${procurementFees.toLocaleString("en-US")}`);
+ }
}
const wonga = EstWonga.calculate(tables.Wonga, options, {
NC: num(direct.NC),
@@ -282,5 +318,5 @@ const EstNative = (() => {
drawings: [],
};
}
- return { calculate, price, groups, empty, allTables, sourceTables, normalize };
+ return { calculate, price, groups, empty, allTables, sourceTables, normalize, materialBase };
})();
diff --git a/tools/est-test/native-formula.js b/tools/est-test/native-formula.js
index cf7fc46be..b47b53e0e 100644
--- a/tools/est-test/native-formula.js
+++ b/tools/est-test/native-formula.js
@@ -9,6 +9,8 @@ const EstExpression = (() => {
.replace(/%/g, "%")
.replace(/√\s*\(/g, "SQRT(")
.replace(/π/g, "PI")
+ .replace(/(?<=[\d.)])\s*[xX]\s*(?=[\d.(A-Za-z])/g, "*")
+ .replace(/(?<=[A-Z])x(?=[A-Z])/g, "*")
.replace(/(?<=\d),(?=\d{3}(?:\D|$))/g, "");
const tokens = [];
const pattern =
diff --git a/tools/est-test/native-math.js b/tools/est-test/native-math.js
index 28e5ec007..96df0d286 100644
--- a/tools/est-test/native-math.js
+++ b/tools/est-test/native-math.js
@@ -26,7 +26,12 @@ const EstMath = (() => {
}
function floating(value, digits = 0, method = 2) {
const k = 10 ** digits;
- return mode((value + Math.sign(value) * 2e-10) * k, method) / k;
+ // The original keeps x87 extended intermediates. Account for one binary64
+ // rounding unit before truncation so exact decimal boundaries survive JS.
+ const scaled = value * k;
+ return (
+ mode(scaled + Math.sign(value) * (2e-10 * k + Number.EPSILON * Math.abs(scaled)), method) / k
+ );
}
function precision(row, fallback = 0) {
return num(row.DigetIndex)
@@ -63,7 +68,9 @@ const EstMath = (() => {
const result = Object.fromEntries(
components.map((key) => [
key,
- floating(rows.filter(predicate).reduce((n, row) => n + num(row[key]), 0)),
+ floating(
+ rows.filter(predicate).reduce((n, row) => n + even(num(row[key]) * 10000), 0) / 10000,
+ ),
]),
);
result.TC = sum(result);
diff --git a/tools/est-test/native-model.js b/tools/est-test/native-model.js
index c0b562406..6a1a9f4ae 100644
--- a/tools/est-test/native-model.js
+++ b/tools/est-test/native-model.js
@@ -353,15 +353,25 @@ const EstModel = (() => {
project.tables.EstMainDat.push({ Name: "순공사비", LinkIndex: 54 });
const bases = { 재료비: "GC", 노무비: "NC", 경비: "JC", 실적: "SC", 직접공사비: "TC" };
project.tables.Wonga = [{ Gubun: "기초", Name: "직접공사비", BaseField: "TC", Cost: 0 }];
- for (const row of data.costs)
+ const running = ["기초"];
+ for (const [index, row] of data.costs.entries()) {
+ const id = row.id || `추가${index + 1}`;
project.tables.Wonga.push({
- Gubun: row.id,
+ Gubun: id,
Name: row.name,
Youl: `${row.rate}%`,
VSusic:
row.formula ||
- `[${row.base === "누계" ? "기초" : bases[row.base] || "TC"}]*${row.rate}/100`,
+ `(${row.base === "누계" ? running.map((key) => `[${key}]`).join("+") : `[${bases[row.base] || "TC"}]`})*${row.rate}/100`,
});
+ running.push(id);
+ }
+ project.tables.Wonga.push({
+ Gubun: "도급계",
+ Name: "도급액",
+ LinkIndex: 55,
+ VSusic: running.map((key) => `[${key}]`).join("+"),
+ });
return validate(project);
}
function newRow(table, rows) {
@@ -370,13 +380,19 @@ const EstModel = (() => {
PkID: max + 1,
Index: 0,
Name: "새 항목",
+ NewItem: true,
GN: 1,
CalcIndex: 0,
LinkIndex: 0,
Level: 0,
};
if (["StuffcDat", "LaborcDat", "SilJukDat", "EstiMateDat"].includes(table))
- Object.assign(row, { Code: `U${Date.now().toString(36)}`, ValueIndex: 4, InputTC: 0, TC: 0 });
+ Object.assign(row, {
+ Code: `U${Date.now().toString(36)}_${max + 1}`,
+ ValueIndex: 4,
+ InputTC: 0,
+ TC: 0,
+ });
if (table === "Wonga") Object.assign(row, { Gubun: `R${max + 1}`, VSusic: "", Cost: 0 });
if (table === "CostMainDat") Object.assign(row, { LineData: "Q = 1 + 1 = \x122\x12" });
return row;
diff --git a/tools/est-test/native-resources.js b/tools/est-test/native-resources.js
index 187647607..6521c42d4 100644
--- a/tools/est-test/native-resources.js
+++ b/tools/est-test/native-resources.js
@@ -3,14 +3,14 @@ const EstResources = (() => {
function calculate(input) {
const project = EstNative.calculate({
...structuredClone(input),
- options: { ...input.options, SourceRefresh: true },
+ options: { ...input.options, ResourceTracing: true },
});
const tables = project.tables,
heads = EstNative.groups(tables.HeadMainDat),
costs = EstNative.groups(tables.CostMainDat),
output = new Map(),
errors = [...project.calculation.errors];
- function record(kind, code, quantity, amount = 0) {
+ function record(kind, code, quantity, amount = null) {
const source = (tables[EstNative.sourceTables[kind]] || []).find((row) => row.Code === code);
if (!source) {
errors.push(`동원 원천 없음: ${kind}/${code}`);
@@ -25,9 +25,11 @@ const EstResources = (() => {
kind,
quantity: 0,
amount: 0,
+ amountForHours: 0,
};
row.quantity += quantity;
- row.amount += amount;
+ row.amount += amount ?? num(source.TC) * quantity;
+ if (kind === 105 && quantity === 0 && amount !== null) row.amountForHours += amount;
output.set(id, row);
}
function expand(kind, code, multiplier, path) {
@@ -70,15 +72,23 @@ const EstResources = (() => {
let rate = 1;
const bid = group.rows.find((row) => num(row.LinkIndex) === 52);
if (bid) rate = num(bid.GN) / 100;
+ const conversion = group.rows.find((row) => num(row.LinkIndex) === 68);
+ if (conversion)
+ rate *= num(conversion.GN) / (["%", "%"].includes(conversion.Unite) ? 100 : 1);
for (const row of group.rows.slice(1)) {
if (num(row.CalcIndex) >= 2 || [51, 52, 58, 62, 68].includes(num(row.LinkIndex)))
continue;
+ const source = tables[EstNative.sourceTables[num(row.LinkIndex)]]?.find(
+ (r) => r.Code === row.Code,
+ );
+ const unitRatio = row.ApplyNacChal && num(source?.TC) ? num(row.TU) / num(source.TC) : 1;
const quantity =
- (multiplier * num(row.GN) * rate) / (["%", "%"].includes(row.Unite) ? 100 : 1);
+ (multiplier * num(row.GN) * rate * unitRatio) /
+ (["%", "%"].includes(row.Unite) ? 100 : 1);
if ([103, 104].includes(num(row.LinkIndex)))
expand(num(row.LinkIndex), row.Code, quantity, [...path, id]);
else if (EstNative.sourceTables[num(row.LinkIndex)])
- record(num(row.LinkIndex), row.Code, quantity, multiplier * num(row.TC));
+ record(num(row.LinkIndex), row.Code, quantity, multiplier * num(row.TC) * rate);
}
return;
}
@@ -92,20 +102,25 @@ const EstResources = (() => {
!EstNative.sourceTables[num(row.LinkIndex)]
)
continue;
- expand(num(row.LinkIndex), row.Code, num(row.GN), []);
+ const source = tables[EstNative.sourceTables[num(row.LinkIndex)]]?.find(
+ (r) => r.Code === row.Code,
+ );
+ const unitRatio = row.ApplyNacChal && num(source?.TC) ? num(row.TU) / num(source.TC) : 1;
+ expand(num(row.LinkIndex), row.Code, num(row.GN) * unitRatio, []);
}
const rows = [...output.values()];
for (const row of rows)
- if (row.kind === 105 && row.amount) {
+ if (row.kind === 105 && row.amountForHours) {
const price = num(tables.MachTitleDat.find((r) => r.Code === row.Code)?.TC);
if (price) {
- row.quantity += row.amount / price;
+ row.quantity += row.amountForHours / price;
row.Unite = "HR";
} else {
row.Unite = "제외금액";
row.quantity = 0;
}
}
+ rows.forEach((row) => delete row.amountForHours);
return { rows, errors: [...new Set(errors)] };
}
return { calculate };
diff --git a/tools/est-test/native-rows.js b/tools/est-test/native-rows.js
index 8f3a87a91..5e1b1455d 100644
--- a/tools/est-test/native-rows.js
+++ b/tools/est-test/native-rows.js
@@ -56,9 +56,11 @@ const EstRows = (() => {
const rounded =
num(row.Index) === 1
? EstMath.half(quotient)
- : num(row.Index) === 2
- ? Math.trunc(quotient)
- : EstMath.half(quotient + 0.5);
+ : num(row.Gangub) < 1
+ ? quotient
+ : num(row.Index) === 2
+ ? Math.trunc(quotient)
+ : EstMath.half(quotient + 0.5);
components.forEach((k) => (row[k] = 0));
row[target[num(row.SCheck)] || "JC"] = rounded * step - base;
row.TU = base;
@@ -70,8 +72,8 @@ const EstRows = (() => {
const values = Object.fromEntries(
components.map((k) => [k, eligible.reduce((n, r) => n + num(r[k]), 0)]),
);
- const kind = baseKind(row),
- sc = target[num(row.SCheck)];
+ const kind = num(row.LinkIndex) === 68 ? null : baseKind(row),
+ sc = num(row.LinkIndex) === 68 ? undefined : target[num(row.SCheck)];
units.forEach((key) => (row[key] = 0));
if (sc) row[units[components.indexOf(sc)]] = kind === "TC" ? sum(values) : num(values[kind]);
else
diff --git a/tools/est-test/native-settings.js b/tools/est-test/native-settings.js
new file mode 100644
index 000000000..c361945ca
--- /dev/null
+++ b/tools/est-test/native-settings.js
@@ -0,0 +1,93 @@
+const EstSettings = (() => {
+ function show() {
+ const project = EstApp.state.project;
+ EstActions.form(
+ "계산 환경 설정",
+ [
+ {
+ name: "head",
+ label: "일위대가 성분 소수자리",
+ type: "number",
+ value: project.options.HeadDigits || 0,
+ },
+ {
+ name: "estimate",
+ label: "내역 성분 소수자리",
+ type: "number",
+ value: project.options.EstDigits || 0,
+ },
+ {
+ name: "total",
+ value: project.options.TotalFix ? "1" : "0",
+ label: "도급액 고정",
+ choices: [
+ ["0", "해제"],
+ ["1", "원가 LinkIndex 55행 금액에 고정"],
+ ],
+ },
+ {
+ name: "fit",
+ value: project.options.TotalFit ? "1" : "0",
+ label: "성분 합계 맞춤",
+ choices: [
+ ["0", "해제"],
+ ["1", "최대 나머지 배분"],
+ ],
+ },
+ {
+ name: "dollar",
+ value: project.options.DollarCostCut ? "1" : "0",
+ label: "외화 원화환산 천원 절사",
+ choices: [
+ ["0", "원 단위"],
+ ["1", "천원 단위"],
+ ],
+ },
+ {
+ name: "jdl",
+ value: project.options.JDLCalcMethod ? "1" : "0",
+ label: "운전사 중간금액 소수1자리 절사",
+ choices: [
+ ["0", "해제"],
+ ["1", "사용"],
+ ],
+ },
+ {
+ name: "materials",
+ value: project.options.GCToWonga ? "1" : "0",
+ label: "순직접공사비 재료비 처리",
+ choices: [
+ ["0", "간접재료비"],
+ ["1", "직접재료비"],
+ ],
+ },
+ {
+ name: "procurement",
+ value: project.options.GangubExceptJodalRatio ? "1" : "0",
+ label: "산업안전보건관리비의 관급자재대에서 조달수수료",
+ choices: [
+ ["0", "포함"],
+ ["1", "제외"],
+ ],
+ },
+ ],
+ (values) => {
+ if (![values.head, values.estimate].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,
+ TotalFix: values.total === "1",
+ TotalFit: values.fit === "1",
+ DollarCostCut: values.dollar === "1",
+ JDLCalcMethod: values.jdl === "1",
+ GCToWonga: values.materials === "1",
+ GangubExceptJodalRatio: values.procurement === "1",
+ }),
+ );
+ },
+ );
+ }
+ return { show };
+})();
diff --git a/tools/est-test/native-text.js b/tools/est-test/native-text.js
index 165303115..5ec45d61a 100644
--- a/tools/est-test/native-text.js
+++ b/tools/est-test/native-text.js
@@ -11,20 +11,23 @@ const EstText = (() => {
const sumCurrency = (values) =>
values.reduce((n, value) => n + EstMath.even(num(value) * 10000), 0) / 10000;
const stripLabels = (text) =>
- String(text).replace(/\x0e[^\x0e]*\x0e|\x13[^\x13]*\x13|\x15[^\x15]*\x15/g, "");
+ String(text)
+ .replace(/\x0e[^\x0e]*\x0e|\x13[^\x13]*\x13|\x15[^\x15]*\x15|\([가-힣][^()]*\)/g, "")
+ .replace(/[;;]/g, ":");
function expression(text) {
return clean(
String(text).replace(
- /\x0e[^\x0e]*\x0e|\x13[^\x13]*\x13|\x15[^\x15]*\x15|\{[^}]*\}|\([가-힣][^()]*\)/g,
+ /\x0e[^\x0e]*\x0e|\x13[^\x13]*\x13|\x15[^\x15]*\x15|\{[^}]*\}|\([가-힣][^()]*\)|\(\s*\d+(?:\.\d+)?\s*[˚°]\s*\)|<[^>]*>/g,
"",
),
)
.replace(
- /(\d)\s*(?:공\/?㎥|공|단|면|경간|개월|월|인|㎥|㎢|㎡|hr|sec|min|km|ton|kg|㎏|㎞|ℓ|회|개소|개|대|분|초|본|장|매|병|조|식|일|시간|원|포|동|μ|EA|mm|cm|m|g|L)(?:\/(?:개소|개|대|본|인|분|포|\$|hr|일|㎥|㎡|m|a))?/gi,
+ /(\d)\s*(?:공\/?㎥|공|단|면|겹|경간|개월|월|년|인|㎥|㎢|㎡|hr|sec|min|kwh|kw|km|ton|kg|㎏|㎞|ℓ|회|개소|개|대|분|초|본|장|매|병|조|식|일|시간|원|포|동|주|톤|μ|₩|EA|mm|cm|m3|m2|m|g|L|a)(?:\/(?:개소|경간|개|대|본|인|분|포|매|주|톤|\$|hr|일|㎥|㎡|m|a|EA))?/gi,
"$1",
)
.replace(/(?<=\d)\s*\/(?:일|㎥|㎡)(?=\s|$)/g, "")
- .replace(/(?<=\))\s*(?:개|인|본|면|단)(?:\/(?:㎡|㎥|hr))?/g, "")
+ .replace(/(?<=\))\s*(?:개|인|본|면|단|회)(?:\/(?:㎡|㎥|hr))?/g, "")
+ .replace(/^\s*[-*•]?\s*[가-힣][가-힣\s]*\s+(?=[\d.(])/, "")
.replace(/[①-⑳]/g, "")
.replace(/=\s*$/, "");
}
@@ -38,11 +41,15 @@ const EstText = (() => {
changed = new Map(),
currentSource = null,
totals = {},
- levelTotals = new Map(),
- finalLevel = -1;
+ finalLevel = -1,
+ plainTotals = {},
+ singleComponent = -1,
+ hasAutoTotals = false;
function finish() {
if (!head) return;
- components.forEach((key) => (head[key] = num(totals[key])));
+ components.forEach(
+ (key) => (head[key] = !hasAutoTotals ? floating(num(plainTotals[key])) : num(totals[key])),
+ );
head.TC = sum(head);
titles.push({ ...head });
}
@@ -55,7 +62,7 @@ const EstText = (() => {
changed = new Map();
currentSource = null;
totals = {};
- levelTotals = new Map();
+ plainTotals = {};
for (const [key, tag] of [
["Name", "\x0e"],
["Size", "\x13"],
@@ -64,10 +71,18 @@ const EstText = (() => {
const match = String(row.LineData || "").match(new RegExp(`${tag}([^${tag}]+)${tag}`));
if (match) row[key] = match[1];
}
- const levels = [];
+ const levels = [],
+ componentRows = [];
for (let j = index + 1; j < rows.length && num(rows[j].Index) <= 0; j++)
if (String(rows[j].LineData || "").includes("\x1a")) levels.push(num(rows[j].Level));
+ else if (component[num(rows[j].SCheck)] && marker.test(String(rows[j].LineData || "")))
+ componentRows.push(j);
finalLevel = levels.length ? Math.min(...levels) : -1;
+ hasAutoTotals = levels.length > 0;
+ singleComponent =
+ !hasAutoTotals && num(head.SCheck) > 0 && componentRows.length === 1
+ ? componentRows[0]
+ : -1;
continue;
}
if (!head || !row.LineData) continue;
@@ -117,12 +132,18 @@ const EstText = (() => {
digits = 0;
const assignedResults = new Map();
const assignedNames = new Map();
+ let componentDeclaration;
try {
if (matched && /─/.test(text)) {
const above = clean(rows[index - 1]?.LineData),
below = clean(rows[index + 1]?.LineData);
- const tops = above.trim().split(/\s{2,}/),
- bottoms = below.trim().split(/\s{2,}/);
+ const columns = (value) =>
+ value
+ .replace(/\s*([×÷+*/−-])\s*/g, "$1")
+ .trim()
+ .split(/\s{2,}/);
+ const tops = columns(above),
+ bottoms = columns(below);
let part = 0;
const formula = text
.slice(text.indexOf("=") + 1)
@@ -133,9 +154,17 @@ const EstText = (() => {
});
calculated = EstExpression.evaluate(expression(formula), (key) => variables[key]);
} else if (matched && text.includes("\x1a")) {
- if (sc === -5)
- calculated = sumCurrency(Object.values(levelTotals.get(num(row.Level)) || totals));
- else {
+ if (sc === -5) {
+ const section = [];
+ for (let j = index - 1; j >= 0; j--) {
+ const previous = rows[j];
+ if (!previous.LineData) continue;
+ if (!String(previous.LineData).includes("\x1a") || !component[num(previous.SCheck)])
+ break;
+ section.push(num(previous.TC));
+ }
+ calculated = sumCurrency(section);
+ } else {
let body = clean(text.slice(0, text.indexOf("\x12")))
.replace(/=\s*$/, "")
.trim();
@@ -157,7 +186,11 @@ const EstText = (() => {
} else if (matched && sc < 0) {
let body = stripLabels(text.slice(0, text.indexOf("\x12"))).replace(/=\s*$/, "");
if (body.includes(":")) body = body.slice(body.indexOf(":") + 1);
- body = body.split("=")[0];
+ const parts = body.split("=");
+ 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];
calculated = EstExpression.evaluate(expression(body), (key) => variables[key]);
}
const assignmentText = text.replace(
@@ -176,6 +209,7 @@ const EstText = (() => {
const assignment = assignments[j],
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*$/, "");
if (body.includes("─") && calculated !== null) {
variables[assignment[1]] = floating(calculated, 2, 1);
@@ -188,15 +222,22 @@ const EstText = (() => {
.trim()
.replace(/\s+(?:min|hr|sec)\s*$/i, "")
.split(/\s{2,}(?=\d+\s*[가-힣]|[가-힣])/)[0];
+ if (!result)
+ body = body
+ .replace(/\s*,\s+[가-힣].*$/, "")
+ .replace(/^([-+]?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?)(?:\s{2,}|\s*,\s+).*$/, "$1");
// 단위가 붙은 상수와 주석은 수치 부분만 사용한다.
const literal = body.match(/^([-+]?\d[\d,.]*)(?:\s*[A-Za-z가-힣㎥㎡ℓ˚°()].*)$/);
- if (literal) body = literal[1];
+ if (literal && !/^[xX](?=[A-Za-z\d.(])/.test(body.slice(literal[1].length).trim()))
+ body = literal[1];
let value;
try {
value = EstExpression.evaluate(body, (key) => variables[key]);
} catch (error) {
if (!result && error.message.startsWith("참조 항목 없음:")) continue;
- if (parts.length > 1)
+ if (!result && parts.length > 1)
+ value = EstExpression.evaluate(expression(parts.at(-1)), (key) => variables[key]);
+ else if (parts.length > 1)
value = EstExpression.evaluate(
expression(parts[parts.length - 2]),
(key) => variables[key],
@@ -223,7 +264,12 @@ const EstText = (() => {
.filter((x) => x.trim())
.pop();
if (body.includes(":")) body = body.slice(body.indexOf(":") + 1);
- calculated = EstExpression.evaluate(expression(body), (key) => variables[key]);
+ body = expression(body)
+ .replace(/\s*([×÷+*/−-])\s*/g, "$1")
+ .trim()
+ .split(/\s{2,}/)
+ .pop();
+ calculated = EstExpression.evaluate(body, (key) => variables[key]);
}
if (matched && calculated !== null) {
const explicit = num(row.DigetIndex) ? EstMath.precision(row) : null;
@@ -231,16 +277,23 @@ const EstText = (() => {
row.Precision ??
explicit?.digits ??
(sc < 0 || num(row.LinkIndex) !== 0
- ? num(row.Level) === finalLevel && text.includes("\x1a")
+ ? (num(row.Level) === finalLevel && text.includes("\x1a")) ||
+ index === singleComponent
? 0
: 1
: Math.max(2, matched[1].split(".")[1]?.length || 0));
const method =
row.RoundMode || explicit?.method || (sc < 0 || num(row.LinkIndex) !== 0 ? 2 : 1);
- calculated = floating(calculated, digits, method);
+ const results = [...text.matchAll(/\x12([-\d.,]+)\x12/g)];
+ const intermediate =
+ 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,
+ );
const firstValue = calculated;
let rendered = text.replace(marker, `\x12${calculated}\x12`);
- const results = [...text.matchAll(/\x12([-\d.,]+)\x12/g)];
let assignmentName = assignedNames.get(results[0]?.index);
for (let part = 1; part < results.length; part++) {
const previous = results[part - 1],
@@ -256,8 +309,11 @@ const EstText = (() => {
expression(`${calculated}${operation}`),
(key) => variables[key],
),
- format?.digits ?? digits,
- format?.method ?? method,
+ format?.digits ??
+ (intermediate && part < results.length - 1
+ ? Math.max(2, next[1].split(".")[1]?.length || 0)
+ : digits),
+ format?.method ?? (intermediate && part < results.length - 1 ? 1 : method),
);
assignmentName = assignedNames.get(next.index) || assignmentName;
if (assignmentName) variables[assignmentName] = calculated;
@@ -270,6 +326,7 @@ const EstText = (() => {
checks.push({ slot: index, part, stored: old, calculated });
}
row.TC = calculated;
+ if (componentDeclaration) variables[componentDeclaration] = calculated;
if (currentSource && text.includes("\x03")) {
let body = stripLabels(text.slice(0, text.indexOf("\x12"))).split("=")[0];
if (body.includes(":")) body = body.slice(body.indexOf(":") + 1);
@@ -278,6 +335,18 @@ const EstText = (() => {
expression(body.replace(/\x03[-\d.,]+\x03/g, "1")),
(key) => variables[key],
);
+ for (let part = 1; part < results.length; part++) {
+ const operation = expression(
+ text.slice(
+ results[part - 1].index + results[part - 1][0].length,
+ results[part].index,
+ ),
+ );
+ row.ResourceFactor = EstExpression.evaluate(
+ expression(`${row.ResourceFactor}${operation}`),
+ (key) => variables[key],
+ );
+ }
row.ResourceCode = currentSource.Code;
row.ResourceKind = currentSource.SourceKind;
row.ResourceComponent = component[sc] || "";
@@ -289,8 +358,6 @@ const EstText = (() => {
changed.set(oldValue, firstValue);
checks.push({ slot: index, stored: oldValue, calculated: firstValue });
if (text.includes("\x1a") && component[sc]) {
- if (sc === -1 || !levelTotals.has(num(row.Level))) levelTotals.set(num(row.Level), {});
- levelTotals.get(num(row.Level))[component[sc]] = calculated;
if (num(row.Level) === finalLevel) totals[component[sc]] = calculated;
}
} else if (!matched && component[sc] && text.includes("\x03") && !text.includes("\x1a")) {
@@ -306,14 +373,15 @@ const EstText = (() => {
changed.get(numeric(value)) ?? numeric(value),
num(row.Level) === finalLevel ? 0 : 1,
);
- if (sc === -1 || !levelTotals.has(num(row.Level))) levelTotals.set(num(row.Level), {});
- levelTotals.get(num(row.Level))[component[sc]] = calculated;
+ row.TC = calculated;
if (num(row.Level) === finalLevel) totals[component[sc]] = calculated;
}
} catch (error) {
errors.push(`${head.Code}:${index + 1} ${error.message}`);
row.Error = error.message;
}
+ if (!hasAutoTotals && component[sc])
+ plainTotals[component[sc]] = num(plainTotals[component[sc]]) + num(row.TC);
}
finish();
return { rows, titles, errors, checks };
diff --git a/tools/est-test/native-wonga.js b/tools/est-test/native-wonga.js
index d91b14e79..31560c06c 100644
--- a/tools/est-test/native-wonga.js
+++ b/tools/est-test/native-wonga.js
@@ -17,19 +17,29 @@ const EstWonga = (() => {
function pass() {
const done = new Set(),
active = new Set();
- function resolve(name) {
+ function effective(index, owner) {
+ const cost = evaluate(index);
+ if (
+ options.GangubExceptJodalRatio &&
+ /산업안전|안전보건/.test(compact(rows[owner]?.Name)) &&
+ compact(rows[index].Name).includes("관급")
+ )
+ return cost - num(rows[index].NC);
+ return cost;
+ }
+ function resolve(name, owner) {
if (Object.hasOwn(bases, name)) return bases[name];
const index = keys.get(name) ?? keys.get(compact(name));
- return index === undefined ? undefined : evaluate(index);
+ return index === undefined ? undefined : effective(index, owner);
}
- function range(start, end) {
+ function range(start, end, owner) {
let a = keys.get(start),
b = keys.get(end);
if (a === undefined || b === undefined) throw Error(`범위 참조 없음: ${start}:${end}`);
if (a > b) [a, b] = [b, a];
let sum = 0;
for (let i = a; i <= b; i++)
- if ([0, 1, 4].includes(num(rows[i].CalcIndex))) sum += evaluate(i);
+ if ([0, 1, 4].includes(num(rows[i].CalcIndex))) sum += effective(i, owner);
return sum;
}
function evaluate(index) {
@@ -39,9 +49,16 @@ const EstWonga = (() => {
if (active.has(index)) throw Error(`원가 수식 순환 참조: ${key(row)}`);
active.add(index);
if (row.BaseField && Object.hasOwn(bases, row.BaseField)) row.Cost = bases[row.BaseField];
- else if (row.VSusic && ![1, 3].includes(num(row.CalcIndex)))
+ else if (
+ row.VSusic &&
+ !/^원금액\s*:/.test(row.VSusic) &&
+ ![1, 3].includes(num(row.CalcIndex))
+ )
row.Cost = Math.trunc(
- EstExpression.evaluate(row.VSusic, resolve, { numericReferences: true, range }),
+ EstExpression.evaluate(row.VSusic, (name) => resolve(name, index), {
+ numericReferences: true,
+ range: (a, b) => range(a, b, index),
+ }),
);
else row.Cost = num(row.Cost);
active.delete(index);
diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs
index acbc28081..65d09a512 100644
--- a/tools/est-test/server.cjs
+++ b/tools/est-test/server.cjs
@@ -46,6 +46,7 @@ const allowed = new Set([
"native-views.js",
"native-actions.js",
"native-extra.js",
+ "native-settings.js",
...scripts,
]);
function reply(res, status, data, type = "application/json; charset=utf-8") {