fix(est): preserve component rates in resource totals

This commit is contained in:
2026-11-05 13:39:04 +09:00
parent a007d6358a
commit c6118c97ed
5 changed files with 301 additions and 73 deletions
+174
View File
@@ -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
-44
View File
@@ -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
+3 -1
View File
@@ -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`는 이전 데모의 회귀 시험용이며 현재 진입 화면에서 사용하지 않습니다.
+111 -26
View File
@@ -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)
+13 -2
View File
@@ -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}`);
}