fix(est): propagate text subtotals and bid rates
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 수정 저장은 미구현입니다. 원본 대체품의 완료 판정으로 보지 않습니다.
|
||||
|
||||
검사:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -16,7 +16,7 @@ const EstViews = (() => {
|
||||
[57, "조달수수료"],
|
||||
[58, "비율"],
|
||||
[59, "경계"],
|
||||
[61, "자릿수"],
|
||||
[61, "산근 소계"],
|
||||
[62, "구간비율"],
|
||||
[67, "내역비율"],
|
||||
[68, "묶음끝"],
|
||||
|
||||
Reference in New Issue
Block a user