fix(est): propagate assigned prices into final totals

This commit is contained in:
2026-11-05 14:23:21 +09:00
parent a6fc896655
commit 3e9fb28714
3 changed files with 108 additions and 11 deletions
+66
View File
@@ -69,3 +69,69 @@ def test_parenthesized_volume_and_count_units_recalculate_from_variables():
assert result["rows"][2]["TC"] == 0.8
assert result["rows"][3]["TC"] == 557971.2
assert result["rows"][4]["TC"] == 76.8
def test_quoted_source_conversion_declaration_propagates_changed_value():
result = run(
"math",
r"""[2,3].map(quantity=>EstText.calculate([
{Index:1,Code:'quote',LineData:'quote'},
{LineData:` * 중량환산 : 사석기준 'Q1 = ${quantity}.0 톤/㎥'`},
{LineData:' 노무비 : \x03235,204\x03 ×1.04인×Q1 = 235,204 ×1.04×2 = \x12489,224.3\x12',SCheck:-1}
]))""",
)
assert all(item["errors"] == [] for item in result)
assert [item["rows"][2]["TC"] for item in result] == [489224.3, 733836.4]
def test_group_sum_accepts_assigned_price_and_refreshes_from_source():
result = run(
"math",
r"""[550,600].map(price=>{
const p=EstNative.empty();p.options.SourceRefresh=true;
p.tables.StuffcDat=[{Code:'M',ValueIndex:4,InputTC:price}];
p.tables.CostMainDat=[
{Index:1,Code:'C',LineData:'C'},
{LinkIndex:5,Code:'M',LineData:' \x0ebrick\x0e \x06A\x06\x03550\x03 : 별산'},
{LinkIndex:62,SCheck:-1,LineData:' \x07A\x07\x1a550\x1a × 5% = \x1227.5\x12'},
{SCheck:-1,LineData:' \x1a노무비:27\x1a'},
{SCheck:-5,LineData:' \x1a계:\x1a \x1227\x12'}];
p.tables.EstMainDat=[{Code:'C',LinkIndex:104,GN:10},{LinkIndex:54}];
return EstNative.calculate(p);
})""",
)
assert all(item["calculation"]["errors"] == [] for item in result)
assert [item["tables"]["CostMainDat"][2]["TC"] for item in result] == [27.5, 30]
assert [item["tables"]["EstMainDat"][0]["TC"] for item in result] == [270, 300]
def test_subtotal_includes_price_only_rows_and_updates_truncated_final_amount():
result = run(
"math",
r"""[1094,2000].map(price=>{
const p=EstNative.empty();
p.tables.StuffcDat=[{Code:'T',ValueIndex:4,InputTC:110754},{Code:'H',ValueIndex:4,InputTC:price}];
p.tables.CostMainDat=[
{Index:1,Code:'C',LineData:'transport'},
{Level:2,LineData:'1. 운반 및 하역'},
{LinkIndex:5,Code:'T',LineData:' \x0etruck\x0e (\x03110,754\x03 ÷12) ×1.5 = \x1213,844.2\x12'},
{LinkIndex:5,Code:'H',LineData:' \x0eunloading\x0e \x031,094\x03'},
{LinkIndex:61,SCheck:-3,TC:14938.2,LineData:' 경비소계 : \x0c14,938.2\x0c'},
{Level:2,LineData:'2. 합계'},
{Level:-2,SCheck:-3,LineData:' \x1a경비:14,938\x1a'},
{Level:-2,SCheck:-5,LineData:' \x1a계:\x1a \x1214,938\x12'}];
p.tables.EstMainDat=[{Code:'C',LinkIndex:104,GN:10},{LinkIndex:54}];
const first=EstNative.calculate(p),again=EstNative.calculate(first);
return {first,again};
})""",
)
assert all(item["first"]["calculation"]["errors"] == [] for item in result)
assert [item["first"]["tables"]["CostMainDat"][4]["TC"] for item in result] == [
14938.2,
15844.2,
]
for key in ["first", "again"]:
assert [item[key]["tables"]["EstMainDat"][0]["TC"] for item in result] == [
149380,
158440,
]
+5 -1
View File
@@ -36,7 +36,11 @@ Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요
설계변경 비교는 웹에서 확정한 당초 사본이 없을 때 원본 파일의 당초 수량·금액을 사용합니다. 별도 당초 확정 사본이 있으면 그 사본이 우선합니다. 원본 당초 전체 자료와 원가 서식의 활성 선택을 자동 추정하지는 않습니다.
원본 산근의 `배`·`번`·`줄`·`set`·`kg/set`·`㎜`, 괄호 뒤 면적·체적 단위와 별표로 감싼 중량 선언도 계산합니다. 측량설계 저장값 대조는 80,658/80,682 일치·해석 오류 10건, 가시설은 13,978/13,979 일치·해석 오류 4건입니다. 범위 조건과 누락된 참조 등은 임의 값으로 대체하지 않습니다.
원본 산근의 `배`·`번`·`줄`·`set`·`kg/set`·`㎜`, 괄호 뒤 면적·체적 단위와 별표·따옴표로 감싼 변수 선언도 계산합니다. 측량설계 저장값 대조는 80,659/80,682 일치·해석 오류 5건, 가시설은 13,978/13,979 일치·해석 오류 4건입니다. 범위 조건과 누락된 참조 등은 임의 값으로 대체하지 않습니다.
구간 합계와 소계는 결과 금액뿐 아니라 단가만 적힌 지정 행도 포함합니다. 구간 합계의 표지와 최종 합계 표지를 구분하며, 이미 정수로 절사된 최종 합계도 앞 성분 금액과의 합산 관계가 확인되면 변경액을 다시 집계합니다. 단가 550→600 변경이 구간 비율 27.5→30, 최종 합계 27→30, 내역 270→300으로 전달되는 것을 저장·취소까지 확인했습니다.
이 보완 후 해양 자료는 소계 표지까지 포함해 134,133/134,208 저장값 일치·해석 오류 99건입니다. 계산·이력 전용 시험 64개가 통과했으며, 남은 불일치와 원본 실행 미확인은 별개로 유지합니다.
**전체 원본 동등성은 아직 확보하지 못했습니다.** 기성·물가변동·연차 등은 분석 분기와 합성 시나리오를 시험했으며 원본 업무 샘플 대조는 없습니다. 원본 화면 실행 비교는 못했습니다. 원본 페이지·도면 배치, CAD/VEC0650 편집, 원본 인쇄 서식, Excel/XML/JDL 변환, ESTX 수정 저장은 미구현입니다. 원본 대체품의 완료 판정으로 보지 않습니다.
+37 -10
View File
@@ -10,6 +10,8 @@ const EstText = (() => {
const numeric = (s) => Number(String(s).replaceAll(",", ""));
const sumCurrency = (values) =>
values.reduce((n, value) => n + EstMath.even(num(value) * 10000), 0) / 10000;
const autoTotal = (row) =>
num(row.LinkIndex) !== 62 && String(row.LineData || "").includes("\x1a");
const stripLabels = (text) =>
String(text)
.replace(/\x0e[^\x0e]*\x0e|\x13[^\x13]*\x13|\x15[^\x15]*\x15|\([가-힣][^()]*\)/g, "")
@@ -87,7 +89,7 @@ const EstText = (() => {
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));
if (autoTotal(rows[j])) 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;
@@ -153,7 +155,7 @@ const EstText = (() => {
const values = [];
for (let j = index - 1; j >= 0 && !(num(rows[j].Index) > 0); j--)
for (const match of String(rows[j].LineData || "").matchAll(
/\x06([A-Z])\x06\s*([\x12\x0c])([-\d.,]+)\2/g,
/\x06([A-Z])\x06\s*([\x03\x12\x0c])([-\d.,]+)\2/g,
))
if (match[1] === group) values.unshift(numeric(match[3]));
if (!values.length) selectionError = `합계 구간 ${group}에 지정된 결과가 없습니다.`;
@@ -166,7 +168,7 @@ const EstText = (() => {
const previous = rows[j];
if (num(previous.Index) !== -1) {
const results = [
...String(previous.LineData || "").matchAll(/([\x12\x0c])([-\d.,]+)\1/g),
...String(previous.LineData || "").matchAll(/([\x03\x12\x0c])([-\d.,]+)\1/g),
];
if (results.length) values.push(numeric(results.at(-1)[2]));
}
@@ -219,6 +221,7 @@ const EstText = (() => {
);
else if (currentSource && sc === 0 && /\x0e/.test(original))
text = text.replace(/\x03([-\d.,]+)\x03/g, () => `\x03${num(currentSource.TC)}\x03`);
row.LineData = text;
text = text.replace(
/\x11([-\d.,]+)\x11/g,
(whole, value) => `\x11${changed.get(numeric(value)) ?? numeric(value)}\x11`,
@@ -273,8 +276,7 @@ const EstText = (() => {
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;
if (!autoTotal(previous) || !component[num(previous.SCheck)]) break;
section.push(num(previous.TC));
}
calculated = sumCurrency(section);
@@ -316,7 +318,7 @@ const EstText = (() => {
? []
: [
...assignmentText.matchAll(
/(?:^\s*| {2,}|[,,]\s*|(?<=[가-힣])\s+|[::]\s*)([A-Za-z][A-Za-z0-9]*)\s*=/g,
/(?:^\s*| {2,}|[,,]\s*|(?<=[가-힣])\s+|[::]\s*|['"]\s*)([A-Za-z][A-Za-z0-9]*)\s*=/g,
),
];
for (let j = 0; j < assignments.length; j++) {
@@ -325,6 +327,8 @@ const EstText = (() => {
let body = text.slice(assignment.index + assignment[0].length, end);
if (!matched && /^\s*\*.*\*\s*$/.test(text)) body = body.replace(/\*\s*$/, "");
body = body.replace(/\{[^}]*\}/g, "");
if (/['"]\s*[A-Za-z][A-Za-z0-9]*\s*=/.test(assignment[0]))
body = body.replace(/['"]\s*$/, "");
if (!matched) body = body.split("{")[0];
body = body.split(/\s+[::]\s*/)[0].replace(/[,,]\s*$/, "");
if (body.includes("─") && calculated !== null) {
@@ -394,7 +398,7 @@ const EstText = (() => {
row.Precision ??
explicit?.digits ??
(sc < 0 || num(row.LinkIndex) !== 0
? (num(row.Level) === finalLevel && text.includes("\x1a")) ||
? (num(row.Level) === finalLevel && autoTotal(row)) ||
index === singleComponent ||
options.CostValueTrunc
? 0
@@ -475,7 +479,7 @@ const EstText = (() => {
row.LineData = rendered;
changed.set(oldValue, firstValue);
checks.push({ slot: index, stored: oldValue, calculated: firstValue });
if (text.includes("\x1a") && component[sc]) {
if (autoTotal(row) && component[sc]) {
if (num(row.Level) === finalLevel) totals[component[sc]] = calculated;
}
} else if (!matched && component[sc] && text.includes("\x03") && !text.includes("\x1a")) {
@@ -485,13 +489,36 @@ const EstText = (() => {
1,
);
row.LineData = text;
} else if (!matched && text.includes("\x1a") && component[sc]) {
const value = clean(text).split(":").slice(1).join(":").trim();
} else if (!matched && autoTotal(row) && component[sc]) {
let value = clean(text).split(":").slice(1).join(":").trim();
// Bare final totals can contain an already truncated amount rather than an equation.
// Rebuild only when the original component rows establish that subtotal relationship.
const savedValue = clean(original).split(":").slice(1).join(":").trim();
if (num(row.Level) === finalLevel && /^[-\d.,]+$/.test(savedValue)) {
const before = [],
after = [];
for (let j = index - 1; j >= 0 && num(rows[j].Index) <= 0; j--) {
if (num(rows[j].SCheck) !== sc) continue;
if (autoTotal(rows[j])) break;
const results = [...String(input[j].LineData || "").matchAll(/\x12([-\d.,]+)\x12/g)];
if (!results.length && input[j].TC == null) continue;
before.push(results.length ? numeric(results.at(-1)[1]) : num(input[j].TC));
after.push(num(rows[j].TC));
if (num(rows[j].LinkIndex) === 61) break;
}
if (before.length && floating(sumCurrency(before), 0, 2) === numeric(savedValue))
value = String(sumCurrency(after));
}
const calculated = floating(
EstExpression.evaluate(expression(value || "0"), (key) => variables[key]),
num(row.Level) === finalLevel ? 0 : 1,
);
row.TC = calculated;
if (value !== clean(text).split(":").slice(1).join(":").trim()) {
const start = text.indexOf(":") + 1,
end = text.lastIndexOf("\x1a");
row.LineData = text.slice(0, start) + calculated + text.slice(end);
}
if (num(row.Level) === finalLevel) totals[component[sc]] = calculated;
}
} catch (error) {