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,