From bb0f7134b5ad49fbe6b39b350fe40fcbf38835ab Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 5 Nov 2026 07:04:50 +0900 Subject: [PATCH 1/3] =?UTF-8?q?knowledge(=EC=9A=94=EA=B5=AC):=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20645=20=E2=80=94=20=EC=88=98=EC=A0=95=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=EC=98=9B=20=EB=B0=A9=EC=8B=9D=20+=20=EC=83=88=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=ED=91=9C=EA=B8=B0=20=C2=B7=20=EB=B3=84?= =?UTF-8?q?=EB=8F=84=EA=B3=84=EC=83=81=20=ED=92=88=EB=AA=A9=EC=A7=80?= =?UTF-8?q?=EC=A0=95=20=EC=97=86=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/raw/요구_1104/사용자요구_1104.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/raw/요구_1104/사용자요구_1104.md b/docs/raw/요구_1104/사용자요구_1104.md index 1d6ac8398..1da5b023d 100644 --- a/docs/raw/요구_1104/사용자요구_1104.md +++ b/docs/raw/요구_1104/사용자요구_1104.md @@ -154,3 +154,8 @@ M02 의 엑셀 불러오기에서 경쟁사 2개의 프로그램의 출력을 ## 요구 644 (11-05 · 사용자 · 수정 모달 되돌림 검토) 일위대가 불러오기가 메인페이지로 엑셀화면과 효표리스트가 메인페이지로 오는건 좋음. 하지만 수정버튼을 눌렀을때 나오던 모달창이었던 부분이 많이 바뀌었는데 모달창으로 다시변경 검토, 구조물 조합의 일위대가와 공용이었으니 조심해야함. + +## 요구 645 (11-05 · 사용자 · 644 답 — 옛 방식 · 별도계상) + +1. 대부분 이전 방식을 채택하되 신규 추가된 기능 별도로 표기 +2. 별도계상은 모아서 한번에 계산한다는의미임. 그러니 프로젝트 내부에 복사하였을때 품목결장을 해야함. 그러니 현재 엑셀 불러오기기능에서는 별도계상은 품목지정 필요없음. 이기능은 기존에 자재에서 선택할수 있었음. From d922ad13f346a77e27c87efd9e21465ed41db701 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 5 Nov 2026 07:12:08 +0900 Subject: [PATCH 2/3] fix(est): match project costs and stored formulas --- resources/tester/helper_est_archive.cjs | 3 +- resources/tester/helper_est_native.cjs | 39 +++++- resources/tester/test_est_rea_parity.py | 163 ++++++++++++++++++++++++ tools/est-test/README.md | 2 + tools/est-test/import-estx.cjs | 26 +++- tools/est-test/index.html | 1 + tools/est-test/native-actions.js | 81 ++---------- tools/est-test/native-app.js | 9 +- tools/est-test/native-business.js | 6 +- tools/est-test/native-engine.js | 64 ++++++++-- tools/est-test/native-formula.js | 2 + tools/est-test/native-math.js | 11 +- tools/est-test/native-model.js | 24 +++- tools/est-test/native-resources.js | 31 +++-- tools/est-test/native-rows.js | 12 +- tools/est-test/native-settings.js | 93 ++++++++++++++ tools/est-test/native-text.js | 122 ++++++++++++++---- tools/est-test/native-wonga.js | 29 ++++- tools/est-test/server.cjs | 1 + 19 files changed, 569 insertions(+), 150 deletions(-) create mode 100644 tools/est-test/native-settings.js 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) => ``).join("")}
영역검사 수일치
${esc(group)}${result.results.filter((r) => r.group === group).length}${result.results.filter((r) => r.group === group && r.pass).length}
`, ); - } 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") { From e23fec8c9ca8cee6c81dc1dbe9bd827ecaa3bc80 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 5 Nov 2026 07:17:50 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(786-1):=20=EB=B3=84=EB=8F=84=EA=B3=84?= =?UTF-8?q?=EC=83=81=20=EC=A4=84=20=ED=92=88=EB=AA=A9=20=EA=B3=A0=EB=A5=B4?= =?UTF-8?q?=EA=B8=B0=20=EC=97=86=EC=95=B0=20=E2=80=94=20=ED=9B=84=EB=B3=B4?= =?UTF-8?q?=20=EA=B3=A0=EC=A0=95=20=EC=A4=84=20=ED=95=98=EB=82=98=20=C2=B7?= =?UTF-8?q?=20SC=20=ED=95=A9=20=EB=B0=96=20=EA=B3=A0=EC=A0=95=20=EC=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Row.judge: 별도계상 줄은 M01 · 사전 · 장부 후보 안 찾고 고정 줄 하나(품목 「프로젝트에서」 표지) · 갈래 수 그대로 - Make: 별도계상 고정 줄 = 단가 0 · 메모 「별도계상 · 품목은 프로젝트에서」(합 밖) - Picks.ts: apartPick 표지 셈 - 시험 642_3 · 642_5 · 642_6 Picks 몫을 새 꼴로 고침(옛 단언: 레미콘 자재 목록 별도계상 칸) Co-Authored-By: Claude Sonnet 5.5 --- M02_MasterTemplete/M02_Hopyo_Make.py | 11 +++++++++ M02_MasterTemplete/M02_Hopyo_Row.py | 16 ++++++++----- .../logic/M02_MasterTemplete_Hopyo_Picks.ts | 4 ++++ resources/tester/test_642_3_row.py | 18 ++++++++++++++- resources/tester/test_642_5_make.py | 20 ++++++++++++---- resources/tester/test_642_6_screen.py | 23 +++++++++++++++++++ 6 files changed, 80 insertions(+), 12 deletions(-) diff --git a/M02_MasterTemplete/M02_Hopyo_Make.py b/M02_MasterTemplete/M02_Hopyo_Make.py index 5f5067296..8becc8fef 100644 --- a/M02_MasterTemplete/M02_Hopyo_Make.py +++ b/M02_MasterTemplete/M02_Hopyo_Make.py @@ -7,6 +7,7 @@ SC(`M02_Hopyo_Build.Builder` 가 줄 차례 · 확인 안 한 줄 = 모두 고정 길 그대로) — 한 엑셀 줄 = 고른 것마다 우리 줄(같은 별칭 = 엑셀 품명 · 줄 `본뜬 {호표, 차례, 행}`) · 수량 {손 = 엑셀 수량 × 몫 × 배} · 기초 = 로직 줄(같은 로직 둘째 = 한 줄 SC 사본) · 재료 · 유가 = 자재 목록(별도계상) · 인력 · 기계 · 유가 이음 · + 별도계상 줄(`고정` + 별도계상 · 786-1) = 단가 0 고정 줄(합 밖 · 메모 「별도계상 · 품목은 프로젝트에서」) · 율 = 고정 줄 `비율 {기준, 비목}` · 고정 = 엑셀 단가 고정 줄 · 단가산출 · 일위대가 = 참조 (`단산N` = 같은 옮기기 블록 SK · `제N호표` = 같은 HP 호표 SC — 하위 먼저 · 확인 안 된 하위 = 모두 고정). SK(블록) = 바탕 없음 + 더한줄 — 기초 `{종류 로직, 입력값, 수량 = 몫}` · 참조 · 단위 줄(`항목`)에 이은 @@ -44,6 +45,7 @@ _SUB = {"단가산출": re.compile(r"^단산(\d+)$"), "일위대가": re.compile LINK = {"인력": "노임", "기계": "기계", "유가": "유가"} OLD_PICK = {"인력": "노임", "기계": "기계", "재료": "자재", "유가": "자재"} NEED = "입력 필요" +APART_MEMO = "별도계상 · 품목은 프로젝트에서" COVER = "이미 옮긴 것이 있음 — 덮으려면 [덮어 옮기기](덮음)" @@ -156,6 +158,13 @@ class Lines: ref = {"키": "SC-미리"} if b.dry else hkids.save(wrap, b.same)[0] # 미리 보기 = 쓰기 없음 self.lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}}) + def apart(self, line: dict, alias: str | None, mark: dict) -> None: + """별도계상 줄(786-1) = 합 밖 고정 줄 — 단가 0(셈에 안 듦) · 품명 · 규격 · 단위 · 수량 그대로 · + 품목은 프로젝트에 복사할 때 정함(메모 표지).""" + zero = {**line, "단가": {}, "금액": {}} + self.put(zero, {"이음": dict.fromkeys(hfix.fixed.LINKS)}, alias, None, mark) + self.lines[-1]["메모"] = f"{APART_MEMO} · {self.lines[-1].get('메모') or ''}".strip(" ·") + def picks(self, at: int, line: dict, picks: list[dict]) -> None: """고른 것(검사 · 다시 셈 끝난 것) → 그 엑셀 줄의 우리 줄 · 자재.""" alias = str(line.get("이름") or "")[: labor.ALIAS_MAX].strip() or None @@ -175,6 +184,8 @@ class Lines: elif kind == "율": self.put(line, {"이음": {"비율": p["비목"]}}, alias, None, mark) self.lines[-1]["고정"]["비율"] = {"기준": p["기준"], "비목": p["비목"]} + elif kind == "고정" and p.get("별도계상"): + self.apart(line, alias, mark) elif kind == "고정": self.put(line, {"이음": dict.fromkeys(hfix.fixed.LINKS)}, alias, None, mark) else: diff --git a/M02_MasterTemplete/M02_Hopyo_Row.py b/M02_MasterTemplete/M02_Hopyo_Row.py index 2d534ef9e..c139154b7 100644 --- a/M02_MasterTemplete/M02_Hopyo_Row.py +++ b/M02_MasterTemplete/M02_Hopyo_Row.py @@ -13,7 +13,8 @@ 그 밖 = 고정. 후보 찾기 차례 = 장부(`Ledger.recall` · 고른 것 꼴) › M01 표마다(LB · MT · MO · EQ) › M02 층 SK · SC 같은 이름(위 층까지 · 참고). -판정 = 제안 있음(장부 짝 · 후보 하나 · 별도계상 · 하위 호표 · 율) · 2차 필요(단가산출 줄 = +판정 = 제안 있음(장부 짝 · 후보 하나 · 별도계상 = 고정 줄 하나(`품목` 표지) · 하위 호표 · 율) · + 2차 필요(단가산출 줄 = `Bind` · `Solve` 몫) · 후보 여럿 · 후보 없음(고정 금액 제안). 고른 것 = `{갈래 기초|단가산출|일위대가|인력|재료|유가|기계|율|고정, 키, 이름, 규격, 단위, 단가, 입력값, 출처, 몫, 비목?, 기준?, 별도계상?, 층?, 주인?}`(설계 2-1 · 만들기 = `Make` 몫). @@ -310,9 +311,13 @@ def _rate_pick(line: dict[str, Any]) -> dict[str, Any]: return one +# 별도계상 줄 표지 `품목` — 품목은 프로젝트에 복사할 때 정함(요구 645-2) +ITEM_LATER = "프로젝트에서" + + def _fixed(line: dict[str, Any], apart: bool = False) -> dict[str, Any]: price = {k: _plain((line.get("단가") or {}).get(k)) or 0 for k in COSTS} - more = {"별도계상": True} if apart else {} + more = {"별도계상": True, "품목": ITEM_LATER} if apart else {} return pick( "고정", None, line.get("이름"), line.get("규격"), line.get("단위"), 단가=price, **more ) @@ -354,12 +359,13 @@ def judge( out["번"] = no qty = {"엑셀": line.get("수량"), "배": 1, "갈래": "같음"} picks: list[dict[str, Any]] = [] + if why == "별도계상": # 모아서 한 번에 셈 — 품목 후보 · 장부 짝 안 찾음(고정 줄 하나 · 786-1) + return {**out, "후보": [_fixed(line, True)], "판정": "제안 있음", "수량": qty} found = None if folder is not None: found = ledger.recall(line, folder, book, doc=doc) if found and found.get("갈래") == "짝": picks = _from_ledger(found) - apart = why == "별도계상" for k in kinds if not picks else (): if k == "일위대가" and no is not None: picks.append(pick("일위대가", f"제{no}호표", line.get("이름"), line.get("규격"), @@ -374,8 +380,6 @@ def judge( picks += m01_picks(k, line) if not found: picks += [p for p in (docs or {}).get(mm.plain(line.get("이름")), []) if p["갈래"] in kinds] - if apart: - picks = [{**p, "별도계상": True} for p in picks] or [_fixed(line, True)] picks = list({(p["갈래"], p.get("키"), p.get("층")): p for p in picks}.values()) if found and found.get("갈래") in ("짝", "우리에 없음", "안 씀"): out["장부"] = {k: found.get(k) for k in ("갈래", "층", "제안", "규격다름")} @@ -385,7 +389,7 @@ def judge( found and found.get("갈래") in ("짝", "안 씀", "우리에 없음") and not found.get("규격다름") ): verdict = "제안 있음" - elif apart or kinds[0] in ("일위대가", "율") or len(picks) == 1: + elif kinds[0] in ("일위대가", "율") or len(picks) == 1: verdict = "제안 있음" elif picks: verdict = "후보 여럿" diff --git a/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Picks.ts b/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Picks.ts index 0e7aa0988..cc53a6caa 100644 --- a/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Picks.ts +++ b/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Picks.ts @@ -6,6 +6,7 @@ * · 제안 = 미리 체크로만(캐시에 없으면 서버 제안) · 확인 = 사용자 [확인] · [호표 제안 모두 확인] 누름만(자동 없음) * · 기초 고른 것 `필요` = 엔진이 쓰는데 못 읽은 칸(입력 필요) + 엇갈림 칸 — 값이 비면 그 줄 확인 · 옮김 막음 * · 옮기기 몸 = 확인한 줄 · 블록만 · `필요` 걷음 · 줄 `배`(단위 환산 사용자 값) 얹음 · 금액 · 품 칸 안 보냄(서버가 다시 셈) + * · 별도계상 줄 = 서버 제안이 고정 줄 하나(`apartPick`) — 고름 없이 [확인] · [옮기기] 됨(막는 칸 없음) * · 칠 = 확인함 success · 품 다름 warning · 고를 것 info · 후보 없음 danger * 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장). * ========================================================================== */ @@ -95,6 +96,9 @@ export const linePicks = (d: JudgeDraft, no: number, row: JudgeRow): DraftPick[] export const blockPicks = (d: JudgeDraft, s: Solved): DraftPick[] => d.블록[String(s.번)]?.고른 ?? solvedPicks(s); +/** 별도계상 줄(786-1) — 고정 줄 하나 · 품목은 프로젝트에 복사할 때 정함 · 고름 없이 확인 · 옮김 */ +export const apartPick = (p: Pick): boolean => p.갈래 === "고정" && p.별도계상 === true; + export const samePick = (a: Pick, b: Pick): boolean => a.갈래 === b.갈래 && a.키 === b.키 && a.이름 === b.이름 && (a.규격 ?? "") === (b.규격 ?? ""); diff --git a/resources/tester/test_642_3_row.py b/resources/tester/test_642_3_row.py index 12578d2f3..b40e1997e 100644 --- a/resources/tester/test_642_3_row.py +++ b/resources/tester/test_642_3_row.py @@ -87,7 +87,23 @@ def test_북부청_줄_판_판정과_후보(bukbu): assert all(set(x["갈래"]) <= set(row.KINDS) and x["후보"] is not None for x in lines) first = {x["행"]: x for x in out[1]} # 제1호표(설계 2-10 기대표) assert first[6]["갈래"] == ["재료"] and first[6]["근거"] == "별도계상" - assert first[6]["판정"] == "제안 있음" and all(p["별도계상"] for p in first[6]["후보"]) + assert first[6]["판정"] == "제안 있음" and len(first[6]["후보"]) == 1 + apart = [x for x in lines if x["근거"] == "별도계상"] # 786-1 별도계상 64 줄 = 고정 줄 하나 + assert len(apart) == 64 and all( + x["갈래"] == ["재료"] and x["판정"] == "제안 있음" for x in apart + ) + for x in apart: + (p,) = x["후보"] + assert (p["갈래"], p["키"], p["별도계상"], p["품목"]) == ( + "고정", + None, + True, + "프로젝트에서", + ) + assert p["단위"] and p["단가"] == {"노무비": 0, "재료비": 0, "경비": 0} and "장부" not in x + assert all( + p["갈래"] != "고정" or "품목" not in p for x in lines for p in x["후보"] if x not in apart + ) assert first[7]["번"] == 46 and first[7]["판정"] == "2차 필요" assert first[10]["갈래"] == ["재료"] and first[10]["근거"].startswith("사전 자재단가") rate = next(x for x in lines if x["갈래"] == ["율"]) diff --git a/resources/tester/test_642_5_make.py b/resources/tester/test_642_5_make.py index aa449eb04..e9d96cf21 100644 --- a/resources/tester/test_642_5_make.py +++ b/resources/tester/test_642_5_make.py @@ -85,8 +85,11 @@ def test_판정_검산_옮기기(client): a, b = _base(rebar, 0), _base(rebar, 1) b["입력값"] = {**b["입력값"], "구분": "토목"} # 엇갈림 = 사용자 고름 a["몫"] = 0.5 - mat = first["줄"][0] # 레미콘 별도계상 - cement = {**mat["후보"][0], "별도계상": True} + mat = first["줄"][0] # 레미콘 별도계상 — 786-1 후보 = 고정 줄 하나(품목은 프로젝트에서) + assert mat["판정"] == "제안 있음" and len(mat["후보"]) == 1 + cement = mat["후보"][0] + assert (cement["갈래"], cement["키"], cement["별도계상"]) == ("고정", None, True) + assert cement["품목"] == "프로젝트에서" stuck = {"갈래": "기초", "키": "GC000211", "입력값": {"유형": "보통"}} ex3 = _base(form) # 예시 3 GC000211 보통 · 4회 · 높이감률 · 구멍땜 = 입력 필요 url = f"{API}/hopyo/{KEY}/check" @@ -155,9 +158,16 @@ def test_판정_검산_옮기기(client): if (x.get("고정") or {}).get("출처", {}).get("줄") == form["차례"] ) assert "본뜬" not in held # 막힌 줄 = 고정 줄(엑셀 숫자) - cement_row = next(m for m in sc1["자재"] if m["코드"] == cement["키"]) - assert labor.mark_of(cement_row, "별도계상") == "별도계상" - assert cement_row["수량"]["손"] == mat["수량"]["엑셀"] + held_apart = next( + x for x in sc1["담은로직"] if (x.get("본뜬") or {}).get("차례") == mat["차례"] + ) + fx = held_apart["고정"] # 별도계상 줄 = 합 밖 고정 줄(단가 0 · 자재 목록에 안 넣음) + assert fx["이름"] == mat["이름"] and fx["규격"] == "별도계상" and not any(fx["단가"].values()) + assert held_apart["수량"]["손"] == mat["수량"]["엑셀"] + assert held_apart["메모"].startswith("별도계상 · 품목은 프로젝트에서") + without = {**sc1, "담은로직": [x for x in sc1["담은로직"] if x is not held_apart]} + got_sum, base_sum = labor.server(sc1)["계산값"], labor.server(without)["계산값"] + assert got_sum["반영합"] == base_sum["반영합"] # 서버 정본 셈 — 별도계상 줄은 합에 안 듦 sk = _doc("sangeun", out["단가산출"][0]["키"]) assert sk["바탕"] is None and [x["로직"] for x in sk["더한줄"]] == ["GC001140"] diff --git a/resources/tester/test_642_6_screen.py b/resources/tester/test_642_6_screen.py index f6f5fe10a..fbd4cc05b 100644 --- a/resources/tester/test_642_6_screen.py +++ b/resources/tester/test_642_6_screen.py @@ -125,6 +125,29 @@ def test_호표_제안_모두_확인은_다풀린_제안만(judged): assert got[2]["수"] == 1 and list(got[2]["body"]["호표"]["1"]) == ["0"] +def test_별도계상_줄은_고름없이_확인_옮김(judged): + """786-1 — 별도계상 줄 = 고정 줄 하나 · 막는 칸 없음 · [확인] 하면 옮기기 몸에 들어감.""" + row = judged[1][0] # 레미콘 별도계상 + got = _run( + [ + {"fn": "rowDefaults", "args": [row]}, + {"fn": "suggestReady", "args": [row]}, + {"fn": "confirmable", "args": [{"$": 0}]}, + {"fn": "blockedOf", "args": [{"$": 0}]}, + {"fn": "apartPick", "args": [row["후보"][0]]}, + {"fn": "emptyDraft", "args": []}, + {"fn": "putLine", "args": [{"$": 5}, 1, 0, {"고른": {"$": 0}, "확인": True}]}, + {"fn": "moveBody", "args": [{"$": 6}, FILE]}, + ] + ) + (p,) = got[0] + assert p["갈래"] == "고정" and p["별도계상"] is True and p["품목"] == "프로젝트에서" + assert got[1] is True and got[2] is True and got[3] == [] and got[4] is True + assert got[7]["막힘"] == [] and got[7]["수"] == 1 + (moved,) = got[7]["body"]["호표"]["1"]["0"] + assert moved["갈래"] == "고정" and moved["별도계상"] is True and "필요" not in moved + + def test_예시1_단산10_입력필요0(judged): row = _row(judged, 2, 10) got = _run(