fix(est): apply selected quantity calculations

This commit is contained in:
2026-11-07 07:37:40 +09:00
parent 864ff1f55b
commit 4c6bb9d70a
5 changed files with 71 additions and 40 deletions
@@ -102,3 +102,28 @@ def test_insert_and_move_quantity_rows_keeps_item_results_linked():
assert result["linked"]["errors"] == [] assert result["linked"]["errors"] == []
assert result["linked"]["items"][1]["GN"] == 6 assert result["linked"]["items"][1]["GN"] == 6
assert result["forward"]["errors"] assert result["forward"]["errors"]
def test_manual_and_selected_sheet_totals_validate_before_application():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();
p.tables.EstMainDat=[{Code:'X',Level:0,GN:1,MulCode:{binary:'YWJj'}}];
p.quantities=[{Code:'A',formula:'2',TargetCode:'X'},
{Code:'B',formula:'[A]*3',TargetCode:'X'}];
const manual=EstQuantity.totals(p,'manual');EstQuantity.applyTotals(p,manual);
const a=structuredClone(p.tables.EstMainDat[0]);
p.quantitySheets=[{Code:'Q',options:{},rows:[{LineData:'4+5 = \\x120\\x12',markers:['r1']}],
items:[{Code:'r1',GN:0,TargetCode:'X'}]}];
const sheet=EstQuantity.totals(p,'Q');EstQuantity.applyTotals(p,sheet);
let invalid='';p.quantities.push({Code:'C',formula:'1',TargetCode:'missing'});
try{EstQuantity.totals(p,'manual')}catch(e){invalid=e.message}
return {a,b:p.tables.EstMainDat[0],invalid};
})()""",
)
assert result["a"]["GN"] == 8
assert result["a"]["MulCode"] == {"binary": "YWJj"}
assert result["b"]["GN"] == 9
assert result["b"]["MulCode"] == {"binary": "YWJj"}
assert result["invalid"]
+2
View File
@@ -29,6 +29,8 @@ Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요
수량 화면에서 새 산출서를 만들고 이름과 산출 항목의 품명·규격을 편집할 수 있습니다. `산출 행 추가`로 결과 표지가 연결된 행과 항목을 함께 만든 뒤 수식을 입력하고, 연결 내역코드를 지정해 계산 수량을 내역에 반영합니다. 행 아래 삽입, 위·아래 이동과 산출 행·산출서 삭제도 가능하며, 이 구조 변경은 JSON 저장과 다시 열기를 지원합니다. 기존 연결은 내역코드를 비워 해제할 수 있습니다. 수량 화면에서 새 산출서를 만들고 이름과 산출 항목의 품명·규격을 편집할 수 있습니다. `산출 행 추가`로 결과 표지가 연결된 행과 항목을 함께 만든 뒤 수식을 입력하고, 연결 내역코드를 지정해 계산 수량을 내역에 반영합니다. 행 아래 삽입, 위·아래 이동과 산출 행·산출서 삭제도 가능하며, 이 구조 변경은 JSON 저장과 다시 열기를 지원합니다. 기존 연결은 내역코드를 비워 해제할 수 있습니다.
재계산 메뉴의 수량 반영도 현재 선택한 산출서 또는 직접 작성 수식표를 사용합니다. 반영 전에 모든 연결 대상과 수식을 검사하며, 기존 원본 연결 데이터는 그대로 보관합니다.
## 검증과 남은 범위 ## 검증과 남은 범위
기계 성분·합계 48건, 일위 41건, 내역 37건, 산출 결과 118건과 호표 8건, 원가 수식 23건을 대조합니다. 원가 도급액 고정 반복 차이 `324 → -32 → 3 → 0`을 재현합니다. 단가 갱신 후 전체 재계산과 반복 계산의 안정성도 시험합니다. 기계 성분·합계 48건, 일위 41건, 내역 37건, 산출 결과 118건과 호표 8건, 원가 수식 23건을 대조합니다. 원가 도급액 고정 반복 차이 `324 → -32 → 3 → 0`을 재현합니다. 단가 갱신 후 전체 재계산과 반복 계산의 안정성도 시험합니다.
+2 -21
View File
@@ -410,28 +410,9 @@ const EstActions = (() => {
false, false,
); );
else if (name === "apply-quantity") { else if (name === "apply-quantity") {
const values = Object.create(null), const totals = EstQuantity.totals(project, state.quantitySheet);
totals = new Map();
for (const row of project.quantities) {
const value = EstMath.floating(
EstExpression.evaluate(row.formula, (key) => values[key]),
row.digits ?? 2,
1,
);
values[row.Code] = value;
if (row.TargetCode) totals.set(row.TargetCode, (totals.get(row.TargetCode) || 0) + value);
}
if (!totals.size) throw Error("연결 내역코드를 입력하세요.");
if (confirm("연결된 내역 수량을 산출 결과로 바꾸시겠습니까?")) if (confirm("연결된 내역 수량을 산출 결과로 바꾸시겠습니까?"))
EstApp.change(() => { EstApp.change(() => EstQuantity.applyTotals(project, totals));
for (const row of project.tables.EstMainDat)
if (totals.has(row.Code)) {
row.GN = totals.get(row.Code);
row.MullangIndex = 1;
row.ExcelSheetIndex = 0;
row.MulCode = row.Code;
}
});
} else if (name === "add-round") { } else if (name === "add-round") {
const next = { const next = {
id: uid(), id: uid(),
+1
View File
@@ -101,6 +101,7 @@ const EstApp = (() => {
state.selection.clear(); state.selection.clear();
state.group = ""; state.group = "";
state.page = 0; state.page = 0;
state.quantitySheet = state.project.quantitySheets?.[0]?.Code || "manual";
state.roundId = state.roundId =
state.project.progress.find((round) => round.id === state.roundId)?.id || state.project.progress.find((round) => round.id === state.roundId)?.id ||
state.project.progress[0]?.id; state.project.progress[0]?.id;
+41 -19
View File
@@ -113,6 +113,44 @@ const EstQuantity = (() => {
errors: result.errors, errors: result.errors,
}; };
} }
function totals(project, selected) {
let items;
if (selected && selected !== "manual") {
const sheet = project.quantitySheets?.find((s) => s.Code === selected);
if (!sheet) throw Error("선택한 수량산출서가 없습니다.");
const output = calculate(sheet);
if (output.errors.length) throw Error(output.errors.join(" / "));
items = output.items;
} else {
const values = Object.create(null);
items = (project.quantities || []).map((row) => {
if (!row.Code || Object.hasOwn(values, row.Code))
throw Error(`산출코드가 비어 있거나 중복됩니다: ${row.Code || ""}`);
const GN = EstMath.floating(
EstExpression.evaluate(row.formula || "0", (key) => values[key]),
row.digits ?? 2,
1,
);
values[row.Code] = GN;
return { ...row, GN };
});
}
const quantities = new Map();
for (const item of items) {
const code = item.TargetCode ?? item.LinkCode;
if (!code) continue;
const targets = project.tables.EstMainDat.filter((r) => r.Code === code && !Number(r.Level));
if (!targets.length) throw Error(`연결 내역코드 없음: ${code}`);
if (targets.length > 1) throw Error(`연결 내역코드 중복: ${code}`);
quantities.set(code, (quantities.get(code) || 0) + Number(item.GN || 0));
}
if (!quantities.size) throw Error("연결할 내역코드를 먼저 입력하세요.");
return quantities;
}
function applyTotals(project, quantities) {
for (const row of project.tables.EstMainDat)
if (quantities.has(row.Code) && !Number(row.Level)) row.GN = quantities.get(row.Code);
}
function selector(state) { function selector(state) {
const { esc } = EstApp; const { esc } = EstApp;
return `<div class="business-tools"><label>수량산출서 <select id="quantity-sheet-choice"><option value="manual">수식표 직접 작성</option>${(state.project.quantitySheets || []).map((sheet) => `<option value="${esc(sheet.Code)}" ${state.quantitySheet === sheet.Code ? "selected" : ""}>${esc(sheet.Name || sheet.Code)}</option>`).join("")}</select></label><button data-quantity-new>새 산출서</button></div>`; return `<div class="business-tools"><label>수량산출서 <select id="quantity-sheet-choice"><option value="manual">수식표 직접 작성</option>${(state.project.quantitySheets || []).map((sheet) => `<option value="${esc(sheet.Code)}" ${state.quantitySheet === sheet.Code ? "selected" : ""}>${esc(sheet.Name || sheet.Code)}</option>`).join("")}</select></label><button data-quantity-new>새 산출서</button></div>`;
@@ -224,30 +262,14 @@ const EstQuantity = (() => {
} }
if (!event.target.closest("[data-quantity-apply]")) return; if (!event.target.closest("[data-quantity-apply]")) return;
try { try {
const output = calculate(sheet); const quantities = totals(state.project, state.quantitySheet);
if (output.errors.length) throw Error(output.errors.join(" / ")); EstApp.change(() => applyTotals(state.project, quantities));
const quantities = new Map();
for (const item of output.items)
if (item.TargetCode ?? item.LinkCode) {
const code = item.TargetCode ?? item.LinkCode;
const targets = state.project.tables.EstMainDat.filter(
(r) => r.Code === code && !Number(r.Level),
);
if (!targets.length) throw Error(`연결 내역코드 없음: ${code}`);
if (targets.length > 1) throw Error(`연결 내역코드 중복: ${code}`);
quantities.set(code, (quantities.get(code) || 0) + Number(item.GN || 0));
}
if (!quantities.size) throw Error("연결할 내역코드를 먼저 입력하세요.");
EstApp.change(() => {
for (const row of state.project.tables.EstMainDat)
if (quantities.has(row.Code) && !Number(row.Level)) row.GN = quantities.get(row.Code);
});
} catch (error) { } catch (error) {
EstApp.state.notice = error.message; EstApp.state.notice = error.message;
EstApp.render(); EstApp.render();
} }
}); });
} }
return { entities, fromWpt, calculate, selector, render, install }; return { entities, fromWpt, calculate, totals, applyTotals, selector, render, install };
})(); })();
if (typeof module !== "undefined" && module.exports) module.exports = EstQuantity; if (typeof module !== "undefined" && module.exports) module.exports = EstQuantity;