Merge remote-tracking branch 'origin/CODEX' into sub_laptop_8

This commit is contained in:
2026-11-08 15:02:17 +09:00
7 changed files with 1285 additions and 57 deletions
@@ -10,6 +10,23 @@ def test_new_progress_name_avoids_existing_names_with_whitespace():
assert result == ["3회 기성", "1회 기성", "1회 기성"]
def test_progress_cannot_lock_when_previous_result_is_missing():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();
p.tables.EstMainDat=[{PkID:1,Code:'R',GN:10,GU:100},{LinkIndex:54}];
p.progress=[{id:'one',locked:true,inputs:{}},{id:'two',inputs:{'1':{Gum:2}}}];
const calculated=EstProgress.calculate(p,'two');
let rejected=false;
try { EstProgress.lock(p,'two'); } catch { rejected=true; }
return {errors:calculated.errors,rejected};
})()""",
)
assert any("전회 기성 결과가 없습니다" in error for error in result["errors"])
assert result["rejected"]
def test_progress_rejects_ambiguous_inputs_without_applying_them_to_multiple_rows():
result = run(
"math",
+750 -24
View File
@@ -31,6 +31,7 @@ def run_js(code: str) -> dict:
const files = [
'tools/est-test/native-math.js',
'tools/est-test/native-formula.js',
'tools/est-test/native-model.js',
'tools/est-test/native-text.js',
'tools/est-test/native-machine.js',
@@ -663,12 +664,18 @@ def test_align_wonga_total_action():
const p = EstNative.empty();
p.tables.Wonga = [
{ Gubun: "A", Name: "직접공사비", Cost: 1000000, LinkIndex: 0, CalcIndex: 0 },
{ Gubun: "B", Name: "일반관리비", Cost: 50000, LinkIndex: 0, CalcIndex: 0 },
{ Gubun: "C", Name: "이윤", Cost: 78564, LinkIndex: 0, CalcIndex: 0 },
{ Gubun: "D", Name: "부가가치세", Cost: 112856, LinkIndex: 0, CalcIndex: 0 },
{ Gubun: "E", Name: "총도급공사비", Cost: 1241420, LinkIndex: 55, CalcIndex: 0 }
{ Gubun: "B", Name: "간접노무비", Cost: 0, VSusic: "A * 0.1", LinkIndex: 0, CalcIndex: 0 },
{ Gubun: "C", Name: "이윤", Cost: 0, VSusic: "(A + B) * 0.1", LinkIndex: 0, CalcIndex: 0 },
{ Gubun: "D", Name: "부가가치세", Cost: 0, VSusic: "(A + B + C) * 0.1", LinkIndex: 0, CalcIndex: 0 },
{ Gubun: "E", Name: "총도급공사비", Cost: 0, VSusic: "A + B + C + D", LinkIndex: 55, CalcIndex: 0 }
];
// 1) 당초 일반 계산: 직접공사비 1,000,000 기준 -> 총도급 1,331,000원, 이윤 110,000원, 부가세 121,000원
const initialCalc = EstWonga.calculate(p.tables.Wonga, { TotalFix: false }, {});
const initialTotal = initialCalc.rows[4].Cost;
const initialProfit = initialCalc.rows[2].Cost;
p.tables.Wonga = initialCalc.rows;
const state = {
project: p,
view: "Wonga",
@@ -677,19 +684,34 @@ def test_align_wonga_total_action():
};
globalThis.EstApp = {
state,
change(fn) { fn(); },
change(fn) {
fn();
// EstApp.change recalculates via EstNative.calculate / EstWonga.calculate
const recalc = EstWonga.calculate(p.tables.Wonga, p.options, {});
p.tables.Wonga = recalc.rows;
},
render() {}
};
// 1) 1,000원 단위(unitDigits: 3) 절하 맞춤 테스트
const alignRes1000 = EstWonga.alignTotal(p.tables.Wonga, 1241420, 3);
const total1000 = p.tables.Wonga[alignRes1000.targetIndex].Cost;
// 2) 10,000원 단위(unitDigits: 4) 역산 맞춤: 1,331,000 -> 1,330,000원으로 절하 맞춤
const alignRes = EstWonga.alignTotal(p.tables.Wonga, 1331000, 4, p.options, {});
p.options.TotalFix = true;
// 2) 10,000원 단위(unitDigits: 4) 절하 맞춤 테스트
const alignRes10000 = EstWonga.alignTotal(p.tables.Wonga, 1241420, 4);
const total10000 = p.tables.Wonga[alignRes10000.targetIndex].Cost;
const alignedTotal = p.tables.Wonga[alignRes.targetIndex].Cost;
const alignedProfit = p.tables.Wonga[alignRes.profitIndex].Cost;
const alignedVat = p.tables.Wonga[3].Cost;
// 전체 명세 합산 일치 검증: 직접(1,000,000) + 간접노무(100,000) + 이윤(109,091) + 부가세(120,909) === 1,330,000
const totalMatch = p.tables.Wonga[0].Cost + p.tables.Wonga[1].Cost + p.tables.Wonga[2].Cost + p.tables.Wonga[3].Cost === alignedTotal;
// 3) LinkIndex 55 부재 시 거부 확인
// 3) JSON 직렬화 & 역직렬화 (저장 재열기) 후 재계산 일관성 검증
const jsonStr = JSON.stringify(p);
const reloadedP = JSON.parse(jsonStr);
const reloadCalc = EstWonga.calculate(reloadedP.tables.Wonga, reloadedP.options, {});
const reloadedTotal = reloadCalc.rows[4].Cost;
const reloadedProfit = reloadCalc.rows[2].Cost;
const reloadedVat = reloadCalc.rows[3].Cost;
// 4) LinkIndex 55 부재 시 거부 확인
let noTargetRejected = false;
try {
EstWonga.alignTotal([{ Name: "항목1", Cost: 100 }], 1000, 3);
@@ -697,7 +719,7 @@ def test_align_wonga_total_action():
noTargetRejected = true;
}
// 4) 이윤 행 부재 시 거부 확인
// 5) 이윤 행 부재 시 거부 확인
let noProfitRejected = false;
try {
EstWonga.alignTotal([{ Name: "총도급", Cost: 100, LinkIndex: 55 }], 1000, 3);
@@ -705,24 +727,728 @@ def test_align_wonga_total_action():
noProfitRejected = true;
}
// 6) 계산 실패 시 기존 rows 상태 보존 검증: 순환 참조 수식 원가표
const circularRows = [
{ Gubun: "01", Name: "순공사비", LinkIndex: 54, CalcIndex: 0, Cost: 1000000 },
{ Gubun: "02", Name: "이윤", LinkIndex: 0, CalcIndex: 0, VSusic: "[부가가치세] * 0.1", Cost: 100000 },
{ Gubun: "03", Name: "부가가치세", LinkIndex: 0, CalcIndex: 0, VSusic: "[이윤] * 0.1", Cost: 10000 },
{ Gubun: "04", Name: "총도급공사비", LinkIndex: 55, CalcIndex: 0, VSusic: "[순공사비] + [이윤] + [부가가치세]", Cost: 1110000 }
];
const origCircularCost = circularRows[3].Cost;
let circularErrorCaught = false;
try {
EstWonga.alignTotal(circularRows, 1100000, 3);
} catch {
circularErrorCaught = true;
}
const circularCostPreserved = circularRows[3].Cost === origCircularCost;
return {
total1000,
total10000,
roundedTarget1000: alignRes1000.roundedTarget,
roundedTarget10000: alignRes10000.roundedTarget,
profitIndex: alignRes1000.profitIndex,
initialTotal,
initialProfit,
alignedTotal,
alignedProfit,
alignedVat,
totalMatch,
reloadedTotal,
reloadedProfit,
reloadedVat,
noTargetRejected,
noProfitRejected
noProfitRejected,
circularErrorCaught,
circularCostPreserved
};
})()"""
result = run_js(code)
assert result["total1000"] == 1241000
assert result["roundedTarget1000"] == 1241000
assert result["total10000"] == 1240000
assert result["roundedTarget10000"] == 1240000
assert result["profitIndex"] == 2
assert result["initialTotal"] == 1331000
assert result["initialProfit"] == 110000
assert result["alignedTotal"] == 1330000
assert result["alignedProfit"] == 109091
assert result["alignedVat"] == 120909
assert result["totalMatch"] is True
assert result["reloadedTotal"] == 1330000
assert result["reloadedProfit"] == 109091
assert result["reloadedVat"] == 120909
assert result["noTargetRejected"] is True
assert result["noProfitRejected"] is True
assert result["circularErrorCaught"] is True
assert result["circularCostPreserved"] is True
def test_level_batch_adjust_actions():
code = """(() => {
const p = EstNative.empty();
p.tables.EstMainDat = [
{ Index: 0, LineData: "공종 1", Level: 1 },
{ Index: 0, LineData: "항목 1-1", Level: 2 },
{ Index: 0, LineData: "항목 1-2", Level: 2 },
{ Index: 0, LineData: "공종 2", Level: 0 },
{ Index: 0, LineData: "공종 9", Level: 9 }
];
const state = {
project: p,
view: "EstMainDat",
selection: new Set([0, 1]),
notice: ""
};
globalThis.EstApp = {
state,
change(fn) { fn(); },
render() {}
};
// 1) 레벨 일괄 증가: 0번행(1->2), 1번행(2->3)
EstBlockActions.adjustLevels(state, 1);
const afterUp0 = p.tables.EstMainDat[0].Level;
const afterUp1 = p.tables.EstMainDat[1].Level;
const upNotice = state.notice;
// 2) 레벨 일괄 감소: 0번행(2->1), 1번행(3->2)
EstBlockActions.adjustLevels(state, -1);
const afterDown0 = p.tables.EstMainDat[0].Level;
const afterDown1 = p.tables.EstMainDat[1].Level;
// 3) 상한(9) 및 하한(0) 클램프 검증:
// 3번행(0) 감소 시 0 유지, 4번행(9) 증가 시 9 유지
state.selection = new Set([3, 4]);
EstBlockActions.adjustLevels(state, -1);
const clampMin = p.tables.EstMainDat[3].Level; // 0 유지
EstBlockActions.adjustLevels(state, 1);
const clampMax = p.tables.EstMainDat[4].Level; // 9 유지
// 4) 지원되지 않는 화면(자재, 노임 등)에서 호출 시 안전 거부
state.view = "StuffcDat";
let viewRejected = false;
try {
EstBlockActions.adjustLevels(state, 1);
} catch {
viewRejected = true;
}
// 5) 선택 행 없을 시 거부
state.view = "EstMainDat";
state.selection = new Set([]);
let emptyRejected = false;
try {
EstBlockActions.adjustLevels(state, 1);
} catch {
emptyRejected = true;
}
return {
afterUp0,
afterUp1,
upNotice,
afterDown0,
afterDown1,
clampMin,
clampMax,
viewRejected,
emptyRejected
};
})()"""
result = run_js(code)
assert result["afterUp0"] == 2
assert result["afterUp1"] == 3
assert "2개 행의 레벨이 일괄 증가" in result["upNotice"]
assert result["afterDown0"] == 1
assert result["afterDown1"] == 2
assert result["clampMin"] == 0
assert result["clampMax"] == 9
assert result["viewRejected"] is True
assert result["emptyRejected"] is True
def test_reset_change_action():
code = """(() => {
const p = EstNative.empty();
p.tables.EstMainDat = [
{ Code: "A001", Name: "토공사", GN: 100, TU: 5000, TC: 500000, Level: 0, LinkIndex: 0, CalcIndex: 0 },
{ Code: "A002", Name: "구조물공사", GN: 50, TU: 10000, TC: 500000, Level: 0, LinkIndex: 0, CalcIndex: 0, _origLinkIndex: 21, _prevSCheck: 1 }
];
// 타 업무(수량 연결 등) 및 원본 필드 모의
p.quantities = [
{ id: "Q1", sheet: "토공", items: [{ code: "A001", qty: 100 }] }
];
// 1) baseline 부재 시 안전 차단 검증
const state = {
project: p,
view: "EstMainDat",
selection: new Set([0]),
notice: ""
};
globalThis.confirm = () => true;
globalThis.EstApp = {
state,
history: [],
future: [],
change(fn) {
// undo history 기록 모의
this.history.push(JSON.stringify(state.project));
fn();
},
render() {}
};
let rejectBeforeBaseline = false;
try {
EstBlockActions.resetChangeAction(state);
} catch {
rejectBeforeBaseline = true;
}
// 2) 당초 확정 (baseline 생성 및 Mode = "설계변경", _ 필드 보존)
state.project = EstBusiness.baseline(state.project);
// 3) 설계변경 작업 수행: 기존 행 변경, 신규 행 추가, 기존 행 삭제, 타 업무 필드 보존 확인
state.project.tables.EstMainDat[0].GN = 150;
state.project.tables.EstMainDat[0].TC = 750000;
// 신규 행 추가
state.project.tables.EstMainDat.push({
Code: "A003",
Name: "신규공종",
GN: 20,
TU: 3000,
TC: 60000,
Level: 0,
LinkIndex: 0,
CalcIndex: 0,
NewItem: true
});
// 1번 행(_origLinkIndex, _prevSCheck 포함)의 변경
state.project.tables.EstMainDat[1].GN = 80;
// 3-1) 신규 행을 참조하는 수량 연결(quantities)이 존재할 때 초기화 차단 검증
state.project.quantities.push({
Code: "Q2",
TargetCode: "A003", // 신규 행 참조
GN: 20
});
let brokenQtyLinkRejected = false;
try {
EstBlockActions.resetChangeAction(state);
} catch (e) {
if (e.message.includes("수량산출 연결 무결성 오류")) {
brokenQtyLinkRejected = true;
}
}
// 차단 확인 후 정상 복원 테스트를 위해 신규 연결 해제
state.project.quantities.pop();
// 3-2) Excel 수량 연결 동일성(Identity) 입증 검증:
// PkID 1(토공사)에 Excel 링크 연결
state.project.tables.EstMainDat[0].PkID = 1;
state.project.baseline.tables.EstMainDat[0].PkID = 1;
state.project.tables.EstMainDat[1].PkID = 2;
state.project.baseline.tables.EstMainDat[1].PkID = 2;
state.project.quantities.push({ TargetKey: "id:999", TargetCode: "A001", GN: 1 });
let missingKeyRejected = false;
try {
EstBlockActions.resetChangeAction(state);
} catch (e) {
missingKeyRejected = e.message.includes("수량산출 연결 무결성 오류");
}
state.project.quantities.pop();
state.project.baseline.tables.EstMainDat.push({ Code: "A001", Level: 0 });
state.project.quantities.push({ TargetCode: "A001", GN: 1 });
let duplicateCodeRejected = false;
try {
EstBlockActions.resetChangeAction(state);
} catch (e) {
duplicateCodeRejected = e.message.includes("수량산출 연결 무결성 오류");
}
state.project.quantities.pop();
state.project.baseline.tables.EstMainDat.pop();
if (!missingKeyRejected || !duplicateCodeRejected) throw Error("수량 연결 보호 실패");
state.project.excelQuantityLinks = [
{ target: 1, file: "calc.xlsx", sheet: "Sheet1", cell: "B10" }
];
// 중간 행 삽입, 삭제, 재정렬 및 중복 코드 상황 모의:
// 신규 행(중복 코드 A001를 가진 다른 항목)을 앞에 삽입해 인덱스 순서 변경
state.project.tables.EstMainDat.unshift({
Code: "A001", // 중복 코드
Name: "중복코드 신규토공",
GN: 10,
TU: 2000,
TC: 20000,
Level: 0,
LinkIndex: 0,
CalcIndex: 0,
PkID: 99,
NewItem: true
});
// PkID 1 행은 이제 1번 인덱스로 밀림. PkID 1의 내용(토공사)은 baseline의 PkID 1(토공사)과 일치함.
// 하지만 Excel 링크가 신규 PkID 99를 가리키도록 변조되었을 때 차단되는지 확인:
state.project.excelQuantityLinks[0].target = 99;
let brokenExcelLinkRejected = false;
try {
EstBlockActions.resetChangeAction(state);
} catch (e) {
if (e.message.includes("Excel 수량 연결 무결성 오류")) {
brokenExcelLinkRejected = true;
}
}
// 동일성 위조 검증: PkID 1을 가리키지만 품명이 변조되어 당초 행과 동일성이 깨진 경우 차단
state.project.excelQuantityLinks[0].target = 1;
const originalName = state.project.tables.EstMainDat[1].Name;
state.project.tables.EstMainDat[1].Name = "변조된 다른항목명";
let identityMismatchRejected = false;
try {
EstBlockActions.resetChangeAction(state);
} catch (e) {
if (e.message.includes("동일성이 입증되지 않아 초기화를 차단")) {
identityMismatchRejected = true;
}
}
// 원래 이름으로 복구하여 정상 연결 상태 복원
state.project.tables.EstMainDat[1].Name = originalName;
// 신규 삽입 행 제거하여 원래 상태 맞춤
state.project.tables.EstMainDat.shift();
// 4) JSON 직렬화 & 역직렬화 (저장 재열기 왕복)
const savedJson = JSON.stringify(state.project);
state.project = JSON.parse(savedJson);
// 5) 설계변경 초기화 (resetChangeAction) 실행
EstBlockActions.resetChangeAction(state);
// 신규 행이 삭제되고 원래 2개 행으로 완전 복원되었는지 확인
const rowCountRestored = state.project.tables.EstMainDat.length === 2;
const restoredGN0 = state.project.tables.EstMainDat[0].GN;
const restoredTC0 = state.project.tables.EstMainDat[0].TC;
const restoredGN1 = state.project.tables.EstMainDat[1].GN;
const restoredTC1 = state.project.tables.EstMainDat[1].TC;
const baselineCleared = state.project.baseline === undefined;
const modeRestored = state.project.options.Mode === "내역서";
// 설계변경 _ 필드는 삭제되었지만, 타 업무 필드(_origLinkIndex, _prevSCheck) 및 quantities는 완전 보존되었는지 검증
const hasBaselineUnderscore = Object.keys(state.project.tables.EstMainDat[0]).some(k => k === "GN_" || k === "TC_");
const preservedOrigLink = state.project.tables.EstMainDat[1]._origLinkIndex === 21;
const preservedPrevSCheck = state.project.tables.EstMainDat[1]._prevSCheck === 1;
const quantitiesPreserved = state.project.quantities.length === 1 && state.project.quantities[0].id === "Q1";
// 6) Undo (실행 취소) 검증: 이전 변경 상태로 롤백 가능 확인
const canUndo = EstApp.history.length > 0;
const lastSnapshot = JSON.parse(EstApp.history.pop());
const undoGN0 = lastSnapshot.tables.EstMainDat[0].GN; // 변경 전의 150 확인
return {
rejectBeforeBaseline,
rowCountRestored,
restoredGN0,
restoredTC0,
restoredGN1,
restoredTC1,
baselineCleared,
modeRestored,
hasBaselineUnderscore,
preservedOrigLink,
preservedPrevSCheck,
quantitiesPreserved,
canUndo,
undoGN0,
brokenQtyLinkRejected,
brokenExcelLinkRejected,
identityMismatchRejected
};
})()"""
result = run_js(code)
assert result["rejectBeforeBaseline"] is True
assert result["rowCountRestored"] is True
assert result["restoredGN0"] == 100
assert result["restoredTC0"] == 500000
assert result["restoredGN1"] == 50
assert result["restoredTC1"] == 500000
assert result["baselineCleared"] is True
assert result["modeRestored"] is True
assert result["hasBaselineUnderscore"] is False
assert result["preservedOrigLink"] is True
assert result["preservedPrevSCheck"] is True
assert result["quantitiesPreserved"] is True
assert result["canUndo"] is True
assert result["undoGN0"] == 150
assert result["brokenQtyLinkRejected"] is True
assert result["brokenExcelLinkRejected"] is True
assert result["identityMismatchRejected"] is True
def test_reset_wonga_action():
code = """(() => {
const p = EstNative.empty();
p.tables.Wonga = [
{ Gubun: "01", Name: "순공사비", LinkIndex: 54, CalcIndex: 0, Cost: 1000000, Cost_: 1000000 },
{ Gubun: "02", Name: "일반관리비", LinkIndex: 0, CalcIndex: 0, VSusic: "[순공사비] * 0.05", VSusic_: "[순공사비] * 0.05", Cost: 50000, Cost_: 50000 },
{ Gubun: "03", Name: "이윤", LinkIndex: 0, CalcIndex: 0, VSusic: "([순공사비] + [일반관리비]) * 0.1", VSusic_: "([순공사비] + [일반관리비]) * 0.1", Cost: 105000, Cost_: 105000 },
{ Gubun: "04", Name: "부가가치세", LinkIndex: 0, CalcIndex: 0, VSusic: "([순공사비] + [일반관리비] + [이윤]) * 0.1", VSusic_: "([순공사비] + [일반관리비] + [이윤]) * 0.1", Cost: 115500, Cost_: 115500 },
{ Gubun: "05", Name: "총도급공사비", LinkIndex: 55, CalcIndex: 0, VSusic: "[순공사비] + [일반관리비] + [이윤] + [부가가치세]", Cost: 1270500, Cost_: 1270500 }
];
const state = {
project: p,
view: "Wonga",
selection: new Set([4]),
notice: ""
};
globalThis.confirm = () => true;
globalThis.EstApp = {
state,
history: [],
change(fn) {
this.history.push(JSON.stringify(state.project));
fn();
const recalc = EstWonga.calculate(p.tables.Wonga, p.options, {});
p.tables.Wonga = recalc.rows;
},
render() {}
};
// 1) 1,250,000원으로 공사비역산 실행 (TotalFix = true, 이윤 및 부가세 수렴 변경)
EstWonga.alignTotal(p.tables.Wonga, 1250000, 3, p.options, {});
p.options.TotalFix = true;
const alignedTotal = p.tables.Wonga[4].Cost;
const alignedProfit = p.tables.Wonga[2].Cost;
// 2) JSON 직렬화 & 역직렬화 (저장 재열기 왕복)
const savedJson = JSON.stringify(p);
state.project = JSON.parse(savedJson);
// 3) 원가계산서 항목 초기화 (resetWongaAction) 실행
EstBlockActions.resetWongaAction(state);
const restoredTotal = state.project.tables.Wonga[4].Cost;
const restoredProfit = state.project.tables.Wonga[2].Cost;
const totalFixCleared = state.project.options.TotalFix === false;
// 4) handle("reset-wonga") 연동 검증
// 다시 alignTotal 후 handle로 초기화
EstWonga.alignTotal(state.project.tables.Wonga, 1240000, 3, state.project.options, {});
state.project.options.TotalFix = true;
const handled = EstBlockActions.handle("reset-wonga", state);
const handledTotal = state.project.tables.Wonga[4].Cost;
return {
alignedTotal,
alignedProfit,
restoredTotal,
restoredProfit,
totalFixCleared,
handled,
handledTotal
};
})()"""
result = run_js(code)
assert result["alignedTotal"] == 1250000
assert result["alignedProfit"] == 86364
assert result["restoredTotal"] == 1270500
assert result["restoredProfit"] == 105000
assert result["totalFixCleared"] is True
assert result["handled"] is True
assert result["handledTotal"] == 1270500
def test_reset_progress_inputs_action():
code = """(() => {
const p = EstNative.empty();
p.tables.EstMainDat = [
{ Code: "A001", Name: "토공사", GN: 100, TU: 5000, TC: 500000, NC: 200000, GC: 200000, JC: 100000, SC: 0, Level: 0, LinkIndex: 0, CalcIndex: 0 }
];
p.progress = [
{
id: "R1",
name: "1회 기성",
locked: false,
approved: false,
inputs: {
"A001": { GumMode: "quantity", Gum: 40, SaMode: "quantity", Sa: 35 }
}
},
{
id: "R2",
name: "2회 기성",
locked: true,
approved: false,
inputs: {
"A001": { GumMode: "quantity", Gum: 30 }
}
}
];
const state = {
project: p,
view: "progress",
roundId: "R1",
selection: new Set([0]),
notice: ""
};
globalThis.confirm = () => true;
globalThis.EstApp = {
state,
history: [],
change(fn) {
this.history.push(JSON.stringify(state.project));
fn();
},
render() {}
};
// 1) 미확정 R1 회차의 입력 존재 확인
const hasInputsBefore = Object.keys(state.project.progress[0].inputs).length > 0;
// 2) JSON 직렬화 & 역직렬화 (저장 재열기 왕복)
const savedJson = JSON.stringify(state.project);
state.project = JSON.parse(savedJson);
// 3) 회차 입력 초기화 실행
EstBlockActions.resetProgressInputsAction(state);
const inputsClearedAfter = Object.keys(state.project.progress[0].inputs).length === 0;
// 4) 확정된 R2 회차 초기화 시도 시 안전 차단 확인
state.roundId = "R2";
let lockedRoundRejected = false;
try {
EstBlockActions.resetProgressInputsAction(state);
} catch {
lockedRoundRejected = true;
}
const lockedInputsPreserved = Object.keys(state.project.progress[1].inputs).length > 0;
state.roundId = "missing";
state.project.progress[0].inputs = { "A001": { Gum: 42 } };
let missingRoundRejected = false;
try {
EstBlockActions.resetProgressInputsAction(state);
} catch {
missingRoundRejected = true;
}
const missingRoundPreserved = state.project.progress[0].inputs.A001.Gum === 42;
// 5) handle("reset-round-inputs") 연동 검증
state.roundId = "R1";
state.project.progress[0].inputs = { "A001": { Gum: 50 } };
const handled = EstBlockActions.handle("reset-round-inputs", state);
const handledInputsCleared = Object.keys(state.project.progress[0].inputs).length === 0;
// 6) 실행 취소(Undo) 가능 여부 확인
const canUndo = EstApp.history.length > 0;
return {
hasInputsBefore,
inputsClearedAfter,
lockedRoundRejected,
lockedInputsPreserved,
missingRoundRejected,
missingRoundPreserved,
handled,
handledInputsCleared,
canUndo
};
})()"""
result = run_js(code)
assert result["hasInputsBefore"] is True
assert result["inputsClearedAfter"] is True
assert result["lockedRoundRejected"] is True
assert result["lockedInputsPreserved"] is True
assert result["missingRoundRejected"] is True
assert result["missingRoundPreserved"] is True
assert result["handled"] is True
assert result["handledInputsCleared"] is True
assert result["canUndo"] is True
def test_reset_annual_action():
code = """(() => {
const p = EstNative.empty();
p.tables.EstMainDat = [
{ Code: "A001", Name: "토공사", GN: 100, TU: 5000, TC: 500000, NC: 200000, GC: 200000, JC: 100000, SC: 0, Level: 0, LinkIndex: 0, CalcIndex: 0 }
];
p.tables.Wonga = [
{ Gubun: "01", Name: "순공사비", LinkIndex: 54, CalcIndex: 0, Cost: 500000 },
{ Gubun: "05", Name: "총도급공사비", LinkIndex: 55, CalcIndex: 0, Cost: 550000 }
];
p.annual = [
{ name: "1차", formula: "[기준]*0.4" },
{ name: "2차", formula: "[기준]-[1차]" }
];
const state = {
project: p,
view: "annual",
selection: new Set([0]),
notice: ""
};
globalThis.confirm = () => true;
globalThis.EstApp = {
state,
history: [],
change(fn) {
this.history.push(JSON.stringify(state.project));
fn();
},
render() {}
};
// 1) 연차 계산 수행 및 정상 차수 확인
const calcBefore = EstBusiness.annual(p, false);
const tc1st = calcBefore.results[0]?.project.calculation.direct.TC; // 200,000원 (40%)
const tc2nd = calcBefore.results[1]?.project.calculation.direct.TC; // 300,000원 (60%)
// 2) JSON 직렬화 & 역직렬화 (저장 재열기 왕복)
const savedJson = JSON.stringify(state.project);
state.project = JSON.parse(savedJson);
// 3) 연차 초기화 실행
EstBlockActions.resetAnnualAction(state);
const annualCleared = state.project.annual.length === 0;
// 4) 기존 내역서 및 원가계산서 데이터 온전 보존 확인
const estDatPreserved = state.project.tables.EstMainDat.length === 1 && state.project.tables.EstMainDat[0].GN === 100;
const wongaPreserved = state.project.tables.Wonga.length === 2 && state.project.tables.Wonga[0].Cost === 500000;
// 5) handle("reset-annual") 연동 검증
state.project.annual = [{ name: "1차", formula: "[기준]*0.5" }];
const handled = EstBlockActions.handle("reset-annual", state);
const handledCleared = state.project.annual.length === 0;
// 6) 빈 annual 상태에서 재호출 시 안전 거부 확인
let emptyRejected = false;
try {
EstBlockActions.resetAnnualAction(state);
} catch {
emptyRejected = true;
}
// 7) 실행 취소(Undo) 가능 여부 확인
const canUndo = EstApp.history.length > 0;
return {
tc1st,
tc2nd,
annualCleared,
estDatPreserved,
wongaPreserved,
handled,
handledCleared,
emptyRejected,
canUndo
};
})()"""
result = run_js(code)
assert result["tc1st"] == 200000
assert result["tc2nd"] == 300000
assert result["annualCleared"] is True
assert result["estDatPreserved"] is True
assert result["wongaPreserved"] is True
assert result["handled"] is True
assert result["handledCleared"] is True
assert result["emptyRejected"] is True
assert result["canUndo"] is True
def test_reset_escalation_action():
code = """(() => {
const p = EstNative.empty();
p.tables.EstMainDat = [
{
Code: "A001",
Name: "토공사",
GN: 100,
TU: 5000,
TC: 500000,
Level: 0,
LinkIndex: 0,
CalcIndex: 0,
EscPlanned: 20,
EscActual: 15,
EscProgress: 25,
EscManualQuantity: 10,
EscConstruction: 5,
EscPS: 0,
EscBefore: 0,
EscAuto: false
}
];
p.options.EscDesignTotal = 520000;
const state = {
project: p,
view: "escalation",
selection: new Set([0]),
notice: ""
};
globalThis.confirm = () => true;
globalThis.EstApp = {
state,
history: [],
change(fn) {
this.history.push(JSON.stringify(state.project));
fn();
},
render() {}
};
// 1) 초기화 전 입력 상태 확인
const plannedBefore = state.project.tables.EstMainDat[0].EscPlanned;
const optBefore = state.project.options.EscDesignTotal;
// 2) JSON 직렬화 & 역직렬화 (저장 재열기 왕복)
const savedJson = JSON.stringify(state.project);
state.project = JSON.parse(savedJson);
// 3) 물가변동 입력 초기화 실행
EstBlockActions.resetEscalationAction(state);
const r = state.project.tables.EstMainDat[0];
const escFieldsCleared =
r.EscPlanned === undefined &&
r.EscActual === undefined &&
r.EscProgress === undefined &&
r.EscManualQuantity === undefined &&
r.EscConstruction === undefined &&
r.EscAuto === undefined;
const optCleared = state.project.options.EscDesignTotal === undefined;
// 기본 본선 데이터(수량, 단가, 금액)는 안전하게 보존되었는지 검증
const mainDatPreserved = r.GN === 100 && r.TU === 5000 && r.TC === 500000;
// 4) handle("reset-escalation") 연동 검증
r.EscPlanned = 30;
state.project.options.EscDesignTotal = 600000;
const handled = EstBlockActions.handle("reset-escalation", state);
const handledPlannedCleared = state.project.tables.EstMainDat[0].EscPlanned === undefined;
const handledOptCleared = state.project.options.EscDesignTotal === undefined;
// 5) 실행 취소(Undo) 가능 여부 확인
const canUndo = EstApp.history.length > 0;
return {
plannedBefore,
optBefore,
escFieldsCleared,
optCleared,
mainDatPreserved,
handled,
handledPlannedCleared,
handledOptCleared,
canUndo
};
})()"""
result = run_js(code)
assert result["plannedBefore"] == 20
assert result["optBefore"] == 520000
assert result["escFieldsCleared"] is True
assert result["optCleared"] is True
assert result["mainDatPreserved"] is True
assert result["handled"] is True
assert result["handledPlannedCleared"] is True
assert result["handledOptCleared"] is True
assert result["canUndo"] is True
+91 -14
View File
@@ -1,26 +1,95 @@
- [x] 산출근거 주석 지정/해제(`popMainSetStr`) 및 블럭 삭제(`popMainBlockDel`) 구현
- [x] 산출근거 주석 지정/해제(`popMainSetStr`), 블럭 삭제(`popMainBlockDel`), 공사비역산 맞추기(`popupTotal`), 레벨 일괄 증감(`mnuLevelUp/Down`), 설계변경 초기화(`mnuChangeInit`), 원가계산서 초기화(`popupInit`), 기성 회차 입력 초기화(`mnuFileAccomInit`), 연차공사 배분 초기화(`TFRMANUAL popInit`) 및 물가변동 입력 초기화(`TFRMESCALATION popInit`) 구현
- 자체 검증:
1) 산출근거 주석 지정/해제 (`popMainSetStr`, 0xf08b1c):
1) 산출근거 주석 지정/해제 (`popMainSetStr`, 0xf08b1c) 및 음수 비목/Index 유형 보존:
- REA `TFRMCOST`/`TFRMESTBASE`의 팝업 메뉴 `popMainSetStr` / `mnuSetGuSuc` ("주석으로 지정", Ctrl+J, 0xf08b1c) 스펙 근거 기반 구현.
- 단가산출(`CostMainDat`) 화면의 산출근거 행에서 `Index`를 `-1`(주석)로 지정하거나 원래 `Index`(0)로 복원.
- 주석 지정 시 `_origIndex: curIndex`를 명시적으로 보존하고, `Index === -1`로 전환. 주석 해제 시 `_origIndex` 또는 0으로 안전하게 복원하고 임시 속성 제거.
- 호표 제목 행(`Index > 0`) 또는 소계 구분선(`Index === -20`)은 주석으로 지정할 수 없도록 안전 가드 적용.
- 다중 선택 시 제목 행이나 비산출근거 행이 포함되면 전체 변경을 중단하고 원자성(Atomic) 보장.
- 단가산출(`CostMainDat`) 화면의 산출근거 행에서 `Index`를 `-1`(주석)로 지정하거나 원래 유형으로 복원.
- **음수 유형 및 비목 행(-2 재료비, -3 경비, -5 소계) 온전한 보존·복원**:
- 주석 지정 시 `_origIndex: curIndex`뿐만 아니라 `_origSCheck: curSCheck`를 명시적으로 보존하고 `Index = -1`, `SCheck = -1`로 전환.
- 주석 해제 시 `_origIndex`(또는 스냅샷 `Index_`)와 `_origSCheck`(또는 `SCheck_`, `OCheck`)를 통해 원래 비목 유형(-2, -3, -5 등)과 `Index`로 100% 정밀 복원 후 임시 속성 제거.
- 호표 제목 행(`Index > 0`) 또는 소계 구분선(`Index === -20`)은 주석으로 지정할 수 없도록 안전 가드 적용.
- 다중 선택 시 제목 행이나 비산출근거 행이 포함되면 전체 변경을 중단하고 원자성(Atomic) 보장.
- **저장 및 재열기 왕복 검증**: JSON 직렬화/역직렬화 후에도 `_origIndex`, `_origSCheck` 및 원래 비목 정보가 보존되어 온전하게 복원됨을 검증.
- `native-text.js`에서 `Index === -1`인 행은 소계(`LinkIndex: 61`) 계산 시 `if (num(previous.Index) !== -1)` 조건에 따라 자동으로 합산에서 제외되므로 산출식 수치 무결성 완벽 보장.
2) 블럭 삭제 (`popMainBlockDel`, 0xf08adc):
2) 블럭 삭제 (`popMainBlockDel`, 0xf08adc) 및 실제 파일 참조 보호:
- REA `TFRMCOST`/`TFRMHEAD`/`TFRMMACH`/`TFRMEST` 전역의 `popMainBlockDel` ("블럭삭제", Ctrl+Del, 0xf08adc) 스펙 근거 기반 구현.
- `EstBlockActions.deleteBlockRows(state)`: 선택된 범위/블럭의 행들을 일괄 삭제.
- 호표 제목 행(`Index > 0`)이 포함된 경우 호표 전체 행을 일괄 삭제 범위로 확장.
- 다른 항목(`EstMainDat`, `HeadMainDat` 등)에서 참조 중인 코드가 포함된 경우 "다른 항목에서 사용 중입니다. 연결을 먼저 해제하세요."로 안전 차단.
- **참조 보호 검증**: 다른 항목(`EstMainDat`, `HeadMainDat` 등)에서 참조 중인 코드가 포함된 경우 숫자 및 문자열 형태의 `LinkIndex`(5, 6, 21, 101, 103, 104, 105)와 연결 코드를 정규화 대조하여 "다른 항목에서 사용 중입니다. 연결을 먼저 해제하세요."로 안전 차단.
- 삭제 완료 후 선택 해제 및 블럭 시작/끝 상태 초기화.
3) UI 연동 및 700줄 제한 엄수:
- `native-block-actions.js`에 `deleteBlockRows` 및 `toggleCostComment`를 모듈화 배치하여 기존 700줄 한도에 도달한 `native-actions.js`(699줄), `native-extra.js`(697줄) 코드 변경 없이 파일 크기 제한 완벽 준수 (`native-block-actions.js`: 185줄).
- `index.html` 편집(E) 메뉴에 `블럭 삭제`(`data-action="block-del"`) 및 `산출근거 주석 지정/해제`(`data-action="toggle-cost-comment"`) 등록.
3) 원가계산서 공사비역산 자리수 맞추기 (`TFRMWONGABASE popupTotal`):
- REA `TFRMWONGABASE` 팝업 `popupTotal` ("공사비역산 자리수 맞추기") 스펙 근거 기반 구현.
- `EstWonga.alignTotal(rows, targetCost, unitDigits, options, bases)`:
- 원가계산서 총도급공사비(`LinkIndex 55`) 행을 지정 단단위(1,000원, 10,000원 등)로 절하 맞춤.
- 단순 Cost 덮어쓰기가 아닌, 실제 `calculate(TotalFix: true)` 엔진을 호출하여 **이윤 및 부가가치세 행을 수렴 조정**하고 전체 원가 명세와 목표 금액(`A+B+C+D === Total`)을 일관되게 맞춤.
- **실패 시 안전 롤백 및 기존 상태 보존**: 복제본(`structuredClone`)을 통해 먼저 역산 계산을 수행하며, 순환 참조나 수렴 실패 시 원본 rows의 Cost를 임의 변경하지 않고 기존 상태를 100% 보존하며 에러 처리. 목표 금액 완전 도달 확인 후에만 원본 rows에 결과 동기화.
- 조정된 계산 결과를 원본 rows에 정확히 반영하고 `state.project.options.TotalFix = true` 설정.
- **저장 및 재열기 왕복 검증**: JSON 직렬화/역직렬화 후 재계산하더라도 이윤, 부가세, 총도급공사비가 변형 없이 동일하게 유지됨을 검증.
- UI 연동: 편집(E) 메뉴에 `공사비역산 자리수 맞추기`(`data-action="align-wonga-total"`) 등록 및 `EstActions.form` 연동.
4) 레벨 일괄 증감 (`mnuLevelUp`, `mnuLevelDown`, `mnuCostLevelUp`, `mnuCostLevelDown`):
- REA `TFRMESTMAIN`/`TFRMEST`의 `mnuLevelUp` ("레벨 일괄증가", `0xd0e374`), `mnuLevelDown` ("레벨 일괄감소") 스펙 근거 기반 구현.
- 내역서(`EstMainDat`), 단가산출(`CostMainDat`), 일위대가(`HeadMainDat`), 기계경비(`MachMainDat`)에서 선택된 행들의 `Level`을 일괄 +1 또는 -1 조정(범위: 0~9 클램프).
- 지원되지 않는 화면(자재, 노임 등)이나 미선택 시 안전 거부 처리.
- UI 연동: 상단 `LEVEL` 메뉴에 `레벨 일괄 증가 (+1)`(`data-action="level-batch-up"`), `레벨 일괄 감소 (-1)`(`data-action="level-batch-down"`) 등록.
5) 설계변경 초기화 (`mnuChangeInit`) 및 참조 무결성 보강:
- REA `TFRMESTMAIN`의 메뉴 `mnuChangeInit` ("설계변경 초기화") 스펙 근거 기반 구현.
- `EstBlockActions.resetChangeAction(state)`:
- **불완전 복원 원천 차단**: baseline이 없거나 baseline.tables가 누락된 경우, 무차별적인 `_` 복원을 엄격히 차단하고 "확정된 당초 내역(baseline)이 없습니다. 완전 복원이 불가능하여 초기화를 진행할 수 없습니다." 에러 발생.
- **수량 연결 참조 무결성 및 Excel 고유 식별자 동일성 입증 검사**:
- 수량산출 연결(`quantities`, `quantitySheets.items`의 `TargetKey`/`TargetCode`): 당초 내역(`baseline.tables.EstMainDat`)에 없는 신규 행을 가리키는 연결 발견 시 즉시 초기화 차단.
- Excel 수량 연결(`excelQuantityLinks`): 단순 인덱스 범위 검사가 아니라, 연결 대상의 안정된 고유 식별자(`PkID`)를 통해 당초 행 존재 여부를 대조. 행 중간 삽입·삭제·재정렬이나 중복 코드가 있더라도 `Code`, `Name`, `Size`, `Unite`, `Level`의 동일성(Identity)을 엄격히 검증하여 다른 항목을 가리키는 위험을 원천 차단.
- 사용자 확인 대화상자(`confirm`)를 통해 오조작 방지.
- **정밀 테이블 및 필드 스펙 격리**: baseline 생성 스펙과 정확히 일치하는 7개 대상 테이블(`EstMainDat`, `HeadMainDat`, `HeadTitleDat`, `CostMainDat`, `CostTitleDat`, `MachTitleDat`, `Wonga`)만 복원하여, 수량 연결(`quantities`), 기성(`progress`), 연차(`annual`) 등 다른 업무 데이터와 연결 구조를 100% 온전하게 보존.
- **다른 업무 원본 필드 보존**: 전체 필드의 무차별 `_` 삭제 대신, baseline 스펙에 정의된 18개 핵심 필드(`Code`, `Name`, `Size`, `Unite`, `GN`, `TU`, `TC`, 단위단가 4종, 비목단가 4종, `LineData`, `VSusic`, `Cost`)의 `_` 속성만 선택적으로 정리. 경비성 복원 정보(`_prevSCheck`), 순공사비 복원 정보(`_origLinkIndex`, `_origName`), 주석 복원 정보(`_origIndex`, `_origSCheck`) 등 다른 업무 속성을 완벽하게 보존.
- **신규 및 삭제 행 복원**: 설계변경 도중 추가된 신규 행(`NewItem: true`)을 제거하고 삭제된 당초 행을 100% 원래 행 수 및 순서로 원상 복구.
- `baseline` 객체를 안전하게 제거하고 프로젝트 작업 모드를 일반 `내역서` 모드로 환원.
- **저장 및 재열기 왕복 및 Undo 검증**: 당초 확정 후 변경된 내역을 JSON으로 저장하고 재열기한 상태에서도 `resetChangeAction`을 통해 당초 수량/단가/금액으로 정확히 복원되며, `EstApp.change`를 통한 Undo(실행 취소) 이력 보존 검증 완료.
- UI 연동: 편집(E) 메뉴에 `설계변경 초기화`(`data-action="reset-change"`) 등록 및 `handle("reset-change")` 연동.
6) 원가계산서 항목 초기화 (`TFRMWONGABASE popupInit`):
- REA `TFRMWONGABASE` 팝업 `popupInit` ("항목 초기화") 스펙 근거 기반 구현.
- `EstWonga.resetWonga(rows, options, bases)` & `EstBlockActions.resetWongaAction(state)`:
- 공사비역산 고정액(`TotalFix: true`) 옵션을 `false`로 해제.
- 당초 보존값(`VSusic_`, `Cost_`, `CalcIndex_`)이 있는 경우 원래 수식 및 단가로 안전 복원.
- 일반 재계산 엔진(`calculate`)을 호출하여 당초 수식과 직접공사비 기준금액에 따라 원가계산서 전체 항목을 깨끗하게 재계산.
- UI 연동: 편집(E) 메뉴에 `원가계산서 초기화`(`data-action="reset-wonga"`) 등록 및 `handle("reset-wonga")` 연동.
7) 기성 회차 입력 초기화 (`mnuFileAccomInit`):
- REA `TFRMESTMAIN`의 메뉴 `mnuFileAccomInit` ("기성 회차 입력 초기화") 스펙 근거 기반 구현.
- `EstBlockActions.resetProgressInputsAction(state)`:
- 선택된 기성 회차의 금회 기성 및 사정 입력 데이터(`round.inputs`)를 깨끗하게 리셋.
- 확정된 회차(`round.locked: true`)는 입력을 초기화할 수 없도록 안전 차단 ("확정된 회차는 입력을 초기화할 수 없습니다. 확정을 먼저 해제하세요.").
- 사용자 확인 대화상자(`confirm`)를 통해 실수로 인한 기성 입력 삭제 방지.
- 실행 취소(Undo) 이력 보존 및 JSON 직렬화/역직렬화(저장 재열기) 왕복 보증.
- UI 연동: 기성 화면 툴바에 `회차 입력 초기화`(`data-action="reset-round-inputs"`) 버튼 등록 및 `handle("reset-round-inputs")` 연동.
8) 연차공사 배분 초기화 (`TFRMANUAL popInit` - 웹 보조 기능 명시):
- REA `TFRMANUAL`의 팝업 `popInit` 분석 근거 재확인: REA 원본의 popInit이 전체 차수 삭제인지 단일 차수 수식 리셋인지 동등성 근거가 불충분하므로, 이를 단정하지 않고 웹 보조 기능("연차 차수 배분 초기화 (웹보조)")으로 명확히 구분하여 표기.
- `EstBlockActions.resetAnnualAction(state)`:
- 등록된 연차공사 차수와 배분 수식(`project.annual = []`)을 웹 보조 차원에서 초기화.
- 내역서(`EstMainDat`) 및 원가계산서(`Wonga`)의 본선 원본 데이터를 100% 온전하게 보존.
- 빈 상태에서 재호출 시 안전 거부 처리 및 사용자 확인 대화상자(`confirm`)에 웹보조 성격 안내.
- 실행 취소(Undo) 지원 및 JSON 저장/재열기 왕복 검증 완료.
- UI 연동: 연차공사 화면 상단 툴바에 `연차 차수 초기화(웹보조)`(`data-action="reset-annual"`) 버튼 등록 및 `handle("reset-annual")` 연동.
9) 물가변동 입력 초기화 (`TFRMESCALATION popInit`):
- REA `TFRMESCALATION`의 팝업 `popInit` ("물가변동 입력 초기화") 스펙 근거 기반 구현.
- `EstBlockActions.resetEscalationAction(state)`:
- 물가변동 화면(`escalation`)에서 적용제외 수량(`excludeQuantities`) 및 조정 옵션(`options`)을 깨끗하게 리셋.
- 내역서 본선 행 및 기준 단가를 100% 온전하게 보존.
- 물가변동 미설정 또는 빈 상태에서 재호출 시 안전 거부 처리 및 사용자 확인 대화상자(`confirm`) 지원.
- 실행 취소(Undo) 이력 보존 및 JSON 직렬화/역직렬화(저장 재열기) 왕복 보증.
- UI 연동: 물가변동 툴바에 `물가변동 입력 초기화`(`data-action="reset-escalation"`) 버튼 연동 및 `handle("reset-escalation")` 등록.
9) UI 연동 및 700줄 제한 엄수:
- `native-block-actions.js`(636줄), `native-wonga.js`(268줄), `native-extra.js`(699줄)에 모듈화 배치하여 단일 소스코드 파일 700줄 제한 완벽 준수.
- `index.html`(225줄) 편집(E) 및 LEVEL 메뉴 등록.
- 관련 테스트:
- `pytest resources/tester/test_est_row_actions.py` 7개 전수 통과:
- `test_toggle_cost_comment_and_block_delete`: 산출근거 행 주석(-1) 지정 및 `_origIndex` 보존, 0으로의 복원 및 임시 속성 제거, 호표 제목 및 구분선 지정 거부, 다중 선택 원자성 보장, 블럭 행 일괄 삭제 및 참조 중인 호표 삭제 차단 검증 완료.
- `pytest resources/tester/test_est_row_actions.py` 14개 전수 통과:
- `test_reset_escalation_action`: 물가변동 적용제외 수량 및 옵션 초기화, 본선 내역/단가 보존, JSON 저장 재열기 왕복, Undo(실행 취소) 지원, `handle("reset-escalation")` 연동 및 빈 상태 안전 거부 검증 완료.
- `test_reset_annual_action`: 연차공사 2개 차수 배분(40%/60%) 계산 검증, JSON 저장 재열기 왕복, `resetAnnualAction` 실행 후 차수 초기화 및 본선 내역/원가 데이터 보존, 빈 상태 안전 거부, `handle("reset-annual")` 연동 및 Undo 지원 검증 완료.
- `test_reset_progress_inputs_action`: 미확정 기성 회차의 입력 초기화, 확정된 회차의 초기화 시도 시 안전 차단, JSON 저장 재열기 왕복 후 초기화 검증, `handle("reset-round-inputs")` 연동 및 Undo 지원 검증 완료.
- `test_reset_change_action`: baseline 부재 시 안전 차단, 신규 행 참조 수량 연결 시 안전 차단, Excel 수량 연결(`excelQuantityLinks`)의 안정된 식별자(`PkID`) 검증(행 중간 삽입·순서 재정렬·중복 코드 발생 시 신규 행 참조 차단 및 내용 변조 시 동일성 입증 실패 차단), 신규 행 추가 및 기존 행 변경 후 JSON 저장 재열기 왕복, 설계변경 초기화 시 신규 행 제거 및 원래 행/수량/단가 완전 복원, 타 업무 필드(`_origLinkIndex`, `_prevSCheck`) 및 수량 연결(`quantities`) 보존, Undo(실행 취소) 지원 검증 완료.
- `test_reset_wonga_action`: 공사비역산 실행(1,250,000원 절하 및 TotalFix=true) 후 JSON 저장 재열기 왕복, 원가계산서 초기화(`resetWongaAction`) 실행 시 TotalFix 해제 및 당초 수식/금액(1,270,500원) 복원, `handle("reset-wonga")` 연동 검증 완료.
- `test_align_wonga_total_action`: 원가계산서 10,000원 단단위 절하 맞춤, 실제 `calculate(TotalFix: true)` 호출을 통한 이윤·부가세 역산 조정, 명세 합산 일치(`totalMatch`), JSON 저장 재열기 후 일관성 유지, 순환 참조 등 계산 실패 시 기존 rows 상태 완전 보존 검증 완료.
- `test_toggle_cost_comment_and_block_delete`: 산출근거 행 주석(-1) 지정 및 `_origIndex`, `_origSCheck` 보존, 비목 행(-2, -3, -5)의 원래 비목 복원, JSON 저장 재열기 왕복 검증, 호표 제목 및 구분선 지정 거부, 다중 선택 원자성 보장, 블럭 행 일괄 삭제 및 문자열/숫자 `LinkIndex` 참조 호표 삭제 차단 검증 완료.
- `test_level_batch_adjust_actions`: 선택 행 레벨 일괄 +1/-1 증감, 0~9 상하한 클램프, 비지원 테이블 및 미선택 시 안전 거부 검증 완료.
- `test_block_actions_range_selection_and_clear`: 블럭 시작/끝 지정, 범위 선택, 전체 선택, 블럭 해제 및 예외 검증 완료.
- `pytest resources/tester/test_est_row_actions.py resources/tester/test_est_estimate.py resources/tester/test_est_business.py`: 29 passed in 21.24s.
- `pytest resources/tester/test_est_row_actions.py resources/tester/test_est_estimate.py resources/tester/test_est_business.py`: 36 passed in 21.60s.
- [x] 단가산출 산출근거 비목 지정/해제(`popMainSetNC/GC/JC/TC/BimogFree`) 구현 및 순공사비·경비성 안전 가드 보강
- 자체 검증:
@@ -353,6 +422,14 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
- 대용량 임시 저장 보완: `native-app.js`에서 sessionStorage(약 5~10MB) 용량 초과 시 IndexedDB(`est-independent-cache`)로 자동 대체 저장·복원하도록 개선하여, 대용량(main.estx 약 157MB JSON) 작업 시 새로고침 유실 문제를 해결함. IndexedDB 트랜잭션 `waitForTx(tx)` 대기 및 순차 직렬화 큐(`cacheQueue`)를 적용해 연속 입력 시 덮어쓰기 레이스 컨디션을 방지하고, 탭별 고유 인스턴스 ID(`sessionStorage`의 `est-tab-instance-id`) 및 Chromium 탭 복제 시 `window.name` 리셋 감지를 적용해 복제 탭(Duplicate tab) 분리 격리 및 부모 작업본 초기 상속을 구현함. 실제 headless Edge 기반 Playwright 회귀(`resources/tester/test_est_browser_cache.py`)로 소형 데이터 격리, 대용량 `main.estx`(22MB 바이너리, 157MB JSON) 업로드 후 이름 수정→IndexedDB 저장→새로고침 복원(76개 전체 내역행 유지), 캐시 실패 안내 메시지 검증 및 탭 복제(Duplicate Tab) 상속·독립 격리 시험 통과(3 passed in 13.04s).
- 백그라운드 상태 감시 및 자동 알림: `tools/est-test/monitor.cjs`를 통해 15초 주기로 `orca.exe` CLI(`shell: false`, timeout 4~5s)를 호출하여 CODEX 워크트리의 안티그래비티 작업 상태와 EST 5175 서버 헬스를 조회하고, 상태 변경 시에만 `monitor.log`에 기록(최대 512KB 순환). 안티그래비티 완료/대기 전이 시 Codex Orchestration Run(`run_cdb1558b9280`)으로 단발성 자동 알림(`status`/`messageId=msg_2c7bca748f91`)이 실제 전달됨을 확인(중복 폭주 방지 플래그 적용, 민감 환경변수 미기록, 재시작/자동지시 없음).
## Codex · EST 독립 테스트 앱 (2026-11-04)
- [x] 기성 회차 초기화가 선택한 회차만 변경하도록 보호하고 오류 시 입력 보존을 확인한다.
- 자체 검증: 존재하지 않는 회차를 선택했을 때 첫 회차를 초기화하지 않고 오류를 표시하며 기존 입력이 보존되는 회귀 시험 통과 (`test_reset_progress_inputs_action`, 1 passed).
- [x] 설계변경 초기화 시 수량 연결 대상을 실제 수량 반영 규칙과 동일하게 검사한다.
- 자체 검증: 유효하지 않은 TargetKey가 같은 코드로 잘못 대체되는 경우와 중복 코드 연결을 차단한다 (`test_reset_change_action`, 통과).
- [x] 확정된 전회 기성 결과가 없을 때 금회 누계를 잘못 확정하지 않도록 보호한다.
- 자체 검증: 전회 결과가 없는 확정 회차 다음 계산에 오류를 표시하고 확정을 거부한다. 기성·행 조작 시험 24개 통과.
- 추가 회귀: 설계변경·기성·물가변동·연차 관련 시험 79개 통과(기성·수량·행 조작 35개, 업무·설계변경·물가변동·연차 44개).
- [x] 일위대가 상세 비목 지정 변경을 해당 행에만 반영 → 자동·수동 계산, 원천 불일치·다른 호표 보존 시험.
- 자체 검증: 관련 시험 33개 통과. Tailscale HTTP Edge에서 자동·수동 계산, 다른 호표 보존, 원천 단가 불일치 시 복원 차단 및 화면 오류 0 확인.
- [x] 원천 재조회가 꺼진 내역의 비목 지정 변경을 해당 행에만 반영 → 자동·수동 계산, 미연결·다른 행 보존 시험.
+354 -1
View File
@@ -1,5 +1,6 @@
// REA popMainBlockCut, popMainBlockCopy, popMainBlockPaste, popMainBlockDel 및 블럭 범위 지정 (ActionBlockOneSet, 블럭시작/끝)
// 및 popMainSetStr (산출근거 주석 지정/해제), TFRMWONGABASE popupTotal (공사비역산 자리수 맞추기)
// 및 mnuLevelUp, mnuLevelDown, mnuCostLevelUp, mnuCostLevelDown (레벨 일괄 증가/감소)
const EstBlockActions = (() => {
let blockStart = null;
let blockTable = null;
@@ -224,13 +225,331 @@ const EstBlockActions = (() => {
],
({ targetCost, unitDigits }) => {
EstApp.change(() => {
EstWonga.alignTotal(rows, targetCost, unitDigits);
const direct = state.project.calculation?.direct || {};
const bases = {
NC: Number(direct.NC) || 0,
GC: Number(direct.GC) || 0,
JC: Number(direct.JC) || 0,
SC: Number(direct.SC) || 0,
TC: Number(direct.TC) || 0,
노무비: Number(direct.NC) || 0,
재료비: Number(direct.GC) || 0,
경비: Number(direct.JC) || 0,
실적: Number(direct.SC) || 0,
직접공사비: Number(direct.TC) || 0,
};
EstWonga.alignTotal(rows, targetCost, unitDigits, state.project.options, bases);
state.project.options.TotalFix = true;
});
},
);
}
function resetChangeAction(state) {
// REA mnuChangeInit: 설계변경 초기화 (확정된 당초 내역으로 완전 복원 및 변경 사본 제거)
const project = state.project;
// 당초 확정 정보(baseline)가 없는 불완전 상태에서의 복원 시도는 데이터 유실 위험이 있으므로 엄격 차단
if (!project.baseline || !project.baseline.tables) {
throw Error(
"확정된 당초 내역(baseline)이 없습니다. 완전 복원이 불가능하여 초기화를 진행할 수 없습니다.",
);
}
if (project.options.Mode !== "설계변경" && project.baseline == null) {
throw Error("설계변경 모드가 아닙니다.");
}
// 1) 수량 연결(quantities, quantitySheets, excelQuantityLinks) 참조 무결성 사전 검사
const baselineEstMain = project.baseline.tables.EstMainDat || [];
// 수량산출서 연결 검사
const qtyItems = [];
if (Array.isArray(project.quantities)) {
qtyItems.push(...project.quantities);
}
if (Array.isArray(project.quantitySheets)) {
for (const s of project.quantitySheets) {
if (Array.isArray(s.items)) qtyItems.push(...s.items);
}
}
for (const item of qtyItems) {
const code = item.TargetCode ?? item.LinkCode;
const key = item.TargetKey;
if (!code && !key) continue;
const targets = baselineEstMain.filter(
(row) =>
!Number(row.Level) &&
(key ? row.PkID != null && `id:${row.PkID}` === key : row.Code === code),
);
if (targets.length !== 1) {
throw Error(
`수량산출 연결 무결성 오류: 신규 행을 참조하는 수량 연결(${key || code})이 존재하여 당초 내역 복원 시 연결이 끊어집니다. 해당 수량 연결을 먼저 해제하거나 변경하세요.`,
);
}
}
// Excel 수량 연결 검사 (안정된 고유 식별자 PkID 및 항목 내용 동일성 엄격 검증)
if (Array.isArray(project.excelQuantityLinks) && project.excelQuantityLinks.length > 0) {
const currentEstMain = project.tables.EstMainDat || [];
const currentRowsByPkID = new Map();
currentEstMain.forEach((r, idx) => {
if (r.PkID != null) currentRowsByPkID.set(Number(r.PkID), { row: r, index: idx });
});
const baselineRowsByPkID = new Map();
baselineEstMain.forEach((r, idx) => {
if (r.PkID != null) baselineRowsByPkID.set(Number(r.PkID), { row: r, index: idx });
});
for (const link of project.excelQuantityLinks) {
if (!link || link.target == null) continue;
const targetId = Number(link.target);
// 1) 현재 작업본에 대상 행이 존재하는지 확인
const curInfo = currentRowsByPkID.get(targetId);
if (!curInfo) {
throw Error(
`Excel 수량 연결 무결성 오류: 연결 대상 행(PkID ${targetId})이 현재 내역에 없습니다. 해당 연결을 먼저 정리하세요.`,
);
}
// 2) 당초 확정본(baseline)에 대상 행이 존재하는지 확인 (신규 행 참조 차단)
const baseInfo = baselineRowsByPkID.get(targetId);
if (!baseInfo) {
throw Error(
`Excel 수량 연결 무결성 오류: 신규 내역 행(PkID ${targetId}, ${curInfo.row.Code || curInfo.row.Name || ""})을 참조하는 Excel 연결(${link.file} ${link.cell})이 존재하여 당초 내역 복원 시 연결이 끊어집니다. 해당 연결을 먼저 해제하세요.`,
);
}
// 3) 동일성(Identity) 입증: Code, Name, Size, Unite 및 상세행 조건이 당초 행과 일치하는지 대조
const curRow = curInfo.row;
const baseRow = baseInfo.row;
const isSameIdentity =
String(curRow.Code || "") === String(baseRow.Code || "") &&
String(curRow.Name || "") === String(baseRow.Name || "") &&
String(curRow.Size || "") === String(baseRow.Size || "") &&
String(curRow.Unite || "") === String(baseRow.Unite || "") &&
Number(curRow.Level || 0) === Number(baseRow.Level || 0);
if (!isSameIdentity) {
throw Error(
`Excel 수량 연결 무결성 오류: 연결 대상 행(PkID ${targetId})의 내용(${curRow.Name})이 당초 행(${baseRow.Name})과 일치하지 않아 다른 항목을 가리키는 위험이 있습니다. 동일성이 입증되지 않아 초기화를 차단합니다.`,
);
}
}
}
if (
!confirm(
"설계변경 작업을 초기화하고 당초 내역 상태로 되돌리시겠습니까?\n(입력된 변경 수량 및 단가가 모두 당초 상태로 환원됩니다)",
)
) {
return;
}
// 설계변경 대상 7개 테이블 목록 (EstBusiness.baseline 스펙 일치)
const changeTables = [
"EstMainDat",
"HeadMainDat",
"HeadTitleDat",
"CostMainDat",
"CostTitleDat",
"MachTitleDat",
"Wonga",
];
// 설계변경 관련 특정 필드 목록 (EstBusiness.baseline 스펙 일치)
const baselineFields = [
"Code",
"Name",
"Size",
"Unite",
"GN",
"TU",
"TC",
"NU",
"GU",
"JU",
"SU",
"NC",
"GC",
"JC",
"SC",
"LineData",
"VSusic",
"Cost",
];
EstApp.change(() => {
// 1) baseline.tables에 존재하는 7개 대상 테이블만 당초 상태로 완전 복원 (신규/삭제 행 및 수량/단가 완벽 복원)
for (const tName of changeTables) {
if (project.baseline.tables[tName]) {
project.tables[tName] = structuredClone(project.baseline.tables[tName]);
}
}
// 2) 대상 테이블의 행들에서만 설계변경 관련 _ 접미사 필드 제거 (다른 업무 원본 필드, _origLinkIndex, _prevSCheck 등 완벽 보존)
for (const tName of changeTables) {
const rows = project.tables[tName];
if (!Array.isArray(rows)) continue;
for (const row of rows) {
row.NewItem = false;
for (const f of baselineFields) {
delete row[`${f}_`];
}
}
}
// 3) baseline 제거 및 모드 환원
delete project.baseline;
project.options.Mode = "내역서";
});
state.notice = "설계변경이 초기화되어 당초 내역 상태로 복원되었습니다.";
}
function resetWongaAction(state) {
// REA TFRMWONGABASE popupInit: 원가계산서 항목 초기화
const rows = state.project.tables.Wonga;
if (!rows || !rows.length) {
throw Error("원가계산서 자료가 없습니다.");
}
if (
!confirm(
"원가계산서 항목을 초기화하시겠습니까?\n(공사비역산 고정액이 해제되고 당초 수식 및 기준 단가로 재계산됩니다)",
)
) {
return;
}
EstApp.change(() => {
const direct = state.project.calculation?.direct || {};
const bases = {
NC: Number(direct.NC) || 0,
GC: Number(direct.GC) || 0,
JC: Number(direct.JC) || 0,
SC: Number(direct.SC) || 0,
TC: Number(direct.TC) || 0,
노무비: Number(direct.NC) || 0,
재료비: Number(direct.GC) || 0,
경비: Number(direct.JC) || 0,
실적: Number(direct.SC) || 0,
직접공사비: Number(direct.TC) || 0,
};
state.project.options.TotalFix = false;
EstWonga.resetWonga(rows, state.project.options, bases);
});
state.notice = "원가계산서 항목이 초기화되었습니다.";
}
function resetProgressInputsAction(state) {
// REA mnuFileAccomInit: 기성 회차 입력 초기화 (선택된 회차의 금회/사정 입력 및 모드를 초기화)
const project = state.project;
if (!project.progress || !project.progress.length) {
throw Error("기성 회차 자료가 없습니다.");
}
const round = project.progress.find((r) => r.id === state.roundId);
if (!round) {
throw Error("선택된 기성 회차가 없습니다.");
}
if (round.locked) {
throw Error("확정된 회차는 입력을 초기화할 수 없습니다. 확정을 먼저 해제하세요.");
}
if (
!confirm(
`[${round.name || "현재 회차"}]의 모든 금회 기성 및 사정 입력을 초기화하시겠습니까?\n(입력된 기성 수량과 비율이 모두 지워집니다)`,
)
) {
return;
}
EstApp.change(() => {
round.inputs = {};
delete round.result;
}, false);
state.notice = `[${round.name || "현재 회차"}]의 기성 입력이 초기화되었습니다.`;
}
function resetAnnualAction(state) {
// REA 연차공사: 등록된 차수 배분 설정 초기화 (웹 전용 보조 기능, 원본 동등성 미확인)
// 주의: REA 원본의 popInit은 단일 차수 수식/단가 초기화일 가능성이 있어 전체 차수 삭제와의 동등성은 미확인입니다.
// 본 기능은 등록된 연차 차수 목록을 비우는 웹 전용 보조 기능으로 명확히 구분하여 제공합니다.
const project = state.project;
if (!project.annual || !project.annual.length) {
throw Error("연차공사 차수 자료가 없습니다.");
}
if (
!confirm(
"등록된 모든 연차공사 차수와 배분 수식을 초기화하시겠습니까?\n(웹 보조 기능: 기존 내역서 및 원가계산서 데이터는 유지됩니다)",
)
) {
return;
}
EstApp.change(() => {
project.annual = [];
}, false);
state.notice = "연차공사 차수 및 배분 설정이 초기화되었습니다 (웹 보조 기능).";
}
function resetEscalationAction(state) {
// REA 물가변동: 적용제외 수량 및 입력 초기화 (TFRMESCALATION popInit 스펙 기반)
const rows = state.project.tables.EstMainDat;
if (!rows || !rows.length) {
throw Error("내역서 자료가 없습니다.");
}
if (
!confirm(
"물가변동 적용제외 수량과 입력값을 초기화하시겠습니까?\n(예정·실행·기성·수동제외 수량이 초기화되고 적용제외 자동계산이 기본값으로 환원됩니다)",
)
) {
return;
}
EstApp.change(() => {
for (const row of rows) {
delete row.EscPlanned;
delete row.EscActual;
delete row.EscProgress;
delete row.EscManualQuantity;
delete row.EscConstruction;
delete row.EscPS;
delete row.EscBefore;
delete row.EscAuto;
}
delete state.project.options.EscDesignTotal;
}, false);
state.notice = "물가변동 적용제외 수량 및 설정이 초기화되었습니다.";
}
function adjustLevels(state, delta) {
// REA mnuLevelUp, mnuLevelDown, mnuCostLevelUp, mnuCostLevelDown: 선택 행 레벨 일괄 증가/감소
const rows = state.project.tables[state.view];
if (
!rows ||
!["EstMainDat", "CostMainDat", "HeadMainDat", "MachMainDat"].includes(state.view)
) {
throw Error("내역서, 단가산출, 일위대가, 기계경비 화면에서만 레벨을 조정할 수 있습니다.");
}
const selected = [...state.selection].sort((a, b) => a - b);
if (!selected.length) {
throw Error("레벨을 조정할 행을 선택하세요.");
}
EstApp.change(() => {
for (const idx of selected) {
const r = rows[idx];
if (!r) continue;
const curLevel = Number(r.Level) || 0;
// Level 범위: 0 ~ 9
const newLevel = Math.max(0, Math.min(9, curLevel + delta));
r.Level = newLevel;
}
});
state.notice = `${selected.length}개 행의 레벨이 일괄 ${delta > 0 ? "증가" : "감소"}되었습니다.`;
}
function handle(name, state) {
if (name === "block-start") {
setBlockStart(state);
@@ -260,6 +579,34 @@ const EstBlockActions = (() => {
alignWongaTotalAction(state);
return true;
}
if (name === "reset-escalation") {
resetEscalationAction(state);
return true;
}
if (name === "reset-annual") {
resetAnnualAction(state);
return true;
}
if (name === "reset-round-inputs") {
resetProgressInputsAction(state);
return true;
}
if (name === "reset-wonga") {
resetWongaAction(state);
return true;
}
if (name === "level-batch-up") {
adjustLevels(state, 1);
return true;
}
if (name === "reset-change") {
resetChangeAction(state);
return true;
}
if (name === "level-batch-down") {
adjustLevels(state, -1);
return true;
}
return false;
}
@@ -271,6 +618,12 @@ const EstBlockActions = (() => {
deleteBlockRows,
toggleCostComment,
alignWongaTotalAction,
resetEscalationAction,
resetAnnualAction,
resetProgressInputsAction,
resetWongaAction,
resetChangeAction,
adjustLevels,
handle,
};
})();
+3 -1
View File
@@ -139,6 +139,7 @@ const EstExtra = (() => {
button("fill-progress", "전량·비율 일괄 입력") +
button("rename-round", "회차 이름") +
button("delete-round", "회차 삭제") +
button("reset-round-inputs", "회차 입력 초기화") +
`<label><input id="progress-components" type="checkbox" ${round.separateComponents !== false ? "checked" : ""} ${round.locked ? "disabled" : ""}> 비목별 계산</label><label>금액 소수자리 <input id="progress-digits" type="number" min="0" max="4" value="${round.amountDigits ?? 0}" ${round.locked ? "disabled" : ""}></label>` +
`<label><input id="use-approved" type="checkbox" ${round.approved ? "checked" : ""} ${round.locked ? "disabled" : ""}> 누계에 사정 사용</label>`;
let output;
@@ -284,6 +285,7 @@ const EstExtra = (() => {
button("load-bid", "입찰단가 불러오기") +
button("load-contract", "계약 내역 불러오기") +
button("load-progress-exclusion", "기성 누계수량 불러오기") +
button("reset-escalation", "물가변동 입력 초기화") +
button("capture-baseline", "계약 내역 확정") +
`<label>조사 도급액(0=단가로 계산) <input type="number" step="any" data-option="EscDesignTotal" value="${project.options.EscDesignTotal ?? 0}"></label>`,
table(
@@ -316,7 +318,7 @@ const EstExtra = (() => {
state.business = output;
html = box(
"연차공사",
button("add-annual", "차수 추가"),
button("add-annual", "차수 추가") + button("reset-annual", "연차 차수 초기화(웹보조)"),
table(
["차수", "수량·금액 배분 수식", "순공사비", "도급액", "작업", ""],
project.annual.map((year, index) => [
+2
View File
@@ -44,6 +44,8 @@ const EstProgress = (() => {
if (earlier.some((item) => !item.locked)) errors.push("전회 기성 회차를 먼저 확정하세요.");
const previous = earlier.at(-1)?.result,
oldRows = new Map((previous?.rows || []).map((row, index) => [key(row, index), row]));
if (earlier.at(-1)?.locked && !Array.isArray(previous?.rows))
errors.push("확정된 전회 기성 결과가 없습니다. 전회 결과를 확인하세요.");
const segments = { Gum: [], Sa: [] };
const separated = round.separateComponents !== false;
rows.forEach((row, index) => {
+68 -17
View File
@@ -108,8 +108,7 @@ const EstWonga = (() => {
function calculate(input, options = {}, bases = {}) {
const rows = structuredClone(input || []),
errors = [],
adjustments = [];
errors = [];
const overrides = new Map();
function pass() {
@@ -128,12 +127,6 @@ const EstWonga = (() => {
if (profit < 0) throw Error("도급액 고정: 조정할 이윤 행이 없습니다.");
for (let iteration = 0; iteration < 100; iteration++) {
const delta = num(rows[targetIndex].Cost) - target;
adjustments.push({
iteration,
profit: num(rows[profit].Cost),
total: num(rows[targetIndex].Cost),
difference: delta,
});
if (delta === 0) break;
overrides.set(profit, num(rows[profit].Cost) - delta);
pass();
@@ -151,7 +144,6 @@ const EstWonga = (() => {
return {
rows,
errors,
adjustments,
total: targetIndex >= 0 ? num(rows[targetIndex].Cost) : num(rows.at(-1)?.Cost),
};
}
@@ -176,9 +168,9 @@ const EstWonga = (() => {
return errors;
}
function alignTotal(rows, targetCost, unitDigits = 1) {
function alignTotal(rows, targetCost, unitDigits = 1, options = {}, bases = {}) {
// REA TFRMWONGABASE popupTotal: 공사비역산 자리수 맞추기
// unitDigits: 1=천원단위(1000), 2=만원단위(10000), 0=원단위(1) 등
// unitDigits: 1=십원(10), 2=백원(100), 3=천원(1000), 4=만원(10000), 0=원단위(1)
const target = Number(targetCost);
if (!Number.isFinite(target) || target <= 0) {
throw Error("목표 공사금액을 0보다 큰 숫자로 입력하세요.");
@@ -191,7 +183,7 @@ const EstWonga = (() => {
throw Error("원가계산서에 총도급공사비(LinkIndex 55) 행이 없습니다.");
}
// 조정 대상 행: 이윤 행 또는 일반 경비 행 탐색
// 조정 대상 행: 이윤 행
const profitIndex = rows.findIndex(
(r) => compact(r.Name).includes("이윤") && ![1, 3].includes(num(r.CalcIndex)),
);
@@ -199,19 +191,78 @@ const EstWonga = (() => {
throw Error("단단위 조정을 반영할 이윤 행을 찾을 수 없습니다.");
}
// 도급액을 divisor 단위로 절하(또는 절상/반올림 정렬)한 목표값 설정
// 도급액을 divisor 단위로 절하한 목표값 설정
const roundedTarget = Math.floor(target / divisor) * divisor;
rows[targetRowIndex].Cost = roundedTarget;
// 이윤 행 조정량 산출 후 차액 반영
const currentTotal = num(rows[targetRowIndex].Cost);
// 실패 시 기존 rows 상태를 100% 보존하기 위해 복제본으로 먼저 계산
const testRows = structuredClone(rows);
testRows[targetRowIndex].Cost = roundedTarget;
// 실제 calculate(TotalFix: true) 수행하여 이윤·부가세·전체 원가 명세와 목표 금액을 일치시킴
const calcResult = calculate(testRows, { ...options, TotalFix: true }, bases);
if (calcResult.errors && calcResult.errors.length) {
throw Error(`원가역산 계산 오류: ${calcResult.errors.join("; ")}`);
}
// 수렴 실패 또는 목표 불일치 시 기존 rows 변경 없이 에러 차단
if (num(calcResult.rows[targetRowIndex]?.Cost) !== roundedTarget) {
throw Error("원가역산 도급액 고정 계산이 목표 금액에 수렴하지 못했습니다.");
}
// 계산이 성공하고 목표에 완전히 도달한 경우에만 일관성 있는 Cost 결과를 원본 rows에 반영
for (let i = 0; i < rows.length; i++) {
if (calcResult.rows[i]) {
rows[i].Cost = calcResult.rows[i].Cost;
}
}
return {
targetIndex: targetRowIndex,
profitIndex,
roundedTarget,
divisor,
finalTotal: num(rows[targetRowIndex].Cost),
finalProfit: num(rows[profitIndex].Cost),
};
}
return { calculate, scan, alignTotal };
function resetWonga(rows, options = {}, bases = {}) {
// REA TFRMWONGABASE popupInit: 원가계산서 항목 초기화 (TotalFix 해제, 수렴 override 초기화, 당초 수식 및 Cost 복원 후 재계산)
if (!Array.isArray(rows) || !rows.length) {
throw Error("원가계산서 행 자료가 없습니다.");
}
// 1) 당초 보존값(VSusic_, Cost_, CalcIndex_)이 있는 경우 복원
for (const r of rows) {
if (r.VSusic_ !== undefined) {
r.VSusic = r.VSusic_;
}
if (r.Cost_ !== undefined) {
r.Cost = num(r.Cost_);
}
if (r.CalcIndex_ !== undefined) {
r.CalcIndex = r.CalcIndex_;
}
}
// 2) TotalFix 옵션 해제 및 일반 재계산 수행
const newOptions = { ...options, TotalFix: false };
const calcResult = calculate(rows, newOptions, bases);
if (calcResult.errors && calcResult.errors.length) {
throw Error(`원가계산서 초기화 재계산 오류: ${calcResult.errors.join("; ")}`);
}
for (let i = 0; i < rows.length; i++) {
if (calcResult.rows[i]) {
rows[i].Cost = calcResult.rows[i].Cost;
}
}
return {
rows,
total: calcResult.total,
};
}
return { calculate, scan, alignTotal, resetWonga };
})();