From 4f33cafb12070279aeb8fa3820c1801e02244453 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 10:33:22 +0900 Subject: [PATCH] feat(est): move estimate rows safely --- resources/tester/test_est_change_workflow.py | 22 ++++++++++++++ tools/est-test/index.html | 2 ++ tools/est-test/native-business-actions.js | 16 ++++++++++- tools/est-test/native-business.js | 30 ++++++++++++++++++++ tools/est-test/native-views.js | 2 ++ 5 files changed, 71 insertions(+), 1 deletion(-) diff --git a/resources/tester/test_est_change_workflow.py b/resources/tester/test_est_change_workflow.py index 9afec9ac7..85ce2b940 100644 --- a/resources/tester/test_est_change_workflow.py +++ b/resources/tester/test_est_change_workflow.py @@ -1,6 +1,28 @@ from test_est_rea_parity import run +def test_estimate_move_preserves_row_identity_and_blocks_ambiguous_or_total_rows(): + result = run( + "math", + """(()=>{ + const rows=[{PkID:1,Code:'A'},{PkID:2,Code:'B'},{PkID:3,Code:'C'},{LinkIndex:54}]; + const moved=EstBusiness.moveEstimateRows(rows,new Set([1,2]),-1); + const order=rows.map(row=>row.PkID||0); + const restored=EstBusiness.moveEstimateRows(rows,new Set(moved),1); + let blocked=0; + for (const [data,selection,direction] of [ + [[{Code:'A'},{Code:'A'}],[0],1], + [[{PkID:1},{LinkIndex:54}],[0],1], + [[{PkID:1},{PkID:2},{PkID:3}],[0,2],1], + [[{PkID:1},{Name:'unknown'}],[0],1]]) { + try{EstBusiness.moveEstimateRows(data,new Set(selection),direction)}catch{blocked++} + } + return {moved,order,restored,blocked}; + })()""", + ) + assert result == {"moved": [0, 1], "order": [2, 3, 1, 0], "restored": [1, 2], "blocked": 4} + + def test_duplicate_codes_match_distinct_original_rows_and_track_deletions(): result = run( "math", diff --git a/tools/est-test/index.html b/tools/est-test/index.html index 6b96c8bb2..7cc40bb8f 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -144,6 +144,8 @@ > { const { state } = EstApp, project = state.project, round = currentRound(); - if (name === "unlock-round") { + if (name === "move-estimate-up" || name === "move-estimate-down") { + if (state.view !== "EstMainDat") throw Error("내역서에서만 행을 이동할 수 있습니다."); + const direction = name === "move-estimate-up" ? -1 : 1; + const indices = EstBusiness.moveEstimateRows( + structuredClone(project.tables.EstMainDat), + state.selection, + direction, + ); + if (indices.every((index) => state.selection.has(index))) return; + EstApp.change(() => { + state.selection = new Set( + EstBusiness.moveEstimateRows(project.tables.EstMainDat, state.selection, direction), + ); + }); + } else if (name === "unlock-round") { if (confirm("이 회차와 이후 회차의 확정을 해제하시겠습니까? 입력값은 유지됩니다.")) EstApp.change(() => (state.project = EstProgress.unlock(project, round.id)), false); } else if (name === "delete-round") { diff --git a/tools/est-test/native-business.js b/tools/est-test/native-business.js index ee9e64078..f1956775a 100644 --- a/tools/est-test/native-business.js +++ b/tools/est-test/native-business.js @@ -4,6 +4,35 @@ const EstBusiness = (() => { const copy = (value) => structuredClone(value); const rowKey = (row, index) => String(row.PkID ?? (row.Code || `row:${index}`)); const special = (row) => [57, 58, 62, 67].includes(num(row.LinkIndex)); + function moveEstimateRows(rows, selection, direction) { + const indices = [...selection].sort((a, b) => a - b); + if (!indices.length) throw Error("이동할 내역 행을 선택하세요."); + if ( + ![1, -1].includes(direction) || + indices.some((value, index) => value !== indices[0] + index) + ) + throw Error("연속된 내역 행을 선택하세요."); + const first = indices[0], + last = indices.at(-1), + neighbor = direction < 0 ? first - 1 : last + 1; + if (neighbor < 0 || neighbor >= rows.length) return indices; + const affected = rows.slice(Math.min(first, neighbor), Math.max(last, neighbor) + 1); + if (affected.some((row) => [54, 55, 58, 59].includes(num(row.LinkIndex)))) + throw Error("합계 행은 이동하거나 건너뛸 수 없습니다."); + const counts = new Map(); + rows.forEach((row, index) => { + const key = rowKey(row, index); + counts.set(key, (counts.get(key) || 0) + 1); + }); + for (let index = Math.min(first, neighbor); index <= Math.max(last, neighbor); index++) { + const row = rows[index]; + if ((row.PkID == null && !row.Code) || counts.get(rowKey(row, index)) !== 1) + throw Error("행 식별자가 없거나 중복되어 연결을 보존할 수 없습니다."); + } + const group = rows.splice(first, indices.length); + rows.splice(first + direction, 0, ...group); + return indices.map((index) => index + direction); + } function baseline(project) { const working = { ...project }; delete working.baseline; @@ -355,6 +384,7 @@ const EstBusiness = (() => { annual, newAnnualName, rowKey, + moveEstimateRows, importContract, importBid, annualCopy, diff --git a/tools/est-test/native-views.js b/tools/est-test/native-views.js index 0f496e911..380332e35 100644 --- a/tools/est-test/native-views.js +++ b/tools/est-test/native-views.js @@ -97,6 +97,8 @@ const EstViews = (() => { byId("search").value = state.query; byId("standard-tools").hidden = !project.tables[view]; byId("add-group").hidden = !grouped; + byId("move-estimate-up").hidden = view !== "EstMainDat"; + byId("move-estimate-down").hidden = view !== "EstMainDat"; if (project.tables[view]) { const all = EstApp.visibleRows(), start = state.page * 100,