diff --git a/resources/tester/test_est_quantity_workflow.py b/resources/tester/test_est_quantity_workflow.py
index bbe55e142..b2017857d 100644
--- a/resources/tester/test_est_quantity_workflow.py
+++ b/resources/tester/test_est_quantity_workflow.py
@@ -1,6 +1,26 @@
from test_est_rea_parity import run
+def test_apply_all_quantities_sums_sheets_and_rejects_errors_atomically():
+ result = run(
+ "math",
+ """(()=>{
+ const p=EstNative.empty();p.tables.EstMainDat=[{PkID:1,Code:'A',GN:1},{PkID:2,Code:'B',GN:2}];
+ p.quantities=[{Code:'Q1',formula:'2',TargetKey:'id:1'}];
+ p.quantitySheets=[{Code:'S1',Name:'sheet',options:{},rows:[{LineData:'3 = \\x120\\x12',markers:['a']}],
+ items:[{Code:'a',TargetKey:'id:1'}]},
+ {Code:'S2',Name:'sheet2',options:{},rows:[{LineData:'4 = \\x120\\x12',markers:['b']}],
+ items:[{Code:'b',TargetKey:'id:2'}]}];
+ const values=EstQuantity.allTotals(p);EstQuantity.applyTotals(p,values);
+ const updated=p.tables.EstMainDat.map(row=>row.GN),before=JSON.stringify(p.tables.EstMainDat);
+ p.quantitySheets[1].rows[0].LineData='bad';let rejected=false;
+ try{EstQuantity.applyTotals(p,EstQuantity.allTotals(p))}catch{rejected=true}
+ return {updated,rejected,unchanged:before===JSON.stringify(p.tables.EstMainDat)};
+ })()""",
+ )
+ assert result == {"updated": [5, 4], "rejected": True, "unchanged": True}
+
+
def test_quantity_apply_requires_one_detail_row_per_code():
result = run(
"math",
diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md
index a0fec1e76..e6a5ce208 100644
--- a/tools/est-test/PLAN.md
+++ b/tools/est-test/PLAN.md
@@ -1,4 +1,7 @@
## Codex · EST 독립 테스트 앱 (2026-11-04)
+- [x] 모든 수량산출서·직접 수식표의 내역 수량 일괄 합산 반영 → 동일 대상 합산·오류 시 전체 차단 시험.
+ - 자체 검증: 동일 내역 합산·오류 시 전체 차단 포함 수량·문서 시험 18개 통과, Edge에서 일괄 반영·취소 확인.
+
- 2026-11-07: 사용자 지시로 EST 작업 계획과 검증 기록을 공유 PLAN.md에서 이 파일로 이관. 업무 회귀 시험 79개 통과, openpyxl 경고 7건. Git push는 보류.
diff --git a/tools/est-test/README.md b/tools/est-test/README.md
index f032f7c0d..b45022ab8 100644
--- a/tools/est-test/README.md
+++ b/tools/est-test/README.md
@@ -41,6 +41,8 @@ Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요
재계산 메뉴의 수량 반영도 현재 선택한 산출서 또는 직접 작성 수식표를 사용합니다. 반영 전에 모든 연결 대상과 수식을 검사하며, 기존 원본 연결 데이터는 그대로 보관합니다.
+재계산 메뉴의 `모든 수량산출 합산 반영`은 모든 산출서와 직접 작성 수식표의 연결 수량을 내역 행별로 합산합니다. 어느 산출서든 계산 오류가 있거나 연결 대상이 모호하면 전체 반영을 중단합니다.
+
수량산출서 인쇄 서식에는 직접 작성한 수식표와 산출서의 산식, 계산 수량, 연결 내역이 함께 표시됩니다. 계산할 수 없는 수식은 오류를 표시합니다.
산출 행의 결과 표지 개수가 연결 정보와 다르면 계산 오류로 표시하며, 기존 저장 수량을 내역에 반영하지 않습니다. 화면이나 Excel에서 수식을 수정할 때 결과 표지와 연결 항목을 유지하세요.
diff --git a/tools/est-test/index.html b/tools/est-test/index.html
index 11d0c9f39..a6624cdee 100644
--- a/tools/est-test/index.html
+++ b/tools/est-test/index.html
@@ -96,6 +96,7 @@
+
diff --git a/tools/est-test/native-business-actions.js b/tools/est-test/native-business-actions.js
index 20ae840df..65467e791 100644
--- a/tools/est-test/native-business-actions.js
+++ b/tools/est-test/native-business-actions.js
@@ -45,7 +45,11 @@ const EstBusinessActions = (() => {
const { state } = EstApp,
project = state.project,
round = currentRound();
- if (name === "move-quantity") {
+ if (name === "apply-all-quantities") {
+ const totals = EstQuantity.allTotals(project);
+ if (confirm("모든 수량산출서와 수식표의 연결 수량을 합산해 내역에 반영하시겠습니까?"))
+ EstApp.change(() => EstQuantity.applyTotals(project, totals));
+ } else if (name === "move-quantity") {
const index = Number(node.dataset.row),
direction = Number(node.dataset.direction),
next = index + direction;
diff --git a/tools/est-test/native-quantity.cjs b/tools/est-test/native-quantity.cjs
index 85a409322..f5040a0cc 100644
--- a/tools/est-test/native-quantity.cjs
+++ b/tools/est-test/native-quantity.cjs
@@ -141,7 +141,7 @@ const EstQuantity = (() => {
});
return { rows, errors };
}
- function totals(project, selected) {
+ function totals(project, selected, allowEmpty = false) {
let items;
if (selected && selected !== "manual") {
const sheet = project.quantitySheets?.find((s) => s.Code === selected);
@@ -170,9 +170,17 @@ const EstQuantity = (() => {
const index = targets[0].index;
quantities.set(index, (quantities.get(index) || 0) + Number(item.GN || 0));
}
- if (!quantities.size) throw Error("연결할 내역코드를 먼저 입력하세요.");
+ if (!quantities.size && !allowEmpty) throw Error("연결할 내역코드를 먼저 입력하세요.");
return quantities;
}
+ function allTotals(project) {
+ const combined = new Map();
+ for (const selected of ["manual", ...(project.quantitySheets || []).map((sheet) => sheet.Code)])
+ for (const [index, value] of totals(project, selected, true))
+ combined.set(index, (combined.get(index) || 0) + value);
+ if (!combined.size) throw Error("연결할 내역코드를 먼저 입력하세요.");
+ return combined;
+ }
function applyTotals(project, quantities) {
for (const [index, value] of quantities) project.tables.EstMainDat[index].GN = value;
}
@@ -337,6 +345,7 @@ const EstQuantity = (() => {
calculate,
manual,
totals,
+ allTotals,
applyTotals,
targetSelect,
newCode,