fix(est): match project costs and stored formulas
This commit is contained in:
@@ -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++;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 수정 저장은 미구현입니다. 원본 대체품의 완료 판정으로 보지 않습니다.
|
||||
|
||||
검사:
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<script defer src="native-model.js"></script>
|
||||
<script defer src="native-views.js"></script>
|
||||
<script defer src="native-extra.js"></script>
|
||||
<script defer src="native-settings.js"></script>
|
||||
<script defer src="native-actions.js"></script>
|
||||
<script defer src="native-app.js"></script>
|
||||
</head>
|
||||
|
||||
@@ -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 샘플 대조",
|
||||
`<p><b>${result.checked}개 중 ${result.passed}개 일치 · 불일치 ${result.failed}개</b></p><p>보관된 EST 샘플의 저장값과 계산 결과를 비교했습니다. 원본 실행 비교는 포함하지 않습니다.</p>${result.errors.length ? `<p>${esc(result.errors.join(" / "))}</p>` : ""}<table><thead><tr><th>영역</th><th>검사 수</th><th>일치</th></tr></thead><tbody>${[...new Set(result.results.map((r) => r.group))].map((group) => `<tr><td>${esc(group)}</td><td>${result.results.filter((r) => r.group === group).length}</td><td>${result.results.filter((r) => r.group === group && r.pass).length}</td></tr>`).join("")}</tbody></table>`,
|
||||
);
|
||||
} 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 };
|
||||
})();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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("부가")) {
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user