fix(est): apply selected estimate classification safely

This commit is contained in:
2026-11-07 16:30:44 +09:00
parent c530ac694c
commit 9eae77d8f2
8 changed files with 138 additions and 9 deletions
+1
View File
@@ -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 });
+39
View File
@@ -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",
+2
View File
@@ -1,4 +1,6 @@
## Codex · EST 독립 테스트 앱 (2026-11-04)
- [x] 원천 재조회가 꺼진 내역의 비목 지정 변경을 해당 행에만 반영 → 자동·수동 계산, 미연결·다른 행 보존 시험.
- 자체 검증: 현재 단가를 유지한 비목 지정·일치하는 원천으로 복원, 다른 행·재조회 옵션 보존. 원천 누락·중복·단가 불일치는 복원 차단. Edge에서 자동·수동 계산 확인, 관련 시험 32개 통과.
- [x] 산출근거 문구 일괄 바꾸기 → 현재 호표·전체 범위, 표지 보존·재계산·실행 취소 확인.
- 자체 검증: Tailscale HTTP Edge에서 현재 호표·전체 치환, 일치 없음 안내, 결과·단가 표지 보존과 실행 취소 확인. 산출근거 시험 24개 통과, 수식 변경 후 재계산 확인.
- [x] Excel 파일 읽는 동안 파일·셀 선택이 바뀌면 이전 요청 반영 차단 → 지연 응답 시험.
+2
View File
@@ -18,6 +18,8 @@
내역서 편집 메뉴의 `내역 수량 초기화`는 선택한 상세 행 또는 모든 상세 행의 수량을 0으로 바꿉니다. 비율·합계 자동행은 건드리지 않으며 실행 취소할 수 있습니다.
원천 자료에 연결된 내역 행의 결과비목을 재료·노무·경비로 바꾸면 현재 단가 합계를 유지해 지정한 비목으로 옮깁니다. 비목별 구성으로 되돌릴 때 원천 단가의 합계가 현재 단가와 다르거나 원천이 불명확하면 임의로 복원하지 않고 오류를 표시합니다. 자동계산이 꺼져 있으면 금액은 자동 재계산을 켤 때까지 유지합니다.
인쇄 미리보기는 화면의 100행 페이지 제한 없이 검색 결과/현재 호표의 전체 행을 표시합니다. 출력 범위에서 현재 자료의 모든 호표·행도 선택할 수 있습니다. 원본 인쇄 옵션의 41개 항목 중 CAD를 제외한 40개 서식 이름을 선택하며, ESTX에서 가져온 해당 프로젝트의 인쇄 설정을 우선 사용합니다. 내역·원가·호표/총괄·증감·동원·수량·공정은 표 자료로 출력합니다. 표지·갑지·설명서·시방서·심사 문서는 서식별 본문을 직접 입력합니다. 용지·방향·여백·글자 크기·페이지당 행·시작 쪽·업체명을 저장하고 인쇄/PDF로 출력합니다. ESTX 수정 저장은 서식별 여백과 페이지당 행 수를 반영합니다. 다른 웹 인쇄 설정의 ESTX 쓰기와 원본 인쇄 도형·병합·글꼴 배치의 동등성은 지원하지 않습니다.
별도 인쇄 서식으로 기성·물가변동의 내역서와 원가계산서, 연차 배분표와 연차별 내역서, 설계변경 원가 증감표를 선택할 수 있습니다. 기성은 선택한 회차를 출력하고 확정된 회차는 확정 당시 결과를 사용합니다.
+3 -1
View File
@@ -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)) {
+72 -1
View File
@@ -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 };
})();
+8 -2
View File
@@ -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;
+11 -5
View File
@@ -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 };
})();