diff --git a/resources/tester/helper_est_native.cjs b/resources/tester/helper_est_native.cjs index 1cf5887aa..e38434453 100644 --- a/resources/tester/helper_est_native.cjs +++ b/resources/tester/helper_est_native.cjs @@ -26,6 +26,7 @@ for (const file of [ "native-exchange.js", "native-verify.js", "native-extra.js", + "native-business-actions.js", "native-forms.js", ]) { vm.runInContext(fs.readFileSync(path.join(base, file), "utf8"), context, { filename: file }); diff --git a/resources/tester/test_est_business.py b/resources/tester/test_est_business.py index 254fddba8..165bc04bf 100644 --- a/resources/tester/test_est_business.py +++ b/resources/tester/test_est_business.py @@ -3,6 +3,45 @@ from test_est_rea_parity import run +def test_estimate_classification_refreshes_only_selected_source_and_preserves_manual_mode(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.options.SourceRefresh=false;p.options.AutoCalc=false; + p.tables.StuffcDat=[{Code:'M',Name:'material',Cost1:100,ValueIndex:0},{Code:'N',Name:'other',Cost1:800,ValueIndex:0}]; + p.tables.EstMainDat=[{PkID:1,Code:'M',Name:'material',LinkIndex:5,GN:2,GU:100,GC:200,JC:0}, + {PkID:2,Code:'N',Name:'other',LinkIndex:5,GN:1,GU:5,GC:5},{LinkIndex:54}]; + const row=p.tables.EstMainDat[0],other=JSON.stringify(p.tables.EstMainDat[1]); + EstBusinessActions.setEstimateClassification(p,row,0,3); + const manual={JU:row.JU,GU:row.GU,GC:row.GC,JC:row.JC}; + const auto=EstNative.calculate(p).tables.EstMainDat[0]; + EstBusinessActions.setEstimateClassification(p,row,0,0); + const restored={GU:row.GU,JU:row.JU}; + const missing={PkID:3,Code:'missing',LinkIndex:5,SCheck:0,GU:7};p.tables.EstMainDat.push(missing); + EstBusinessActions.setEstimateClassification(p,missing,3,3); + let rejected=0;try{EstBusinessActions.setEstimateClassification(p,missing,3,0)}catch{rejected++} + EstBusinessActions.setEstimateClassification(p,row,0,3); + p.tables.StuffcDat.push({...p.tables.StuffcDat[0]});try{EstBusinessActions.setEstimateClassification(p,row,0,0)}catch{rejected++} + const mismatch={PkID:4,Code:'N',LinkIndex:5,SCheck:0,GU:5};p.tables.EstMainDat.push(mismatch); + EstBusinessActions.setEstimateClassification(p,mismatch,4,3); + try{EstBusinessActions.setEstimateClassification(p,mismatch,4,0)}catch{rejected++} + p.tables.StuffcDat.push({Code:'L',Cost1:1000000000000,ValueIndex:0}); + const large={PkID:5,Code:'L',LinkIndex:5,SCheck:0,GU:999999999900};p.tables.EstMainDat.push(large); + EstBusinessActions.setEstimateClassification(p,large,5,3); + try{EstBusinessActions.setEstimateClassification(p,large,5,0)}catch{rejected++} + return {manual,auto:{GC:auto.GC,JC:auto.JC},restored,rejected,missing,mismatch,large,other:JSON.stringify(p.tables.EstMainDat[1])===other,flag:p.options.SourceRefresh}; + })()""", + ) + assert result["manual"] == {"JU": 100, "GU": 0, "GC": 200, "JC": 0} + assert result["auto"] == {"GC": 0, "JC": 200} + assert result["restored"] == {"GU": 100, "JU": 0} + assert result["rejected"] == 4 + assert result["missing"]["SCheck"] == 3 and result["missing"]["JU"] == 7 + assert result["mismatch"]["SCheck"] == 3 and result["mismatch"]["JU"] == 5 + assert result["large"]["SCheck"] == 3 and result["large"]["JU"] == 999999999900 + assert result["other"] and not result["flag"] + + def test_annual_formulas_split_component_amounts_and_preserve_remainder(): result = run( "math", diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 4e21fd277..e6b26985e 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -1,4 +1,6 @@ ## Codex · EST 독립 테스트 앱 (2026-11-04) +- [x] 원천 재조회가 꺼진 내역의 비목 지정 변경을 해당 행에만 반영 → 자동·수동 계산, 미연결·다른 행 보존 시험. + - 자체 검증: 현재 단가를 유지한 비목 지정·일치하는 원천으로 복원, 다른 행·재조회 옵션 보존. 원천 누락·중복·단가 불일치는 복원 차단. Edge에서 자동·수동 계산 확인, 관련 시험 32개 통과. - [x] 산출근거 문구 일괄 바꾸기 → 현재 호표·전체 범위, 표지 보존·재계산·실행 취소 확인. - 자체 검증: Tailscale HTTP Edge에서 현재 호표·전체 치환, 일치 없음 안내, 결과·단가 표지 보존과 실행 취소 확인. 산출근거 시험 24개 통과, 수식 변경 후 재계산 확인. - [x] Excel 파일 읽는 동안 파일·셀 선택이 바뀌면 이전 요청 반영 차단 → 지연 응답 시험. diff --git a/tools/est-test/README.md b/tools/est-test/README.md index d25ba0dbc..4ae751c6d 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -18,6 +18,8 @@ 내역서 편집 메뉴의 `내역 수량 초기화`는 선택한 상세 행 또는 모든 상세 행의 수량을 0으로 바꿉니다. 비율·합계 자동행은 건드리지 않으며 실행 취소할 수 있습니다. +원천 자료에 연결된 내역 행의 결과비목을 재료·노무·경비로 바꾸면 현재 단가 합계를 유지해 지정한 비목으로 옮깁니다. 비목별 구성으로 되돌릴 때 원천 단가의 합계가 현재 단가와 다르거나 원천이 불명확하면 임의로 복원하지 않고 오류를 표시합니다. 자동계산이 꺼져 있으면 금액은 자동 재계산을 켤 때까지 유지합니다. + 인쇄 미리보기는 화면의 100행 페이지 제한 없이 검색 결과/현재 호표의 전체 행을 표시합니다. 출력 범위에서 현재 자료의 모든 호표·행도 선택할 수 있습니다. 원본 인쇄 옵션의 41개 항목 중 CAD를 제외한 40개 서식 이름을 선택하며, ESTX에서 가져온 해당 프로젝트의 인쇄 설정을 우선 사용합니다. 내역·원가·호표/총괄·증감·동원·수량·공정은 표 자료로 출력합니다. 표지·갑지·설명서·시방서·심사 문서는 서식별 본문을 직접 입력합니다. 용지·방향·여백·글자 크기·페이지당 행·시작 쪽·업체명을 저장하고 인쇄/PDF로 출력합니다. ESTX 수정 저장은 서식별 여백과 페이지당 행 수를 반영합니다. 다른 웹 인쇄 설정의 ESTX 쓰기와 원본 인쇄 도형·병합·글꼴 배치의 동등성은 지원하지 않습니다. 별도 인쇄 서식으로 기성·물가변동의 내역서와 원가계산서, 연차 배분표와 연차별 내역서, 설계변경 원가 증감표를 선택할 수 있습니다. 기성은 선택한 회차를 출력하고 확정된 회차는 확정 당시 결과를 사용합니다. diff --git a/tools/est-test/native-actions.js b/tools/est-test/native-actions.js index 2c54e0184..579bcaffc 100644 --- a/tools/est-test/native-actions.js +++ b/tools/est-test/native-actions.js @@ -621,7 +621,9 @@ const EstActions = (() => { const previous = row.Code; EstModel.editCode(project, node.dataset.table, Number(node.dataset.row), value); if (state.group === previous && Number(row.Index) > 0) state.group = row.Code; - } else row[key] = value; + } else if (node.dataset.table === "EstMainDat" && key === "SCheck") + EstBusinessActions.setEstimateClassification(project, row, node.dataset.row, value); + else row[key] = value; if (state.view === "escalation" && EstMath.units.includes(key)) project.options.SourceRefresh = false; if (["StuffcDat", "LaborcDat", "EstiMateDat", "SilJukDat"].includes(node.dataset.table)) { diff --git a/tools/est-test/native-business-actions.js b/tools/est-test/native-business-actions.js index dfab4fc40..52358f6e3 100644 --- a/tools/est-test/native-business-actions.js +++ b/tools/est-test/native-business-actions.js @@ -1,4 +1,75 @@ const EstBusinessActions = (() => { + function setEstimateClassification(project, row, index, value) { + index = Number(index); + if (!Number.isInteger(index) || project.tables.EstMainDat[index] !== row) + throw Error("내역 행을 다시 선택하세요."); + const kind = Number(row.LinkIndex), + table = EstNative.sourceTables[kind]; + if ( + !table || + Number(row.Level) || + [1, 3].includes(Number(row.CalcIndex)) || + ![0, 1, 2, 3].includes(value) + ) { + row.SCheck = value; + return; + } + const total = EstMath.units.reduce((sum, key) => sum + EstMath.num(row[key]), 0); + if (!Number.isFinite(total) || Math.abs(total) > Number.MAX_SAFE_INTEGER) + throw Error("현재 단가 합계가 유효하지 않습니다."); + if (value !== 0) { + const target = { 1: "GU", 2: "NU", 3: "JU" }[value]; + for (const key of EstMath.units) row[key] = key === target ? total : 0; + row.SCheck = value; + return; + } + if (!Number(row.SCheck)) { + row.SCheck = value; + return; + } + const sources = project.tables[table] || []; + let matches = row.Code ? sources.filter((source) => source.Code === row.Code) : []; + if (!matches.length && row.Code) + matches = sources.filter( + (source) => Array.isArray(source.PreviousCodes) && source.PreviousCodes.includes(row.Code), + ); + if (!matches.length && row.Name) + matches = sources.filter( + (source) => + EstNative.normalize(source.Name) === EstNative.normalize(row.Name) && + (!row.Size || EstNative.normalize(source.Size) === EstNative.normalize(row.Size)) && + (!row.Unite || EstNative.normalize(source.Unite) === EstNative.normalize(row.Unite)), + ); + if (matches.length > 1) throw Error(`원천 코드가 중복되었습니다: ${row.Code}`); + const had = Object.hasOwn(row, "SCheck"), + before = row.SCheck; + row.SCheck = value; + try { + let failures = null; + const calculated = EstNative.calculate(project, { + refreshEstimateRows: [index], + onEstimateRefresh: (errors) => (failures = errors), + }); + if (!failures) throw Error("내역 비목 재계산을 확인할 수 없습니다."); + if (failures.length) throw Error(failures.join(" / ")); + const refreshed = calculated.tables.EstMainDat[index]; + const refreshedTotal = EstMath.units.reduce( + (sum, key) => sum + EstMath.num(refreshed[key]), + 0, + ); + if ( + !Number.isFinite(refreshedTotal) || + Math.abs(refreshedTotal - total) > + Number.EPSILON * Math.max(1, Math.abs(total), Math.abs(refreshedTotal)) * 4 + ) + throw Error("현재 단가와 원천 단가가 달라 비목별 단가를 복원할 수 없습니다."); + for (const key of EstMath.units) row[key] = refreshed[key]; + } catch (error) { + if (had) row.SCheck = before; + else delete row.SCheck; + throw error; + } + } let purpose = "", documentKey; const currentRound = () => @@ -364,5 +435,5 @@ const EstBusinessActions = (() => { } }); } - return { handle, change, install }; + return { handle, change, install, setEstimateClassification }; })(); diff --git a/tools/est-test/native-engine.js b/tools/est-test/native-engine.js index c2a83412a..868cce395 100644 --- a/tools/est-test/native-engine.js +++ b/tools/est-test/native-engine.js @@ -110,7 +110,7 @@ const EstNative = (() => { } return amount; } - function calculate(input) { + function calculate(input, request = {}) { const project = clone(input), tables = project.tables, options = project.options || {}, @@ -251,7 +251,13 @@ const EstNative = (() => { "HeadTitleDat", [...heads.keys()].map((code) => completed.get(`103:${code}`)).filter(Boolean), ); - const estimate = EstRows.estimate(tables.EstMainDat, options, resolve); + const estimate = EstRows.estimate( + tables.EstMainDat, + options, + resolve, + request.refreshEstimateRows, + ); + request.onEstimateRefresh?.(estimate.refreshErrors); tables.EstMainDat = estimate.rows; errors.push(...estimate.errors); const direct = estimate.direct; diff --git a/tools/est-test/native-rows.js b/tools/est-test/native-rows.js index 5e1b1455d..f02494f82 100644 --- a/tools/est-test/native-rows.js +++ b/tools/est-test/native-rows.js @@ -134,13 +134,14 @@ const EstRows = (() => { finish(); return { rows: output, titles, errors }; } - function estimate(rows, options = {}, resolve = () => null) { + function estimate(rows, options = {}, resolve = () => null, refreshRows = []) { const output = structuredClone(rows), - errors = []; + errors = [], + refreshErrors = []; let segment = [], prior = [], boundary = false; - for (const row of output) { + for (const [index, row] of output.entries()) { const level = num(row.Level), link = num(row.LinkIndex); if (level > 0) { @@ -160,12 +161,15 @@ const EstRows = (() => { if (link === 57) procurement(row, segment); else if ([62, 67, 58].includes(link)) percent(row, segment, options.EstDigits || 0); else { - if (options.SourceRefresh && ![1, 3].includes(num(row.CalcIndex))) { + const requested = refreshRows.includes(index); + if ((options.SourceRefresh || requested) && ![1, 3].includes(num(row.CalcIndex))) { try { const price = resolve(row); + if (requested && !price) throw Error("원천 단가를 찾을 수 없습니다."); if (price) units.forEach((key, i) => (row[key] = num(price[components[i]]))); } catch (error) { errors.push(`${row.Name}: ${error.message}`); + if (requested) refreshErrors.push(error.message); } } amounts(row, options.EstDigits || 0, options.TotalFit); @@ -186,7 +190,9 @@ const EstRows = (() => { Object.assign(row, add(children)); } const direct = output.find((r) => num(r.LinkIndex) === 54) || add(prior); - return { rows: output, direct, errors }; + return refreshRows.length + ? { rows: output, direct, errors, refreshErrors } + : { rows: output, direct, errors }; } return { head, estimate, percent, procurement, subtotal, baseKind }; })();