fix(est): apply head cost classification safely
This commit is contained in:
@@ -42,6 +42,33 @@ def test_estimate_classification_refreshes_only_selected_source_and_preserves_ma
|
||||
assert result["other"] and not result["flag"]
|
||||
|
||||
|
||||
def test_head_classification_preserves_other_groups_and_rejects_unsafe_restoration():
|
||||
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.HeadMainDat=[{Index:1,Code:'H1',Name:'first'},{Index:0,Code:'M',LinkIndex:5,GN:2,GU:100,GC:200,JC:0},
|
||||
{Index:2,Code:'H2',Name:'second'},{Index:0,Code:'N',LinkIndex:5,GN:1,GU:5,GC:5}];
|
||||
const row=p.tables.HeadMainDat[1],other=JSON.stringify(p.tables.HeadMainDat[3]);
|
||||
EstBusinessActions.setEstimateClassification(p,row,1,3,'HeadMainDat');
|
||||
const otherPreserved=JSON.stringify(p.tables.HeadMainDat[3])===other;
|
||||
const manual={JU:row.JU,GU:row.GU,GC:row.GC,JC:row.JC};
|
||||
const auto=EstNative.calculate(p).tables.HeadMainDat[1];
|
||||
EstBusinessActions.setEstimateClassification(p,row,1,0,'HeadMainDat');
|
||||
const restored={GU:row.GU,JU:row.JU};
|
||||
const mismatch=p.tables.HeadMainDat[3];EstBusinessActions.setEstimateClassification(p,mismatch,3,3,'HeadMainDat');
|
||||
let rejected=false;try{EstBusinessActions.setEstimateClassification(p,mismatch,3,0,'HeadMainDat')}catch{rejected=true}
|
||||
return {manual,auto:{GC:auto.GC,JC:auto.JC},restored,rejected,mismatch,otherPreserved,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"] and result["mismatch"]["JU"] == 5
|
||||
assert result["otherPreserved"] and not result["flag"]
|
||||
|
||||
|
||||
def test_annual_formulas_split_component_amounts_and_preserve_remainder():
|
||||
result = run(
|
||||
"math",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
## Codex · EST 독립 테스트 앱 (2026-11-04)
|
||||
- [x] 일위대가 상세 비목 지정 변경을 해당 행에만 반영 → 자동·수동 계산, 원천 불일치·다른 호표 보존 시험.
|
||||
- 자체 검증: 관련 시험 33개 통과. Tailscale HTTP Edge에서 자동·수동 계산, 다른 호표 보존, 원천 단가 불일치 시 복원 차단 및 화면 오류 0 확인.
|
||||
- [x] 원천 재조회가 꺼진 내역의 비목 지정 변경을 해당 행에만 반영 → 자동·수동 계산, 미연결·다른 행 보존 시험.
|
||||
- 자체 검증: 현재 단가를 유지한 비목 지정·일치하는 원천으로 복원, 다른 행·재조회 옵션 보존. 원천 누락·중복·단가 불일치는 복원 차단. Edge에서 자동·수동 계산 확인, 관련 시험 32개 통과.
|
||||
- [x] 산출근거 문구 일괄 바꾸기 → 현재 호표·전체 범위, 표지 보존·재계산·실행 취소 확인.
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
내역서 편집 메뉴의 `내역 수량 초기화`는 선택한 상세 행 또는 모든 상세 행의 수량을 0으로 바꿉니다. 비율·합계 자동행은 건드리지 않으며 실행 취소할 수 있습니다.
|
||||
|
||||
원천 자료에 연결된 내역 행의 결과비목을 재료·노무·경비로 바꾸면 현재 단가 합계를 유지해 지정한 비목으로 옮깁니다. 비목별 구성으로 되돌릴 때 원천 단가의 합계가 현재 단가와 다르거나 원천이 불명확하면 임의로 복원하지 않고 오류를 표시합니다. 자동계산이 꺼져 있으면 금액은 자동 재계산을 켤 때까지 유지합니다.
|
||||
원천 자료에 연결된 내역 또는 일위대가 상세 행의 결과비목을 재료·노무·경비로 바꾸면 현재 단가 합계를 유지해 지정한 비목으로 옮깁니다. 비목별 구성으로 되돌릴 때 원천 단가의 합계가 현재 단가와 다르거나 원천이 불명확하면 임의로 복원하지 않고 오류를 표시합니다. 자동계산이 꺼져 있으면 금액은 자동 재계산을 켤 때까지 유지합니다.
|
||||
|
||||
인쇄 미리보기는 화면의 100행 페이지 제한 없이 검색 결과/현재 호표의 전체 행을 표시합니다. 출력 범위에서 현재 자료의 모든 호표·행도 선택할 수 있습니다. 원본 인쇄 옵션의 41개 항목 중 CAD를 제외한 40개 서식 이름을 선택하며, ESTX에서 가져온 해당 프로젝트의 인쇄 설정을 우선 사용합니다. 내역·원가·호표/총괄·증감·동원·수량·공정은 표 자료로 출력합니다. 표지·갑지·설명서·시방서·심사 문서는 서식별 본문을 직접 입력합니다. 용지·방향·여백·글자 크기·페이지당 행·시작 쪽·업체명을 저장하고 인쇄/PDF로 출력합니다. ESTX 수정 저장은 서식별 여백과 페이지당 행 수를 반영합니다. 다른 웹 인쇄 설정의 ESTX 쓰기와 원본 인쇄 도형·병합·글꼴 배치의 동등성은 지원하지 않습니다.
|
||||
|
||||
|
||||
@@ -621,8 +621,8 @@ 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 if (node.dataset.table === "EstMainDat" && key === "SCheck")
|
||||
EstBusinessActions.setEstimateClassification(project, row, node.dataset.row, value);
|
||||
} else if (key === "SCheck")
|
||||
EstBusinessActions.setClassification(project, row, node, value);
|
||||
else row[key] = value;
|
||||
if (state.view === "escalation" && EstMath.units.includes(key))
|
||||
project.options.SourceRefresh = false;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
const EstBusinessActions = (() => {
|
||||
function setEstimateClassification(project, row, index, value) {
|
||||
function setEstimateClassification(project, row, index, value, rowTable = "EstMainDat") {
|
||||
index = Number(index);
|
||||
if (!Number.isInteger(index) || project.tables.EstMainDat[index] !== row)
|
||||
if (!Number.isInteger(index) || project.tables[rowTable]?.[index] !== row)
|
||||
throw Error("내역 행을 다시 선택하세요.");
|
||||
const kind = Number(row.LinkIndex),
|
||||
table = EstNative.sourceTables[kind];
|
||||
if (
|
||||
!table ||
|
||||
Number(row.Level) ||
|
||||
(rowTable === "EstMainDat" ? Number(row.Level) : Number(row.Index) > 0) ||
|
||||
[1, 3].includes(Number(row.CalcIndex)) ||
|
||||
![0, 1, 2, 3].includes(value)
|
||||
) {
|
||||
@@ -46,13 +46,15 @@ const EstBusinessActions = (() => {
|
||||
row.SCheck = value;
|
||||
try {
|
||||
let failures = null;
|
||||
const calculated = EstNative.calculate(project, {
|
||||
refreshEstimateRows: [index],
|
||||
onEstimateRefresh: (errors) => (failures = errors),
|
||||
});
|
||||
const calculated = EstNative.calculate(
|
||||
project,
|
||||
rowTable === "HeadMainDat"
|
||||
? { refreshHeadRows: [index], onHeadRefresh: (errors) => (failures = errors) }
|
||||
: { refreshEstimateRows: [index], onEstimateRefresh: (errors) => (failures = errors) },
|
||||
);
|
||||
if (!failures) throw Error("내역 비목 재계산을 확인할 수 없습니다.");
|
||||
if (failures.length) throw Error(failures.join(" / "));
|
||||
const refreshed = calculated.tables.EstMainDat[index];
|
||||
const refreshed = calculated.tables[rowTable][index];
|
||||
const refreshedTotal = EstMath.units.reduce(
|
||||
(sum, key) => sum + EstMath.num(refreshed[key]),
|
||||
0,
|
||||
@@ -70,6 +72,12 @@ const EstBusinessActions = (() => {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function setClassification(project, row, node, value) {
|
||||
const table = node.dataset.table;
|
||||
if (["EstMainDat", "HeadMainDat"].includes(table))
|
||||
setEstimateClassification(project, row, node.dataset.row, value, table);
|
||||
else row.SCheck = value;
|
||||
}
|
||||
let purpose = "",
|
||||
documentKey;
|
||||
const currentRound = () =>
|
||||
@@ -435,5 +443,5 @@ const EstBusinessActions = (() => {
|
||||
}
|
||||
});
|
||||
}
|
||||
return { handle, change, install, setEstimateClassification };
|
||||
return { handle, change, install, setEstimateClassification, setClassification };
|
||||
})();
|
||||
|
||||
@@ -178,14 +178,21 @@ const EstNative = (() => {
|
||||
const group = (kind === 103 ? heads : costs).get(code);
|
||||
if (!group) throw Error(`하위 자료 없음: ${id}`);
|
||||
active.add(id);
|
||||
const refreshHeadRows =
|
||||
kind === 103
|
||||
? (request.refreshHeadRows || [])
|
||||
.filter((index) => index >= group.start && index < group.start + group.rows.length)
|
||||
.map((index) => index - group.start)
|
||||
: [];
|
||||
const result =
|
||||
kind === 103
|
||||
? EstRows.head(group.rows, options, resolve)
|
||||
? EstRows.head(group.rows, options, resolve, refreshHeadRows)
|
||||
: EstText.calculate(
|
||||
group.rows,
|
||||
options,
|
||||
options.SourceRefresh || options.ResourceTracing ? resolve : () => null,
|
||||
);
|
||||
if (refreshHeadRows.length) request.onHeadRefresh?.(result.refreshErrors);
|
||||
const title = {
|
||||
...tables[sourceTables[kind]].find((row) => row.Code === code),
|
||||
...result.titles[0],
|
||||
|
||||
@@ -82,10 +82,11 @@ const EstRows = (() => {
|
||||
);
|
||||
return amounts(row, digits);
|
||||
}
|
||||
function head(rows, options = {}, resolve = () => null) {
|
||||
function head(rows, options = {}, resolve = () => null, refreshRows = []) {
|
||||
const output = structuredClone(rows),
|
||||
titles = [],
|
||||
errors = [];
|
||||
errors = [],
|
||||
refreshErrors = [];
|
||||
let current = null,
|
||||
body = [];
|
||||
function finish() {
|
||||
@@ -93,7 +94,7 @@ const EstRows = (() => {
|
||||
Object.assign(current, add(body));
|
||||
titles.push({ ...current });
|
||||
}
|
||||
for (const row of output) {
|
||||
for (const [index, row] of output.entries()) {
|
||||
if (num(row.Index) > 0) {
|
||||
finish();
|
||||
current = row;
|
||||
@@ -119,12 +120,15 @@ const EstRows = (() => {
|
||||
body = [row];
|
||||
continue;
|
||||
} 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(`${current.Name}: ${error.message}`);
|
||||
if (requested) refreshErrors.push(error.message);
|
||||
}
|
||||
}
|
||||
amounts(row, options.HeadDigits || 0, options.TotalFit);
|
||||
@@ -132,7 +136,9 @@ const EstRows = (() => {
|
||||
body.push(row);
|
||||
}
|
||||
finish();
|
||||
return { rows: output, titles, errors };
|
||||
return refreshRows.length
|
||||
? { rows: output, titles, errors, refreshErrors }
|
||||
: { rows: output, titles, errors };
|
||||
}
|
||||
function estimate(rows, options = {}, resolve = () => null, refreshRows = []) {
|
||||
const output = structuredClone(rows),
|
||||
|
||||
Reference in New Issue
Block a user