fix(est): propagate text subtotals and bid rates

This commit is contained in:
2026-11-05 13:17:44 +09:00
parent 1454b3c315
commit 216e2ae2a7
7 changed files with 236 additions and 13 deletions
File diff suppressed because one or more lines are too long
+4 -2
View File
@@ -23,8 +23,10 @@ for (const table of [
b = table.includes("Title") ? lookup.get(a.Code) : q.tables[table][i];
if (a.Index > 0) group = a.Code;
if (table === "CostMainDat") {
const before = [...String(a.LineData || "").matchAll(/\x12([-\d.,]+)\x12/g)],
after = [...String(b?.LineData || "").matchAll(/\x12([-\d.,]+)\x12/g)];
const markers =
a.LinkIndex === 61 ? /[\x12\x0c]([-\d.,]+)[\x12\x0c]/g : /\x12([-\d.,]+)\x12/g;
const before = [...String(a.LineData || "").matchAll(markers)],
after = [...String(b?.LineData || "").matchAll(markers)];
for (let part = 0; part < before.length; part++) {
const x = Number(before[part][1].replaceAll(",", "")),
y = after[part] ? Number(after[part][1].replaceAll(",", "")) : null;
+114 -2
View File
@@ -432,9 +432,9 @@ def test_text_material_only_subtotal_does_not_reuse_prior_labor():
assert result["rows"][6]["TC"] == 30
def test_original_sea_forest_and_temporary_works_text_layouts():
def test_original_text_layouts_and_selected_group_subtotals():
results = run("text-layout")
assert len(results) == 3
assert len(results) == 4
checked = 0
for result in results:
assert result["errors"] == [], result["code"]
@@ -536,3 +536,115 @@ def test_original_cost_truncation_and_intermediate_precision_options():
assert result["override"]["rows"][1]["TC"] == 100.19
assert result["mid1"]["checks"][-1]["calculated"] == 721.5
assert result["mid2"]["checks"][-1]["calculated"] == 721.51
def test_source_price_refresh_recalculates_text_subtotal_and_percentage_reference():
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'},
{Level:1,LineData:' material section'},
{Index:0,Code:'M',LinkIndex:5,SCheck:-2,LineData:' \x0ematerial\x0e: \x03100\x03 ×2 = \x12200\x12'},
{Index:-20,LinkIndex:61,SCheck:-2,LineData:' material subtotal: \x0c200\x0c'},
{LinkIndex:58,SCheck:-3,OCheck:-2,LineData:' \x0c200\x0c ×5% = \x1210\x12'},
{Level:1,LineData:' total'},
{Level:-1,SCheck:-2,LineData:' \x1a재료비:200\x1a'},
{Level:-1,SCheck:-3,LineData:' \x1a경비:10\x1a'},
{Level:-1,SCheck:-5,LineData:' \x1a계:\x1a \x12210\x12'}];
p.tables.CostTitleDat=[{Code:'C'}];
return EstNative.calculate(p);
})()""",
)
assert result["calculation"]["errors"] == []
rows = result["tables"]["CostMainDat"]
assert rows[2]["TC"] == 300
assert rows[3]["TC"] == 300
assert "\x0c300\x0c" in rows[3]["LineData"]
assert rows[4]["TC"] == 15
assert rows[0]["TC"] == 315
def test_selected_text_group_uses_distinct_rows_with_equal_original_amounts():
result = run(
"math",
r"""EstText.calculate([
{Index:1,Code:'C'},
{Code:'M',LinkIndex:5,SCheck:-2,LineData:' \x0ematerial\x0e: \x03100\x03 = \x06A\x06\x12100\x12'},
{Code:'L',LinkIndex:6,SCheck:-1,LineData:' \x0elabor\x0e: \x03100\x03 = \x06A\x06\x12100\x12'},
{LinkIndex:62,SCheck:-3,LineData:' \x07A\x07\x1a100 + 100\x1a = \x12200\x12 ×10% = \x1220\x12'}
],{},row=>row.Code==='M'?{GC:150,TC:150}:{NC:200,TC:200})""",
)
assert result["errors"] == []
assert result["rows"][3]["TC"] == 35
assert "150 + 200" in result["rows"][3]["LineData"]
def test_text_bid_rate_row_preserves_native_component_walk_and_currency_precision():
result = run(
"math",
r"""EstText.calculate([
{Index:1,Code:'C'},
{Level:1,LineData:' bid section'},
{SCheck:-1,LineData:' 노무비: 100 = \x12100\x12'},
{SCheck:-1,LineData:' 노무비: 50 = \x1250\x12'},
{SCheck:-2,LineData:' 재료비: 20 = \x1220\x12'},
{SCheck:-2,LineData:' 재료비: 30 = \x1230\x12'},
{SCheck:-3,LineData:' 경비: 12.3456 = \x1212.3456\x12',Precision:4},
{LinkIndex:52,LineData:' 낙찰률 \x1590\x15 %'}
])""",
)
assert result["errors"] == []
assert result["rows"][-1]["NC"] == result["rows"][0]["NC"] == 90
assert result["rows"][0]["GC"] == 45
assert result["rows"][0]["JC"] == 11.111
assert result["rows"][0]["TC"] == 146.111
def test_text_group_markers_are_editable_and_missing_group_is_reported():
result = run(
"math",
r"""(()=>{
const line='\x06A\x06\x12100\x12 + \x07A\x07';
return {shown:EstText.display(line),roundtrip:EstText.encode(EstText.display(line))===line,
missing:EstText.calculate([{Index:1,Code:'C'},
{LinkIndex:62,SCheck:-3,LineData:'\x07A\x07\x1a100\x1a = \x12100\x12 ×10% = \x1210\x12'}])};
})()""",
)
assert result["roundtrip"]
assert "⟦구간지정⟧A⟦구간지정⟧" in result["shown"]
assert "⟦구간합계⟧A⟦구간합계⟧" in result["shown"]
assert len(result["missing"]["errors"]) == 1
assert "A" in result["missing"]["rows"][1]["Error"]
def test_fixed_text_formula_clears_the_previous_row_error():
result = run(
"math",
r"""(()=>{
const bad=EstText.calculate([{Index:1,Code:'C'},
{LineData:' Q=(1+2 = \x123\x12'}]);
bad.rows[1].LineData=' Q=1+2 = \x123\x12';
return {bad:bad.errors,good:EstText.calculate(bad.rows)};
})()""",
)
assert len(result["bad"]) == 1
assert result["good"]["errors"] == []
assert "Error" not in result["good"]["rows"][1]
def test_text_total_without_result_marker_evaluates_its_expression():
result = run(
"math",
r"""EstText.calculate([
{Index:1,Code:'C'},
{Level:-1,SCheck:-1,LineData:' \x1a노무비:10 + 10\x1a'},
{Level:-1,SCheck:-2,LineData:' \x1a재료비:\x1a'},
{Level:-1,SCheck:-5,LineData:' \x1a계:\x1a \x1220\x12'}
])""",
)
assert result["errors"] == []
assert result["rows"][0]["TC"] == 20
assert result["rows"][1]["TC"] == 20
assert result["rows"][2]["TC"] == 0
+2
View File
@@ -22,6 +22,8 @@
계산 환경에서 원본 `CostValueTrunc`(성분 금액 원 미만 절사)와 `MidCostDiget`(중간금액 소수자리)을 제어합니다. 원천·호표 코드를 바꾸면 작업본 연결도 함께 바뀌며, 당초 계약 자료는 보존됩니다. 해양·산림·가시설의 분수 배치와 구획 소계 원본 발췌를 회귀 시험에 포함합니다. 전체 해양 자료는 133,710개 저장값 중 133,635개 일치, 수식 해석 오류 99건이 남아 있습니다. 저장 결과 대조만으로 원본 실행 동등성을 판정하지 않습니다.
단가 변경은 산근 소계(61), 비목 참조 비율(58), 선택 구간 합계(62)를 거쳐 내역으로 전달됩니다. 산출근거 편집기의 `구간지정`·`구간합계` 표지로 A~Z 묶음을 제어합니다. 소계 표지 1,433개를 추가한 대용량 원본 대조는 115,145개 일치·해석 오류 0이며, 원본 산근 낙찰 행(52)의 성분 계산과 연결 편집도 회귀 시험합니다.
**전체 원본 동등성은 아직 확보하지 못했습니다.** 기성·물가변동·연차 등은 분석 분기와 합성 시나리오를 시험했으며 원본 업무 샘플 대조는 없습니다. 원본 화면 실행 비교는 못했습니다. 원본 페이지·도면 배치, CAD/VEC0650 편집, 원본 인쇄 서식, Excel/XML/JDL 변환, ESTX 수정 저장은 미구현입니다. 원본 대체품의 완료 판정으로 보지 않습니다.
검사:
+1 -1
View File
@@ -247,7 +247,7 @@ const EstActions = (() => {
const row = activeRows()[Number(node.dataset.row)];
showDialog(
"산출근거 편집",
`<p>품명·규격·단가·변수·결과·합계 표지를 짝으로 지정합니다.</p><div class="marker-tools">${["품명", "규격", "단위", "단가", "변수", "다른줄", "결과", "합계"].map((m) => `<button data-marker="${m}">${m}</button>`).join("")}</div><textarea id="line-editor" rows="10">${esc(EstText.display(row.LineData))}</textarea><button data-action="line-apply" data-row="${node.dataset.row}">적용</button>`,
`<p>표지는 짝으로 지정합니다. 구간지정 A~Z는 결과 앞에, 구간합계는 합산할 같은 문자를 감쌉니다.</p><div class="marker-tools">${["품명", "규격", "단위", "단가", "변수", "다른줄", "결과", "합계", "구간지정", "구간합계"].map((m) => `<button data-marker="${m}">${m}</button>`).join("")}</div><textarea id="line-editor" rows="10">${esc(EstText.display(row.LineData))}</textarea><button data-action="line-apply" data-row="${node.dataset.row}">적용</button>`,
);
} else if (name === "line-apply") {
const text = EstApp.byId("line-editor").value;
+113 -6
View File
@@ -41,6 +41,7 @@ const EstText = (() => {
variables = Object.create(null),
changed = new Map(),
currentSource = null,
finalOverride = null,
totals = {},
finalLevel = -1,
plainTotals = {},
@@ -48,6 +49,12 @@ const EstText = (() => {
hasAutoTotals = false;
function finish() {
if (!head) return;
if (finalOverride) {
components.forEach((key) => (head[key] = num(finalOverride[key])));
head.TC = sum(head);
titles.push({ ...head });
return;
}
components.forEach(
(key) => (head[key] = !hasAutoTotals ? floating(num(plainTotals[key])) : num(totals[key])),
);
@@ -56,12 +63,14 @@ const EstText = (() => {
}
for (let index = 0; index < rows.length; index++) {
const row = rows[index];
delete row.Error;
if (num(row.Index) > 0) {
finish();
head = row;
variables = Object.create(null);
changed = new Map();
currentSource = null;
finalOverride = null;
totals = {};
plainTotals = {};
for (const [key, tag] of [
@@ -87,6 +96,32 @@ const EstText = (() => {
continue;
}
if (!head || !row.LineData) continue;
if (num(row.Level) > 0) finalOverride = null;
if ([51, 52].includes(num(row.LinkIndex))) {
if (num(row.LinkIndex) === 52) {
const rate = String(row.LineData).match(/\x15([-\d.,]+)\x15/);
const multiplier = rate ? numeric(rate[1]) / 100 : 0;
const base = Object.fromEntries(components.map((key) => [key, 0]));
for (let j = index - 1; j >= 0; j--) {
const previous = rows[j],
key = component[num(previous.SCheck)];
if (num(previous.Level) > 0 || num(previous.Index) > 0) break;
if (!key) continue;
const values = [
...String(previous.LineData || "").matchAll(/([\x12\x0c])([-\d.,]+)\1/g),
];
const value = values.length ? numeric(values.at(-1)[2]) : num(previous.TC);
// f2c680/f2c74e overwrites NC while walking backward; the other components add.
base[key] = key === "NC" ? value : base[key] + value;
}
components.forEach(
(key) => (row[key] = EstMath.even(base[key] * multiplier * 10000) / 10000),
);
row.TC = sum(row);
}
finalOverride = row;
continue;
}
const original = String(row.LineData),
matched = original.match(marker),
sc = num(row.SCheck);
@@ -101,6 +136,71 @@ const EstText = (() => {
}
}
let text = original.replace(/\{([+-])\}/g, "$1");
let selectionError = "";
if (num(row.LinkIndex) === 62) {
text = text.replace(/(\x07([A-Z])\x07\s*)\x1a[^\x1a]*\x1a/g, (whole, reference, group) => {
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,
))
if (match[1] === group) values.unshift(numeric(match[3]));
if (!values.length) selectionError = `합계 구간 ${group}에 지정된 결과가 없습니다.`;
return values.length ? `${reference}\x1a${values.join(" + ")}\x1a` : whole;
});
}
if (num(row.LinkIndex) === 61 && text.includes("\x0c")) {
const values = [];
for (let j = index - 1; j >= 0; j--) {
const previous = rows[j];
if (num(previous.Index) !== -1) {
const results = [
...String(previous.LineData || "").matchAll(/([\x12\x0c])([-\d.,]+)\1/g),
];
if (results.length) values.push(numeric(results.at(-1)[2]));
}
if (num(previous.Level) > 0 || num(previous.Index) > 0 || num(previous.Index) === -20)
break;
}
const precision =
num(row.DigetIndex) && num(row.Diget) > 0
? EstMath.precision(row)
: { digits: 1, method: 2 };
const subtotal = floating(
sumCurrency(values),
options.CostValueTrunc && sc < 0 ? 0 : precision.digits,
precision.method,
);
text = text.replace(/\x0c([-\d.,]+)\x0c/g, (whole, value) => {
changed.set(numeric(value), subtotal);
return `\x0c${subtotal}\x0c`;
});
row.TC = subtotal;
row.LineData = text;
}
if (num(row.LinkIndex) === 58 && text.includes("\x0c")) {
let sourceKind = num(row.OCheck);
if (/노무|인건|품/.test(clean(text))) sourceKind = -1;
else if (/재료|자재/.test(clean(text))) sourceKind = -2;
else if (/경비/.test(clean(text))) sourceKind = -3;
let value;
for (let j = index - 1; j >= 0; j--) {
const previous = rows[j],
kind = num(previous.SCheck);
if ((!sourceKind && kind < 0 && kind > -4) || (sourceKind && kind === sourceKind)) {
const results = [
...String(previous.LineData || "").matchAll(/([\x12\x0c])([-\d.,]+)\1/g),
];
if (results.length) {
value = numeric(results.at(-1)[2]);
row.OCheck = kind;
break;
}
}
if (num(previous.Level) > 0 || num(previous.Index) > 0) break;
}
if (value !== undefined) text = text.replace(/\x0c[-\d.,]+\x0c/g, `\x0c${value}\x0c`);
}
if (currentSource && sc < 0 && component[sc])
text = text.replace(
/\x03([-\d.,]+)\x03/g,
@@ -112,10 +212,11 @@ const EstText = (() => {
/\x11([-\d.,]+)\x11/g,
(whole, value) => `\x11${changed.get(numeric(value)) ?? numeric(value)}\x11`,
);
text = text.replace(
/\x0c([-\d.,]+)\x0c/g,
(whole, value) => `\x0c${changed.get(numeric(value)) ?? numeric(value)}\x0c`,
);
if (![58, 61].includes(num(row.LinkIndex)))
text = text.replace(
/\x0c([-\d.,]+)\x0c/g,
(whole, value) => `\x0c${changed.get(numeric(value)) ?? numeric(value)}\x0c`,
);
if (text.includes("\x1a") && text.includes(":") && sc !== -5) {
const start = text.indexOf(":") + 1,
end = text.includes("\x12") ? text.indexOf("\x12") : text.length;
@@ -135,6 +236,7 @@ const EstText = (() => {
const assignedNames = new Map();
let componentDeclaration;
try {
if (selectionError) throw Error(selectionError);
if ((matched || /^\s*[A-Za-z][A-Za-z0-9]*\s*=/.test(text)) && /─/.test(text)) {
const above = clean(rows[index - 1]?.LineData),
below = clean(rows[index + 1]?.LineData);
@@ -374,7 +476,7 @@ const EstText = (() => {
} else if (!matched && text.includes("\x1a") && component[sc]) {
const value = clean(text).split(":").slice(1).join(":").trim();
const calculated = floating(
changed.get(numeric(value)) ?? numeric(value),
EstExpression.evaluate(expression(value || "0"), (key) => variables[key]),
num(row.Level) === finalLevel ? 0 : 1,
);
row.TC = calculated;
@@ -391,6 +493,8 @@ const EstText = (() => {
return { rows, titles, errors, checks };
}
const names = {
"\x06": "구간지정",
"\x07": "구간합계",
"\x03": "단가",
"\x0c": "다른줄",
"\x0e": "품명",
@@ -401,7 +505,10 @@ const EstText = (() => {
"\x1a": "합계",
};
const display = (text) =>
String(text || "").replace(/[\x03\x0c\x0e\x11\x12\x13\x15\x1a]/g, (ch) => `⟦${names[ch]}⟧`);
String(text || "").replace(
/[\x03\x06\x07\x0c\x0e\x11\x12\x13\x15\x1a]/g,
(ch) => `⟦${names[ch]}⟧`,
);
const encode = (text) =>
String(text).replace(
/⟦([^⟦⟧]+)⟧/g,
+1 -1
View File
@@ -16,7 +16,7 @@ const EstViews = (() => {
[57, "조달수수료"],
[58, "비율"],
[59, "경계"],
[61, "자릿수"],
[61, "산근 소계"],
[62, "구간비율"],
[67, "내역비율"],
[68, "묶음끝"],