diff --git a/resources/tester/test_est_text_units.py b/resources/tester/test_est_text_units.py index e9810b048..af7b3bae0 100644 --- a/resources/tester/test_est_text_units.py +++ b/resources/tester/test_est_text_units.py @@ -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, + ] diff --git a/tools/est-test/README.md b/tools/est-test/README.md index cfdcdf1b9..98f8d0eb7 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -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 수정 저장은 미구현입니다. 원본 대체품의 완료 판정으로 보지 않습니다. diff --git a/tools/est-test/native-text.js b/tools/est-test/native-text.js index 2d40e8a12..af6dc3ce6 100644 --- a/tools/est-test/native-text.js +++ b/tools/est-test/native-text.js @@ -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) {