diff --git a/resources/tester/test_est_business.py b/resources/tester/test_est_business.py new file mode 100644 index 000000000..59cb99937 --- /dev/null +++ b/resources/tester/test_est_business.py @@ -0,0 +1,174 @@ +# Annual allocation and resource expansion regression cases. + +from test_est_rea_parity import run + + +def test_annual_formulas_split_component_amounts_and_preserve_remainder(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.options.SourceRefresh=false; + p.tables.EstMainDat=[{PkID:1,Code:'R',Name:'item',GN:1,NU:5,GU:3},{LinkIndex:54}]; + p.annual=[{name:'1차',formula:'[기준]*0.5'},{name:'2차',formula:'[기준]-[1차]'}]; + const q=EstNative.calculate(p),before=JSON.stringify(q); + return {output:EstBusiness.annual(q),preserved:before===JSON.stringify(q)}; + })()""", + ) + assert result["preserved"] + output = result["output"] + assert output["errors"] == [] + first, second = [item["project"] for item in output["results"]] + assert first["tables"]["EstMainDat"][0]["GN"] == 0.5 + assert second["tables"]["EstMainDat"][0]["GN"] == 0.5 + assert [item["calculation"]["direct"]["TC"] for item in [first, second]] == [3, 5] + assert first["tables"]["EstMainDat"][0]["NC"] == 2 + assert second["tables"]["EstMainDat"][0]["NC"] == 3 + assert second["tables"]["EstMainDat"][0]["GC"] == 2 + assert all(first["tables"]["EstMainDat"][0][key] == 0 for key in ["TU", "NU", "GU", "JU"]) + + +def test_annual_fixed_amount_with_zero_quantity_and_duplicate_names(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.options.SourceRefresh=false; + p.tables.EstMainDat=[{PkID:1,Code:'R',GN:0,NC:3,GC:7,TC:10,CalcIndex:1},{LinkIndex:54}]; + p.annual=[{name:'1차',formula:'[기준]*0.5'},{name:'2차',formula:'[기준]-[1차]'}]; + const output=EstBusiness.annual(EstNative.calculate(p)); + p.annual[1].name='1차'; + return {output,invalid:EstBusiness.annual(p)}; + })()""", + ) + assert result["output"]["errors"] == [] + assert [ + item["project"]["calculation"]["direct"]["TC"] for item in result["output"]["results"] + ] == [4, 6] + assert result["invalid"]["results"] == [] + assert len(result["invalid"]["errors"]) == 1 + + +def test_resource_expansion_preserves_different_labor_and_material_bid_rates(): + result = run( + "math", + r"""(()=>{ + const p=EstNative.empty(); + p.tables.StuffcDat=[{Code:'M',Name:'material',ValueIndex:4,InputTC:100}]; + p.tables.LaborcDat=[{Code:'L',Name:'labor',ValueIndex:4,InputTC:100}]; + p.tables.CostMainDat=[{Index:1,Code:'C'}, + {Code:'M',LinkIndex:5,SCheck:-2,LineData:' \x0ematerial\x0e: \x03100\x03 = \x12100\x12'}, + {Code:'L',LinkIndex:6,SCheck:-1,LineData:' \x0elabor\x0e: \x03100\x03 = \x12100\x12'}]; + p.tables.CostTitleDat=[{Code:'C'}]; + p.tables.EstMainDat=[{PkID:1,Code:'C',LinkIndex:104,GN:10},{LinkIndex:54}]; + const bid=EstBusiness.bid(EstNative.calculate(p),[80,90,100,100]); + const direct=EstResources.calculate(bid); + p.tables.HeadMainDat=[{Index:1,Code:'H'},{Code:'C',LinkIndex:104,GN:1}]; + p.tables.HeadTitleDat=[{Code:'H'}];p.tables.EstMainDat[0].LinkIndex=103;p.tables.EstMainDat[0].Code='H'; + const nested=EstResources.calculate(EstBusiness.bid(EstNative.calculate(p),[80,90,100,100])); + return {direct,nested}; + })()""", + ) + for output in result.values(): + assert output["errors"] == [] + resources = {row["Code"]: row for row in output["rows"]} + assert resources["M"]["quantity"] == 9 + assert resources["M"]["amount"] == 900 + assert resources["L"]["quantity"] == 8 + assert resources["L"]["amount"] == 800 + + +def test_resource_expansion_selects_nested_head_component_and_all_conversions(): + result = run( + "math", + r"""(()=>{ + const p=EstNative.empty(); + p.tables.StuffcDat=[{Code:'M',Name:'material',ValueIndex:4,InputTC:100}]; + p.tables.LaborcDat=[{Code:'L',Name:'labor',ValueIndex:4,InputTC:100}]; + p.tables.HeadMainDat=[{Index:1,Code:'H',Name:'head'}, + {Code:'M',LinkIndex:5,GN:1},{Code:'L',LinkIndex:6,GN:1}]; + p.tables.HeadTitleDat=[{Code:'H',Name:'head'}]; + p.tables.CostMainDat=[{Index:1,Code:'C'}, + {Code:'H',LinkIndex:103,LineData:' \x0ehead\x0e'}, + {Code:'H',LinkIndex:103,SCheck:-1,LineData:' 노무비: \x03100\x03 = \x12100\x12'}, + {Code:'H',LinkIndex:103,SCheck:-2,LineData:' 재료비: \x03100\x03 = \x12100\x12'}]; + p.tables.CostTitleDat=[{Code:'C'}]; + p.tables.EstMainDat=[{PkID:1,Code:'C',LinkIndex:104,GN:10},{LinkIndex:54}]; + const selected=EstResources.calculate(p); + p.tables.HeadMainDat.push({LinkIndex:68,GN:0.5},{LinkIndex:68,GN:0.1}); + p.tables.EstMainDat[0].Code='H';p.tables.EstMainDat[0].LinkIndex=103; + return {selected,converted:EstResources.calculate(p)}; + })()""", + ) + assert all(output["errors"] == [] for output in result.values()) + assert len(result["selected"]["rows"]) == 2 + assert all( + row["quantity"] == 10 and row["amount"] == 1000 for row in result["selected"]["rows"] + ) + assert all( + row["quantity"] == 0.5 and row["amount"] == 50 for row in result["converted"]["rows"] + ) + + +def test_resource_expansion_includes_money_only_rows_and_percentage_costs(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.options.SourceRefresh=false; + p.tables.EstMainDat=[{PkID:1,Name:'fixed',GN:0,NC:3,GC:7,JC:2,TC:12,CalcIndex:1},{LinkIndex:54}]; + const fixed=EstResources.calculate(p); + const q=EstNative.empty();q.tables.StuffcDat=[{Code:'M',Name:'material',ValueIndex:4,InputTC:100}]; + q.tables.HeadMainDat=[{Index:1,Code:'H'},{Code:'M',LinkIndex:5,GN:1}, + {LinkIndex:58,Size:'합계',SCheck:3,GN:10}];q.tables.HeadTitleDat=[{Code:'H'}]; + q.tables.EstMainDat=[{Code:'H',LinkIndex:103,GN:2},{LinkIndex:54}]; + return {fixed,percentage:EstResources.calculate(q)}; + })()""", + ) + assert all(output["errors"] == [] for output in result.values()) + fixed = {row["Code"]: row for row in result["fixed"]["rows"]} + assert fixed["LZZZZZZZZZZ"]["amount"] == fixed["LZZZZZZZZZZ"]["quantity"] == 3 + assert fixed["MZZZZZZZZZZ"]["amount"] == fixed["MZZZZZZZZZZ"]["quantity"] == 7 + assert fixed["EZZZZZZZZZZ"]["amount"] == 2 + assert fixed["EZZZZZZZZZZ"]["Unite"] == "제외금액" + assert sum(row["amount"] for row in result["percentage"]["rows"]) == 220 + + +def test_cost_code_refresh_without_name_marker_still_tracks_the_actual_resource(): + result = run( + "math", + r"""(()=>{ + const p=EstNative.empty(); + p.tables.StuffcDat=[{Code:'M',Name:'material',ValueIndex:4,InputTC:150}]; + p.tables.CostMainDat=[{Index:1,Code:'C'}, + {Code:'M',LinkIndex:5,SCheck:-2,LineData:' 재료비: \x03100\x03 ×2 = \x12200\x12'}]; + p.tables.CostTitleDat=[{Code:'C'}]; + p.tables.EstMainDat=[{Code:'C',LinkIndex:104,GN:1},{LinkIndex:54}]; + return {calculated:EstNative.calculate(p),resources:EstResources.calculate(p)}; + })()""", + ) + assert result["calculated"]["calculation"]["errors"] == [] + assert result["calculated"]["calculation"]["direct"]["TC"] == 300 + assert result["resources"]["errors"] == [] + assert result["resources"]["rows"][0]["Code"] == "M" + assert result["resources"]["rows"][0]["quantity"] == 2 + assert result["resources"]["rows"][0]["amount"] == 300 + + +def test_unlinked_text_price_does_not_inherit_the_previous_rows_source(): + result = run( + "math", + r"""(()=>{ + const p=EstNative.empty(); + p.tables.StuffcDat=[{Code:'M',Name:'material',ValueIndex:4,InputTC:150}]; + p.tables.CostMainDat=[{Index:1,Code:'C'}, + {Code:'M',LinkIndex:5,SCheck:-2,LineData:' \x0ematerial\x0e: \x03100\x03 = \x12100\x12'}, + {Code:'M',LinkIndex:0,SCheck:-2,LineData:' 직접입력: \x0350\x03 ×2 = \x12100\x12'}]; + p.tables.CostTitleDat=[{Code:'C'}]; + p.tables.EstMainDat=[{Code:'C',LinkIndex:104,GN:1},{LinkIndex:54}]; + return {calculated:EstNative.calculate(p),resources:EstResources.calculate(p)}; + })()""", + ) + assert result["calculated"]["calculation"]["errors"] == [] + assert result["calculated"]["calculation"]["direct"]["TC"] == 250 + resources = {row["Code"]: row for row in result["resources"]["rows"]} + assert resources["M"]["quantity"] == 1 + assert resources["M"]["amount"] == 150 + assert resources["MZZZZZZZZZZ"]["amount"] == 100 diff --git a/resources/tester/test_est_rea_parity.py b/resources/tester/test_est_rea_parity.py index dcc630b39..d144b2a47 100644 --- a/resources/tester/test_est_rea_parity.py +++ b/resources/tester/test_est_rea_parity.py @@ -648,47 +648,3 @@ def test_text_total_without_result_marker_evaluates_its_expression(): assert result["rows"][0]["TC"] == 20 assert result["rows"][1]["TC"] == 20 assert result["rows"][2]["TC"] == 0 - - -def test_annual_formulas_split_component_amounts_and_preserve_remainder(): - result = run( - "math", - """(()=>{ - const p=EstNative.empty();p.options.SourceRefresh=false; - p.tables.EstMainDat=[{PkID:1,Code:'R',Name:'item',GN:1,NU:5,GU:3},{LinkIndex:54}]; - p.annual=[{name:'1차',formula:'[기준]*0.5'},{name:'2차',formula:'[기준]-[1차]'}]; - const q=EstNative.calculate(p),before=JSON.stringify(q); - return {output:EstBusiness.annual(q),preserved:before===JSON.stringify(q)}; - })()""", - ) - assert result["preserved"] - output = result["output"] - assert output["errors"] == [] - first, second = [item["project"] for item in output["results"]] - assert first["tables"]["EstMainDat"][0]["GN"] == 0.5 - assert second["tables"]["EstMainDat"][0]["GN"] == 0.5 - assert [item["calculation"]["direct"]["TC"] for item in [first, second]] == [3, 5] - assert first["tables"]["EstMainDat"][0]["NC"] == 2 - assert second["tables"]["EstMainDat"][0]["NC"] == 3 - assert second["tables"]["EstMainDat"][0]["GC"] == 2 - assert all(first["tables"]["EstMainDat"][0][key] == 0 for key in ["TU", "NU", "GU", "JU"]) - - -def test_annual_fixed_amount_with_zero_quantity_and_duplicate_names(): - result = run( - "math", - """(()=>{ - const p=EstNative.empty();p.options.SourceRefresh=false; - p.tables.EstMainDat=[{PkID:1,Code:'R',GN:0,NC:3,GC:7,TC:10,CalcIndex:1},{LinkIndex:54}]; - p.annual=[{name:'1차',formula:'[기준]*0.5'},{name:'2차',formula:'[기준]-[1차]'}]; - const output=EstBusiness.annual(EstNative.calculate(p)); - p.annual[1].name='1차'; - return {output,invalid:EstBusiness.annual(p)}; - })()""", - ) - assert result["output"]["errors"] == [] - assert [ - item["project"]["calculation"]["direct"]["TC"] for item in result["output"]["results"] - ] == [4, 6] - assert result["invalid"]["results"] == [] - assert len(result["invalid"]["errors"]) == 1 diff --git a/tools/est-test/README.md b/tools/est-test/README.md index 22913d6a3..d0597d5d6 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -26,12 +26,14 @@ 연차공사의 `[기준]*0.5` 수식은 수량과 각 성분 금액에 각각 적용됩니다. 금액은 정수 절사하고, 다음 차수의 `[기준]-[1차]`로 절사 후 잔액을 배분합니다. 차수 사본은 단가를 비운 고정 금액으로 저장하여 다시 열어도 금액을 유지합니다. `[계약수량]`은 이전 작업 파일과의 호환 표기로 `[기준]`과 같은 역할을 합니다. +동원 집계는 비목별 낙찰률과 단위환산을 하위 산근·일위대가에도 전달합니다. 연결 단가가 없는 금액 입력분은 원본 전용 코드로 따로 합산하고, 장비 시간으로 바꿀 수 없는 경비는 `제외금액` 단위로 보존합니다. 산근에서 연결을 해제한 단가는 앞 행의 원천을 가져오지 않습니다. + **전체 원본 동등성은 아직 확보하지 못했습니다.** 기성·물가변동·연차 등은 분석 분기와 합성 시나리오를 시험했으며 원본 업무 샘플 대조는 없습니다. 원본 화면 실행 비교는 못했습니다. 원본 페이지·도면 배치, CAD/VEC0650 편집, 원본 인쇄 서식, Excel/XML/JDL 변환, ESTX 수정 저장은 미구현입니다. 원본 대체품의 완료 판정으로 보지 않습니다. 검사: ```powershell -./venv/Scripts/python.exe -m pytest resources/tester/test_est_rea_parity.py resources/tester/test_est_standalone.py -q +./venv/Scripts/python.exe -m pytest resources/tester/test_est_rea_parity.py resources/tester/test_est_business.py resources/tester/test_est_standalone.py -q ``` `native-*.js`가 현재 화면·계산 엔진이며 브라우저와 로컬 서버가 같은 계산 코드를 사용합니다. `build_sample.py`는 REA 자료에서 입력·기대값을 재생성합니다. 기존 `app.js`, `calc.js`, `formula.js`, `workflows.js`는 이전 데모의 회귀 시험용이며 현재 진입 화면에서 사용하지 않습니다. diff --git a/tools/est-test/native-resources.js b/tools/est-test/native-resources.js index 6521c42d4..3624ff613 100644 --- a/tools/est-test/native-resources.js +++ b/tools/est-test/native-resources.js @@ -32,7 +32,55 @@ const EstResources = (() => { if (kind === 105 && quantity === 0 && amount !== null) row.amountForHours += amount; output.set(id, row); } - function expand(kind, code, multiplier, path) { + function bidComponents(row, source) { + const classified = [1, 2, 3].includes(num(row.SCheck)); + const whole = row.ApplyNacChal && num(source?.TC) ? num(row.TU) / num(source.TC) : 1; + return Object.fromEntries( + components.map((key, index) => [ + key, + classified + ? whole + : row.ApplyNacChal && num(source?.[key]) + ? num(row[EstMath.units[index]]) / num(source[key]) + : 1, + ]), + ); + } + function weightedRate(source, rates) { + return num(source?.TC) + ? components.reduce((amount, key) => amount + num(source[key]) * rates[key], 0) / + num(source.TC) + : 1; + } + function money(row, multiplier, rates) { + const labels = { + NC: [6, "LZZZZZZZZZZ", "노무비"], + GC: [5, "MZZZZZZZZZZ", "재료비"], + JC: [105, "EZZZZZZZZZZ", "경비"], + SC: [21, "SZZZZZZZZZZ", "표준시장단가"], + }; + for (const key of components) { + const amount = num(row[key]) * multiplier * rates[key]; + if (!amount) continue; + const [kind, Code, Name] = labels[key], + id = `${kind}:${Code}`; + const item = output.get(id) || { + Code, + Name, + Size: "금액 입력분", + Unite: key === "JC" ? "제외금액" : "원", + kind, + quantity: 0, + amount: 0, + amountForHours: 0, + }; + item.amount += amount; + if (key !== "JC") item.quantity += amount; + output.set(id, item); + } + } + function expand(kind, code, multiplier, path, componentRates = null) { + const rates = componentRates || Object.fromEntries(components.map((key) => [key, 1])); const id = `${kind}:${code}`; if (path.includes(id)) { errors.push(`동원 순환 참조: ${id}`); @@ -45,20 +93,39 @@ const EstResources = (() => { return; } for (const row of group.rows) { - if (!row.ResourceCode || row.ResourceFactor == null) continue; + if (!row.ResourceCode || row.ResourceFactor == null) { + const key = { [-1]: "NC", [-2]: "GC", [-3]: "JC", [-4]: "SC" }[num(row.SCheck)]; + if (key && !String(row.LineData || "").includes("\x1a") && num(row.LinkIndex) !== 61) + money({ [key]: num(row.TC) }, multiplier, rates); + continue; + } + const source = tables[EstNative.sourceTables[num(row.ResourceKind)]]?.find( + (item) => item.Code === row.ResourceCode, + ); + const directKey = row.ResourceComponent; + const rate = directKey ? rates[directKey] : weightedRate(source, rates); + if (!rate) continue; if ([103, 104].includes(num(row.ResourceKind))) - expand(num(row.ResourceKind), row.ResourceCode, multiplier * num(row.ResourceFactor), [ - ...path, - id, - ]); + expand( + num(row.ResourceKind), + row.ResourceCode, + multiplier * num(row.ResourceFactor), + [...path, id], + Object.fromEntries( + components.map((key) => [ + key, + !row.ResourceComponent || row.ResourceComponent === key ? rates[key] : 0, + ]), + ), + ); else if (num(row.ResourceKind) === 105) - record(105, row.ResourceCode, 0, multiplier * num(row.TC)); + record(105, row.ResourceCode, 0, multiplier * num(row.TC) * rate); else record( num(row.ResourceKind), row.ResourceCode, - multiplier * num(row.ResourceFactor), - multiplier * num(row.TC), + multiplier * num(row.ResourceFactor) * rate, + multiplier * num(row.TC) * rate, ); } return; @@ -70,43 +137,61 @@ const EstResources = (() => { return; } 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; + if (num(row.LinkIndex) === 52) { + rate *= num(row.GN) / 100; + break; + } + if (num(row.LinkIndex) === 68) + rate *= num(row.GN) / (["%", "%"].includes(row.Unite) ? 100 : 1); + } + for (const row of group.rows.slice(1)) { + if (num(row.CalcIndex) >= 2 || [51, 52, 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 bid = bidComponents(row, source); + const classified = { 1: "GC", 2: "NC", 3: "JC" }[num(row.SCheck)]; + const childRates = Object.fromEntries( + components.map((key) => [ + key, + bid[key] * (classified ? rates[classified] : rates[key]), + ]), + ); const quantity = - (multiplier * num(row.GN) * rate * unitRatio) / - (["%", "%"].includes(row.Unite) ? 100 : 1); + (multiplier * num(row.GN) * rate) / (["%", "%"].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) * rate); + expand(num(row.LinkIndex), row.Code, quantity, [...path, id], childRates); + else if (EstNative.sourceTables[num(row.LinkIndex)]) { + const count = quantity * weightedRate(source, childRates); + const amount = + multiplier * + rate * + components.reduce((amount, key) => amount + num(row[key]) * rates[key], 0); + if (count || amount) record(num(row.LinkIndex), row.Code, count, amount); + } else money(row, multiplier * rate, rates); } return; } - record(kind, code, multiplier); + const source = tables[EstNative.sourceTables[kind]]?.find((row) => row.Code === code); + record(kind, code, multiplier * weightedRate(source, rates)); } for (const row of tables.EstMainDat) { if (num(row.LinkIndex) === 54) break; if ( num(row.Level) > 0 || num(row.CalcIndex) >= 2 || - !EstNative.sourceTables[num(row.LinkIndex)] + [51, 52, 68].includes(num(row.LinkIndex)) ) continue; + if (!EstNative.sourceTables[num(row.LinkIndex)]) { + money(row, 1, { NC: 1, GC: 1, JC: 1, SC: 1 }); + 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; - expand(num(row.LinkIndex), row.Code, num(row.GN) * unitRatio, []); + expand(num(row.LinkIndex), row.Code, num(row.GN), [], bidComponents(row, source)); } const rows = [...output.values()]; for (const row of rows) diff --git a/tools/est-test/native-text.js b/tools/est-test/native-text.js index 4e457d6a0..ad19bea1f 100644 --- a/tools/est-test/native-text.js +++ b/tools/est-test/native-text.js @@ -63,7 +63,10 @@ const EstText = (() => { } for (let index = 0; index < rows.length; index++) { const row = rows[index]; + currentSource = null; delete row.Error; + for (const key of ["ResourceFactor", "ResourceCode", "ResourceKind", "ResourceComponent"]) + delete row[key]; if (num(row.Index) > 0) { finish(); head = row; @@ -126,11 +129,19 @@ const EstText = (() => { matched = original.match(marker), sc = num(row.SCheck); const oldValue = matched ? numeric(matched[1]) : null; - if (num(row.LinkIndex) > 0 && /\x0e/.test(original)) { + if ( + [5, 6, 21, 101, 103, 104, 105].includes(num(row.LinkIndex)) && + (row.Code || /[\x0e\x03]/.test(original)) + ) { const name = original.match(/\x0e([^\x0e]+)\x0e/), size = original.match(/\x13([^\x13]+)\x13/); try { - currentSource = resolve({ ...row, Name: name?.[1], Size: size?.[1] }); + currentSource = null; + currentSource = resolve({ + ...row, + Name: name?.[1] ?? row.Name, + Size: size?.[1] ?? row.Size, + }); } catch (error) { errors.push(`${head.Code}:${index + 1} ${error.message}`); }