wip(est-test): 개발 중 저장 — 전역 깃 싱크(요구 686-2) · tools/est-test · resources/tester

This commit is contained in:
2026-11-08 12:12:37 +09:00
parent 5e72aff136
commit 1beaefa529
48 changed files with 7159 additions and 316 deletions
+4
View File
@@ -19,8 +19,12 @@ for (const file of [
"native-resources.js",
"native-schedule.js",
"native-issues.js",
"native-haljung.js",
"native-vat.js",
"native-attachments.js",
"native-standard.js",
"native-estimate.js",
"native-basic-prices.js",
"native-model.js",
"native-c3r.js",
"native-exchange.js",
+63
View File
@@ -59,3 +59,66 @@ def test_estx_export_rejects_web_excel_links_instead_of_dropping_them():
timeout=10,
)
assert "JSON" in result.stdout.decode("utf-8")
def test_estx_export_rejects_web_schedule_additions_or_changes_instead_of_dropping_them():
# 1) 일반 항목 추가
script_add = """try{require('./tools/est-test/export-estx.cjs').exportEstx({originalArchive:'AA==',schedule:[{id:'1',Code:'A',Name:'새공정',start:'',end:'',weight:0,progress:0}]});process.exit(1)}catch(e){process.stdout.write(e.message)}"""
result_add = subprocess.run(
["node", "-e", script_add],
cwd=Path(__file__).resolve().parents[2],
capture_output=True,
check=True,
timeout=10,
)
assert "JSON" in result_add.stdout.decode("utf-8")
assert "예정공정표" in result_add.stdout.decode("utf-8")
# 2) 빈 문자열 및 0값만 있는 행도 차단
script_empty = """try{require('./tools/est-test/export-estx.cjs').exportEstx({originalArchive:'AA==',schedule:[{id:'2',Code:'',Name:'',start:'',end:'',weight:0,progress:0}]});process.exit(1)}catch(e){process.stdout.write(e.message)}"""
result_empty = subprocess.run(
["node", "-e", script_empty],
cwd=Path(__file__).resolve().parents[2],
capture_output=True,
check=True,
timeout=10,
)
assert "JSON" in result_empty.stdout.decode("utf-8")
assert "예정공정표" in result_empty.stdout.decode("utf-8")
# 3) 다른 필드만 있거나 빈 객체 행도 차단
script_blank = """try{require('./tools/est-test/export-estx.cjs').exportEstx({originalArchive:'AA==',schedule:[{custom:'field'}]});process.exit(1)}catch(e){process.stdout.write(e.message)}"""
result_blank = subprocess.run(
["node", "-e", script_blank],
cwd=Path(__file__).resolve().parents[2],
capture_output=True,
check=True,
timeout=10,
)
assert "JSON" in result_blank.stdout.decode("utf-8")
assert "예정공정표" in result_blank.stdout.decode("utf-8")
def test_estx_export_rejects_web_custom_documents_instead_of_dropping_them():
script_doc = """try{require('./tools/est-test/export-estx.cjs').exportEstx({originalArchive:'AA==',documents:{'173':'주요심사내용'}});process.exit(1)}catch(e){process.stdout.write(e.message)}"""
result_doc = subprocess.run(
["node", "-e", script_doc],
cwd=Path(__file__).resolve().parents[2],
capture_output=True,
check=True,
timeout=10,
)
assert "JSON" in result_doc.stdout.decode("utf-8")
assert "서식 문서" in result_doc.stdout.decode("utf-8")
# 공백 문자열(" ")도 사용자 입력으로 보존하기 위해 ESTX 저장 차단
script_space = """try{require('./tools/est-test/export-estx.cjs').exportEstx({originalArchive:'AA==',documents:{'173':' '}});process.exit(1)}catch(e){process.stdout.write(e.message)}"""
result_space = subprocess.run(
["node", "-e", script_space],
cwd=Path(__file__).resolve().parents[2],
capture_output=True,
check=True,
timeout=10,
)
assert "JSON" in result_space.stdout.decode("utf-8")
assert "서식 문서" in result_space.stdout.decode("utf-8")
+364
View File
@@ -0,0 +1,364 @@
from test_est_rea_parity import run
def test_basic_prices_changes_for_material_and_labor():
result = run(
"math",
"""(()=>{
const p = EstNative.empty();
p.tables.EstMainDat = [
{ PkID: 1, Code: 'M1', Name: 'changed mat', LinkIndex: 5, GN: 2, TC: 200 },
{ PkID: 2, Code: 'L1', Name: 'changed lab', LinkIndex: 6, GN: 1, TC: 50 },
{ PkID: 4, Code: 'M2', Name: 'new mat', LinkIndex: 5, GN: 1, TC: 40 },
{ PkID: 5, Code: 'L2', Name: 'new lab', LinkIndex: 6, GN: 1, TC: 80 },
];
p.baseline = {
tables: {
EstMainDat: [
{ PkID: 1, Code: 'M1', Name: 'changed mat', LinkIndex: 5, GN: 1, TC: 100 },
{ PkID: 2, Code: 'L1', Name: 'changed lab', LinkIndex: 6, GN: 1, TC: 30 },
{ PkID: 3, Code: 'M3', Name: 'deleted mat', LinkIndex: 5, GN: 3, TC: 300 },
{ PkID: 6, Code: 'L3', Name: 'deleted lab', LinkIndex: 6, GN: 2, TC: 120 },
]
}
};
return {
matChanges: EstBasicPrices.changes(p, 'material'),
labChanges: EstBasicPrices.changes(p, 'labor'),
matPrint: EstForms.data('material-change', p),
labPrint: EstForms.data('labor-change', p),
};
})()""",
)
mat_rows = {row["Code"]: row for row in result["matChanges"]}
assert set(mat_rows) == {"M1", "M2", "M3"}
assert mat_rows["M1"]["difference"] == 100
assert mat_rows["M3"]["status"] == "삭제"
assert mat_rows["M2"]["status"] == "신규"
assert result["matPrint"]["rows"] == result["matChanges"]
lab_rows = {row["Code"]: row for row in result["labChanges"]}
assert set(lab_rows) == {"L1", "L2", "L3"}
assert lab_rows["L1"]["difference"] == 20
assert lab_rows["L3"]["status"] == "삭제"
assert lab_rows["L2"]["status"] == "신규"
assert result["labPrint"]["rows"] == result["labChanges"]
def test_basic_prices_usage_reports_applied_and_source_prices_without_mutation():
result = run(
"math",
"""(()=>{
const p = EstNative.empty();
p.tables.StuffcDat = [
{ Code: 'M1', Name: 'mat 1', ValueIndex: 0, Cost1: 1200 },
{ Code: 'M2', Name: 'mat 2', ValueIndex: 0, Cost1: 500 },
{ Code: 'M2', Name: 'mat 2 dup', ValueIndex: 0, Cost1: 600 },
];
p.tables.LaborcDat = [
{ Code: 'L1', Name: 'lab 1', ValueIndex: 0, Cost1: 200000 },
];
p.tables.EstMainDat = [
{ Code: 'M1', Name: 'mat 1', LinkIndex: 5, GN: 2, TU: 1000, GU: 1000, GC: 2000, TC: 2000 },
{ Code: 'M2', Name: 'dup mat', LinkIndex: 5, GN: 1, TU: 450, GU: 450, GC: 450, TC: 450 },
{ Code: 'M3', Name: 'missing mat', LinkIndex: 5, GN: 1, TU: 200, GU: 200, GC: 200, TC: 200 },
{ Code: 'L1', Name: 'lab 1', LinkIndex: 6, GN: 1, TU: 180000, NU: 180000, NC: 180000, TC: 180000 },
];
const before = JSON.stringify(p);
const matUsage = EstBasicPrices.usage(p, 'material');
const labUsage = EstBasicPrices.usage(p, 'labor');
const matPrint = EstForms.data('material-usage', p);
const labPrint = EstForms.data('labor-usage', p);
return { matUsage, labUsage, matPrint, labPrint, same: before === JSON.stringify(p) };
})()""",
)
assert result["same"]
assert [row["status"] for row in result["matUsage"]] == [
"연결됨",
"코드 중복",
"연결 없음",
]
assert result["matUsage"][0]["sourcePrice"] == 1200
assert result["matUsage"][0]["appliedPrice"] == 1000
assert result["matUsage"][0]["difference"] == -200
assert result["matUsage"][1]["sourcePrice"] is None
assert result["matPrint"]["rows"] == result["matUsage"]
assert len(result["labUsage"]) == 1
assert result["labUsage"][0]["status"] == "연결됨"
assert result["labUsage"][0]["sourcePrice"] == 200000
assert result["labUsage"][0]["appliedPrice"] == 180000
assert result["labUsage"][0]["difference"] == -20000
assert result["labPrint"]["rows"] == result["labUsage"]
def test_basic_prices_apply_row_and_all_prices_with_autocalc_modes():
result = run(
"math",
"""(()=>{
const p = EstNative.empty();
p.options.SourceRefresh = false;
p.options.AutoCalc = false; // 수동계산 모드
p.tables.StuffcDat = [
{ Code: 'M1', Name: 'mat 1', ValueIndex: 0, Cost1: 1500 },
{ Code: 'M2', Name: 'mat 2', ValueIndex: 0, Cost1: 3000 },
];
p.tables.LaborcDat = [
{ Code: 'L1', Name: 'lab 1', ValueIndex: 0, Cost1: 220000 },
];
p.tables.EstMainDat = [
{ Code: 'M1', Name: 'item mat 1', LinkIndex: 5, GN: 2, TU: 1000, GU: 1000, GC: 2000, TC: 2000, SCheck: 2 }, // SCheck=2: 노무비 몰아주기
{ Code: 'M2', Name: 'item mat 2', LinkIndex: 5, GN: 3, TU: 2000, GU: 2000, GC: 6000, TC: 6000 },
{ Code: 'L1', Name: 'item lab 1', LinkIndex: 6, GN: 2, TU: 200000, NU: 200000, NC: 400000, TC: 400000 },
];
// 1. 수동계산 상태에서 자재 행 0 단가 적용 (SCheck=2 보존 검증)
// AutoCalc=false이므로 단가는 NU=1500, TU=1500으로 갱신되나 기존 금액(GC=2000, TC=2000) 보존
EstBasicPrices.applyRowPrice(p, 0, 'material');
const afterRow0 = {
TU: p.tables.EstMainDat[0].TU,
GU: p.tables.EstMainDat[0].GU,
NU: p.tables.EstMainDat[0].NU,
GC: p.tables.EstMainDat[0].GC,
NC: p.tables.EstMainDat[0].NC,
TC: p.tables.EstMainDat[0].TC,
SCheck: p.tables.EstMainDat[0].SCheck,
};
// 2. 자재 일괄 적용: 남은 행 1 적용 (단가 3000 갱신, 수동계산 금액 GC=6000 보존)
const matCount = EstBasicPrices.applyAllPrices(p, 'material');
const afterMatAll = {
TU: p.tables.EstMainDat[1].TU,
GU: p.tables.EstMainDat[1].GU,
GC: p.tables.EstMainDat[1].GC,
TC: p.tables.EstMainDat[1].TC,
};
// 3. 노임 단가 적용: 행 2 적용 (단가 220000 갱신, 금액 NC=400000 보존)
const labCount = EstBasicPrices.applyAllPrices(p, 'labor');
const afterLab = {
TU: p.tables.EstMainDat[2].TU,
NU: p.tables.EstMainDat[2].NU,
NC: p.tables.EstMainDat[2].NC,
TC: p.tables.EstMainDat[2].TC,
};
// 4. AutoCalc=true로 전환 후 전체 재계산: GN(수량)을 반영한 금액으로 일괄 전파
p.options.AutoCalc = true;
const calculated = EstNative.calculate(p);
const afterRecalc = {
row0_NC: calculated.tables.EstMainDat[0].NC,
row0_TC: calculated.tables.EstMainDat[0].TC,
row1_GC: calculated.tables.EstMainDat[1].GC,
row1_TC: calculated.tables.EstMainDat[1].TC,
row2_NC: calculated.tables.EstMainDat[2].NC,
row2_TC: calculated.tables.EstMainDat[2].TC,
};
return { afterRow0, matCount, afterMatAll, labCount, afterLab, afterRecalc };
})()""",
)
# 행 0: SCheck=2 보존되어 NU=1500, GU=0, 금액은 수동계산 유지(GC=2000, NC=0, TC=2000)
assert result["afterRow0"]["TU"] == 1500
assert result["afterRow0"]["NU"] == 1500
assert result["afterRow0"]["GU"] == 0
assert result["afterRow0"]["GC"] == 2000
assert result["afterRow0"]["TC"] == 2000
assert result["afterRow0"]["SCheck"] == 2
# 행 1: 단가 3000, 수동 금액 6000 보존
assert result["matCount"] == 1
assert result["afterMatAll"]["TU"] == 3000
assert result["afterMatAll"]["GU"] == 3000
assert result["afterMatAll"]["GC"] == 6000
assert result["afterMatAll"]["TC"] == 6000
# 행 2: 단가 220000, 수동 금액 400000 보존
assert result["labCount"] == 1
assert result["afterLab"]["TU"] == 220000
assert result["afterLab"]["NU"] == 220000
assert result["afterLab"]["NC"] == 400000
assert result["afterLab"]["TC"] == 400000
# 전체 재계산 후: GN(수량) 반영 정상 산출
# 행 0: GN=2 -> NC=3000, TC=3000
assert result["afterRecalc"]["row0_NC"] == 3000
assert result["afterRecalc"]["row0_TC"] == 3000
# 행 1: GN=3 -> GC=9000, TC=9000
assert result["afterRecalc"]["row1_GC"] == 9000
assert result["afterRecalc"]["row1_TC"] == 9000
# 행 2: GN=2 -> NC=440000, TC=440000
assert result["afterRecalc"]["row2_NC"] == 440000
assert result["afterRecalc"]["row2_TC"] == 440000
def test_edge_basic_prices_material_and_labor_workflow_when_server_running():
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(channel="msedge", headless=True)
page = browser.new_page()
try:
page.goto("http://100.65.141.15:5175", timeout=5000)
page.wait_for_function("() => Boolean(EstApp.state.project)")
except Exception:
browser.close()
import pytest
pytest.skip("EST 5175 server not reachable")
# 1. 자재단가 및 노임단가 테스트 데이터 세팅
page.evaluate(
"""(() => {
const p = EstNative.empty();
p.options.SourceRefresh = false;
p.tables.StuffcDat = [
{ Code: 'M01', Name: '시멘트 포대', Size: '40kg', Unite: '포', ValueIndex: 0, Cost1: 6000 },
];
p.tables.LaborcDat = [
{ Code: 'L01', Name: '특별인부', Unite: '인', ValueIndex: 0, Cost1: 180000 },
];
p.tables.EstMainDat = [
{ PkID: 301, Code: 'M01', Name: '시멘트 포대', Size: '40kg', Unite: '포', LinkIndex: 5, GN: 10, TU: 5000, GU: 5000, GC: 50000, TC: 50000 },
{ PkID: 302, Code: 'L01', Name: '특별인부', Unite: '인', LinkIndex: 6, GN: 5, TU: 160000, NU: 160000, NC: 800000, TC: 800000 },
];
EstApp.setProject(p);
EstApp.state.view = 'material-usage';
EstApp.render();
})()"""
)
# 2. 자재단가 사용 내역 화면 확인
assert page.locator("#view-title:has-text('자재단가 사용 내역')").is_visible()
btn_mat_apply = page.locator("button[data-basic-apply-row='0']")
assert btn_mat_apply.is_visible()
btn_mat_apply.click()
# 자재 단가 6,000으로 적용 확인
assert page.evaluate("EstApp.state.project.tables.EstMainDat[0].TU") == 6000
assert page.evaluate("EstApp.state.project.tables.EstMainDat[0].GU") == 6000
# 3. 노임단가 사용 내역 화면으로 전환
page.evaluate(
"""(() => {
EstApp.state.view = 'labor-usage';
EstApp.render();
})()"""
)
assert page.locator("#view-title:has-text('노임단가 사용 내역')").is_visible()
btn_lab_apply = page.locator("button[data-basic-apply-all='labor']")
assert btn_lab_apply.is_visible()
btn_lab_apply.click()
# 노임 단가 180,000으로 일괄 적용 확인
assert page.evaluate("EstApp.state.project.tables.EstMainDat[1].TU") == 180000
assert page.evaluate("EstApp.state.project.tables.EstMainDat[1].NU") == 180000
# 4. Undo(실행 취소) 검증
page.locator(".toolbar button[data-action='undo']").click()
assert page.evaluate("EstApp.state.project.tables.EstMainDat[1].TU") == 160000
page.locator(".toolbar button[data-action='undo']").click()
assert page.evaluate("EstApp.state.project.tables.EstMainDat[0].TU") == 5000
# 5. 기계경비 사용 내역 화면 전환 및 개별/일괄 적용, Undo 검증
page.evaluate(
"""(() => {
EstApp.navigate('machine-usage');
})()"""
)
assert page.locator("#view-title:has-text('기계경비 사용 내역')").is_visible()
browser.close()
def test_machine_price_usage_changes_and_safety_rejection():
result = run(
"math",
"""(()=>{
const p = structuredClone(project);
// 신규 항목(TC_ null) 추가하여 비교 검증
p.tables.MachTitleDat.push({
Code: 'E_NEW_999',
Name: '신규 기계',
Size: '10Ton',
Unite: 'hr',
TC: 50000,
NC: 30000,
GC: 10000,
JC: 10000,
TC_: null,
NC_: null,
GC_: null,
JC_: null
});
const usage = EstMachine.usage(p);
const changes = EstMachine.changes(p);
const newItemUsage = usage.find(u => u.Code === 'E_NEW_999');
const newItemChange = changes.find(c => c.Code === 'E_NEW_999');
const excUsage = usage.find(u => u.Code === 'E0000000000');
// 파괴적인 당초값 덮어쓰기 적용 차단 검증
let blockedSingle = 0, singleErrMsg = '';
try {
EstMachine.applyRowPrice(p, 0);
} catch (e) {
blockedSingle++;
singleErrMsg = e.message;
}
let blockedAll = 0, allErrMsg = '';
try {
EstMachine.applyAllPrices(p);
} catch (e) {
blockedAll++;
allErrMsg = e.message;
}
// 호표 단가 및 단가산출 금액 불변 보존 확인
const t0Unchanged = (p.tables.MachTitleDat[0].TC === 103047);
const costRow0 = p.tables.CostMainDat.find(r => r.LinkIndex === 105 && r.Code === 'E0000000000' && r.TC > 0);
const costUnchanged = (costRow0.TC === 1682.7);
return {
usageCount: usage.length,
changesCount: changes.length,
newItemUsage,
newItemChange,
excUsage,
blockedSingle,
singleErrMsg,
blockedAll,
allErrMsg,
t0Unchanged,
costUnchanged
};
})()""",
)
assert result["usageCount"] == 9
assert result["changesCount"] == 9
# 신규 항목 TC_ null 안전 처리 검증
assert result["newItemUsage"]["originalPrice"] is None
assert result["newItemUsage"]["difference"] is None
assert result["newItemUsage"]["status"] == "신규(당초없음)"
assert result["newItemUsage"]["canApply"] is False
assert result["newItemChange"]["status"] == "신규"
# 기존 항목 당초/현재 비교 검증
assert result["excUsage"]["appliedPrice"] == 103047
assert result["excUsage"]["originalPrice"] == 62207
assert result["excUsage"]["difference"] == 40840
assert result["excUsage"]["status"] == "당초차이"
assert result["excUsage"]["canApply"] is False
# 파괴적인 apply 차단 및 데이터 보존 검증
assert result["blockedSingle"] == 1
assert "덮어쓰기 적용은 지원하지 않습니다" in result["singleErrMsg"]
assert result["blockedAll"] == 1
assert "덮어쓰기 적용은 지원하지 않습니다" in result["allErrMsg"]
assert result["t0Unchanged"] is True
assert result["costUnchanged"] is True
+181
View File
@@ -0,0 +1,181 @@
import os
from pathlib import Path
import pytest
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
def test_edge_indexeddb_cache_and_tab_isolation_when_server_running():
with sync_playwright() as p:
browser = p.chromium.launch(channel="msedge", headless=True)
context = browser.new_context()
page1 = context.new_page()
try:
page1.goto("http://100.65.141.15:5175", timeout=5000)
except Exception:
pytest.skip("EST 5175 server not reachable")
page1.wait_for_function("() => Boolean(EstApp.state.project)")
# 1. 탭 1에서 프로젝트 이름 수정 및 비동기 캐시 완료 대기
page1.evaluate("""async () => {
EstApp.change(p => { p.name = '탭 1 프로젝트 수정'; });
await new Promise(r => setTimeout(r, 100));
}""")
# 2. 탭 1 새로고침 후 복원 확인
page1.reload()
page1.wait_for_function("() => Boolean(EstApp.state.project)")
name1 = page1.evaluate("EstApp.state.project.name")
assert name1 == "탭 1 프로젝트 수정"
# 3. 탭 2 독립 생성
page2 = context.new_page()
page2.goto("http://100.65.141.15:5175")
page2.wait_for_function("() => Boolean(EstApp.state.project)")
# 탭 2는 탭 1의 세션 스토리지를 공유하지 않고 독립적으로 시작
page2.evaluate("EstApp.change(p => { p.name = '탭 2 독자 프로젝트'; })")
name2 = page2.evaluate("EstApp.state.project.name")
assert name2 == "탭 2 독자 프로젝트"
# 4. 탭 1 다시 새로고침 시 탭 2에 의해 덮어써지지 않는지 확인
page1.reload()
page1.wait_for_function("() => Boolean(EstApp.state.project)")
assert page1.evaluate("EstApp.state.project.name") == "탭 1 프로젝트 수정"
browser.close()
def test_edge_indexeddb_large_main_estx_reload_restore_when_server_running():
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
if not sample.exists():
sample = Path("resources/knowledge/original/원가계산/다산소프트/2026-07-main.estx")
if not sample.exists():
pytest.skip("main.estx sample not available")
with sync_playwright() as p:
browser = p.chromium.launch(channel="msedge", headless=True)
page = browser.new_page()
try:
page.goto("http://100.65.141.15:5175", timeout=5000)
except Exception:
pytest.skip("EST 5175 server not reachable")
page.wait_for_function("() => Boolean(EstApp.state.project)")
# 대용량 main.estx 업로드
page.locator("#open-file").set_input_files(str(sample))
page.wait_for_function(
"() => EstApp.state.project.tables.EstMainDat.length > 50", timeout=30000
)
# 수정 후 캐시 대기
page.evaluate("EstApp.change(p => { p.name = '대용량 main 수정본'; })")
page.wait_for_timeout(2000)
# 새로고침 후 복원 확인
page.reload()
page.wait_for_function("() => Boolean(EstApp.state.project)", timeout=30000)
restored_name = page.evaluate("EstApp.state.project.name")
restored_rows = page.evaluate("EstApp.state.project.tables?.EstMainDat?.length || 0")
assert restored_name == "대용량 main 수정본"
assert restored_rows > 50
# 대용량 캐시 저장 실패 시 안내 메시지 검증
notice = page.evaluate("""async () => {
const originalSave = EstApp.cache;
// 인위적으로 IndexedDB 에러 유발 모의
const db = await (new Promise(r => {
const req = indexedDB.open("est-independent-cache", 1);
req.onsuccess = () => r(req.result);
}));
// 읽기 전용 모드로 변경 시도하거나 sessionStorage 차단 모의
const res = await EstApp.cache("x".repeat(100));
return EstApp.state.notice;
}""")
assert isinstance(notice, str)
browser.close()
def test_edge_duplicate_tab_isolation_and_inheritance_when_server_running():
with sync_playwright() as p:
browser = p.chromium.launch(channel="msedge", headless=True)
context = browser.new_context()
page1 = context.new_page()
try:
page1.goto("http://100.65.141.15:5175", timeout=5000)
except Exception:
pytest.skip("EST 5175 server not reachable")
page1.wait_for_function("() => Boolean(EstApp.state.project)")
# 탭 1에서 이름 수정
page1.evaluate("""async () => {
EstApp.change(p => { p.name = '원래 탭 프로젝트'; });
await new Promise(r => setTimeout(r, 200));
}""")
tab1_id = page1.evaluate("sessionStorage.getItem('est-tab-instance-id')")
assert tab1_id == page1.evaluate("window.name")
# 브라우저의 "탭 복제(Duplicate Tab)" 시뮬레이션:
# Chromium 복제 탭은 이전 탭의 sessionStorage 전체를 복제받지만, window.name은 빈 문자열("")로 시작합니다.
tab1_data = page1.evaluate(f"sessionStorage.getItem('est-web-v2-working-{tab1_id}')")
page_dup = context.new_page()
# 1회성 init script: 최초 로드 시 복제 순간 모의 후 플래그로 1회만 동작하도록 제어
# 브라우저 실제 환경에서는 복제 순간 1회만 window.name이 빈 문자열이고 sessionStorage에 부모 데이터가 복제됨.
# page_dup.reload() 시 add_init_script가 다시 실행되어 window.name을 지우거나 부모 id로 되돌리는 현상을 방지하기 위해 sessionStorage 플래그 사용
page_dup.add_init_script(f"""
if (!sessionStorage.getItem('__test_cloned_injected')) {{
sessionStorage.setItem('__test_cloned_injected', '1');
sessionStorage.setItem('est-tab-instance-id', '{tab1_id}');
if ({"true" if tab1_data else "false"}) {{
sessionStorage.setItem('est-web-v2-working-{tab1_id}', {repr(tab1_data) if tab1_data else '""'});
}}
window.name = '';
}}
""")
page_dup.goto("http://100.65.141.15:5175")
page_dup.wait_for_function("() => Boolean(EstApp.state.project)")
# 1) 복제 탭은 부모 탭의 데이터('원래 탭 프로젝트')를 성공적으로 상속받았는지 확인
info = page_dup.evaluate("""() => {
return {
windowName: window.name,
sessionTab: sessionStorage.getItem('est-tab-instance-id'),
parentTabId: EstApp.parentTabId,
name: EstApp.state.project.name,
keys: Object.keys(sessionStorage)
};
}""")
print("DEBUG INFO:", info)
dup_name = info["name"]
assert dup_name == "원래 탭 프로젝트"
# 2) 복제 탭은 부모 탭과 다른 새 고유 인스턴스 ID를 발급받았는지 확인
dup_tab_id = page_dup.evaluate("sessionStorage.getItem('est-tab-instance-id')")
assert dup_tab_id != tab1_id
assert dup_tab_id == page_dup.evaluate("window.name")
# 3) 복제 탭에서 데이터를 독립적으로 수정했을 때 원래 탭에 영향을 주지 않는지 확인
page_dup.evaluate("""async () => {
EstApp.change(p => { p.name = '복제 탭에서만 변경'; });
await new Promise(r => setTimeout(r, 200));
}""")
# 원래 탭 1 새로고침 시 원래 데이터 유지 확인
page1.reload()
page1.wait_for_function("() => Boolean(EstApp.state.project)")
assert page1.evaluate("EstApp.state.project.name") == "원래 탭 프로젝트"
# 복제 탭 새로고침 시 복제 탭 변경 사항 유지 확인
page_dup.reload()
page_dup.wait_for_function("() => Boolean(EstApp.state.project)")
assert page_dup.evaluate("EstApp.state.project.name") == "복제 탭에서만 변경"
browser.close()
+287
View File
@@ -378,3 +378,290 @@ def test_large_original_baseline_stays_within_the_save_request_limit():
result = run("baseline-size", str(archive))
assert result["archivePreserved"]
assert result["bytes"] < 256 * 1024 * 1024
def test_unit_calc_group_conversion_preserves_percentages_subtotals_fixed_and_other_groups():
result = run(
"math",
"""(()=>{
const p = EstNative.empty();
p.options.SourceRefresh = true;
p.tables.LaborcDat = [
{ Code: "L001", Name: "보통인부", Unite: "인", Cost1: 100000, TC: 100000 }
];
p.tables.StuffcDat = [
{ Code: "M001", Name: "시멘트", Unite: "포", Cost1: 10000, TC: 10000 }
];
p.tables.HeadMainDat = [
// 대상 공종 H001
{ Index: 1, Code: "H001", Name: "기존공종", Size: "100", Unite: "m3", GN: 0, TC: 0 },
// 1) 일반 비목 행 (수량 환산 적용)
{ Index: 0, Code: "L001", LinkIndex: 6, Name: "보통인부", Size: "", Unite: "인", GN: 1.0, TU: 100000, NU: 100000, GU: 0, JU: 0, NC: 100000, GC: 0, JC: 0, TC: 100000 },
// 2) 고정금액 행 (CalcIndex = 1, 환산 제외)
{ Index: 0, Code: "FIX01", LinkIndex: 0, CalcIndex: 1, Name: "고정항목", Size: "", Unite: "식", GN: 1.0, TU: 5000, NU: 0, GU: 0, JU: 5000, NC: 0, GC: 0, JC: 5000, TC: 5000 },
// 3) 비율 행 (LinkIndex = 58, GN = 10(%), 환산 제외)
{ Index: 0, Code: "", LinkIndex: 58, Name: "공구손료", Size: "10%", Unite: "%", GN: 10.0, TU: 0, NU: 0, GU: 0, JU: 0, NC: 0, GC: 0, JC: 0, TC: 0 },
// 4) 소계 행 (LinkIndex = 51, 환산 제외)
{ Index: 0, Code: "", LinkIndex: 51, Name: "소계", Size: "", Unite: "", GN: 0, TU: 0, NU: 0, GU: 0, JU: 0, NC: 0, GC: 0, JC: 0, TC: 0 },
// 5) 낙찰률 행 (LinkIndex = 52, GN = 80(%), 환산 제외)
{ Index: 0, Code: "", LinkIndex: 52, Name: "낙찰적용", Size: "80%", Unite: "%", GN: 80.0, TU: 0, NU: 0, GU: 0, JU: 0, NC: 0, GC: 0, JC: 0, TC: 0 },
// 비대상 공종 H002 (보존 검증)
{ Index: 1, Code: "H002", Name: "다른공종", Size: "공통", Unite: "식", GN: 0, TC: 0 },
{ Index: 0, Code: "M001", LinkIndex: 5, Name: "시멘트", Size: "", Unite: "포", GN: 3.0, TU: 10000, NU: 0, GU: 10000, JU: 0, NC: 0, GC: 30000, JC: 0, TC: 30000 }
];
// 1) factor = 0.5 환산
const halfConverted = EstBusiness.convertGroupUnit(p, "H001", {
name: "환산공종",
size: "50",
factor: 0.5,
unit: "식"
});
// 2) factor = 2 환산 (halfConverted 기준 재환산 -> 원래 수량 1.0으로 복귀)
const doubleConverted = EstBusiness.convertGroupUnit(halfConverted, "H001", {
name: "환산공종2",
size: "100",
factor: 2.0,
unit: "식"
});
const h1Half = halfConverted.tables.HeadMainDat;
const h1Double = doubleConverted.tables.HeadMainDat;
return {
// factor 0.5 결과
halfR1GN: h1Half[1].GN,
halfFixedGN: h1Half[2].GN,
halfPercentGN: h1Half[3].GN,
halfSubtotalGN: h1Half[4].GN,
halfBidGN: h1Half[5].GN,
halfOtherGroupGN: h1Half[7].GN,
// factor 2.0 결과
doubleR1GN: h1Double[1].GN,
doubleFixedGN: h1Double[2].GN,
doublePercentGN: h1Double[3].GN,
doubleSubtotalGN: h1Double[4].GN,
doubleBidGN: h1Double[5].GN,
doubleOtherGroupGN: h1Double[7].GN,
// AutoCalc false 옵션 보존 검증
autoCalcFalsePreserved: (() => {
const p2 = structuredClone(p);
p2.options.AutoCalc = false;
const res = EstBusiness.convertGroupUnit(p2, "H001", { factor: 0.5 });
return res.options.AutoCalc === false;
})()
};
})()""",
)
# factor 0.5
assert result["halfR1GN"] == 0.5
assert result["halfFixedGN"] == 1.0 # 고정금액 행 GN 불변
assert result["halfPercentGN"] == 10.0 # 비율 행 GN 불변
assert result["halfSubtotalGN"] == 0 # 소계 행 불변
assert result["halfBidGN"] == 80.0 # 낙찰률 행 GN 불변
assert result["halfOtherGroupGN"] == 3.0 # 다른 공종 H002 행 GN 불변
# factor 2.0 (원상복귀)
assert result["doubleR1GN"] == 1.0
assert result["doubleFixedGN"] == 1.0
assert result["doublePercentGN"] == 10.0
assert result["doubleBidGN"] == 80.0
assert result["doubleOtherGroupGN"] == 3.0
# AutoCalc false 옵션 보존
assert result["autoCalcFalsePreserved"] is True
def test_haljung_calculation_apply_to_head_and_cost_groups():
result = run(
"math",
"""(()=>{
// 1) 일위대가 공종에 할증 적용: 노무비 1000원의 10% 할증을 노무비에 가산
const p = EstNative.empty();
p.tables.HeadMainDat = [
{ Index: 1, Code: "H01", Name: "일위공종1", TC: 0 },
{ Index: 0, LinkIndex: 6, Name: "보통인부", GN: 1, NU: 1000, NC: 1000, TU: 1000, TC: 1000 },
{ Index: 0, LinkIndex: 5, Name: "모래", GN: 2, GU: 500, GC: 1000, TU: 500, TC: 1000 },
{ Index: 0, LinkIndex: 51, Name: "소계", TC: 0 }
];
p.tables.HeadTitleDat = [{ Code: "H01" }];
// 노무비의 10% 할증을 노무비에 적용 (sourceKind=2, targetKind=2, rate=10)
const p1 = EstHalJung.apply(p, "HeadMainDat", "H01", {
name: "할 증",
sourceKind: 2,
targetKind: 2,
rate: 10
});
const hRows = p1.tables.HeadMainDat;
const haljungRow = hRows[3]; // 보통인부, 모래 뒤, 소계 앞
const subtotalRow = hRows[4];
const titleRow = hRows[0];
// 1-2) 특정 비목(노무비 sourceKind=2)에서 적용비목이 비목별(targetKind=0)일 때 원천 비목(노무비 NC)에 가산되는지 확인
const p1_target0 = EstHalJung.apply(p, "HeadMainDat", "H01", {
name: "노무비별할증",
sourceKind: 2,
targetKind: 0,
rate: 10
});
const haljungRow_t0 = p1_target0.tables.HeadMainDat[3];
// 2) 일위대가 공종에 합계의 5% 할증을 경비에 적용 (sourceKind=4, targetKind=3, rate=5)
p1.tables.HeadMainDat.push(
{ Index: 1, Code: "H02", Name: "일위공종2" },
{ Index: 0, LinkIndex: 5, Name: "재료A", GN: 1, GU: 2000, GC: 2000, TU: 2000, TC: 2000 },
{ Index: 0, LinkIndex: 51, Name: "소계" }
);
p1.tables.HeadTitleDat.push({ Code: "H02" });
const p2 = EstHalJung.apply(p1, "HeadMainDat", "H02", {
name: "야간할증",
sourceKind: 4,
targetKind: 3,
rate: 5
});
const h2Rows = p2.tables.HeadMainDat;
const cHaljungRow = h2Rows[7]; // H01 5행 뒤 H02(제목, 재료A, 야간할증, 소계)
// 3) 잘못된 입력 거부 (0 또는 유효하지 않은 rate, 존재하지 않는 공종, 다른 테이블)
let rejected = 0;
try { EstHalJung.apply(p, "HeadMainDat", "H01", { rate: 0 }); } catch { rejected++; }
try { EstHalJung.apply(p, "HeadMainDat", "NON_EXIST", { rate: 10 }); } catch { rejected++; }
try { EstHalJung.apply(p, "CostMainDat", "H01", { rate: 10 }); } catch { rejected++; }
return {
haljungRow: {
Name: haljungRow.Name,
Size: haljungRow.Size,
Unite: haljungRow.Unite,
GN: haljungRow.GN,
NC: haljungRow.NC,
GC: haljungRow.GC,
JC: haljungRow.JC,
TC: haljungRow.TC,
SCheck: haljungRow.SCheck,
OCheck: haljungRow.OCheck
},
subtotalNU: subtotalRow.NU,
subtotalTU: subtotalRow.TU,
titleTC: titleRow.TC,
titleNC: titleRow.NC,
haljungTarget0: {
NC: haljungRow_t0.NC,
GC: haljungRow_t0.GC,
JC: haljungRow_t0.JC,
TC: haljungRow_t0.TC,
SCheck: haljungRow_t0.SCheck
},
cHaljungRow: {
Name: cHaljungRow.Name,
Size: cHaljungRow.Size,
Unite: cHaljungRow.Unite,
JC: cHaljungRow.JC,
TC: cHaljungRow.TC
},
rejected
};
})()""",
)
# 1) 일위대가 노무비 1000원의 10% = 100원 노무비 가산 확인
assert result["haljungRow"]["Name"] == "할 증"
assert result["haljungRow"]["Size"] == "노무비의"
assert result["haljungRow"]["Unite"] == "%"
assert result["haljungRow"]["GN"] == 10
assert result["haljungRow"]["NC"] == 100
assert result["haljungRow"]["GC"] == 0
assert result["haljungRow"]["JC"] == 0
assert result["haljungRow"]["TC"] == 100
assert result["haljungRow"]["SCheck"] == 2 # 노무비에
assert result["haljungRow"]["OCheck"] == 2 # 노무비의
# 소계(LinkIndex 51)의 단가 성분(NU/TU) 및 공종 제목행(Index 1) 합계:
# 노무비 1000 + 100 = 1100, 재료비 1000 => 합계 2100
assert result["subtotalNU"] == 1100
assert result["subtotalTU"] == 2100
assert result["titleNC"] == 1100
assert result["titleTC"] == 2100
# 1-2) sourceKind=2(노무비) & targetKind=0(비목별) -> 노무비(NC)에 100원 반영 확인 (경비 JC로 안 감)
assert result["haljungTarget0"]["NC"] == 100
assert result["haljungTarget0"]["GC"] == 0
assert result["haljungTarget0"]["JC"] == 0
assert result["haljungTarget0"]["TC"] == 100
assert result["haljungTarget0"]["SCheck"] == 2
# 2) 단가산출 합계 2000원의 5% = 100원 경비 가산 확인
assert result["cHaljungRow"]["Name"] == "야간할증"
assert result["cHaljungRow"]["Size"] == "합계의"
assert result["cHaljungRow"]["JC"] == 100
assert result["cHaljungRow"]["TC"] == 100
# 3) 안전성 거부 3건 확인
assert result["rejected"] == 3
def test_vat_calculation_exclude_include_and_unit_rounding_for_materials():
result = run(
"math",
"""(()=>{
const p = EstNative.empty();
p.tables.StuffcDat = [
{ Code: "M01", Name: "시멘트", Cost1: 11000, Cost2: 12100, ValueIndex: 0, InputTC: 11000, TC: 11000 },
{ Code: "M02", Name: "철근", Cost1: 10000, ValueIndex: 4, InputTC: 10000, TC: 10000 },
{ Code: "M03", Name: "단단위테스트", Cost1: 12345, InputTC: 12345, TC: 12345 }
];
// 1) M01 선택 후 부가세 제외 (/1.1)
const p1 = EstVat.apply(p, [0], {
calcMethod: 1, // 부가세제외 (/1.1)
diget: 0, // 원단위
digetMethod: 1 // 절하
});
// 2) M02에 부가세 포함 (*1.1) 및 십원 단위(-1자리) 절하 적용
const p2 = EstVat.apply(p, [1], {
calcMethod: 0, // 부가세포함 (*1.1)
diget: 1, // 십원 단위 절하
digetMethod: 1
});
// 3) M03에 부가세 제외 (/1.1) 후 단단위 사사오입(digetMethod: 0) 및 절상(digetMethod: 2)
// 12345 / 1.1 = 11222.7272...
// diget=1 (십원 단위 사사오입): 11220
// diget=1 (십원 단위 절상): 11230
const p3Round = EstVat.apply(p, [2], { calcMethod: 1, diget: 1, digetMethod: 0 });
const p3Up = EstVat.apply(p, [2], { calcMethod: 1, diget: 1, digetMethod: 2 });
return {
m1Cost1: p1.tables.StuffcDat[0].Cost1,
m1Cost2: p1.tables.StuffcDat[0].Cost2,
m1TC: p1.tables.StuffcDat[0].TC,
m2Cost1: p2.tables.StuffcDat[1].Cost1,
m2TC: p2.tables.StuffcDat[1].TC,
m3RoundCost: p3Round.tables.StuffcDat[2].Cost1,
m3UpCost: p3Up.tables.StuffcDat[2].Cost1
};
})()""",
)
# M01: 11000 / 1.1 = 10000, 12100 / 1.1 = 11000
# ValueIndex=0(최저단가) -> 재계산 후 TC도 10000으로 보존됨
assert result["m1Cost1"] == 10000
assert result["m1Cost2"] == 11000
assert result["m1TC"] == 10000
# M02: 10000 * 1.1 = 11000, InputTC/TC도 11000 확인
assert result["m2Cost1"] == 11000
assert result["m2TC"] == 11000
# M03: 12345 / 1.1 = 11222.72... -> 십원 단위 사사오입(11220), 절상(11230)
assert result["m3RoundCost"] == 11220
assert result["m3UpCost"] == 11230
@@ -23,6 +23,416 @@ def test_estimate_move_preserves_row_identity_and_blocks_ambiguous_or_total_rows
assert result == {"moved": [0, 1], "order": [2, 3, 1, 0], "restored": [1, 2], "blocked": 4}
def test_edge_estimate_move_rows_up_and_down_with_undo():
from playwright.sync_api import sync_playwright
import pytest
with sync_playwright() as p:
browser = p.chromium.launch(channel="msedge", headless=True)
page = browser.new_page()
try:
page.goto("http://100.65.141.15:5175", timeout=5000)
except Exception:
pytest.skip("EST 5175 server not reachable")
page.wait_for_function("() => Boolean(EstApp.state.project)")
# 내역서 화면 확인
page.evaluate("EstApp.navigate('EstMainDat')")
page.wait_for_function("() => EstApp.state.view === 'EstMainDat'")
# 1번 행의 고유 코드 또는 PkID 기록
row_id_target = page.evaluate("EstApp.state.project.tables.EstMainDat[1].PkID")
# 1번 행(2번째 행)을 선택하고 아래로 이동 시험
page.evaluate("""() => {
EstApp.state.selection = new Set([1]);
EstApp.render();
}""")
btn_down = page.locator("#move-estimate-down")
assert btn_down.is_visible()
btn_down.click()
# 이동 후 해당 행이 2번 위치로 이동했는지 확인
pos_after_down = page.evaluate(f"""() => {{
return EstApp.state.project.tables.EstMainDat.findIndex(r => r.PkID === {row_id_target});
}}""")
assert pos_after_down == 2
sel_after_down = page.evaluate("Array.from(EstApp.state.selection)")
assert sel_after_down == [2]
# 다시 위로 이동 시험
btn_up = page.locator("#move-estimate-up")
assert btn_up.is_visible()
btn_up.click()
pos_after_up = page.evaluate(f"""() => {{
return EstApp.state.project.tables.EstMainDat.findIndex(r => r.PkID === {row_id_target});
}}""")
assert pos_after_up == 1
sel_after_up = page.evaluate("Array.from(EstApp.state.selection)")
assert sel_after_up == [1]
# 실행 취소(Undo) 시험: 툴바 버튼 클릭 시 다시 2번 위치로 되돌아감
btn_undo = page.locator(".toolbar button[data-action='undo']")
assert btn_undo.is_visible()
btn_undo.click()
page.wait_for_timeout(500)
pos_after_undo = page.evaluate(f"""() => {{
return EstApp.state.project.tables.EstMainDat.findIndex(r => r.PkID === {row_id_target});
}}""")
assert pos_after_undo == 2
browser.close()
def test_group_detail_move_preserves_row_identity_and_blocks_title_and_boundaries():
result = run(
"math",
"""(()=>{
const rows = [
{Index:1,Code:'H1',Name:'Title 1',PkID:100},
{Index:0,Code:'D1',Name:'Detail 1',PkID:101},
{Index:0,Code:'D2',Name:'Detail 2',PkID:102},
{Index:0,Code:'D3',Name:'Detail 3',PkID:103},
{Index:1,Code:'H2',Name:'Title 2',PkID:200},
{Index:0,Code:'D4',Name:'Detail 4',PkID:201}
];
const moved = EstBusiness.moveGroupDetailRows(rows, 'H1', new Set([2]), 1);
const order = rows.slice(0, 4).map(r => r.Code);
const restored = EstBusiness.moveGroupDetailRows(rows, 'H1', new Set(moved), -1);
let blocked = 0;
// 1: 제목 행(Index 0) 이동 시도
try { EstBusiness.moveGroupDetailRows(rows, 'H1', new Set([0]), 1); } catch { blocked++; }
// 2: 다른 호표 행 포함 시도
try { EstBusiness.moveGroupDetailRows(rows, 'H1', new Set([4]), 1); } catch { blocked++; }
// 3: 비연속 행 선택 시도
try { EstBusiness.moveGroupDetailRows(rows, 'H1', new Set([1, 3]), 1); } catch { blocked++; }
// 4: 상한 경계(제목 바로 다음 행을 위로) - 이동 없이 원래 인덱스 반환
const topBoundary = EstBusiness.moveGroupDetailRows(rows, 'H1', new Set([1]), -1);
// 5: 하한 경계(호표 마지막 행을 아래로) - 이동 없이 원래 인덱스 반환
const bottomBoundary = EstBusiness.moveGroupDetailRows(rows, 'H1', new Set([3]), 1);
return { moved, order, restored, blocked, topBoundary, bottomBoundary };
})()""",
)
assert result == {
"moved": [3],
"order": ["H1", "D1", "D3", "D2"],
"restored": [2],
"blocked": 3,
"topBoundary": [1],
"bottomBoundary": [3],
}
def test_edge_group_detail_move_rows_up_and_down_with_undo():
from playwright.sync_api import sync_playwright
import pytest
with sync_playwright() as p:
browser = p.chromium.launch(channel="msedge", headless=True)
page = browser.new_page()
try:
page.goto("http://100.65.141.15:5175", timeout=5000)
except Exception:
pytest.skip("EST 5175 server not reachable")
page.wait_for_function("() => Boolean(EstApp.state.project)")
# 일위대가(HeadMainDat) 화면으로 이동
page.evaluate("EstApp.navigate('HeadMainDat')")
page.wait_for_function("() => EstApp.state.view === 'HeadMainDat'")
# 첫 번째 호표 코드 및 2번 상세 행(전체 인덱스 2)의 PkID 확인
group_code = page.evaluate("EstApp.state.group")
assert group_code
target_pkid = page.evaluate("EstApp.state.project.tables.HeadMainDat[2].PkID")
# 2번 상세 행 선택
page.evaluate("""() => {
EstApp.state.selection = new Set([2]);
EstApp.render();
}""")
btn_down = page.locator("#move-estimate-down")
assert btn_down.is_visible()
btn_down.click()
# 이동 후 해당 행이 3번 위치로 이동했는지 확인
pos_after_down = page.evaluate(f"""() => {{
return EstApp.state.project.tables.HeadMainDat.findIndex(r => r.PkID === {target_pkid});
}}""")
assert pos_after_down == 3
sel_after_down = page.evaluate("Array.from(EstApp.state.selection)")
assert sel_after_down == [3]
# 다시 위로 이동 시험
btn_up = page.locator("#move-estimate-up")
assert btn_up.is_visible()
btn_up.click()
pos_after_up = page.evaluate(f"""() => {{
return EstApp.state.project.tables.HeadMainDat.findIndex(r => r.PkID === {target_pkid});
}}""")
assert pos_after_up == 2
sel_after_up = page.evaluate("Array.from(EstApp.state.selection)")
assert sel_after_up == [2]
# 실행 취소(Undo) 시험: 툴바 버튼 클릭 시 다시 3번 위치로 복원
btn_undo = page.locator(".toolbar button[data-action='undo']")
assert btn_undo.is_visible()
btn_undo.click()
page.wait_for_timeout(500)
pos_after_undo = page.evaluate(f"""() => {{
return EstApp.state.project.tables.HeadMainDat.findIndex(r => r.PkID === {target_pkid});
}}""")
assert pos_after_undo == 3
browser.close()
def test_cost_group_detail_move_preserves_subtotal_and_blocks_order_dependent_errors():
result = run(
"math",
"""(()=>{
const cost = project.tables.CostMainDat;
const grps = EstNative.groups(cost);
const grp4 = grps.get('D0000000004');
const start4 = grp4.start;
const p = structuredClone(project);
// 1: D0000000004 호표의 포장공(start+10, Index -20) 행을 아래(보통인부 start+12 건너)로 이동
// Index -20 시작 표지가 선두로 유지되면서 소계(14,373.6) 및 호표 총액(16,421) 완벽 보존
const moved4 = EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000004', new Set([start4 + 10]), 1, 'CostMainDat');
const calcAfterMove = EstNative.calculate(p);
const subtotal4 = calcAfterMove.tables.CostMainDat[start4 + 14].TC;
const total4 = calcAfterMove.tables.CostMainDat[start4].TC;
// 2: 다시 위로 원복 이동
const restored4 = EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000004', new Set(moved4), -1, 'CostMainDat');
const calcAfterRestore = EstNative.calculate(p);
const subtotalRestore = calcAfterRestore.tables.CostMainDat[start4 + 14].TC;
// 3: 보호 차단 시험
let blockedSubtotal = 0;
try {
EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000004', new Set([start4 + 14]), 1, 'CostMainDat');
} catch { blockedSubtotal++; }
let blockedRatio = 0;
try {
EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000004', new Set([start4 + 18]), 1, 'CostMainDat');
} catch { blockedRatio++; }
const grp0 = grps.get('D0000000000');
const start0 = grp0.start;
let blockedVar = 0;
try {
// D0000000000의 Q 변수 산식 행(start0+11)을 아래로 이동 시 참조 오류 차단
EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000000', new Set([start0 + 11]), 1, 'CostMainDat');
} catch { blockedVar++; }
// 4: D0000000005 다중 -20 구간 보존 시험
const grp5 = grps.get('D0000000005');
const start5 = grp5.start;
const moved5 = EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000005', new Set([start5 + 15]), 1, 'CostMainDat');
const m20Count5 = p.tables.CostMainDat.slice(start5, start5 + grp5.rows.length).filter(r => Number(r.Index) === -20).length;
const calc5 = EstNative.calculate(p);
const sub1_5 = calc5.tables.CostMainDat[start5 + 19].TC;
const sub2_5 = calc5.tables.CostMainDat[start5 + 27].TC;
// 5: 앞줄 참조/소계 결과가 다른 정상 값으로 바뀌는 안전하지 않은 이동 차단 시험
// D0000000004에서 재료비 행(start4+6)을 단계별로 내려서 노무비 포장공(start4+10) 위치로 침범 시도
let blockedSubtotalShift = 0;
try {
const m1 = EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000004', new Set([start4 + 6]), 1, 'CostMainDat');
const m2 = EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000004', new Set(m1), 1, 'CostMainDat');
const m3 = EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000004', new Set(m2), 1, 'CostMainDat');
EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000004', new Set(m3), 1, 'CostMainDat');
} catch {
blockedSubtotalShift++;
}
// 6: PkID 없는 행, 다중 선택 내 구간 경계 포함 및 앞줄 참조 복수 행 이동 보수적 차단 시험
let blockedMultiM20 = 0;
try {
// D0000000005에서 -20이 중간에 포함된 3개 연속 행 이동 시도
EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000005', new Set([start5 + 22, start5 + 23, start5 + 24]), 1, 'CostMainDat');
} catch { blockedMultiM20++; }
let blockedRefMulti = 0;
try {
// 앞줄 참조(\x0c) 행을 포함한 복수 행 동시 이동 시도
EstBusiness.moveGroupDetailRows(p.tables.CostMainDat, 'D0000000004', new Set([start4 + 17, start4 + 18]), 1, 'CostMainDat');
} catch { blockedRefMulti++; }
// 7: A/B 동일값 선행 행 교환 시 뒤따르는 직전 결과 참조 대상 왜곡 재현 및 보수적 차단 시험
const ambigRows = [
{ Index: 1, Code: 'CAMBIG' },
{ PkID: 1, SCheck: -1, LineData: 'A = 100 = \x12100\x12' },
{ PkID: 2, SCheck: -1, LineData: 'B = 100 = \x12100\x12' },
{ PkID: 3, LinkIndex: 58, SCheck: -3, LineData: '\x0c100\x0c * 2 = \x12200\x12' }
];
// A와 B의 교환을 허용할 경우 B가 변경되었을 때 C가 참조하는 대상이 원래 B에서 A로 왜곡됨을 재현:
const origAmbig = EstText.calculate(structuredClone(ambigRows));
const swappedAmbig = [ambigRows[0], ambigRows[2], ambigRows[1], ambigRows[3]];
const ambigBChangedOrig = structuredClone(ambigRows);
ambigBChangedOrig[2].LineData = 'B = 200 = \x12200\x12';
const ambigBChangedSwapped = structuredClone(swappedAmbig);
ambigBChangedSwapped[1].LineData = 'B = 200 = \x12200\x12';
const origRes = EstText.calculate(ambigBChangedOrig);
const swappedRes = EstText.calculate(ambigBChangedSwapped);
const shiftReproduced = (origRes.rows[3].TC === 400 && swappedRes.rows[3].TC === 200);
// moveGroupDetailRows에서 이와 같은 모호한 참조 대상 구간 행 이동을 확실히 차단하는지 검증:
let blockedAmbigRef = 0;
let ambigErrMsg = '';
try {
EstBusiness.moveGroupDetailRows(ambigRows, 'CAMBIG', new Set([1]), 1, 'CostMainDat');
} catch (e) {
blockedAmbigRef++;
ambigErrMsg = e.message;
}
// 8: LinkIndex 58 이외 일반 행의 \x0c 앞줄 참조 모호성 차단 시험
const generalRefRows = [
{ Index: 1, Code: 'CGENREF' },
{ PkID: 10, SCheck: 0, LineData: 'X1 = 50 = \x1250\x12' },
{ PkID: 11, SCheck: 0, LineData: 'X2 = 50 = \x1250\x12' },
{ PkID: 12, SCheck: 0, LinkIndex: 0, LineData: '일반행 : \x0c50\x0c + 10 = \x1260\x12' }
];
let blockedGenRef = 0;
try {
EstBusiness.moveGroupDetailRows(generalRefRows, 'CGENREF', new Set([1]), 1, 'CostMainDat');
} catch {
blockedGenRef++;
}
// 9: 동일 변수 재정의(중복 할당) 구간 행 교환 보수적 차단 시험
const varRedefineRows = [
{ Index: 1, Code: 'CVARREDEF' },
{ PkID: 20, LineData: 'W = 10 = \x1210\x12' },
{ PkID: 21, LineData: 'W = 10 = \x1210\x12' },
{ PkID: 22, LineData: 'ANS = W * 3 = \x1230\x12' }
];
let blockedVarRedef = 0;
let varRedefErrMsg = '';
try {
EstBusiness.moveGroupDetailRows(varRedefineRows, 'CVARREDEF', new Set([1]), 1, 'CostMainDat');
} catch (e) {
blockedVarRedef++;
varRedefErrMsg = e.message;
}
return {
moved4,
restored4,
subtotal4,
total4,
subtotalRestore,
blockedSubtotal,
blockedRatio,
blockedVar,
m20Count5,
sub1_5,
sub2_5,
blockedSubtotalShift,
blockedMultiM20,
blockedRefMulti,
shiftReproduced,
blockedAmbigRef,
ambigErrMsg,
blockedGenRef,
blockedVarRedef,
varRedefErrMsg
};
})()""",
)
assert result["shiftReproduced"] is True
assert result["blockedAmbigRef"] == 1
assert "직전 결과를 참조하는 행이 뒤따라" in result["ambigErrMsg"]
assert result["moved4"] == [172]
assert result["restored4"] == [171]
assert result["subtotal4"] == 14373.6
assert result["total4"] == 16421
assert result["subtotalRestore"] == 14373.6
assert result["blockedSubtotal"] == 1
assert result["blockedRatio"] == 1
assert result["blockedVar"] == 1
assert result["m20Count5"] == 2
assert result["sub1_5"] == 1383.4
assert result["sub2_5"] == 1407.8
assert result["blockedSubtotalShift"] == 1
assert result["blockedMultiM20"] == 1
assert result["blockedRefMulti"] == 1
assert result["blockedGenRef"] == 1
assert result["blockedVarRedef"] == 1
assert "동일 변수(W)를 재정의하는 구간에서는" in result["varRedefErrMsg"]
def test_edge_cost_group_detail_move_rows_with_subtotal_and_undo():
from playwright.sync_api import sync_playwright
import pytest
with sync_playwright() as p:
browser = p.chromium.launch(channel="msedge", headless=True)
page = browser.new_page()
try:
page.goto("http://100.65.141.15:5175", timeout=5000)
except Exception:
pytest.skip("EST 5175 server not reachable")
page.wait_for_function("() => Boolean(EstApp.state.project)")
# 단가산출(CostMainDat) 화면 및 D0000000004 호표로 이동
page.evaluate("EstApp.navigate('CostMainDat', 'D0000000004')")
page.wait_for_function(
"() => EstApp.state.view === 'CostMainDat' && EstApp.state.group === 'D0000000004'"
)
# 시작 인덱스 조회 및 포장공 행(start + 10) 선택
start_idx = page.evaluate(
"EstNative.groups(EstApp.state.project.tables.CostMainDat).get('D0000000004').start"
)
target_row_idx = start_idx + 10
target_pkid = page.evaluate(
f"EstApp.state.project.tables.CostMainDat[{target_row_idx}].PkID"
)
page.evaluate(f"""() => {{
EstApp.state.selection = new Set([{target_row_idx}]);
EstApp.render();
}}""")
btn_down = page.locator("#move-estimate-down")
assert btn_down.is_visible()
btn_down.click()
# 이동 후 위치 확인 및 소계/총액 보존 확인
pos_after_down = page.evaluate(f"""() => {{
return EstApp.state.project.tables.CostMainDat.findIndex(r => r.PkID === {target_pkid});
}}""")
assert pos_after_down == target_row_idx + 1
subtotal_after_down = page.evaluate(
f"EstApp.state.project.tables.CostMainDat[{start_idx + 14}].TC"
)
assert subtotal_after_down == 14373.6
# 실행 취소(Undo)로 원래 위치 원복
btn_undo = page.locator(".toolbar button[data-action='undo']")
assert btn_undo.is_visible()
btn_undo.click()
page.wait_for_timeout(500)
pos_after_undo = page.evaluate(f"""() => {{
return EstApp.state.project.tables.CostMainDat.findIndex(r => r.PkID === {target_pkid});
}}""")
assert pos_after_undo == target_row_idx
browser.close()
def test_duplicate_codes_match_distinct_original_rows_and_track_deletions():
result = run(
"math",
@@ -155,3 +565,151 @@ def test_blank_codes_have_distinct_keys_and_metadata_changes_are_reported():
assert len(set(result["keys"][1:])) == 2
assert result["rows"][0]["status"] == "changed"
assert result["rows"][0]["difference"] == 0
def test_bid_rates_apply_and_restore_original_rates():
result = run(
"math",
"""(()=>{
const p = EstNative.empty();
p.options.SourceRefresh = false;
p.tables.EstMainDat = [
{ PkID: 1, Code: 'R1', Name: '노무재료경비항목', GN: 2, NU: 1000, GU: 2000, JU: 500, SU: 0, TU: 3500, TC: 7000 }
];
p.tables.HeadMainDat = [
{ PkID: 10, Code: 'H1', Name: '일위대가항목', Index: 0, GN: 1, NU: 10000, GU: 5000, JU: 1000, SU: 0, TU: 16000, TC: 16000 }
];
// 0) 원본에 기존 O필드가 존재하는 행 준비
p.tables.EstMainDat.push({
PkID: 2, Code: 'R2', Name: '기존O필드항목', GN: 1, NU: 500, GU: 500, JU: 0, SU: 0, TU: 1000, TC: 1000,
OTU: 1000, ONU: 500, OGU: 500, OJU: 0, OSU: 0
});
// 1) 낙찰률 80% 적용
const bidded = EstBusiness.bid(p, [80, 80, 80, 80]);
const bidEstRow = bidded.tables.EstMainDat[0];
const bidHeadRow = bidded.tables.HeadMainDat[0];
const bidExistingORow = bidded.tables.EstMainDat[1];
// 2) 당초(100%) 복원
const restored = EstBusiness.restoreBid(bidded);
const resEstRow = restored.tables.EstMainDat[0];
const resHeadRow = restored.tables.HeadMainDat[0];
const resExistingORow = restored.tables.EstMainDat[1];
return {
bidEst: { NU: bidEstRow.NU, GU: bidEstRow.GU, JU: bidEstRow.JU, TU: bidEstRow.TU, ApplyNacChal: bidEstRow.ApplyNacChal },
bidHead: { NU: bidHeadRow.NU, GU: bidHeadRow.GU, JU: bidHeadRow.JU, TU: bidHeadRow.TU, ApplyNacChal: bidHeadRow.ApplyNacChal },
resEst: { NU: resEstRow.NU, GU: resEstRow.GU, JU: resEstRow.JU, TU: resEstRow.TU, hasApplyNacChal: 'ApplyNacChal' in resEstRow },
resHead: { NU: resHeadRow.NU, GU: resHeadRow.GU, JU: resHeadRow.JU, TU: resHeadRow.TU, hasApplyNacChal: 'ApplyNacChal' in resHeadRow },
resExistingO: { NU: resExistingORow.NU, OTU: resExistingORow.OTU, ONU: resExistingORow.ONU, OGU: resExistingORow.OGU }
};
})()""",
)
assert result["bidEst"]["NU"] == 800
assert result["bidEst"]["GU"] == 1600
assert result["bidEst"]["JU"] == 400
assert result["bidEst"]["TU"] == 2800
assert result["bidEst"]["ApplyNacChal"] == 1
assert result["bidHead"]["NU"] == 8000
assert result["bidHead"]["GU"] == 4000
assert result["bidHead"]["JU"] == 800
assert result["bidHead"]["TU"] == 12800
assert result["bidHead"]["ApplyNacChal"] == 1
assert result["resEst"]["NU"] == 1000
assert result["resEst"]["GU"] == 2000
assert result["resEst"]["JU"] == 500
assert result["resEst"]["TU"] == 3500
assert result["resEst"]["hasApplyNacChal"] is False
assert result["resHead"]["NU"] == 10000
assert result["resHead"]["GU"] == 5000
assert result["resHead"]["JU"] == 1000
assert result["resHead"]["TU"] == 16000
assert result["resHead"]["hasApplyNacChal"] is False
# 기존 O필드 보존 검증: 원래 있던 OTU/ONU/OGU가 삭제되지 않고 유지됨
assert result["resExistingO"]["NU"] == 500
assert result["resExistingO"]["OTU"] == 1000
assert result["resExistingO"]["ONU"] == 500
assert result["resExistingO"]["OGU"] == 500
def test_bid_rates_consecutive_apply_source_refresh_and_roundtrip_restore():
result = run(
"math",
"""(()=>{
// 1) 원천 단가(StuffcDat 200)와 내역 초기단가(GU 150)가 다르고 SourceRefresh=true인 프로젝트
const p = EstNative.empty();
p.options = { SourceRefresh: true, AutoCalc: true };
p.tables.StuffcDat = [{ Code: 'M1', Name: '자재', ValueIndex: 4, InputTC: 200 }];
p.tables.CostMainDat = [
{ Index: 1, Code: 'C1' },
{ Code: 'M1', LinkIndex: 5, SCheck: -2, LineData: ' \\x0e자재\\x0e: \\x03200\\x03 = \\x12200\\x12' }
];
p.tables.CostTitleDat = [{ Code: 'C1' }];
p.tables.EstMainDat = [
{ PkID: 1, Code: 'C1', LinkIndex: 104, GN: 10, NU: 100, GU: 150, JU: 50, TU: 300, TC: 3000 }
];
// 2) 1차 낙찰률 적용 (80%) -> 2차 연속 낙찰률 적용 (70%)
const bid1 = EstBusiness.bid(p, [80, 80, 80, 80]);
const bid2 = EstBusiness.bid(bid1, [70, 70, 70, 70]);
// 2차 적용 후 단가: 최초(100, 150, 50) 기준 70%인 (70, 105, 35)이어야 하며, 80%의 70%(56%)로 왜곡되면 안 됨
const bid2Row = bid2.tables.EstMainDat[0];
// 3) 직렬화 및 역직렬화 (저장 -> 재열기 시뮬레이션)
const reloaded = JSON.parse(JSON.stringify(bid2));
// 4) 당초(100%) 복원
const restored = EstBusiness.restoreBid(reloaded);
const resRow = restored.tables.EstMainDat[0];
// 5) 수동계산(AutoCalc: false) 모드에서의 연속 적용 및 복원
const manualP = structuredClone(p);
manualP.options.AutoCalc = false;
const manualBid = EstBusiness.bid(EstBusiness.bid(manualP, [80, 80, 80, 80]), [70, 70, 70, 70]);
const manualRestored = EstBusiness.restoreBid(manualBid);
return {
bid2Row: { NU: bid2Row.NU, GU: bid2Row.GU, JU: bid2Row.JU, TU: bid2Row.TU },
bid2Option: bid2.options.SourceRefresh,
resRow: { NU: resRow.NU, GU: resRow.GU, JU: resRow.JU, TU: resRow.TU, TC: resRow.TC },
resOption: restored.options.SourceRefresh,
hasPreBidRates: 'PreBidRates' in resRow,
manualBidRow: { NU: manualBid.tables.EstMainDat[0].NU, GU: manualBid.tables.EstMainDat[0].GU },
manualResRow: { NU: manualRestored.tables.EstMainDat[0].NU, GU: manualRestored.tables.EstMainDat[0].GU }
};
})()""",
)
# 연속 낙찰률 적용 시 최초 단가(100, 150, 50) 기준 70% = (70, 105, 35) 유지
assert result["bid2Row"]["NU"] == 70
assert result["bid2Row"]["GU"] == 105
assert result["bid2Row"]["JU"] == 35
assert result["bid2Row"]["TU"] == 210
# 낙찰 중에는 원천 갱신 차단
assert result["bid2Option"] is False
# 복원 후: 원천 단가(200)로 덮이지 않고 최초 내역 단가(100, 150, 50)로 정확히 복원
assert result["resRow"]["NU"] == 100
assert result["resRow"]["GU"] == 150
assert result["resRow"]["JU"] == 50
assert result["resRow"]["TU"] == 300
assert result["resRow"]["TC"] == 3000
# 사용자의 원래 SourceRefresh 옵션(true) 복원 및 임시 키 정리
assert result["resOption"] is True
assert result["hasPreBidRates"] is False
# 수동 계산(AutoCalc: false) 모드에서도 최초 단가 및 복원 일관성 확인
assert result["manualBidRow"]["NU"] == 70
assert result["manualBidRow"]["GU"] == 105
assert result["manualResRow"]["NU"] == 100
assert result["manualResRow"]["GU"] == 150
+231
View File
@@ -149,6 +149,58 @@ def test_escalation_cost_print_uses_computed_cost_breakdowns():
assert result["rows"][-1]["excludedCost"] == 240
def test_resource_view_filtering_and_by_work_expansion():
result = run(
"math",
"""(()=>{
const p = structuredClone(project);
const allRows = EstResources.calculate(p, false).rows;
const byWorkRows = EstResources.calculate(p, true).rows;
const laborOnly = allRows.filter(r => r.kind === 6);
const materialOnly = allRows.filter(r => r.kind === 5);
const machineOnly = allRows.filter(r => r.kind === 105);
const laborByWork = byWorkRows.filter(r => r.kind === 6);
// 공종별 집계 합산과 전체 집계의 금액/수량 일치 검증
const laborAllQty = laborOnly.reduce((s, r) => s + r.quantity, 0);
const laborAllAmt = laborOnly.reduce((s, r) => s + r.amount, 0);
const laborWorkQty = laborByWork.reduce((s, r) => s + r.quantity, 0);
const laborWorkAmt = laborByWork.reduce((s, r) => s + r.amount, 0);
// 서식 데이터(EstForms.data) 연동 확인
const f112 = EstForms.data(112, p, {project: p});
const f13 = EstForms.data(13, p, {project: p});
return {
allCount: allRows.length,
byWorkCount: byWorkRows.length,
laborCount: laborOnly.length,
materialCount: materialOnly.length,
machineCount: machineOnly.length,
laborByWorkCount: laborByWork.length,
qtyDiff: Math.abs(laborAllQty - laborWorkQty),
amtDiff: Math.abs(laborAllAmt - laborWorkAmt),
f112Count: f112.rows.length,
f13Count: f13.rows.length,
f112HasWork: f112.columns.includes('WorkCode') && f112.columns.includes('WorkName')
};
})()""",
)
assert result["allCount"] == 27
assert result["byWorkCount"] == 72
assert result["laborCount"] == 7
assert result["materialCount"] == 13
assert result["machineCount"] == 7
assert result["laborByWorkCount"] == 30
assert result["qtyDiff"] < 1e-9
assert result["amtDiff"] < 1e-6
assert result["f112Count"] == 30
assert result["f13Count"] == 7
assert result["f112HasWork"] is True
def test_resource_report_separates_each_work_item_without_changing_aggregate():
result = run(
"math",
@@ -239,6 +291,55 @@ def test_schedule_imports_distinct_rows_with_repeated_or_empty_codes():
assert result["again"] == []
def test_schedule_move_row_and_clear_operations():
result = run(
"math",
"""(()=>{
const p = EstNative.empty();
p.schedule = [
{id:'1', Code:'A', Name:'첫번째', start:'2026-01-01', end:'2026-01-10', weight:100, progress:10},
{id:'2', Code:'B', Name:'두번째', start:'2026-01-11', end:'2026-01-20', weight:200, progress:20},
{id:'3', Code:'C', Name:'세번째', start:'2026-01-21', end:'2026-01-31', weight:300, progress:30}
];
// 상하 경계 이동 무반응 확인
EstSchedule.moveRow(p, 0, -1);
const atTop = p.schedule.map(r => r.Code);
EstSchedule.moveRow(p, 2, 1);
const atBottom = p.schedule.map(r => r.Code);
// 중간 행 아래로 이동
EstSchedule.moveRow(p, 1, 1);
const afterDown = p.schedule.map(r => r.Code);
// 마지막 행 위로 이동
EstSchedule.moveRow(p, 2, -1);
const afterUp = p.schedule.map(r => r.Code);
// 공정 전체 비우기
const countBeforeClear = p.schedule.length;
EstSchedule.clearSchedule(p);
const countAfterClear = p.schedule.length;
return {
atTop,
atBottom,
afterDown,
afterUp,
countBeforeClear,
countAfterClear
};
})()""",
)
assert result["atTop"] == ["A", "B", "C"]
assert result["atBottom"] == ["A", "B", "C"]
assert result["afterDown"] == ["A", "C", "B"]
assert result["afterUp"] == ["A", "B", "C"]
assert result["countBeforeClear"] == 3
assert result["countAfterClear"] == 0
def test_quantity_print_includes_manual_and_sheet_formulas_and_errors():
result = run(
"math",
@@ -259,3 +360,133 @@ def test_quantity_print_includes_manual_and_sheet_formulas_and_errors():
assert result["rows"][3]["TargetCode"] == "Y"
assert result["rows"][2]["Error"]
assert result["errors"]
def test_public_view_forms_print_gong_method_options():
result = run(
"math",
"""(()=>{
const p = EstNative.empty();
p.tables.EstMainDat = [
{ PkID: 1, Level: 1, Code: 'C01', Name: '토공사' },
{ PkID: 2, Level: 0, Code: 'A01', Name: '터파기', Size: '일반', Unite: 'm3', GN: 100, TU: 500, TC: 50000, NC: 20000, GC: 30000, JC: 0, SC: 0 }
];
p.tables.HeadMainDat = [
{ Index: 1, Code: 'H01', Name: '터파기 호표', TC: 500 },
{ Index: 0, Code: 'L01', Name: '인부', Unite: '인', GN: 0.1, NU: 1000, GU: 0, JU: 0, SU: 0, TC: 100 }
];
// 1) 기본(PrintGongMethod=0): 공람용 내역서(117), 일위대가(115), 총괄표(116, 118) 수량만/금액제외
p.printSettings = { PrintGongMethod: 0 };
const d117_m0 = EstForms.data(117, p);
const d115_m0 = EstForms.data(115, p);
const d118_m0 = EstForms.data(118, p);
const d116_m0 = EstForms.data(116, p);
// 2) PrintGongMethod=1: 단가/금액만 (수량 제외)
p.printSettings = { PrintGongMethod: 1 };
const d117_m1 = EstForms.data(117, p);
const d115_m1 = EstForms.data(115, p);
// 3) PrintGongMethod=2: 수량 + 단가/금액 모두 포함
p.printSettings = { PrintGongMethod: 2 };
const d117_m2 = EstForms.data(117, p);
const d115_m2 = EstForms.data(115, p);
const d118_m2 = EstForms.data(118, p);
// 4) 표준 내역서(1) 및 일위대가(2)는 공람옵션 영향 없이 전체 열 출력
const d1 = EstForms.data(1, p);
const d2 = EstForms.data(2, p);
return {
d117_m0: d117_m0.columns,
d115_m0: d115_m0.columns,
d118_m0: d118_m0.columns,
d116_m0: d116_m0.columns,
d117_m1: d117_m1.columns,
d115_m1: d115_m1.columns,
d117_m2: d117_m2.columns,
d115_m2: d115_m2.columns,
d118_m2: d118_m2.columns,
d1: d1.columns,
d2: d2.columns
};
})()""",
)
# 1) m0 (수량만): GN만 포함, 단가/금액 비목 제외
assert (
"GN" in result["d117_m0"]
and "TC" not in result["d117_m0"]
and "TU" not in result["d117_m0"]
)
assert (
"GN" in result["d115_m0"]
and "TC" not in result["d115_m0"]
and "NU" not in result["d115_m0"]
)
assert "TC" not in result["d118_m0"] and "NC" not in result["d118_m0"]
assert "TC" not in result["d116_m0"] and "NC" not in result["d116_m0"]
# 2) m1 (단가만): GN 제외, 단가/금액 포함
assert "GN" not in result["d117_m1"] and "TC" in result["d117_m1"] and "TU" in result["d117_m1"]
assert "GN" not in result["d115_m1"] and "TC" in result["d115_m1"] and "NU" in result["d115_m1"]
# 3) m2 (수량, 단가 모두): GN, 단가, 금액 모두 포함
assert "GN" in result["d117_m2"] and "TC" in result["d117_m2"]
assert "GN" in result["d115_m2"] and "TC" in result["d115_m2"]
assert "TC" in result["d118_m2"]
# 4) 일반 서식(1, 2)은 불변 유지
assert "GN" in result["d1"] and "TC" in result["d1"]
assert "GN" in result["d2"] and "TC" in result["d2"]
def test_print_apply_field_and_sum_bottom_preserve_all_rows_and_no_destructive_filtering():
result = run(
"math",
"""(()=>{
const p = EstNative.empty();
// 공종(레벨1), 소계, 0원 항목, 음수 변경분, 일반 상세항목 혼재
p.tables.EstMainDat = [
{ Code: "", Name: "토공사", Size: "", Unite: "", GN: 0, TU: 0, TC: 0, Level: 1 },
{ Code: "A01", Name: "터파기", Size: "토사", Unite: "m3", GN: 100, TU: 5000, TC: 500000, NC: 300000, GC: 100000, JC: 100000, SC: 0, Level: 0 },
{ Code: "A02", Name: "잔토처리", Size: "덤프15t", Unite: "m3", GN: 50, TU: 0, TC: 0, NC: 0, GC: 0, JC: 0, SC: 0, Level: 0 },
{ Code: "A03", Name: "설계변경감소", Size: "규격", Unite: "식", GN: -10, TU: 10000, TC: -100000, NC: -50000, GC: -50000, JC: 0, SC: 0, Level: 0 },
{ Code: "", Name: "소계", Size: "", Unite: "", GN: 0, TU: 0, TC: 400000, Level: 0 }
];
p.tables.HeadMainDat = [
{ Code: "H01", Name: "일위대가항목", Size: "", Unite: "식", GN: 1, NU: 1000, GU: 2000, JU: 0, SU: 0, TC: 3000 },
{ Code: "H02", Name: "0원대가항목", Size: "", Unite: "", GN: 1, NU: 0, GU: 0, JU: 0, SU: 0, TC: 0 }
];
// PrintApplyField / PrintSumBottom 옵션이 어떤 값이든 행과 제목·0원·음수 행이 삭제되지 않고 100% 보존되는지 확인
p.printSettings = { PrintApplyField: 1, PrintSumBottom: 1 };
const d1 = EstForms.data(1, p);
const d2 = EstForms.data(2, p);
const d117 = EstForms.data(117, p);
const d115 = EstForms.data(115, p);
const grid1 = EstForms.grid(d1);
return {
d1Count: d1.rows.length,
d2Count: d2.rows.length,
d117Count: d117.rows.length,
d115Count: d115.rows.length,
hasZeroRow: d1.rows.some(r => r.Code === "A02" && r.TU === 0),
hasNegativeRow: d1.rows.some(r => r.Code === "A03" && r.TC === -100000),
hasTitleRow: d1.rows.some(r => r.Name === "토공사" && r.Level === 1),
hasSubtotalRow: d1.rows.some(r => r.Name === "소계"),
noInvalidSumBottomTfoot: !grid1.includes("<tfoot>")
};
})()""",
)
assert result["d1Count"] == 5
assert result["d2Count"] == 2
assert result["d117Count"] == 5
assert result["d115Count"] == 2
assert result["hasZeroRow"] is True
assert result["hasNegativeRow"] is True
assert result["hasTitleRow"] is True
assert result["hasSubtotalRow"] is True
assert result["noInvalidSumBottomTfoot"] is True
+262
View File
@@ -0,0 +1,262 @@
from test_est_rea_parity import run
def test_estimate_changes_include_new_deleted_and_changed_link_types():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.tables.EstMainDat=[
{PkID:1,Code:'A',Name:'changed',LinkIndex:101,GN:2,TC:200},
{PkID:2,Code:'B',Name:'changed link',LinkIndex:5,GN:1,TC:50},
{PkID:4,Code:'D',Name:'new',LinkIndex:101,GN:1,TC:40}];
p.baseline={tables:{EstMainDat:[
{PkID:1,Code:'A',Name:'changed',LinkIndex:101,GN:1,TC:100},
{PkID:2,Code:'B',Name:'changed link',LinkIndex:101,GN:1,TC:50},
{PkID:3,Code:'C',Name:'deleted',LinkIndex:101,GN:3,TC:300},
{PkID:5,Code:'E',Name:'other',LinkIndex:5,GN:1,TC:10}]}};
return {rows:EstEstimate.changes(p),print:EstForms.data('estimate-change',p)};
})()""",
)
rows = {row["Code"]: row for row in result["rows"]}
assert set(rows) == {"A", "B", "C", "D"}
assert rows["A"]["difference"] == 100
assert rows["B"]["status"] == "변경"
assert rows["C"]["status"] == "삭제" and rows["C"]["index"] is None
assert rows["D"]["status"] == "신규"
assert result["print"]["rows"] == result["rows"]
def test_estimate_usage_reports_applied_and_source_prices_without_mutation():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.tables.EstiMateDat=[
{Code:'A',Name:'source',InputTC:12000,InputJC:12000},
{Code:'B',InputTC:5000,InputJC:5000},{Code:'B',InputTC:6000,InputJC:6000}
];
p.tables.EstMainDat=[
{Code:'A',Name:'work',LinkIndex:101,GN:2,TU:10000,JC:20000,TC:20000},
{Code:'B',Name:'duplicate',LinkIndex:101,GN:1,TU:4500,JC:4500,TC:4500},
{Code:'C',Name:'missing',LinkIndex:101,GN:1,TU:2000,JC:2000,TC:2000},
{Code:'A',Name:'material',LinkIndex:5,GN:1,TU:1000}
];
const before=JSON.stringify(p), rows=EstEstimate.usage(p), print=EstForms.data('estimate-usage',p);
return {rows,print,same:before===JSON.stringify(p)};
})()""",
)
assert result["same"]
assert [row["status"] for row in result["rows"]] == ["연결됨", "코드 중복", "연결 없음"]
assert result["rows"][0]["sourcePrice"] == 12000
assert result["rows"][0]["appliedPrice"] == 10000
assert result["rows"][0]["difference"] == -2000
assert result["rows"][1]["sourcePrice"] is None
assert result["print"]["rows"] == result["rows"]
def test_estimate_apply_row_and_all_prices():
result = run(
"math",
"""(()=>{
const p = EstNative.empty();
p.options.SourceRefresh = false;
p.options.AutoCalc = false;
p.tables.EstiMateDat = [
{ Code: 'A', Name: 'estimate A', InputTC: 15000, InputGC: 5000, InputNC: 7000, InputJC: 3000, InputSC: 0 },
{ Code: 'B', Name: 'estimate B', InputTC: 30000, InputJC: 30000 },
{ Code: 'D', Name: 'dup', InputTC: 5000 },
{ Code: 'D', Name: 'dup', InputTC: 6000 },
];
p.tables.EstMainDat = [
{ Code: 'A', Name: 'item A', LinkIndex: 101, GN: 2, TU: 10000, GU: 3000, NU: 5000, JU: 2000, GC: 6000, NC: 10000, JC: 4000, TC: 20000, SCheck: 1 },
{ Code: 'B', Name: 'item B', LinkIndex: 101, GN: 3, TU: 20000, JU: 20000, JC: 60000, TC: 60000 },
{ Code: 'C', Name: 'item C', LinkIndex: 101, GN: 1, TU: 5000, JU: 5000, JC: 5000, TC: 5000 },
{ Code: 'D', Name: 'item D', LinkIndex: 101, GN: 1, TU: 4000, JU: 4000, JC: 4000, TC: 4000 },
{ Code: 'OTHER', Name: 'other row', LinkIndex: 5, GN: 5, TU: 1000, GC: 5000, TC: 5000 },
];
const initialUsage = EstEstimate.usage(p);
const otherBefore = JSON.stringify(p.tables.EstMainDat[4]);
// 행 0(SCheck=1: 재료비 몰아주기 설정)에 단가 적용:
// SCheck=1이 보존되어 GU=15000, NU=0, JU=0, SU=0 및 GN=2 -> GC=30000, TC=30000
EstEstimate.applyRowPrice(p, 0);
const afterRow0 = {
row0_TU: p.tables.EstMainDat[0].TU,
row0_GU: p.tables.EstMainDat[0].GU,
row0_NU: p.tables.EstMainDat[0].NU,
row0_JU: p.tables.EstMainDat[0].JU,
row0_GC: p.tables.EstMainDat[0].GC,
row0_TC: p.tables.EstMainDat[0].TC,
row0_SCheck: p.tables.EstMainDat[0].SCheck,
otherPreserved: JSON.stringify(p.tables.EstMainDat[4]) === otherBefore,
sourceRefresh: p.options.SourceRefresh,
autoCalc: p.options.AutoCalc,
};
// SCheck를 0으로 초기화한 뒤 다시 적용하면 원천의 재료/노무/경비 비목 분할(5000/7000/3000)이 유지됨
p.tables.EstMainDat[0].SCheck = 0;
EstEstimate.applyRowPrice(p, 0);
const afterRow0_split = {
row0_TU: p.tables.EstMainDat[0].TU,
row0_GU: p.tables.EstMainDat[0].GU,
row0_NU: p.tables.EstMainDat[0].NU,
row0_JU: p.tables.EstMainDat[0].JU,
row0_GC: p.tables.EstMainDat[0].GC,
row0_NC: p.tables.EstMainDat[0].NC,
row0_JC: p.tables.EstMainDat[0].JC,
row0_TC: p.tables.EstMainDat[0].TC,
};
let errMissing = false;
try { EstEstimate.applyRowPrice(p, 2); } catch { errMissing = true; }
let errDup = false;
try { EstEstimate.applyRowPrice(p, 3); } catch { errDup = true; }
// 남은 대상인 행 1(수량 3)에 대해 applyAllPrices 적용
const appliedCount = EstEstimate.applyAllPrices(p);
const afterRow1 = {
row1_TU: p.tables.EstMainDat[1].TU,
row1_JU: p.tables.EstMainDat[1].JU,
row1_JC: p.tables.EstMainDat[1].JC,
row1_TC: p.tables.EstMainDat[1].TC,
};
// AutoCalc=true 전환 후 전체 재계산: 금액이 수량(GN)을 반영해 갱신됨
p.options.AutoCalc = true;
const calculated = EstNative.calculate(p);
const afterRecalc = {
row0_GC: calculated.tables.EstMainDat[0].GC,
row0_NC: calculated.tables.EstMainDat[0].NC,
row0_JC: calculated.tables.EstMainDat[0].JC,
row0_TC: calculated.tables.EstMainDat[0].TC,
row1_JC: calculated.tables.EstMainDat[1].JC,
row1_TC: calculated.tables.EstMainDat[1].TC,
};
return {
initialCanApply: initialUsage.filter(u => u.canApply).length,
afterRow0,
afterRow0_split,
errMissing,
errDup,
appliedCount,
afterRow1,
afterRecalc,
};
})()""",
)
assert result["initialCanApply"] == 2
# AutoCalc=false 시 단가는 GU=15000으로 갱신되나 기존 금액(GC=6000, TC=20000) 보존
assert result["afterRow0"]["row0_TU"] == 15000
assert result["afterRow0"]["row0_GU"] == 15000
assert result["afterRow0"]["row0_NU"] == 0
assert result["afterRow0"]["row0_JU"] == 0
assert result["afterRow0"]["row0_GC"] == 6000
assert result["afterRow0"]["row0_TC"] == 20000
assert result["afterRow0"]["row0_SCheck"] == 1
assert result["afterRow0"]["otherPreserved"] is True
assert result["afterRow0"]["sourceRefresh"] is False
assert result["afterRow0"]["autoCalc"] is False
# SCheck=0 시 원천 비목 분할(5000/7000/3000) 유지 및 기존 금액(GC=6000, NC=10000, JC=4000, TC=20000) 보존
assert result["afterRow0_split"]["row0_TU"] == 15000
assert result["afterRow0_split"]["row0_GU"] == 5000
assert result["afterRow0_split"]["row0_NU"] == 7000
assert result["afterRow0_split"]["row0_JU"] == 3000
assert result["afterRow0_split"]["row0_GC"] == 6000
assert result["afterRow0_split"]["row0_NC"] == 10000
assert result["afterRow0_split"]["row0_JC"] == 4000
assert result["afterRow0_split"]["row0_TC"] == 20000
assert result["errMissing"] is True
assert result["errDup"] is True
assert result["appliedCount"] == 1
# 행 1: 단가 30000 갱신, 수동계산 상태이므로 기존 금액(JC=60000, TC=60000) 보존
assert result["afterRow1"]["row1_TU"] == 30000
assert result["afterRow1"]["row1_JU"] == 30000
assert result["afterRow1"]["row1_JC"] == 60000
assert result["afterRow1"]["row1_TC"] == 60000
# AutoCalc=true 재계산 후 GN(수량)을 반영한 금액으로 정상 계산
assert result["afterRecalc"]["row0_GC"] == 10000
assert result["afterRecalc"]["row0_NC"] == 14000
assert result["afterRecalc"]["row0_JC"] == 6000
assert result["afterRecalc"]["row0_TC"] == 30000
assert result["afterRecalc"]["row1_JC"] == 90000
assert result["afterRecalc"]["row1_TC"] == 90000
def test_edge_estimate_usage_apply_price_and_undo_when_server_running():
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(channel="msedge", headless=True)
page = browser.new_page()
try:
page.goto("http://100.65.141.15:5175", timeout=5000)
page.wait_for_function("() => Boolean(EstApp.state.project)")
except Exception:
browser.close()
import pytest
pytest.skip("EST 5175 server not reachable")
# 1. 테스트 프로젝트 데이터 세팅 (견적단가 및 내역 행 주입)
page.evaluate(
"""(() => {
const p = EstNative.empty();
p.options.SourceRefresh = false;
p.tables.EstiMateDat = [
{ Code: 'E001', Name: '견적비목 1', Size: '규격A', Unite: '식', InputTC: 50000, InputJC: 50000 },
{ Code: 'E002', Name: '견적비목 2', Size: '규격B', Unite: '식', InputTC: 80000, InputJC: 80000 },
];
p.tables.EstMainDat = [
{ PkID: 201, Code: 'E001', Name: '견적비목 1', Size: '규격A', Unite: '식', LinkIndex: 101, GN: 10, TU: 40000, JU: 40000, JC: 400000, TC: 400000 },
{ PkID: 202, Code: 'E002', Name: '견적비목 2', Size: '규격B', Unite: '식', LinkIndex: 101, GN: 5, TU: 70000, JU: 70000, JC: 350000, TC: 350000 },
];
EstApp.setProject(p);
EstApp.state.view = 'estimate-usage';
EstApp.render();
})()"""
)
# 2. 견적단가 사용 내역 화면 확인
assert page.locator("#view-title:has-text('견적단가 사용 내역')").is_visible()
btn_apply_all = page.locator("button[data-estimate-apply-all]")
assert btn_apply_all.is_visible()
assert "기초단가 일괄 적용 (2건)" in btn_apply_all.text_content()
# 3. 개별 단가 적용 버튼 클릭 (1행 E001)
btn_row_apply = page.locator("button[data-estimate-apply-row='0']")
assert btn_row_apply.is_visible()
btn_row_apply.click()
# 1행이 50,000으로 갱신되었는지 확인
tu_row0 = page.evaluate("EstApp.state.project.tables.EstMainDat[0].TU")
tc_row0 = page.evaluate("EstApp.state.project.tables.EstMainDat[0].TC")
assert tu_row0 == 50000
assert tc_row0 == 500000
# 버튼 라벨이 (1건)으로 변경되었는지 확인
assert "기초단가 일괄 적용 (1건)" in btn_apply_all.text_content()
# 4. 일괄 적용 버튼 클릭 (남은 1건 E002 적용)
btn_apply_all.click()
tu_row1 = page.evaluate("EstApp.state.project.tables.EstMainDat[1].TU")
tc_row1 = page.evaluate("EstApp.state.project.tables.EstMainDat[1].TC")
assert tu_row1 == 80000
assert tc_row1 == 400000
# 이제 일괄 적용 버튼은 disabled 상태인지 확인
assert btn_apply_all.is_disabled()
# 5. Undo(실행 취소) 검증
page.locator(".toolbar button[data-action='undo']").click()
# 1단계 취소 후 2행은 원래대로 70,000 복구
assert page.evaluate("EstApp.state.project.tables.EstMainDat[1].TU") == 70000
page.locator(".toolbar button[data-action='undo']").click()
# 2단계 취소 후 1행도 원래대로 40,000 복구
assert page.evaluate("EstApp.state.project.tables.EstMainDat[0].TU") == 40000
browser.close()
+493
View File
@@ -26,6 +26,52 @@ def test_data_issues_detect_duplicate_missing_and_mismatched_links_without_modif
assert len(items) == 6
def test_data_issues_detect_cost_main_dat_missing_and_duplicate_links():
result = run(
"math",
"""(()=>{
const p = EstNative.empty();
p.tables.StuffcDat = [{Code:'M_GOOD', Name:'자재1'}, {Code:'M_DUP', Name:'자재2'}, {Code:'M_DUP', Name:'자재2-복사'}];
p.tables.CostMainDat = [
{Index:1, Code:'D001', Name:'단가산출호표1'},
{Index:0, Code:'M_GOOD', LinkIndex:5, LineData:'정상 자재행'},
{Index:0, Code:'M_MISSING', LinkIndex:5, LineData:'미연결 자재행'},
{Index:0, Code:'M_DUP', LinkIndex:5, LineData:'중복 자재행'}
];
const issues = EstIssues.scan(p);
return {
costIssues: issues.filter(x => x.table === 'CostMainDat')
};
})()""",
)
items = {(row["index"], row["code"], row["message"]) for row in result["costIssues"]}
assert (2, "M_MISSING", "연결 자료 없음") in items
assert (3, "M_DUP", "연결 코드 중복") in items
assert len(items) == 2
def test_data_issues_filtering_by_table_and_type():
result = run(
"math",
"""(()=>{
const p = EstNative.empty();
p.tables.StuffcDat = [{Code:'M_DUP', Name:'A'}, {Code:'M_DUP', Name:'B'}];
p.tables.EstMainDat = [{Code:'M_MISS', LinkIndex:5, Name:'없음'}];
const all = EstIssues.scan(p);
const stuffOnly = all.filter(i => (i.table || '') === 'StuffcDat');
const dupOnly = all.filter(i => i.message === '코드 중복');
return {
total: all.length,
stuffCount: stuffOnly.length,
dupCount: dupOnly.length
};
})()""",
)
assert result["total"] == 3
assert result["stuffCount"] == 2
assert result["dupCount"] == 2
def test_cost_error_location_requires_unique_group_and_valid_row():
result = run(
"math",
@@ -41,3 +87,450 @@ def test_cost_error_location_requires_unique_group_and_valid_row():
assert result[0]["table"] == "CostMainDat"
assert result[0]["index"] == 1
assert all(row["table"] == "" for row in result[1:])
def test_data_issues_detect_quantity_target_missing_and_duplicate():
result = run(
"math",
"""(()=>{
const p = EstNative.empty();
p.tables.EstMainDat = [
{ PkID: 101, Code: 'E1', Name: '단일 대상' },
{ PkID: 201, Code: 'DUP', Name: '중복 대상 1' },
{ PkID: 202, Code: 'DUP', Name: '중복 대상 2' }
];
p.quantitySheets = [
{
Code: 'Q1',
items: [
{ Code: 'r1', TargetCode: 'E1' },
{ Code: 'r2', TargetCode: 'MISSING' },
{ Code: 'r3', TargetCode: 'DUP' },
// 두 행이 코드 중복('DUP')이지만 TargetKey('id:201')로 고유하게 연결된 정상 사례
{ Code: 'r4', TargetCode: 'DUP', TargetKey: 'id:201' },
// LinkCode fallback으로 정상 연결된 사례
{ Code: 'r5', LinkCode: 'E1' },
// 잘못된 TargetKey 사례
{ Code: 'r6', TargetKey: 'id:999' }
]
}
];
p.quantities = [
{ Code: 'm1', TargetKey: 'id:202' },
{ Code: 'm2', TargetCode: 'MISSING_M' }
];
return EstIssues.scan(p).filter(x => x.table === 'quantity');
})()""",
)
items = {(row["code"], row["message"], row["sheetCode"]) for row in result}
# r1: 정상 (E1 단일 매칭) -> 오류 없음
# r4: 정상 (DUP이지만 TargetKey id:201 지정) -> 오류 없음
# r5: 정상 (LinkCode E1 지정) -> 오류 없음
# m1: 정상 (수식표 id:202 지정) -> 오류 없음
assert ("r2", "수량 대상 내역 없음: MISSING", "Q1") in items
assert ("r3", "수량 대상 내역 중복: DUP", "Q1") in items
assert ("r6", "수량 대상 내역 없음: id:999", "Q1") in items
assert ("m2", "수량 대상 내역 없음: MISSING_M", "manual") in items
assert len(items) == 4
def test_edge_issues_click_quantity_navigates_to_sheet():
from playwright.sync_api import sync_playwright
import pytest
with sync_playwright() as p:
browser = p.chromium.launch(channel="msedge", headless=True)
page = browser.new_page()
try:
page.goto("http://100.65.141.15:5175", timeout=5000)
except Exception:
pytest.skip("EST 5175 server not reachable")
page.wait_for_function("() => Boolean(EstApp.state.project)")
# 오류 검색 화면으로 전환 및 인위적 오류 수량산출서 주입
page.evaluate("""() => {
EstApp.change(p => {
p.quantitySheets ||= [];
p.quantitySheets.push({
Code: 'TEST_ERR_Q',
Name: '오류 테스트 산출서',
rows: [{ LineData: '1 = \\x120\\x12', markers: ['e_r1'] }],
items: [{ Code: 'e_r1', TargetCode: 'NON_EXISTENT_CODE_XYZ', GN: 10 }]
});
}, false);
EstApp.navigate('issues');
}""")
# 오류 검색 화면에서 '산출서 열기' 버튼 탐색 및 클릭
btn = page.locator("button[data-issue-quantity='TEST_ERR_Q']")
assert btn.count() >= 1
btn.first.click()
# 수량산출 화면으로 전환되었고 해당 산출서가 활성화되었는지 확인
page.wait_for_function("() => EstApp.state.view === 'quantity'")
current_view = page.evaluate("EstApp.state.view")
current_sheet = page.evaluate("EstApp.state.quantitySheet")
assert current_view == "quantity"
assert current_sheet == "TEST_ERR_Q"
# 다시 오류 검색 화면으로 돌아가서 바로 내역 연결 드롭다운 선택 시험
page.evaluate("""() => {
EstApp.navigate('issues');
}""")
page.wait_for_function("() => EstApp.state.view === 'issues'")
# select[data-issue-target-select='TEST_ERR_Q'] 요소 확인 및 옵션 선택
select = page.locator("select[data-issue-target-select='TEST_ERR_Q']")
assert select.count() >= 1
# 첫 번째 유효 내역 행의 value를 선택
options = select.locator("option")
assert options.count() > 1
target_val = options.nth(1).get_attribute("value")
select.select_option(target_val)
# 상태 변경이 작업본(project)에 즉시 반영되었는지 확인
linked_key = page.evaluate("""() => {
const sheet = EstApp.state.project.quantitySheets.find(s => s.Code === 'TEST_ERR_Q');
return sheet ? sheet.items[0].TargetKey : null;
}""")
assert linked_key == target_val
browser.close()
def test_data_issues_detect_wonga_formula_syntax_and_missing_reference_errors():
result = run(
"math",
"""(()=>{
const p = structuredClone(project);
const normalIssues = EstIssues.scan(p).filter(x => x.table === 'Wonga');
// 원가 계산서 항목에 잘못된 참조 및 잘못된 범위 산식 주입
const badP = structuredClone(project);
badP.tables.Wonga[5].VSusic = '4 * 0.165 + UNKNOWN_VAR';
badP.tables.Wonga[21].VSusic = '(6:INVALID_RANGE_END)';
const badIssues = EstIssues.scan(badP).filter(x => x.table === 'Wonga');
return {
normalCount: normalIssues.length,
badIssues
};
})()""",
)
assert result["normalCount"] == 0
items = {(row["index"], row["code"], row["message"]) for row in result["badIssues"]}
assert (5, "5", "참조 항목 없음: UNKNOWN_VAR") in items
assert (21, "C", "범위 참조 없음: 6:INVALID_RANGE_END") in items
assert len(items) == 2
def test_wonga_scan_handles_division_zero_values_cycles_ranges_and_basefield():
result = run(
"math",
"""(()=>{
// 1) 실제 값이 다른 A, B의 유효한 1/(A-B) 나눗셈 (scan에서 1로 단순 치환하지 않고 실제 값으로 평가해 0으로 나눔 오탐 없음)
const w1 = [{Gubun:'A',Cost:10},{Gubun:'B',Cost:5},{Gubun:'C',VSusic:'1/(A-B)'}];
const sc1 = EstWonga.scan(w1);
// 2) 실제 0값 참조 계산 (A가 0이고 A + 10 정상 평가)
const w2 = [{Gubun:'A',Cost:0},{Gubun:'B',VSusic:'A + 10'}];
const sc2 = EstWonga.scan(w2);
// 3) 상호 순환 참조 감지 (A -> B -> A)
const w3 = [{Gubun:'A',VSusic:'B'},{Gubun:'B',VSusic:'A'}];
const sc3 = EstWonga.scan(w3);
// 4) BaseField 우선: VSusic에 유효하지 않은 식이어도 BaseField가 우선 적용되어 오류 없음
const w4 = [{Gubun:'A',BaseField:'NC',VSusic:'INVALID_IF_EVALUATED',Cost:0}];
const sc4 = EstWonga.scan(w4, {}, {NC: 500});
// 5) 정상 범위 참조 (A:B 역순/정순 포함)
const w5 = [{Gubun:'B',Cost:10},{Gubun:'A',Cost:20},{Gubun:'C',VSusic:'(A:B)'}];
const sc5 = EstWonga.scan(w5);
// 6) 유효하지 않은 범위 참조
const w6 = [{Gubun:'A',Cost:10},{Gubun:'B',VSusic:'(A:INVALID)'}];
const sc6 = EstWonga.scan(w6);
return {
sc1, sc2, sc3, sc4, sc5, sc6
};
})()""",
)
# 1) A!=B 나눗셈: 오탐 0건
assert result["sc1"] == []
# 2) 0값 참조: 오탐 0건
assert result["sc2"] == []
# 3) 순환 참조: calculate와 동일하게 순환 탐지
assert any("원가 수식 순환 참조" in item["message"] for item in result["sc3"])
assert len(result["sc3"]) >= 1
# 4) BaseField 우선: 수식 무시되고 기본 비목 매핑되어 오탐 0건
assert result["sc4"] == []
# 5) 정상 범위 (A:B): 정상 합산되어 오탐 0건
assert result["sc5"] == []
# 6) 비정상 범위: 오류 정확 탐지
assert any("범위 참조 없음: A:INVALID" in item["message"] for item in result["sc6"])
assert len(result["sc6"]) == 1
def test_wonga_scan_parity_with_calculate_forward_refs_old_cost_bases_ranges_and_cycles():
result = run(
"math",
"""(()=>{
// 1) 전방 참조 및 오래된 Cost:
// B의 수식이 'A*2'로 실제 값은 20이지만 저장된 Cost=0 상태.
// C가 '100/B'를 계산할 때, 오래된 Cost(0)를 쓰면 0으로 나눔 오류이지만,
// calculate와 동일하게 재귀 평가 시 B=20이 되어 C=5 정상 계산되고 오탐 없어야 함.
const w1 = [
{Gubun:'B',Cost:0,VSusic:'A*2'},
{Gubun:'A',Cost:10},
{Gubun:'C',VSusic:'100/B'}
];
const calc1 = EstWonga.calculate(w1);
const scan1 = EstWonga.scan(w1);
// 2) 기초 비목 0 및 양수 전달:
// NC=1000이 전달되면 BaseField: 'NC' 또는 식 내 'NC' 참조가 정상 작동.
// scan에서 bases가 전달되지 않아 0으로 판정되는 문제가 없어야 함.
const w2 = [
{Gubun:'A',BaseField:'NC',Cost:0},
{Gubun:'B',VSusic:'NC * 0.1'},
{Gubun:'C',VSusic:'1000 / B'}
];
const bases = { NC: 1000, 직접공사비: 1000 };
const calc2 = EstWonga.calculate(w2, {}, bases);
const scan2 = EstWonga.scan(w2, {}, bases);
// 3) 구간 제외 행(CalcIndex: 2 또는 3):
// 범위 (A:B)에서 A가 CalcIndex: 2(제외)인 경우 합산에서 제외되나,
// 순환 참조 및 범위 유효성 규칙은 calculate와 일치.
const w3 = [
{Gubun:'A',Cost:10,CalcIndex:2},
{Gubun:'B',Cost:20,CalcIndex:0},
{Gubun:'C',VSusic:'(A:B)'}
];
const calc3 = EstWonga.calculate(w3);
const scan3 = EstWonga.scan(w3);
// 4) 순환 참조 일치성:
// calculate에서 발생하는 순환 오류와 scan의 오류 메시지가 동일
const w4 = [
{Gubun:'X',VSusic:'Y'},
{Gubun:'Y',VSusic:'X'}
];
const calc4 = EstWonga.calculate(w4);
const scan4 = EstWonga.scan(w4);
// 5) issues.scan(project)에서 project.calculation.direct 기초금액 전달 검증
const project = EstNative.empty();
project.tables.Wonga = [
{Gubun:'M',BaseField:'NC',Cost:0},
{Gubun:'N',VSusic:'NC * 0.5'},
{Gubun:'O',VSusic:'500 / N'}
];
project.calculation = {
direct: { NC: 1000, GC: 0, JC: 0, SC: 0, TC: 1000 }
};
const projectIssues = EstIssues.scan(project).filter(i => i.table === 'Wonga');
return {
calc1Errors: calc1.errors,
scan1,
calc1CostB: calc1.rows[0].Cost,
calc1CostC: calc1.rows[2].Cost,
calc2Errors: calc2.errors,
scan2,
calc3Errors: calc3.errors,
scan3,
calc3CostC: calc3.rows[2].Cost,
calc4Errors: calc4.errors,
scan4,
projectIssues
};
})()""",
)
# 1) 전방 참조 & 오래된 Cost(0): calculate와 scan 모두 오류 0건, 정확한 값 평가
assert result["calc1Errors"] == []
assert result["scan1"] == []
assert result["calc1CostB"] == 20
assert result["calc1CostC"] == 5
# 2) 기초 비목 양수 전달: bases 전달로 0 나눔 없이 정상 평가
assert result["calc2Errors"] == []
assert result["scan2"] == []
# 3) 구간 제외 행(CalcIndex: 2): A=10 제외, B=20만 합산되어 C=20, scan 오류 0건
assert result["calc3Errors"] == []
assert result["scan3"] == []
assert result["calc3CostC"] == 20
# 4) 순환 참조: calculate와 scan 메시지 일치
assert "원가 수식 순환 참조: X" in result["calc4Errors"][0]
assert any("원가 수식 순환 참조" in item["message"] for item in result["scan4"])
# 5) project.calculation.direct를 통한 issues.scan 연동: 오탐 0건
assert result["projectIssues"] == []
def test_data_issues_detect_unite_mismatch_and_sync_source_names():
result = run(
"math",
"""(()=>{
const p = EstNative.empty();
p.tables.LaborcDat = [
{ Code: "L001", Name: "보통인부", Size: "공통", Unite: "인" },
{ Code: "L002", Name: "특별인부", Size: "숙련(원천)", Unite: "인" },
{ Code: "L003", Name: "용접공", Size: "1급", Unite: "인" }
];
p.tables.EstMainDat = [
// 1) 품명 불일치 (원천 규격, 단위와 동일)
{ Code: "L001", LinkIndex: 6, Name: "보통 인부(오타)", Size: "공통", Unite: "인" },
// 2) 규격 불일치
{ Code: "L002", LinkIndex: 6, Name: "특별인부", Size: "숙련(내역)", Unite: "인" },
// 3) 단위 불일치 (일괄 반영 대상에서 제외되어야 함)
{ Code: "L003", LinkIndex: 6, Name: "용접공", Size: "1급", Unite: "시간" }
];
const beforeScan = EstIssues.scan(p);
const nameIssue = beforeScan.find(i => i.message === "연결 품명 차이");
const sizeIssue = beforeScan.find(i => i.code === "L002" && i.message === "연결 규격 차이");
const uniteIssue = beforeScan.find(i => i.message === "연결 단위 차이");
return {
hasNameIssue: !!nameIssue,
hasSizeIssue: !!sizeIssue,
hasUniteIssue: !!uniteIssue,
nameSource: nameIssue?.sourceName,
sizeSource: sizeIssue?.sourceSize,
uniteSource: uniteIssue?.sourceUnite
};
})()""",
)
assert result["hasNameIssue"] is True
assert result["hasSizeIssue"] is True
assert result["hasUniteIssue"] is True
assert result["nameSource"] == "보통인부"
assert result["sizeSource"] == "숙련(원천)"
assert result["uniteSource"] == "인"
def test_data_issues_selective_sync_preserves_unit_and_non_empty_size():
from playwright.sync_api import sync_playwright
import pytest
with sync_playwright() as p:
browser = p.chromium.launch(channel="msedge", headless=True)
page = browser.new_page()
try:
page.goto("http://100.65.141.15:5175", timeout=5000)
except Exception:
pytest.skip("EST 5175 server not reachable")
page.wait_for_selector("body")
page.wait_for_function("() => EstApp && EstApp.state && EstApp.state.project !== null")
# 테스트용 데이터 주입
page.evaluate("""() => {
EstApp.change(p => {
p.tables.LaborcDat ||= [];
p.tables.LaborcDat.push(
{ Code: "TEST_L1", Name: "원천 보통인부", Size: "원천 규격A", Unite: "인" },
{ Code: "TEST_L2", Name: "원천 특별인부", Size: "원천 규격B(신규)", Unite: "인" },
{ Code: "TEST_L3", Name: "원천 용접공", Size: "원천 규격C", Unite: "인" }
);
p.tables.EstMainDat ||= [];
p.tables.EstMainDat.push(
// 1) 품명만 다름 (내역 규격은 동일하므로 품명만 동기화)
{ Code: "TEST_L1", LinkIndex: 6, Name: "내역 보통인부(오타)", Size: "원천 규격A", Unite: "인" },
// 2) 규격 차이 (원천 규격으로 동기화)
{ Code: "TEST_L2", LinkIndex: 6, Name: "원천 특별인부", Size: "내역 구규격", Unite: "인" },
// 3) 단위 차이 (일괄 반영 대상에서 제외되어야 함)
{ Code: "TEST_L3", LinkIndex: 6, Name: "원천 용접공", Size: "원천 규격C", Unite: "시간" }
);
}, false);
EstApp.navigate('issues');
}""")
page.wait_for_function("() => EstApp.state.view === 'issues'")
# 1) '불일치 원천 명칭/규격 일괄 반영' 버튼 클릭
sync_all_btn = page.locator("button[data-issue-sync-all='1']")
assert sync_all_btn.count() >= 1
sync_all_btn.click()
# 일괄 반영 후 결과 점검
state1 = page.evaluate("""() => {
const est = EstApp.state.project.tables.EstMainDat;
const r1 = est.find(r => r.Code === "TEST_L1");
const r2 = est.find(r => r.Code === "TEST_L2");
const r3 = est.find(r => r.Code === "TEST_L3");
return {
r1: { Name: r1.Name, Size: r1.Size, Unite: r1.Unite },
r2: { Name: r2.Name, Size: r2.Size, Unite: r2.Unite },
r3: { Name: r3.Name, Size: r3.Size, Unite: r3.Unite }
};
}""")
# TEST_L1: 품명은 원천으로 동기화, 규격/단위는 일치 유지
assert state1["r1"]["Name"] == "원천 보통인부"
assert state1["r1"]["Size"] == "원천 규격A"
assert state1["r1"]["Unite"] == "인"
# TEST_L2: 유효한 원천 규격으로 동기화됨
assert state1["r2"]["Size"] == "원천 규격B(신규)"
# TEST_L3: 단위 불일치는 일괄 반영에서 제외되어 "시간" 그대로 유지됨
assert state1["r3"]["Unite"] == "시간"
# 2) TEST_L3의 '원천단위 반영' 개별 버튼 명시적 클릭 확인
sync_unit_btn = page.locator("button[data-issue-sync-msg='연결 단위 차이']")
assert sync_unit_btn.count() >= 1
assert "원천단위 반영" in sync_unit_btn.first.inner_text()
sync_unit_btn.first.click()
# 개별 반영 후 단위가 동기화되었는지 확인
r3_unite_after = page.evaluate("""() => {
const est = EstApp.state.project.tables.EstMainDat;
const r3 = est.find(r => r.Code === "TEST_L3");
return r3.Unite;
}""")
assert r3_unite_after == "인"
# 3) 실행 취소(Undo)로 이전 복원 검증
page.evaluate("EstActions.undo()")
r3_unite_undone = page.evaluate("""() => {
const est = EstApp.state.project.tables.EstMainDat;
const r3 = est.find(r => r.Code === "TEST_L3");
return r3.Unite;
}""")
assert r3_unite_undone == "시간"
browser.close()
def test_data_issues_detect_percentage_unit_mismatch():
result = run(
"math",
"""(()=>{
const p = structuredClone(project);
p.tables.HeadMainDat.push(
{ Index: 0, Level: 0, LinkIndex: 58, Code: "R1", Name: "비율항목1", Unite: "식", GN: 5 },
{ Index: 0, Level: 0, LinkIndex: 62, Code: "R2", Name: "구간비율2", Unite: "M", GN: 10 },
{ Index: 0, Level: 0, LinkIndex: 52, Code: "R3", Name: "낙찰률3", Unite: "%", GN: 87.745 },
{ Index: 0, Level: 0, LinkIndex: 67, Code: "R4", Name: "제잡비4", Unite: "%", GN: 3 }
);
p.tables.EstMainDat.push(
{ Level: 0, LinkIndex: 57, Code: "R5", Name: "조달수수료5", Unite: "원", GN: 1 }
);
const issues = EstIssues.scan(p).filter(x => x.message === "단위 백분율(%) 불일치");
return issues.map(x => ({ table: x.table, code: x.code, message: x.message, rowUnite: x.rowUnite }));
})()""",
)
assert len(result) == 3
items = {(x["table"], x["code"], x["rowUnite"]) for x in result}
assert ("HeadMainDat", "R1", "식") in items
assert ("HeadMainDat", "R2", "M") in items
assert ("EstMainDat", "R5", "원") in items
+87 -10
View File
@@ -96,7 +96,7 @@ def test_estx_quantity_item_labels_round_trip_when_sample_exists():
pytest.skip("installed EST sample unavailable")
script = """const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs');
const p=m.importEstx(fs.readFileSync(process.argv[1])),before=structuredClone(p.quantitySheets),sheet=p.quantitySheets[0];
sheet.items[0].Name='수정된 품명';sheet.items[0].Size='수정 규격';
sheet.items[0].Name='수정된 품명';sheet.items[0].Size='수정 규격';sheet.items[0].Unite='m2';
const mismatch=p.quantitySheets.find(s=>s.Code==='N0000000083');mismatch.items[4].Name='수량 보존';
const q=m.importEstx(e.exportEstx(p)),item=q.quantitySheets[0].items[0];
const restored=q.quantitySheets.find(s=>s.Code==='N0000000083');
@@ -104,7 +104,7 @@ def test_estx_quantity_item_labels_round_trip_when_sample_exists():
const rest=JSON.stringify(q.quantitySheets.filter(s=>s.Code!==sheet.Code && s.Code!==mismatch.Code))===JSON.stringify(before.filter(s=>s.Code!==sheet.Code && s.Code!==mismatch.Code));
const preserved=restored.items[4].Name==='수량 보존' && restored.items[4].GN===286 && JSON.stringify(restored.rows)===JSON.stringify(before.find(s=>s.Code===mismatch.Code).rows);
p.quantitySheets[0].items[0].TargetCode='new target';let rejected=false;try{e.exportEstx(p)}catch{rejected=true}
console.log(JSON.stringify({name:item.Name,size:item.Size,other,rest,preserved,rejected}));"""
console.log(JSON.stringify({name:item.Name,size:item.Size,unite:item.Unite,other,rest,preserved,rejected}));"""
result = json.loads(
subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT, timeout=45).decode(
"utf-8"
@@ -113,6 +113,7 @@ def test_estx_quantity_item_labels_round_trip_when_sample_exists():
assert result == {
"name": "수정된 품명",
"size": "수정 규격",
"unite": "m2",
"other": True,
"rest": True,
"preserved": True,
@@ -156,6 +157,18 @@ def test_quantity_document_edit_across_character_styles_preserves_tags():
assert result == {"same": True, "tags": True}
def test_quantity_document_edit_across_styles_and_superscript_preserves_structure():
script = r"""const q=require('./tools/est-test/native-quantity.cjs'),w=require('./tools/est-test/export-quantity.cjs');
const doc='<cs nr=1 CharStyleON:16/><cs nr=2/><div><c nr=2/>abc<c nr=1/>2<c nr=2/>def</div>';
const old=q.fromWpt('A','문서',doc,[],{}),edited=structuredClone(old);
edited.rows[0].LineData='abc^(3)xyz';
const next=w.editDocument(doc,old,edited),again=q.fromWpt('A','문서',next,[],{});
process.stdout.write(JSON.stringify({same:JSON.stringify(again.rows)===JSON.stringify(edited.rows),doc:next}));"""
result = json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT).decode("utf-8"))
assert result["same"] is True
assert "<c nr=1/>3<c nr=2/>xyz" in result["doc"]
def test_quantity_document_edit_across_styles_and_separate_marker():
script = r"""const q=require('./tools/est-test/native-quantity.cjs'),w=require('./tools/est-test/export-quantity.cjs');
const doc='<cs nr=1/><cs nr=2/><div><c nr=1/>abc<c nr=2/>def<begin Tag=2 wpsty=[[Name:&#34;r000001&#34;;]]/>6<end Tag=2/></div>';
@@ -253,21 +266,35 @@ def test_estx_options_and_expanded_blob_preserve_unrelated_data_when_sample_exis
def test_recalculation_and_native_export_keep_original_title_identities_when_sample_exists():
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/sample.estx")
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
if not sample.exists():
sample = Path("resources/knowledge/original/원가계산/다산소프트/2026-07-main.estx")
if not sample.exists():
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/sample.estx")
if not sample.exists():
pytest.skip("installed EST sample unavailable")
script = """const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs'),s=require('./tools/est-test/server.cjs');
const bytes=fs.readFileSync(process.argv[1]),p=m.importEstx(bytes),q=s.calculate(p);
const ids=['HeadTitleDat','CostTitleDat','MachTitleDat'].every(key=>p.tables[key].every((r,i)=>r.PkID===q.tables[key][i].PkID));
const row=q.tables.EstMainDat.find(r=>r.GN&&r.TC);row.GN+=1;
const edited=s.calculate(q),out=e.exportEstx(edited),again=m.importEstx(out),original=m.unzip(bytes),files=m.unzip(out);
const bytes=fs.readFileSync(process.argv[1]),p=m.importEstx(bytes);
const hasQuantity=Boolean(p.quantitySheets?.[0]?.items?.[0]);
if (hasQuantity) p.quantitySheets[0].items[0].Unite = 'm2';
const q=s.calculate(p);
const memoKeys=['DifineMemo','SpaceMemo','EscData'];
const preserved=['HeadTitleDat','CostTitleDat','MachTitleDat'].every(key=>
p.tables[key].every((r,i)=>{
const target=q.tables[key][i];
return r.PkID===target.PkID && r.Index===target.Index && r.RecNo===target.RecNo && r.Flag===target.Flag &&
memoKeys.every(k=>JSON.stringify(r[k])===JSON.stringify(target[k]));
})
);
const out=e.exportEstx(q),again=m.importEstx(out),original=m.unzip(bytes),files=m.unzip(out);
const indexes=[...original].filter(([name])=>name.endsWith('.idx')).every(([name,value])=>value.equals(files.get(name)));
process.stdout.write(JSON.stringify({ids,indexes,gn:again.tables.EstMainDat.find(r=>r.PkID===row.PkID).GN,expected:row.GN}));"""
const reimportedUnite=hasQuantity ? again.quantitySheets?.[0]?.items?.[0]?.Unite : 'm2';
process.stdout.write(JSON.stringify({preserved,indexes,reimportedUnite}));"""
result = json.loads(
subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT).decode("utf-8")
)
assert result["ids"] and result["indexes"]
assert result["gn"] == result["expected"]
assert result["preserved"] and result["indexes"]
assert result["reimportedUnite"] == "m2"
def fixture(path):
@@ -325,3 +352,53 @@ def test_estx_patches_values_and_preserves_indexes_drawings_and_rejects_structur
assert result["same"] and result["blocked"] and result["index"] and result["drawing"]
assert result["row"]["Name"] == "변경"
assert result["row"]["GN"] == 3 and result["row"]["TC"] == 300
def test_estx_export_schedule_and_documents_safety_guards(tmp_path):
source = tmp_path / "native.estx"
fixture(source)
script = """const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs');
const b=fs.readFileSync(process.argv[1]);
// 1) 원본 그대로(빈 schedule/빈 documents/null 등)는 정상 통과
const p1 = m.importEstx(b);
p1.schedule = [];
p1.documents = {};
const same1 = e.exportEstx(p1).equals(b);
// 2) schedule 항목 추가 시 차단 검증
let blockedSchedule = false, schedErrMsg = '';
const p2 = m.importEstx(b);
p2.schedule = [{id:'1', Code:'A', Name:'새공정', start:'2026-01-01', end:'2026-01-05', weight:100, progress:10}];
try { e.exportEstx(p2); } catch (err) { blockedSchedule = true; schedErrMsg = err.message; }
// 2-1) 빈 문자열 또는 0값만 있는 schedule 항목 추가 시도 차단 검증
let blockedEmptySchedule = false;
const p2Empty = m.importEstx(b);
p2Empty.schedule = [{id:'2', Code:'', Name:'', start:'', end:'', weight:0, progress:0}];
try { e.exportEstx(p2Empty); } catch (err) { blockedEmptySchedule = true; }
// 3) documents 텍스트 입력 시 차단 검증
let blockedDocs = false, docErrMsg = '';
const p3 = m.importEstx(b);
p3.documents = {'173': '심사사유'};
try { e.exportEstx(p3); } catch (err) { blockedDocs = true; docErrMsg = err.message; }
// 3-1) documents 공백(" ") 입력 시도 사용자 데이터 보존을 위해 차단 검증
let blockedSpaceDocs = false;
const p3Space = m.importEstx(b);
p3Space.documents = {'173': ' '};
try { e.exportEstx(p3Space); } catch (err) { blockedSpaceDocs = true; }
process.stdout.write(JSON.stringify({same1, blockedSchedule, schedErrMsg, blockedEmptySchedule, blockedDocs, docErrMsg, blockedSpaceDocs}));"""
result = json.loads(
subprocess.check_output(["node", "-e", script, str(source)], cwd=ROOT).decode("utf-8")
)
assert result["same1"] is True
assert result["blockedSchedule"] is True
assert "예정공정표" in result["schedErrMsg"] and "JSON" in result["schedErrMsg"]
assert result["blockedEmptySchedule"] is True
assert result["blockedDocs"] is True
assert "서식 문서" in result["docErrMsg"] and "JSON" in result["docErrMsg"]
assert result["blockedSpaceDocs"] is True
assert "서식 문서" in result["docErrMsg"] and "JSON" in result["docErrMsg"]
+16 -1
View File
@@ -141,8 +141,18 @@ def test_create_edit_apply_and_delete_quantity_sheet():
click('[data-quantity-new]');click('[data-quantity-add]');
change({quantityName:''},'Earthwork');
change({quantityItem:'Name',index:'0'},'excavation');
change({quantityItem:'Unite',index:'0'},'m3');
change({quantityTarget:'0'},'X');
change({quantityLine:'0'},'2*3 = ⟦결과⟧0⟦결과⟧');
click('[data-quantity-add-item]');
const withNewItem = state.project.quantitySheets[0].items.length;
// 참조 중인 0번 항목 삭제 시도 -> 차단 및 오류 메시지 설정 확인
click('[data-quantity-delete-item]', { quantityDeleteItem: '0' });
const noticeBlocked = state.notice;
const blockedCount = state.project.quantitySheets[0].items.length;
// 미참조 항목인 1번 항목 삭제 시도 -> 정상 삭제
click('[data-quantity-delete-item]', { quantityDeleteItem: '1' });
const afterItemDeleted = state.project.quantitySheets[0].items.length;
const saved=JSON.parse(JSON.stringify(state.project));
click('[data-quantity-apply]');
const applied=state.project.tables.EstMainDat[0].GN;
@@ -151,11 +161,16 @@ def test_create_edit_apply_and_delete_quantity_sheet():
click('[data-quantity-delete-row]',{quantityDeleteRow:'0'});
const empty=[sheet.rows.length,sheet.items.length];
click('[data-quantity-delete-sheet]');
return {saved,applied,computed,empty,remaining:state.project.quantitySheets.length,selection:state.quantitySheet};
return {saved,applied,computed,empty,withNewItem,blockedCount,afterItemDeleted,noticeBlocked,remaining:state.project.quantitySheets.length,selection:state.quantitySheet};
})()""",
)
assert result["withNewItem"] == 2
assert result["blockedCount"] == 2
assert "연결된 산출 행을 삭제하거나" in result["noticeBlocked"]
assert result["afterItemDeleted"] == 1
assert result["saved"]["quantitySheets"][0]["Name"] == "Earthwork"
assert result["saved"]["quantitySheets"][0]["items"][0]["Name"] == "excavation"
assert result["saved"]["quantitySheets"][0]["items"][0]["Unite"] == "m3"
assert result["computed"]["errors"] == []
assert result["computed"]["items"][0]["GN"] == 6
assert result["applied"] == 6
@@ -125,3 +125,86 @@ def test_fixed_review_preserves_amount_for_reason_or_quantity_only():
})()""",
)
assert result == [100, 100, 20]
def test_review_view_filtering_and_batch_reason_application():
result = run(
"math",
"""(()=>{
const handlers={},p=EstNative.empty();
p.tables.EstMainDat=[
{PkID:1,Code:'A01',Name:'Item1',GN:10,GU:100,TC:1000},
{PkID:2,Code:'A02',Name:'Item2',GN:5,GU:200,TC:1000},
{PkID:3,Code:'A03',Name:'Item3',GN:2,GU:300,TC:600}
];
p.contractReview={
edits:{
'row:1':{GN:8,Reason:'수량조정'},
'row:2':{GU:180}
}
};
let html='';
const domNodes = [
{dataset:{key:'row:1',review:'Reason'}},
{dataset:{key:'row:2',review:'Reason'}},
{dataset:{key:'row:3',review:'Reason'}}
];
globalThis.document={
addEventListener:(name,fn)=>handlers[name]=fn,
querySelectorAll:(sel)=>domNodes
};
globalThis.prompt=(msg)=>'일괄사유적용';
globalThis.EstApp={
state:{project:p,reviewFilter:'all',reviewSelection:new Set()},
change:fn=>fn(),
render:()=>{},
byId:()=>({innerHTML:h=>{html=h;},set innerHTML(h){html=h;}}),
esc:s=>String(s||''),
number:n=>String(n||0)
};
EstReview.render(EstApp.state);
const allRowsCount = (html.match(/<tr class=/g) || []).length;
// 1) changed 필터
EstApp.state.reviewFilter = 'changed';
EstReview.render(EstApp.state);
const changedRowsCount = (html.match(/<tr class=/g) || []).length;
// 2) has-reason 필터
EstApp.state.reviewFilter = 'has-reason';
EstReview.render(EstApp.state);
const hasReasonRowsCount = (html.match(/<tr class=/g) || []).length;
// 3) no-reason 필터
EstApp.state.reviewFilter = 'no-reason';
EstReview.render(EstApp.state);
const noReasonRowsCount = (html.match(/<tr class=/g) || []).length;
// 4) 일괄 사유 적용 (선택 행 있을 때)
EstReview.install();
EstApp.state.reviewSelection.add('row:2');
const clickBatch = () => handlers.click({target:{closest:s=>s==='[data-review-batch-reason]'?{}:null}});
clickBatch();
const afterSelectBatch = p.contractReview.edits['row:2'].Reason;
// 5) 일괄 사유 적용 (선택 행 없을 때 표시 행 전체 적용)
EstApp.state.reviewSelection.clear();
clickBatch();
const afterAllBatch = p.contractReview.edits['row:3']?.Reason;
return {
allRowsCount,
changedRowsCount,
hasReasonRowsCount,
noReasonRowsCount,
afterSelectBatch,
afterAllBatch
};
})()""",
)
assert result["allRowsCount"] == 3
assert result["changedRowsCount"] == 2
assert result["hasReasonRowsCount"] == 1
assert result["noReasonRowsCount"] == 1
assert result["afterSelectBatch"] == "일괄사유적용"
assert result["afterAllBatch"] == "일괄사유적용"
+728
View File
@@ -0,0 +1,728 @@
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def run_js(code: str) -> dict:
runner = f"""
const fs = require('fs');
const vm = require('vm');
const ctx = {{
console,
setTimeout,
clearTimeout,
structuredClone,
URL,
TextDecoder,
TextEncoder,
Buffer,
document: {{
querySelector: () => ({{ setCustomValidity: () => {{}}, reportValidity: () => true }}),
body: {{ append: () => {{}} }},
createElement: () => ({{ click: () => {{}} }})
}},
window: {{}},
confirm: () => true
}};
ctx.window = ctx;
vm.createContext(ctx);
const files = [
'tools/est-test/native-math.js',
'tools/est-test/native-model.js',
'tools/est-test/native-text.js',
'tools/est-test/native-machine.js',
'tools/est-test/native-wonga.js',
'tools/est-test/native-engine.js',
'tools/est-test/native-rows.js',
'tools/est-test/native-business.js',
'tools/est-test/native-extra.js',
'tools/est-test/native-block-actions.js',
'tools/est-test/native-business-actions.js'
];
for (const f of files) {{
const content = fs.readFileSync(f, 'utf8');
vm.runInContext(content, ctx);
}}
const userCode = `{code}`;
const promise = vm.runInContext(userCode, ctx);
Promise.resolve(promise).then(result => {{
process.stdout.write(JSON.stringify(result));
}}).catch(err => {{
console.error(err);
process.exit(1);
}});
"""
out = subprocess.check_output(["node", "-e", runner], cwd=ROOT)
return json.loads(out.decode("utf-8"))
def test_toggle_jc_bimog_and_sun_gongsa():
code = """(async () => {
// 1. 프로젝트 초기 구성
const p = EstNative.empty();
p.tables.StuffcDat = [
{ Code: "M01", Name: "자재1", TU: 1000, GU: 1000, Cost1: 1000, ValueIndex: 0, InputTC: 1000, TC: 1000 }
];
p.tables.LaborcDat = [
{ Code: "L01", Name: "노임1", TU: 2000, NU: 2000, Cost1: 2000, ValueIndex: 0, InputTC: 2000, TC: 2000 }
];
p.tables.EstMainDat = [
{ PkID: 1, Code: "M01", Name: "자재1", LinkIndex: 5, GN: 2, TU: 1000, GU: 1000, GC: 2000, TC: 2000, SCheck: 0 },
{ PkID: 2, Code: "L01", Name: "노임1", LinkIndex: 6, GN: 3, TU: 2000, NU: 2000, NC: 6000, TC: 6000, SCheck: 0 },
{ PkID: 3, Name: "공종", LinkIndex: 0, GN: 0, TC: 0 },
{ PkID: 4, Name: "순공사비", LinkIndex: 54, GN: 0, TC: 8000, _origLinkIndex: 0, _origName: "공종" }
];
// 가상 EstApp 환경
const state = {
project: p,
view: "EstMainDat",
selection: new Set([0])
};
const EstApp = {
state,
change(fn) { fn(); }
};
// 1) toggle-jc-bimog 실행: 자재1 행 SCheck=0 -> 3 (경비성단가 지정)
// GU=1000 -> JU=1000, GC=0, JC=2000, SCheck=3
let stateCopy = state;
globalThis.EstApp = EstApp;
globalThis.EstActions = { form: () => {} };
// 행 0 토글 -> SCheck 3
EstBusinessActions.handle("toggle-jc-bimog");
const r0_afterToggle = { ...p.tables.EstMainDat[0] };
// 행 0 재토글 -> SCheck 0 복원
EstBusinessActions.handle("toggle-jc-bimog");
const r0_restored = { ...p.tables.EstMainDat[0] };
// 행 1 (노임1, SCheck=2로 사전 설정) 경비성 토글 및 원래 SCheck=2 복원 확인
p.tables.EstMainDat[1].SCheck = 2;
state.selection = new Set([1]);
EstBusinessActions.handle("toggle-jc-bimog");
const r1_afterToggle = { ...p.tables.EstMainDat[1] };
EstBusinessActions.handle("toggle-jc-bimog");
const r1_restored = { ...p.tables.EstMainDat[1] };
// 2) toggle-sun-gongsa 실행:
// 행 2(기존공종)을 선택하고 순공사비 지정 -> 기존 행 3(순공사비)은 LinkIndex=0으로 해제되고 행 2가 54로 전환
state.selection = new Set([2]);
EstBusinessActions.handle("toggle-sun-gongsa");
const r2_afterSun = { ...p.tables.EstMainDat[2] };
const r3_afterSun = { ...p.tables.EstMainDat[3] };
// 행 2를 다시 토글 -> 54 해제
EstBusinessActions.handle("toggle-sun-gongsa");
const r2_restored = { ...p.tables.EstMainDat[2] };
// 3) 안전성 거부 확인
let rejected = 0;
// 3-1) 일위대가나 내역서가 아닌 화면에서 jc-bimog 호출 거부
state.view = "StuffcDat";
try { await EstBusinessActions.handle("toggle-jc-bimog"); } catch { rejected++; }
// 3-2) 선택 없는 상태에서 jc-bimog 호출 거부
state.view = "EstMainDat";
state.selection.clear();
try { await EstBusinessActions.handle("toggle-jc-bimog"); } catch { rejected++; }
// 3-3) sun-gongsa는 2개 이상 선택 시 거부
state.selection = new Set([0, 1]);
try { await EstBusinessActions.handle("toggle-sun-gongsa"); } catch { rejected++; }
// 3-4) 자재/원천 연결 행이나 수량이 있는 행(행 0)을 sun-gongsa로 지정 시도 시 안전 차단 거부
state.selection = new Set([0]);
try { await EstBusinessActions.handle("toggle-sun-gongsa"); } catch { rejected++; }
return {
r0_afterToggle,
r0_restored,
r1_afterToggle,
r1_restored,
r2_afterSun,
r3_afterSun,
r2_restored,
rejected
};
})()"""
result = run_js(code)
# 1) 경비성단가 지정 -> SCheck 3, JU=1000, GU=0
assert result["r0_afterToggle"]["SCheck"] == 3
assert result["r0_afterToggle"]["JU"] == 1000
assert result["r0_afterToggle"]["GU"] == 0
assert result["r0_afterToggle"]["TU"] == 1000
# 경비성단가 해제 -> SCheck 0, GU=1000, JU=0
assert result["r0_restored"]["SCheck"] == 0
assert result["r0_restored"]["GU"] == 1000
assert result["r0_restored"]["JU"] == 0
# 노무비(SCheck=2) 경비성 지정 -> SCheck 3, JU=2000, NU=0
assert result["r1_afterToggle"]["SCheck"] == 3
assert result["r1_afterToggle"]["JU"] == 2000
assert result["r1_afterToggle"]["NU"] == 0
# 노무비 경비성 해제 -> 원래 SCheck=2로 온전히 복원, NU=2000, JU=0
assert result["r1_restored"]["SCheck"] == 2
assert result["r1_restored"]["NU"] == 2000
assert result["r1_restored"]["JU"] == 0
# 2) 순공사비 경계 행 전환 -> 행 2가 54, 행 3은 0
assert result["r2_afterSun"]["LinkIndex"] == 54
assert result["r2_afterSun"]["Name"] == "순공사비"
assert result["r3_afterSun"]["LinkIndex"] == 0
assert result["r3_afterSun"]["Name"] == "공종"
# 순공사비 재토글 해제 -> 행 2가 0
assert result["r2_restored"]["LinkIndex"] == 0
# 3) 안전성 거부 4건 모두 통과 확인
assert result["rejected"] == 4
def test_uses_data_search_finds_usages_across_tables():
code = """(() => {
const p = EstNative.empty();
p.tables.StuffcDat = [
{ Code: "M001", Name: "시멘트", Size: "포대", Unite: "포", TC: 5000 },
{ Code: "M002", Name: "모래", Size: null, Unite: "㎥", TC: 20000 }
];
p.tables.LaborcDat = [
{ Code: "L001", Name: "보통인부", Size: null, Unite: "인", TC: 150000 }
];
p.tables.HeadMainDat = [
{ Index: 1, Code: "U001", Name: "콘크리트타설", Size: "구조물", Unite: "㎥", TC: 50000 },
{ Index: 0, Code: "M001", Name: "시멘트", Size: "포대", Unite: "포", GN: 2, TC: 10000 },
{ Index: 0, Code: "L001", Name: "보통인부", Size: null, Unite: "인", GN: 0.1, TC: 15000 },
{ Index: 2, Code: "U002", Name: "몰탈바름", Size: null, Unite: "㎡", TC: 30000 },
{ Index: 0, Code: "M001", Name: "시멘트", Size: "포대", Unite: "포", GN: 0.5, TC: 2500 }
];
p.tables.CostMainDat = [
{ Index: 1, Code: "D001", LineData: "\x0e기초터파기\x0e", TC: 10000 },
{ Index: 0, Code: "M001", LineData: "\x0e시멘트\x0e", TC: 5000 }
];
p.tables.EstMainDat = [
{ PkID: 1, Level: 0, Code: "U001", Name: "콘크리트타설", Size: "구조물", Unite: "㎥", GN: 10, TC: 500000 },
{ PkID: 2, Level: 0, Code: "M001", Name: "시멘트", Size: "포대", Unite: "포", GN: 5, TC: 25000 },
{ PkID: 3, Level: 0, Code: null, Name: "이름만일치모래", Size: null, Unite: "㎥", GN: 1, TC: 20000 }
];
const targetM001 = p.tables.StuffcDat[0];
const m001Usages = EstExtra.findDataUsages(p, targetM001);
const targetU001 = p.tables.HeadMainDat[0];
const u001Usages = EstExtra.findDataUsages(p, targetU001);
const targetNoCode = { Name: "이름만일치모래" };
const noCodeUsages = EstExtra.findDataUsages(p, targetNoCode);
const targetNone = { Code: "NON_EXIST", Name: "없는항목" };
const noneUsages = EstExtra.findDataUsages(p, targetNone);
return {
m001Usages,
u001Usages,
noCodeUsages,
noneUsages
};
})()"""
result = run_js(code)
m001 = result["m001Usages"]
# M001: HeadMainDat 2건 (U001 그룹 1건, U002 그룹 1건), CostMainDat 1건 (D001 그룹), EstMainDat 1건 = 총 4건
assert len(m001) == 4
tables = [u["table"] for u in m001]
assert tables.count("HeadMainDat") == 2
assert tables.count("CostMainDat") == 1
assert tables.count("EstMainDat") == 1
# 그룹 코드 보존 검증
headGroups = [u["group"] for u in m001 if u["table"] == "HeadMainDat"]
assert "U001" in headGroups and "U002" in headGroups
# U001: EstMainDat 1건 검출 (HeadMainDat의 대표행 자신은 제외됨)
u001 = result["u001Usages"]
assert len(u001) == 1
assert u001[0]["table"] == "EstMainDat"
assert u001[0]["quantity"] == 10
# 이름만 있는 항목 검색
noCode = result["noCodeUsages"]
assert len(noCode) == 1
assert noCode[0]["table"] == "EstMainDat"
# 존재하지 않는 항목
assert len(result["noneUsages"]) == 0
def test_toggle_safety_guards_prevent_arbitrary_zero_conversion_without_history():
code = """(() => {
const p = EstNative.empty();
// 기존 원본 54 행: _origLinkIndex가 없고 LinkIndex_=54인 상태 (저장 후 재열기 또는 원본)
p.tables.EstMainDat = [
{ PkID: 1, Name: "원래순공사비", LinkIndex: 54, LinkIndex_: 54, GN: 0, TC: 5000 },
{ PkID: 2, Name: "새로운공종", LinkIndex: 0, GN: 0, TC: 0 },
{ PkID: 3, Code: "M01", Name: "원래경비성자재", LinkIndex: 5, SCheck: 3, SCheck_: 3, GN: 1, TU: 100, JU: 100, TC: 100 }
];
const state = { project: p, view: "EstMainDat", selection: new Set([0]) };
globalThis.EstApp = { state, change(fn) { fn(); } };
let blocked = 0;
// 1) 기존 원본 54 행을 선택하고 해제 시도 -> 복원 정보가 없으므로 임의 0 변환 차단되어야 함
try {
EstExtra.toggleSunGongsa(state);
} catch (err) {
if (err.message.includes("임의 0으로 변환할 수 없습니다")) blocked++;
}
// 2) 다른 행(새로운공종)을 54로 지정 시도 -> 기존 54 행의 원래 유형을 알 수 없으므로 차단되어야 함
state.selection = new Set([1]);
try {
EstExtra.toggleSunGongsa(state);
} catch (err) {
if (err.message.includes("다른 행으로 대체할 수 없습니다")) blocked++;
}
// 3) 원본부터 SCheck 3이었던 행(복원 정보 없는 행)의 경비성 해제 시도 -> 차단되어야 함
state.selection = new Set([2]);
try {
EstExtra.toggleJcBimog(state);
} catch (err) {
if (err.message.includes("임의 변환할 수 없습니다")) blocked++;
}
// 4) 임의 변환 시도가 모두 차단되었으므로 기존 행 속성이 온전하게 보존되었는지 검증
return {
blocked,
r0_LinkIndex: p.tables.EstMainDat[0].LinkIndex,
r1_LinkIndex: p.tables.EstMainDat[1].LinkIndex,
r2_SCheck: p.tables.EstMainDat[2].SCheck
};
})()"""
result = run_js(code)
assert result["blocked"] == 3
assert result["r0_LinkIndex"] == 54
assert result["r1_LinkIndex"] == 0
assert result["r2_SCheck"] == 3
def test_set_cost_line_kind_action():
code = """(async () => {
const p = EstNative.empty();
p.tables.CostMainDat = [
{ Index: 1, Code: "D01", LineData: "\x0e기초터파기\x0e", TC: 1000 },
{ Index: 0, Code: null, LineData: "노무비 산출라인", TC: 500, OCheck: 0, SCheck: 0 },
{ Index: 0, Code: null, LineData: "재료비 산출라인", TC: 300, OCheck: -2, SCheck: -2 }
];
let formCallback = null;
globalThis.EstActions = {
form(title, fields, cb) {
formCallback = cb;
}
};
const state = { project: p, view: "CostMainDat", selection: new Set([1]) };
globalThis.EstApp = { state, change(fn) { fn(); } };
// 1) 행 1을 노무비(-1)로 지정
await EstBusinessActions.handle("set-cost-line-kind");
formCallback({ kind: -1 });
const r1_afterNC = { OCheck: p.tables.CostMainDat[1].OCheck, SCheck: p.tables.CostMainDat[1].SCheck };
// 2) 행 2(기존 재료비 -2)를 일반 해제(0)로 전환
state.selection = new Set([2]);
await EstBusinessActions.handle("set-cost-line-kind");
formCallback({ kind: 0 });
const r2_afterFree = { OCheck: p.tables.CostMainDat[2].OCheck, SCheck: p.tables.CostMainDat[2].SCheck };
// 3) 안전성 거부: CostMainDat가 아닌 화면에서 호출 시 거부
state.view = "EstMainDat";
let rejected = 0;
try {
await EstBusinessActions.handle("set-cost-line-kind");
} catch (err) {
rejected++;
}
return {
r1_afterNC,
r2_afterFree,
rejected
};
})()"""
result = run_js(code)
assert result["r1_afterNC"] == {"OCheck": -1, "SCheck": -1}
assert result["r2_afterFree"].get("OCheck") is None
assert result["r2_afterFree"].get("SCheck") is None
assert result["rejected"] == 1
def test_jc_bimog_multi_selection_atomic_and_json_roundtrip():
code = """(() => {
const p = EstNative.empty();
p.tables.StuffcDat = [
{ Code: "M01", Name: "자재1", TU: 100, GU: 100, Cost1: 100, ValueIndex: 0, InputTC: 100, TC: 100 }
];
p.tables.LaborcDat = [
{ Code: "L01", Name: "노임1", TU: 200, NU: 200, Cost1: 200, ValueIndex: 0, InputTC: 200, TC: 200 }
];
p.tables.EstMainDat = [
// 행 0: 정상 비목별(SCheck=0) -> 경비성 지정 가능
{ PkID: 1, Code: "M01", Name: "자재1", LinkIndex: 5, GN: 1, TU: 100, GU: 100, GC: 100, TC: 100, SCheck: 0, SCheck_: 0 },
// 행 1: 기존 파일 원본부터 SCheck 3이고 이력이 없는 행 -> 해제 시 차단되어야 함
{ PkID: 2, Code: "L01", Name: "노임1", LinkIndex: 6, GN: 1, TU: 200, JU: 200, JC: 200, TC: 200, SCheck: 3, SCheck_: 3 }
];
const state = { project: p, view: "EstMainDat", selection: new Set([0, 1]) };
globalThis.EstApp = { state, change(fn) { fn(); } };
let atomicBlocked = false;
try {
// 복수 선택: 행 0(지정 시도)과 행 1(해제 시도인데 이력 없어 거부 대상)
EstExtra.toggleJcBimog(state);
} catch (err) {
if (err.message.includes("임의 변환할 수 없습니다")) atomicBlocked = true;
}
// 원자적 검증: 행 1 때문에 에러 발생 시 앞선 행 0도 변경되지 않고 SCheck=0 유지 확인
const r0_SCheck_afterFail = p.tables.EstMainDat[0].SCheck;
// 행 0 단독 지정 -> SCheck 3 및 _prevSCheck=0 보존
state.selection = new Set([0]);
EstExtra.toggleJcBimog(state);
const r0_afterToggle = { SCheck: p.tables.EstMainDat[0].SCheck, _prevSCheck: p.tables.EstMainDat[0]._prevSCheck };
// JSON 직렬화 & 역직렬화 (저장 후 재열기 시뮬레이션)
const serialized = JSON.stringify(p);
const reopened = JSON.parse(serialized);
// 재열기 후 해제 실행 -> SCheck_=0 및 _prevSCheck=0을 통해 원래 SCheck 0으로 정확히 복원
const reopenedState = { project: reopened, view: "EstMainDat", selection: new Set([0]) };
globalThis.EstApp.state = reopenedState;
EstExtra.toggleJcBimog(reopenedState);
const r0_restoredReopened = { SCheck: reopened.tables.EstMainDat[0].SCheck };
return {
atomicBlocked,
r0_SCheck_afterFail,
r0_afterToggle,
r0_restoredReopened
};
})()"""
result = run_js(code)
assert result["atomicBlocked"] is True
# 실패 시 앞선 행 0도 변경되지 않고 0 유지 (원자적 검증)
assert result["r0_SCheck_afterFail"] == 0
# 행 0 지정 시 SCheck=3 및 _prevSCheck=0 보존
assert result["r0_afterToggle"]["SCheck"] == 3
assert result["r0_afterToggle"]["_prevSCheck"] == 0
# JSON 재열기 후 해제 시 원래 0으로 온전 복원
assert result["r0_restoredReopened"]["SCheck"] == 0
def test_block_actions_range_selection_and_clear():
code = """(() => {
const p = EstNative.empty();
p.tables.CostMainDat = [
{ Index: 1, Code: "D01", LineData: "\\x0E기초터파기\\x0E", TC: 1000 },
{ Index: 0, Code: null, LineData: "1행", TC: 100 },
{ Index: 0, Code: null, LineData: "2행", TC: 200 },
{ Index: 0, Code: null, LineData: "3행", TC: 300 },
{ Index: 0, Code: null, LineData: "4행", TC: 400 },
{ Index: 0, Code: null, LineData: "5행", TC: 500 }
];
const state = { project: p, view: "CostMainDat", selection: new Set([1]), notice: "" };
globalThis.EstApp = { state, render() {} };
// 1) 1행(index 1)을 블럭 시작으로 지정
EstBlockActions.setBlockStart(state);
const startNotice = state.notice;
// 2) 4행(index 4)을 블럭 끝으로 지정 -> 1~4행(총 4개) 일괄 선택 확인
state.selection = new Set([4]);
EstBlockActions.setBlockEnd(state);
const selectedIndices = [...state.selection].sort((a, b) => a - b);
const endNotice = state.notice;
// 3) 전체 선택 실행
EstBlockActions.selectAllRows(state);
const allSelectedCount = state.selection.size;
// 4) 블럭 해제 실행
EstBlockActions.clearBlockSelection(state);
const clearedCount = state.selection.size;
// 5) 시작 지정 없이 블럭 끝 호출 시 거부
let rejected = 0;
try {
state.selection = new Set([2]);
EstBlockActions.setBlockEnd(state);
} catch {
rejected++;
}
return {
startNotice,
endNotice,
selectedIndices,
allSelectedCount,
clearedCount,
rejected
};
})()"""
result = run_js(code)
assert "2행이 블럭 시작점으로 설정" in result["startNotice"]
assert "2행부터 5행까지 총 4개 행이 블럭으로 선택" in result["endNotice"]
assert result["selectedIndices"] == [1, 2, 3, 4]
assert result["allSelectedCount"] == 6
assert result["clearedCount"] == 0
assert result["rejected"] == 1
def test_toggle_cost_comment_and_block_delete():
code = """(() => {
const p = EstNative.empty();
p.tables.CostMainDat = [
{ Index: 1, Code: "D01", LineData: "\\x0e단가산출 호표1\\x0e", TC: 1000, NC: 1000, GC: 0, JC: 0 },
{ Index: 0, Code: null, LineData: "Q = 10 / 2 = \\x125\\x12", TC: 5, NC: 5, GC: 0, JC: 0 },
{ Index: 0, Code: null, LineData: "Q2 = 20 / 2 = \\x1210\\x12", TC: 10, NC: 10, GC: 0, JC: 0 },
{ Index: -20, Code: null, LineData: "구분선", TC: 0 },
{ Index: 0, Code: null, LineData: "일반행 3", TC: 30 }
];
const state = {
project: p,
view: "CostMainDat",
selection: new Set([1]),
notice: ""
};
globalThis.EstApp = {
state,
change(fn) {
fn();
},
render() {}
};
// 1) 주석 지정: 1번 행(Index=0)을 주석(Index=-1)으로 지정
EstBlockActions.toggleCostComment(state);
const afterCommentIndex = p.tables.CostMainDat[1].Index;
const origIndexSaved = p.tables.CostMainDat[1]._origIndex;
// 2) 주석 해제: 다시 토글 시 원래 Index(0)로 복원 및 _origIndex 제거
EstBlockActions.toggleCostComment(state);
const restoredIndex = p.tables.CostMainDat[1].Index;
const origIndexDeleted = p.tables.CostMainDat[1]._origIndex === undefined;
// 3) 호표 제목 행(Index > 0) 및 구분선 행(Index === -20) 주석 지정 거부 확인
let titleRejected = false;
try {
state.selection = new Set([0]); // 호표 제목 행
EstBlockActions.toggleCostComment(state);
} catch {
titleRejected = true;
}
let sepRejected = false;
try {
state.selection = new Set([3]); // 구분선(-20) 행
EstBlockActions.toggleCostComment(state);
} catch {
sepRejected = true;
}
// 4) 다중 행 선택 및 원자성 검증: 제목 행이 섞여 있으면 전체 거부
let mixedRejected = false;
try {
state.selection = new Set([1, 0, 2]);
EstBlockActions.toggleCostComment(state);
} catch {
mixedRejected = true;
}
const mixedNotToggled = p.tables.CostMainDat[1].Index === 0;
// 5) 블럭 삭제 기능 테스트: 1~2번 상세 행 블럭 삭제
state.selection = new Set([1, 2]);
EstBlockActions.deleteBlockRows(state);
const remainingCount = p.tables.CostMainDat.length;
const remainingLines = p.tables.CostMainDat.map(r => r.LineData);
const selectionCleared = state.selection.size === 0;
// 6) 다른 테이블에서 참조 중인 호표 삭제 시 거부 확인 (숫자 LinkIndex 및 문자열 "104" LinkIndex)
p.tables.EstMainDat = [
{ Code: "D01", LinkIndex: "104", LineData: "내역서 항목", GN: 10 }
];
let refRejected = false;
try {
state.selection = new Set([0]); // D01 호표 제목 행
EstBlockActions.deleteBlockRows(state);
} catch {
refRejected = true;
}
// 7) 비목 행(-2 재료비, -3 경비, -5 소계) 주석 지정 및 원래 비목 복원 + JSON 저장 재열기 검증
p.tables.CostMainDat.push(
{ Index: 0, SCheck: -2, OCheck: -2, LineData: "재료비 행", TC: 200 },
{ Index: 0, SCheck: -3, OCheck: -3, LineData: "경비 행", TC: 300 },
{ Index: 0, SCheck: -5, OCheck: -5, LineData: "소계 행", TC: 500 }
);
// 재료비 행(index 3) 주석 지정
state.selection = new Set([3]);
EstBlockActions.toggleCostComment(state);
const bimogCommentedSCheck = p.tables.CostMainDat[3].SCheck;
const bimogCommentedIndex = p.tables.CostMainDat[3].Index;
const bimogOrigSCheck = p.tables.CostMainDat[3]._origSCheck;
// JSON 직렬화 & 역직렬화 (저장 재열기) 시뮬레이션
const jsonStr = JSON.stringify(p);
const reloadedP = JSON.parse(jsonStr);
state.project = reloadedP;
// 재열기 후 주석 해제 -> 원래 SCheck(-2) 및 Index(0)로 정확히 복원
EstBlockActions.toggleCostComment(state);
const restoredBimogSCheck = state.project.tables.CostMainDat[3].SCheck;
const restoredBimogIndex = state.project.tables.CostMainDat[3].Index;
const origSCheckDeleted = state.project.tables.CostMainDat[3]._origSCheck === undefined;
// 경비(-3), 소계(-5) 다중 선택 일괄 주석 지정 및 해제 검증
state.selection = new Set([4, 5]);
EstBlockActions.toggleCostComment(state);
const multiCommented = [
state.project.tables.CostMainDat[4].SCheck,
state.project.tables.CostMainDat[5].SCheck
];
EstBlockActions.toggleCostComment(state);
const multiRestored = [
state.project.tables.CostMainDat[4].SCheck,
state.project.tables.CostMainDat[5].SCheck
];
return {
afterCommentIndex,
origIndexSaved,
restoredIndex,
origIndexDeleted,
titleRejected,
sepRejected,
mixedRejected,
mixedNotToggled,
remainingCount,
remainingLines,
selectionCleared,
refRejected,
bimogCommentedSCheck,
bimogCommentedIndex,
bimogOrigSCheck,
restoredBimogSCheck,
restoredBimogIndex,
origSCheckDeleted,
multiCommented,
multiRestored
};
})()"""
result = run_js(code)
assert result["afterCommentIndex"] == -1
assert result["origIndexSaved"] == 0
assert result["restoredIndex"] == 0
assert result["origIndexDeleted"] is True
assert result["titleRejected"] is True
assert result["sepRejected"] is True
assert result["mixedRejected"] is True
assert result["mixedNotToggled"] is True
assert result["remainingCount"] == 3
assert result["selectionCleared"] is True
assert result["refRejected"] is True
assert result["bimogCommentedSCheck"] == -1
assert result["bimogCommentedIndex"] == -1
assert result["bimogOrigSCheck"] == -2
assert result["restoredBimogSCheck"] == -2
assert result["restoredBimogIndex"] == 0
assert result["origSCheckDeleted"] is True
assert result["multiCommented"] == [-1, -1]
assert result["multiRestored"] == [-3, -5]
def test_align_wonga_total_action():
code = """(() => {
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 }
];
const state = {
project: p,
view: "Wonga",
selection: new Set([4]),
notice: ""
};
globalThis.EstApp = {
state,
change(fn) { fn(); },
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) 절하 맞춤 테스트
const alignRes10000 = EstWonga.alignTotal(p.tables.Wonga, 1241420, 4);
const total10000 = p.tables.Wonga[alignRes10000.targetIndex].Cost;
// 3) LinkIndex 55 부재 시 거부 확인
let noTargetRejected = false;
try {
EstWonga.alignTotal([{ Name: "항목1", Cost: 100 }], 1000, 3);
} catch {
noTargetRejected = true;
}
// 4) 이윤 행 부재 시 거부 확인
let noProfitRejected = false;
try {
EstWonga.alignTotal([{ Name: "총도급", Cost: 100, LinkIndex: 55 }], 1000, 3);
} catch {
noProfitRejected = true;
}
return {
total1000,
total10000,
roundedTarget1000: alignRes1000.roundedTarget,
roundedTarget10000: alignRes10000.roundedTarget,
profitIndex: alignRes1000.profitIndex,
noTargetRejected,
noProfitRejected
};
})()"""
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["noTargetRejected"] is True
assert result["noProfitRejected"] is True
+264
View File
@@ -47,3 +47,267 @@ def test_standard_usage_reports_applied_and_source_prices_without_mutation():
assert result["rows"][0]["difference"] == -20
assert result["rows"][1]["sourcePrice"] is None
assert result["print"]["rows"] == result["rows"]
def test_standard_apply_row_and_all_prices():
result = run(
"math",
"""(()=>{
const p = EstNative.empty();
p.options.SourceRefresh = false;
p.options.AutoCalc = false;
p.tables.SilJukDat = [
{ Code: 'A', Name: 'standard A', ValueIndex: 4, InputTC: 150, InputGC: 50, InputNC: 70, InputJC: 30, InputSC: 0 },
{ Code: 'B', Name: 'standard B', ValueIndex: 4, InputTC: 300, InputSC: 300 },
{ Code: 'D', Name: 'dup', ValueIndex: 4, InputTC: 50 },
{ Code: 'D', Name: 'dup', ValueIndex: 4, InputTC: 60 },
];
p.tables.EstMainDat = [
{ Code: 'A', Name: 'item A', LinkIndex: 21, GN: 2, TU: 100, GU: 30, NU: 50, JU: 20, GC: 60, NC: 100, JC: 40, TC: 200, SCheck: 1 },
{ Code: 'B', Name: 'item B', LinkIndex: 21, GN: 3, TU: 200, SU: 200, SC: 600, TC: 600 },
{ Code: 'C', Name: 'item C', LinkIndex: 21, GN: 1, TU: 50, SU: 50, SC: 50, TC: 50 },
{ Code: 'D', Name: 'item D', LinkIndex: 21, GN: 1, TU: 40, SU: 40, SC: 40, TC: 40 },
{ Code: 'OTHER', Name: 'other row', LinkIndex: 5, GN: 5, TU: 10, GC: 50, TC: 50 },
];
const initialUsage = EstStandard.usage(p);
const otherBefore = JSON.stringify(p.tables.EstMainDat[4]);
// 행 0(SCheck=1: 재료비 몰아주기 설정)에 단가 적용:
// SCheck=1이 보존되어 GU=150, NU=0, JU=0, SU=0 및 GN=2 -> GC=300, TC=300
EstStandard.applyRowPrice(p, 0);
const afterRow0 = {
row0_TU: p.tables.EstMainDat[0].TU,
row0_GU: p.tables.EstMainDat[0].GU,
row0_NU: p.tables.EstMainDat[0].NU,
row0_JU: p.tables.EstMainDat[0].JU,
row0_GC: p.tables.EstMainDat[0].GC,
row0_TC: p.tables.EstMainDat[0].TC,
row0_SCheck: p.tables.EstMainDat[0].SCheck,
otherPreserved: JSON.stringify(p.tables.EstMainDat[4]) === otherBefore,
sourceRefresh: p.options.SourceRefresh,
autoCalc: p.options.AutoCalc,
};
// SCheck를 0으로 초기화한 뒤 다시 적용하면 원천의 재료/노무/경비 비목 분할(50/70/30)이 유지됨
p.tables.EstMainDat[0].SCheck = 0;
EstStandard.applyRowPrice(p, 0);
const afterRow0_split = {
row0_TU: p.tables.EstMainDat[0].TU,
row0_GU: p.tables.EstMainDat[0].GU,
row0_NU: p.tables.EstMainDat[0].NU,
row0_JU: p.tables.EstMainDat[0].JU,
row0_GC: p.tables.EstMainDat[0].GC,
row0_NC: p.tables.EstMainDat[0].NC,
row0_JC: p.tables.EstMainDat[0].JC,
row0_TC: p.tables.EstMainDat[0].TC,
};
let errMissing = false;
try { EstStandard.applyRowPrice(p, 2); } catch { errMissing = true; }
let errDup = false;
try { EstStandard.applyRowPrice(p, 3); } catch { errDup = true; }
// 남은 대상인 행 1(수량 3)에 대해 applyAllPrices 적용
const appliedCount = EstStandard.applyAllPrices(p);
const afterAll = {
row1_TU: p.tables.EstMainDat[1].TU,
row1_SU: p.tables.EstMainDat[1].SU,
row1_SC: p.tables.EstMainDat[1].SC,
row1_TC: p.tables.EstMainDat[1].TC,
row2_TU: p.tables.EstMainDat[2].TU,
row3_TU: p.tables.EstMainDat[3].TU,
otherPreserved: JSON.stringify(p.tables.EstMainDat[4]) === otherBefore,
};
const finalUsage = EstStandard.usage(p);
// AutoCalc=true 전환 후 전체 재계산: 금액이 수량(GN)을 반영해 갱신됨
p.options.AutoCalc = true;
const calculated = EstNative.calculate(p);
const afterRecalc = {
row0_GC: calculated.tables.EstMainDat[0].GC,
row0_NC: calculated.tables.EstMainDat[0].NC,
row0_JC: calculated.tables.EstMainDat[0].JC,
row0_TC: calculated.tables.EstMainDat[0].TC,
row1_SC: calculated.tables.EstMainDat[1].SC,
row1_TC: calculated.tables.EstMainDat[1].TC,
};
return { initialUsage, afterRow0, afterRow0_split, errMissing, errDup, appliedCount, afterAll, finalUsage, afterRecalc };
})()""",
)
assert result["initialUsage"][0]["canApply"] is True
assert result["initialUsage"][1]["canApply"] is True
assert result["initialUsage"][2]["canApply"] is False
assert result["initialUsage"][3]["canApply"] is False
assert result["afterRow0"] == {
"row0_TU": 150,
"row0_GU": 150,
"row0_NU": 0,
"row0_JU": 0,
"row0_GC": 60,
"row0_TC": 200,
"row0_SCheck": 1,
"otherPreserved": True,
"sourceRefresh": False,
"autoCalc": False,
}
assert result["afterRow0_split"] == {
"row0_TU": 150,
"row0_GU": 50,
"row0_NU": 70,
"row0_JU": 30,
"row0_GC": 60,
"row0_NC": 100,
"row0_JC": 40,
"row0_TC": 200,
}
assert result["errMissing"] is True
assert result["errDup"] is True
assert result["appliedCount"] == 1
assert result["afterAll"] == {
"row1_TU": 300,
"row1_SU": 300,
"row1_SC": 600,
"row1_TC": 600,
"row2_TU": 50,
"row3_TU": 40,
"otherPreserved": True,
}
assert result["afterRecalc"] == {
"row0_GC": 100,
"row0_NC": 140,
"row0_JC": 60,
"row0_TC": 300,
"row1_SC": 900,
"row1_TC": 900,
}
assert result["finalUsage"][0]["canApply"] is False
assert result["finalUsage"][1]["canApply"] is False
def test_edge_standard_usage_apply_price_and_undo_when_server_running():
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(channel="msedge", headless=True)
page = browser.new_page()
try:
page.goto("http://100.65.141.15:5175", timeout=5000)
page.wait_for_function("() => Boolean(EstApp.state.project)")
except Exception:
browser.close()
import pytest
pytest.skip("EST 5175 server not reachable")
# 1. 테스트 프로젝트 데이터 세팅 (표준시장단가 및 내역 행 주입)
page.evaluate(
"""(() => {
const p = EstNative.empty();
p.options.SourceRefresh = false;
p.tables.SilJukDat = [
{ Code: 'S001', Name: '표준토공', Size: '일반', Unite: 'm3', ValueIndex: 4, InputTC: 15000 },
{ Code: 'S002', Name: '표준포장', Size: 't=10cm', Unite: 'm2', ValueIndex: 4, InputTC: 32000 },
];
p.tables.EstMainDat = [
{ PkID: 101, Code: 'S001', Name: '표준토공', Size: '일반', Unite: 'm3', LinkIndex: 21, GN: 10, TU: 12000, SU: 12000, SC: 120000, TC: 120000 },
{ PkID: 102, Code: 'S002', Name: '표준포장', Size: 't=10cm', Unite: 'm2', LinkIndex: 21, GN: 5, TU: 30000, SU: 30000, SC: 150000, TC: 150000 },
];
EstApp.setProject(p);
EstApp.state.view = 'standard';
EstApp.render();
})()"""
)
# 2. 표준시장단가 사용 내역 화면 확인
assert page.locator("#view-title:has-text('표준시장단가 사용 내역')").is_visible()
btn_apply_all = page.locator("button[data-standard-apply-all]")
assert btn_apply_all.is_visible()
assert "기초단가 일괄 적용 (2건)" in btn_apply_all.text_content()
# 3. 개별 단가 적용 버튼 클릭 (1행 S001)
btn_row_apply = page.locator("button[data-standard-apply-row='0']")
assert btn_row_apply.is_visible()
btn_row_apply.click()
# 1행이 15,000으로 갱신되었는지 확인
tu_row0 = page.evaluate("EstApp.state.project.tables.EstMainDat[0].TU")
tc_row0 = page.evaluate("EstApp.state.project.tables.EstMainDat[0].TC")
assert tu_row0 == 15000
assert tc_row0 == 150000
# 버튼 라벨이 (1건)으로 변경되었는지 확인
assert "기초단가 일괄 적용 (1건)" in btn_apply_all.text_content()
# 4. 일괄 적용 버튼 클릭 (남은 1건 S002 적용)
btn_apply_all.click()
tu_row1 = page.evaluate("EstApp.state.project.tables.EstMainDat[1].TU")
tc_row1 = page.evaluate("EstApp.state.project.tables.EstMainDat[1].TC")
assert tu_row1 == 32000
assert tc_row1 == 160000
# 이제 일괄 적용 버튼이 disabled 상태인지 확인
assert btn_apply_all.is_disabled()
# 5. Undo(실행 취소) 검증
page.locator(".toolbar button[data-action='undo']").click()
# 1단계 취소 시 2행은 원래대로 30,000 복귀
assert page.evaluate("EstApp.state.project.tables.EstMainDat[1].TU") == 30000
page.locator(".toolbar button[data-action='undo']").click()
# 2단계 취소 시 1행도 원래대로 12,000 복귀
assert page.evaluate("EstApp.state.project.tables.EstMainDat[0].TU") == 12000
browser.close()
def test_standard_manual_mode_autocalc_false_and_recalc_switch():
result = run(
"math",
"""(()=>{
const p = EstNative.empty();
p.options.SourceRefresh = false;
p.options.AutoCalc = false; // 수동계산 모드
p.tables.SilJukDat = [
{ Code: 'S10', Name: '표준단가 10', ValueIndex: 4, InputTC: 20000, InputSC: 20000 },
];
p.tables.EstMainDat = [
{ Level: 1, Code: '', Name: '공종1', TC: 50000 },
{ Code: 'S10', Name: '품목 10', LinkIndex: 21, GN: 5, TU: 10000, SU: 10000, SC: 50000, TC: 50000 },
];
// 1. 수동계산 상태에서 applyRowPrice 실행
// 해당 행(인덱스 1)의 단가(TU, SU)는 20,000으로 갱신되나,
// AutoCalc=false이므로 자체 금액(SC=50000, TC=50000) 및 상위 공종 행(Level 1, TC=50000) 금액이 모두 보존됨
EstStandard.applyRowPrice(p, 1);
const afterManual = {
row1_TU: p.tables.EstMainDat[1].TU,
row1_SC: p.tables.EstMainDat[1].SC,
row1_TC: p.tables.EstMainDat[1].TC,
row0_Level1_TC: p.tables.EstMainDat[0].TC,
};
// 2. 전체 재계산(AutoCalc=true 전환 및 EstNative.calculate 실행)
p.options.AutoCalc = true;
const calculated = EstNative.calculate(p);
const afterRecalc = {
row1_TU: calculated.tables.EstMainDat[1].TU,
row1_SC: calculated.tables.EstMainDat[1].SC,
row1_TC: calculated.tables.EstMainDat[1].TC,
row0_Level1_TC: calculated.tables.EstMainDat[0].TC,
};
return { afterManual, afterRecalc };
})()""",
)
assert result["afterManual"]["row1_TU"] == 20000
assert result["afterManual"]["row1_SC"] == 50000
assert result["afterManual"]["row1_TC"] == 50000
# 수동 상태에서는 자체 금액 및 상위 공종(Level 1) 금액 모두 보존됨
assert result["afterManual"]["row0_Level1_TC"] == 50000
# 전체 재계산 전환 후 수량(GN=5)을 반영해 자체 금액 및 상위 공종 정상 합산됨
assert result["afterRecalc"]["row1_TU"] == 20000
assert result["afterRecalc"]["row1_SC"] == 100000
assert result["afterRecalc"]["row1_TC"] == 100000
assert result["afterRecalc"]["row0_Level1_TC"] == 100000
+2
View File
@@ -1 +1,3 @@
/data/
monitor.log
monitor.pid
+373 -1
View File
@@ -1,3 +1,357 @@
- [x] 산출근거 주석 지정/해제(`popMainSetStr`) 및 블럭 삭제(`popMainBlockDel`) 구현
- 자체 검증:
1) 산출근거 주석 지정/해제 (`popMainSetStr`, 0xf08b1c):
- REA `TFRMCOST`/`TFRMESTBASE`의 팝업 메뉴 `popMainSetStr` / `mnuSetGuSuc` ("주석으로 지정", Ctrl+J, 0xf08b1c) 스펙 근거 기반 구현.
- 단가산출(`CostMainDat`) 화면의 산출근거 행에서 `Index`를 `-1`(주석)로 지정하거나 원래 `Index`(0)로 복원.
- 주석 지정 시 `_origIndex: curIndex`를 명시적으로 보존하고, `Index === -1`로 전환. 주석 해제 시 `_origIndex` 또는 0으로 안전하게 복원하고 임시 속성 제거.
- 호표 제목 행(`Index > 0`) 또는 소계 구분선(`Index === -20`)은 주석으로 지정할 수 없도록 안전 가드 적용.
- 다중 선택 시 제목 행이나 비산출근거 행이 포함되면 전체 변경을 중단하고 원자성(Atomic) 보장.
- `native-text.js`에서 `Index === -1`인 행은 소계(`LinkIndex: 61`) 계산 시 `if (num(previous.Index) !== -1)` 조건에 따라 자동으로 합산에서 제외되므로 산출식 수치 무결성 완벽 보장.
2) 블럭 삭제 (`popMainBlockDel`, 0xf08adc):
- REA `TFRMCOST`/`TFRMHEAD`/`TFRMMACH`/`TFRMEST` 전역의 `popMainBlockDel` ("블럭삭제", Ctrl+Del, 0xf08adc) 스펙 근거 기반 구현.
- `EstBlockActions.deleteBlockRows(state)`: 선택된 범위/블럭의 행들을 일괄 삭제.
- 호표 제목 행(`Index > 0`)이 포함된 경우 호표 전체 행을 일괄 삭제 범위로 확장.
- 다른 항목(`EstMainDat`, `HeadMainDat` 등)에서 참조 중인 코드가 포함된 경우 "다른 항목에서 사용 중입니다. 연결을 먼저 해제하세요."로 안전 차단.
- 삭제 완료 후 선택 해제 및 블럭 시작/끝 상태 초기화.
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"`) 등록.
- 관련 테스트:
- `pytest resources/tester/test_est_row_actions.py` 7개 전수 통과:
- `test_toggle_cost_comment_and_block_delete`: 산출근거 행 주석(-1) 지정 및 `_origIndex` 보존, 0으로의 복원 및 임시 속성 제거, 호표 제목 및 구분선 지정 거부, 다중 선택 원자성 보장, 블럭 행 일괄 삭제 및 참조 중인 호표 삭제 차단 검증 완료.
- `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.
- [x] 단가산출 산출근거 비목 지정/해제(`popMainSetNC/GC/JC/TC/BimogFree`) 구현 및 순공사비·경비성 안전 가드 보강
- 자체 검증:
1) 순공사비·경비성 토글 안전 가드 및 원자적 일괄 검증 보강:
- **명시적 0/1/2 이력 보존 및 복원**: `toggleJcBimog`에서 경비성(3) 전환 시 원래 `SCheck`(0:비목별, 1:재료, 2:노무)을 `_prevSCheck`에 명시적으로 보존. 해제 시 해당 이력 또는 원본 `SCheck_`가 존재하는 경우에만 정확한 이전 값으로 복원.
- **이력 없는 경비성 3 임의 변환 차단**: `_prevSCheck` 및 `SCheck_`가 없는 기존 3 행이나 원래 유형을 알 수 없는 행의 임의 0 변환을 안전하게 차단.
- **다중 선택 원자성(Atomic) 보장**: 복수 행 선택 토글 시 사전 검증 계획(`plans`)을 수립하여 한 행이라도 거부 대상이 포함되면 전체 변경을 중단하고 앞선 행도 변경되지 않도록 원자적 일괄 적용 보장.
- **JSON 저장 및 재열기 왕복 보증**: JSON 직렬화/역직렬화 후에도 `_prevSCheck` 및 `SCheck_`가 보존되어 당초 SCheck(0/1/2)로 100% 온전하게 복원됨을 검증.
- 원본부터 순공사비(54)인 행의 해제 시도나 대체 시도 역시 `_origLinkIndex`나 `LinkIndex_` 등 원래 유효 유형이 확인되지 않으면 오류로 차단하고 기존 행 속성 보존.
2) 단가산출 산출근거 비목 지정/해제 (`popMainSetNC`, `popMainSetGC`, `popMainSetJC`, `popMainSetTC`, `popMainSetBimogFree`):
- REA `TFRMCOST`/`TFRMESTBASE`의 팝업 메뉴 비목 지정(`0xf0c0f0`, `0x10d815c`) 스펙 근거 기반 구현.
- 단가산출(`CostMainDat`) 화면에서 산출근거 행의 `OCheck` 및 `SCheck`를 노무비(-1), 재료비(-2), 경비(-3), 소계(-5)로 지정하거나 일반 산출식(0)으로 해제.
- UI 연동: 편집(E) 메뉴에 `산출근거 비목 지정/해제`(`data-action="set-cost-line-kind"`) 등록 및 `EstActions.form` 대화상자 지원.
3) 700줄 제한 엄수:
- `native-business-actions.js`: 643줄, `native-extra.js`: 696줄, `index.html`: 211줄, `resources/tester/test_est_row_actions.py`: 422줄.
- 관련 테스트:
- `pytest resources/tester/test_est_row_actions.py` 5개 전수 통과:
- `test_toggle_safety_guards_prevent_arbitrary_zero_conversion_without_history`: 복원 정보 없는 원본 54 행 해제/대체 차단 및 원본 경비성(3) 임의 0 변환 차단 검증.
- `test_jc_bimog_multi_selection_atomic_and_json_roundtrip`: 다중 선택 시 거부 행 발생 시 앞선 행 변경 롤백(원자성), SCheck=0 지정/해제 및 JSON 재열기 왕복 복원 검증.
- `test_set_cost_line_kind_action`: 산출근거 행의 노무비(-1) 지정, 재료비(-2)의 일반 해제(0) 및 타 화면 안전 거부 검증.
- `pytest resources/tester/test_est_row_actions.py resources/tester/test_est_estimate.py resources/tester/test_est_business.py`: 27 passed in 19.26s.
- [x] 데이터 사용위치 검색(`popUsesSerch` / `TFRMUSESDATASERCH`) 구현
- 자체 검증:
1) REA 스펙 근거:
- REA `TfrmUsesDataSerch` (0x10d819c) 및 `popUsesSerch`(내역서, 일위대가, 단가산출, 기계경비 전역 팝업 메뉴) 기반 구현.
- 임의의 품목 행(자재, 노임, 견적, 표준, 일위대가, 단가산출, 기계경비, 내역서)에서 `데이터 사용위치 찾기` 실행 지원.
2) `EstExtra.findDataUsages(project, target)` 검색 엔진 구현:
- 대상 항목의 `Code`(우선) 또는 정규화된 `Name`+`Size`를 기준으로 내역서(`EstMainDat`), 일위대가(`HeadMainDat`), 단가산출(`CostMainDat`), 기계경비(`MachMainDat`)의 참조 행을 전수 검색.
- 대상 항목 자기 자신의 대표 행은 검색 결과에서 안전하게 제외.
- 일위/단산/기계의 경우 상위 공종 코드(`curGroup`)를 함께 추적하여 위치 이동 시 정확한 묶음(`state.group`)으로 진입 지원.
3) UI 연동 및 대화상자 이동 기능:
- `index.html` 상단 툴바에 `사용위치` 버튼(`data-action="uses-data-search"`) 및 편집 메뉴에 `데이터 사용위치 찾기` 추가.
- `EstExtra.showUsesSearchDialog(targetRow)`를 통해 검색된 위치 목록(구분, 코드, 품명/산출근거, 규격, 단위, 수량, 금액) 표시.
- `이동`(`goto-use-location`) 클릭 시 대화상자를 닫고 대상 테이블(`navigate`), 대상 묶음 그룹(`state.group`), 페이지(`state.page`), 선택 행(`state.selection`)으로 즉시 포커스 이동.
4) 700줄 제한 엄수:
- `native-extra.js`: 554줄, `native-business-actions.js`: 685줄, `native-views.js`: 140줄, `index.html`: 210줄.
- 관련 테스트:
- `pytest resources/tester/test_est_row_actions.py` 통과 (`test_uses_data_search_finds_usages_across_tables`: 자재 M001의 일위/단산/내역 4건 검출 및 그룹 보존, 일위 U001의 내역 1건 검출, 이름 일치 항목 검출, 미사용 항목 0건 반환 검증 완료).
- `pytest resources/tester/test_est_row_actions.py resources/tester/test_est_estimate.py` 6개 전수 통과 (0 실패).
- [x] 일위대가 공종 할증계산(`TFRMHALJUNGBIUL`) 및 자재단가 부가세계산(`TFRMCONVERTTOGANGUB`) 구현
- 자체 검증:
1) 일위대가 공종 할증계산 (`TFRMHALJUNGBIUL`):
- REA `TFRMHEAD`의 `DoHalJungClick` (0xf5b7fc) 및 `TFRMHALJUNGBIUL`(명칭, 불러올비목, 적용율, 적용비목, 0xdb4b14) 근거 기반 구현.
- [native-haljung.js](file:///D:/aislo-wt/CODEX/tools/est-test/native-haljung.js)의 `EstHalJung.apply(project, tableKey, groupCode, { name, size, rate, sourceKind, targetKind })`:
- 불러올비목(규격): 0=비목별, 1=재료비의, 2=노무비의, 3=경비의, 4=합계의 (`OCheck`).
- 적용비목: 0=비목별, 1=재료비에, 2=노무비에, 3=경비에 (`SCheck`).
- **비목 매핑 정확성 보장**: `sourceKind`가 특정 비목(1=재료비, 2=노무비, 3=경비)이고 `targetKind = 0`(비목별)인 경우, 기본 경비(`JC`)로 왜곡되지 않고 해당 원천 비목(`SCheck = sInfo.ocheck`)으로 정확히 가산되도록 보정.
- 묶음 내 소계(`LinkIndex = 51, 68`) 직전 위치에 `LinkIndex = 58`(비율) 행을 안전하게 자동 삽입하고 즉시 재계산 반영.
- 0 또는 잘못된 할증율 및 존재하지 않는 공종/화면 요청 안전 거부.
- UI 연동: `native-views.js`에서 일위대가 화면 활성화 시 `할증계산` 버튼(`data-action="haljung-calc"`) 노출 및 대화상자 연동.
2) 자재단가 부가세계산 (`TFRMCONVERTTOGANGUB`):
- REA `TFRMSTUFFC`의 팝업 `popChangeCost` 및 `TFRMCONVERTTOGANGUB`(부가세포함/제외, 단단위계산, 0xf616f0) 근거 기반 구현.
- [native-vat.js](file:///D:/aislo-wt/CODEX/tools/est-test/native-vat.js)의 `EstVat.apply(project, indices, { calcMethod, diget, digetMethod })`:
- 계산방법: 0 = 부가세포함(×1.1), 1 = 부가세제외(÷1.1).
- 단단위계산: 원단위 또는 지정 자리수(1=십원, 2=백원..) 이하에 대해 사사오입/절하/절상 단단위 처리.
- 근거 불충분한 `row.Kind` 변경 기능 비활성화: REA 스펙상 `StuffcDat`에 `Kind` 필드가 소비되지 않으므로 혼선 방지를 위해 자재구분 변경 옵션 제거 및 안전 처리.
- 선택 행 또는 전체 자재단가(`Cost1~6`, `InputTC`, `TC`) 일괄 변환 및 Undo 지원. `ValueIndex`별(최저/평균/최고/선택/직접) 단가 및 재계산 후 원본 단가 보존 검증 완료.
- UI 연동: `native-views.js`에서 자재단가 화면(`StuffcDat`) 활성화 시 `부가세계산` 버튼(`data-action="vat-calc"`) 노출 및 대화상자 연동.
3) 700줄 제한 엄수:
- `native-haljung.js`: 91줄, `native-vat.js`: 58줄, `native-business-actions.js`: 593줄, `native-views.js`: 140줄.
- 관련 테스트:
- `pytest resources/tester/test_est_business.py` 18개 통과 (`test_haljung_calculation_apply_to_head_and_cost_groups`, `test_vat_calculation_exclude_include_and_unit_rounding_for_materials` 검증 보강 통과).
- 핵심 EST 테스트 45개 전수 통과 (0 실패).
- [x] 일위대가 공종 단위환산 계산(`TFRMUNITECALC`) 및 낙찰률 당초단가(100%) 복원(`TFRMBIMOGBIUL`/`TFRMNACCHALAPPLY`) 구현
- 자체 검증:
1) 일위대가 공종 단위환산 계산 (`TFRMUNITECALC`):
- REA `TFRMHEAD`의 팝업/도구 버튼 `popUniteCalc` (`단위환산 계산`, 0xf59bd8) 및 `TFRMUNITECALC`(명칭, 규격, 수량, 단위) 근거 기반 구현.
- `native-business.js`에 `convertGroupUnit(project, groupCode, { name, size, factor, unit })` 함수 구현:
- 선택한 일위대가 공종(묶음)의 제목 행(`Index > 0`)에 명칭(`Name`), 규격(`Size`), 단위(`Unite`) 적용.
- 특수행 제외: 비율 행(`LinkIndex=58, 62`), 낙찰률 행(`LinkIndex=52`), 소계 행(`LinkIndex=51, 68`) 및 계산 안 함/고정금액 행(`CalcIndex=1, 3`)은 수량(`GN`) 배수 변경에서 안전하게 제외하여 백분율 왜곡 및 중복 팽창 방지.
- 일반 비목 행의 수량(`GN`)에만 환산 계수(`factor`)를 부동소수점 곱으로 적용.
- `AutoCalc: false` 등 프로젝트 옵션 보존 및 0 또는 유효하지 않은 계수 거부.
- UI 연동: `index.html` 및 `native-views.js`에서 일위대가 화면(`HeadMainDat`) 활성화 시 `단위환산` 버튼(`data-action="unit-calc"`) 자동 노출.
- 대화상자(`EstActions.form`)를 통해 사용자 입력 접수 후 `EstApp.change()`로 트랜잭션 및 Undo 지원.
2) 낙찰률 당초단가(100%) 복원 (`TFRMBIMOGBIUL`, `TFRMNACCHALAPPLY`):
- REA `btnUndo` (Hint: "당초단가(100%)로 복원합니다.") 근거 구현.
- `native-business.js`의 `bid(project, rates)`:
- 연속 낙찰률 적용 시에도 최초 단가와 최초 옵션을 잃지 않도록 `row.PreBidRates`가 이미 존재하면 덮어쓰지 않고 최초 단가 유지.
- `O${field}`도 최초 단가(`PreBidRates[field]`)를 기준으로 낙찰률을 계산하여 다회차 적용 시의 누적 왜곡 방지.
- 원본 프로젝트의 `SourceRefresh` 설정값은 `next.options._preBidSourceRefresh ??= project.options.SourceRefresh`로 최초 1회만 보존.
- `restoreBid(project)`:
- 원래의 `SourceRefresh`가 `true`이더라도 재계산 도중 원천 단가로 복원 단가가 다시 덮이지 않도록, `SourceRefresh: false` 상태로 복원 재계산 수행 후 마지막에 원래 사용자의 `SourceRefresh` 옵션 상태를 안전하게 복원.
- `PreBidRates` 및 생성된 낙찰 단가를 복원하고, 원본 파일에 이미 존재하던 `O*` 필드(`OTU/ONU/OGU/OJU/OSU`)는 임의 삭제하지 않고 영구 보존.
- 저장·재열기(JSON 왕복), 수동/자동 계산 모드, 원천 단가와 다른 초기 단가 상태에서도 최초 단가 100% 온전 복원 확인.
- 설계변경 화면 도구 모음에 `당초단가(100%) 복원`(`restore-bid`) 버튼 연동.
3) 700줄 제한 엄수:
- `native-business.js`: 692줄, `native-business-actions.js`: 505줄, `native-actions.js`: 699줄, `native-views.js`: 136줄.
- 관련 테스트:
- `pytest resources/tester/test_est_business.py` 16개 통과 (`test_unit_calc_group_conversion_preserves_percentages_subtotals_fixed_and_other_groups` 포함).
- `pytest resources/tester/test_est_change_workflow.py` 15개 통과 (`test_bid_rates_consecutive_apply_source_refresh_and_roundtrip_restore` 추가 통과).
- 핵심 EST 테스트 43개 전수 통과 (0 실패).
- [x] 자료 오류 검색(issues) 및 불일치 데이터 확인: REA `TFRMNAMESIZECHECK`(명칭·규격·단위 불일치) 및 `TFRMPERSENTCHECK`(단위 백분율(%) 불일치 데이터 검색) 검출 구현
- 자체 검증:
1) REA `TFRMNAMESIZECHECK`(명칭 불일치 및 중복 데이타 확인) 근거 기반 검출 및 동기화 구현:
- `native-issues.js`의 `scan()`에서 기존 품명(`Name`), 규격(`Size`) 비교에 더해 **단위(`Unite`)** 불일치(`연결 단위 차이`) 검출 로직 추가.
- 원천 데이터(자재 `StuffcDat`, 노임 `LaborcDat`, 표준 `SilJukDat`, 견적 `EstiMateDat`, 일위 `HeadMainDat`, 단가 `CostMainDat`, 기계 `MachMainDat`)의 실제 명칭·규격·단위(`sourceName`, `sourceSize`, `sourceUnite`)를 이슈 항목에 보존.
- UI 렌더링에 `원천 실제 명칭` 표시 및 `원천명칭 반영`/`원천단위 반영` 개별 버튼(`data-issue-sync-row`), 상단 도구 모음에 `불일치 원천 명칭/규격 일괄 반영` 버튼(`data-issue-sync-all`) 제공.
2) REA `TFRMPERSENTCHECK`(단위 백분율(%) 불일치 데이터 검색) 근거 기반 검출 추가:
- REA `TFRMPERSENTCHECK` ("위의 데이타는 백분율(%)로 계산되어야 할 항목들입니다(할증, 낙찰율, 제잡비, 조달수수료 등). 그러나 단위가 %가 아니기 때문에 백분율로 계산하지 않습니다. 확인하세요.") 근거 구현.
- 일위대가(`HeadMainDat`), 내역서(`EstMainDat`), 기계경비(`MachMainDat`), 단가산출(`CostMainDat`)의 비목 상세 행 중 백분율 연산 특수행(`LinkIndex = 52, 57, 58, 62, 67`)인데 단위(`Unite`)가 `%` 또는 `%`가 아닌 항목을 검출(`단위 백분율(%) 불일치`).
3) 불일치 필드별 안전 반영 및 단위 왜곡·규격 삭제 방지:
- 단위(`Unite`)는 수량 및 단가 산식과 직결되므로 일괄 반영(`sync-all`) 대상에서 완전히 제외하고, 개별 행에서 명시적 승인(`원천단위 반영`) 시에만 처리하도록 분리.
- 규격(`Size`)은 원천 규격이 비어 있지 않은 유효 문자열일 때만 반영하여, 빈 원천 규격으로 기존 내역 규격을 덮어써 지우는 현상 방지.
- 품명(`Name`) 불일치는 품명만 원천 값으로 안전하게 동기화하고 기존 규격·단위를 보존.
- 일괄 반영 범위는 전체 스캔 결과가 아니라 화면의 현재 필터 조건(`tableFilter`, `typeFilter`)에 해당하는 `currentFiltered` 항목만 대상으로 한정.
- 반영 시 `EstApp.change()` 트랜잭션을 통해 실행 취소(Undo) 지원 및 데이터 무결성 보존.
4) 700줄 제한 준수:
- `native-issues.js` 372줄로 엄수.
- 관련 테스트:
- `pytest resources/tester/test_est_issues.py` 12개 통과 (`test_data_issues_detect_percentage_unit_mismatch` 추가).
- 브라우저 Undo 복원 및 원천 단위/규격 안전 처리 검증 완료.
- [x] 인쇄 서식 옵션 검토 및 안전 조치: `PrintApplyField`(단가 0원 누락 위험 방지) 및 `PrintSumBottom`(공종/소계 중복 합산 및 단가 합산 왜곡 차단) 안전 처리
- 자체 검증:
1) 결함 분석 및 안전 조치:
- `PrintSumBottom`: 표시되는 모든 행을 단순 누적할 경우 공종/소계와 상세 항목이 중복 합산되어 실제 공사금액의 2배 이상으로 뻥튀기되고, 단위가 다른 수량 및 단가(TU)를 합산하는 논리적 왜곡이 발생함.
- `PrintApplyField`: REA의 적용 단가 필드 표시 의도를 '단가 0인 행 필터링'으로 오해하여 삭제할 경우, 공종 제목 및 정상적인 0원 항목·음수 정산 항목이 누락되는 중대 위험이 발생함.
- REA 원문의 정확한 세부 집계/필터 알고리즘 근거가 불충분하므로, 서식 데이터의 왜곡 및 행 삭제를 즉시 차단하고 미지원 처리.
2) 코드 정리 및 데이터 무결성 보존:
- `native-forms.js`: `PrintApplyField`에 의한 행 삭제 필터 및 `PrintSumBottom`에 의한 단순 `<tfoot>` 합산 렌더링을 완전히 제거하고, 모든 공종(Level>0), 소계, 0원 항목, 음수 변경분 행이 원본 그대로 보존되도록 복원.
- `native-report.js`: 불안정한 체크박스 UI 노출을 비활성화하고, 공람용 옵션 등 검증된 서식 기능만 안정적으로 제공.
- 바이너리 레코드 오프셋(`import-print.cjs` / `export-print.cjs`)은 데이터 무결성을 위해 안전하게 0/1 값을 읽고 쓸 수 있도록 유지하되 잘못된 화면 처리를 차단.
3) 700줄 제한 준수:
- `native-report.js` 352줄, `native-forms.js` 445줄, `export-print.cjs` 67줄, `import-print.cjs` 75줄로 엄수.
- 관련 테스트:
- `pytest resources/tester/test_est_documents.py` 16개 통과 (`test_print_apply_field_and_sum_bottom_preserve_all_rows_and_no_destructive_filtering` 추가).
- 전체 연계 회귀 시험 54개 전수 통과 (0 실패).
- [x] 공람용 서식(내역서 117, 내역서총괄표 118, 일위대가 115, 일위대가총괄표 116) 인쇄 옵션 및 공람방법(수량만/단가만/수량·단가) 렌더링 구현
- 자체 검증:
1) REA `TFRMESTPRINTPRIVATE` 및 `DatPrintOption.opt`의 `PrintGongMethod` 설정 연동:
- 인쇄 미리보기(`native-report.js`) 서식 선택 시 공람용 서식(115, 116, 117, 118)에 대해 `공람용 방법` 셀렉트(`PrintGongMethod`: 0=수량만, 1=단가만, 2=수량·단가) 노출 및 설정 동기화.
2) `native-forms.js` 열 정의 및 마스킹 로직 구현:
- 공람용 내역서(`117`) 및 일위대가(`115`):
- `PrintGongMethod = 0`(기본값, 수량만): 단가 및 금액 열(TU, TC, NC, GC, JC, SC / NU, GU, JU, SU, TC)을 숨기고 공통정보 및 수량(GN)만 인쇄.
- `PrintGongMethod = 1`(단가만): 수량(GN)을 숨기고 단가 및 금액 열만 인쇄.
- `PrintGongMethod = 2`(수량·단가): 수량과 단가/금액을 모두 인쇄.
- 공람용 총괄표(`116`, `118`): `PrintGongMethod = 0` 시 금액 비목을 제외하고 공통 레벨·공종 열만 안전하게 출력.
- 일반 표준 서식(`1`, `2`, `103`, `106`)은 변경 없이 기존 전체 열 출력 및 데이터 무결성 보존.
3) 700줄 제한 준수:
- `native-report.js` 342줄, `native-forms.js` 439줄로 엄수.
- 관련 테스트:
- `pytest resources/tester/test_est_documents.py` 15개 통과 (`test_public_view_forms_print_gong_method_options` 추가).
- 전체 연계 회귀 시험 52개 전수 통과 (0 실패).
- [x] 계약심사(review) 화면 변경 행 필터링(전체/변경/사유작성/사유미작성) 및 심사 사유 일괄 적용 구현
- 자체 검증:
1) `native-review.js` 보기 필터(`review-filter`) 및 사유 일괄 적용(`review-batch-reason`) 신규 구현:
- 계약심사 테이블 상단에 항목 필터 셀렉트(`전체 항목`, `변경 항목만`, `사유 작성 항목`, `사유 미작성 변경항목`) 추가.
- 대규모 내역서에서 수량/단가 변경이 발생한 항목이나 사유 작성이 누락된 변경 항목만 즉시 선별하여 검토할 수 있도록 개선.
- 개별 행 선택 체크박스(`reviewSelection`) 및 일괄 사유 적용(`data-review-batch-reason`) 지원: 선택된 행들에 대해 공통 사유를 한 번에 입력·반영하며, 선택이 없는 경우 현재 화면에 표시된 필터링 대상 행 전부에 대해 일괄 반영.
- 심사 입력 초기화(`review-clear`) 시 선택 세트도 함께 정리되도록 상태 무결성 보존.
- 입력된 사유는 `contractReview.edits[key].Reason`으로 안전하게 저장되며, 실행 취소(Undo) 및 심사결과요약서(서식 171) 인쇄/엑셀 내보내기 연동 보존.
2) 모델 및 계산 무결성 검증:
- 기존 심사 수량/단가 계산기(`EstReview.calculate`)와 사본 열기(`EstReview.copy`) 로직은 변경 없이 100% 동일하게 유지.
3) 700줄 제한 준수:
- `native-review.js` 258줄로 700줄 이하 엄수.
- 관련 테스트:
- `pytest resources/tester/test_est_review_workflow.py` 8개 통과 (`test_review_view_filtering_and_batch_reason_application` 추가).
- 전체 연계 회귀 시험 50개 전수 통과 (0 실패).
- [x] 자료 오류 검색(issues)의 원가계산서(Wonga) 산출식(VSusic) 참조 유효성 검사 및 실제 계산기(calculate) 의존성/값/오류 로직 단일화 구현
- 자체 검증:
1) `native-wonga.js`의 계산 엔진 로직 단일화(`evaluateRows` 공통화):
- `scan`과 `calculate`가 별도의 파서/DFS/참조 로직을 갖지 않고, 동일한 `evaluateRows` 함수를 공유하여 전방 참조, 의존성 순서, 실제 재귀 계산, 범위(CalcIndex 2, 3 제외 행 필터링), 순환 참조 감지(`active` Set) 로직을 100% 동일하게 실행.
- 저장된 `Cost=0`이어도 수식이 있는 선행 참조 행(예: `B=A*2`)의 실제 재귀 계산 결과를 평가에 사용하여, `A=1/B`와 같은 유효한 나눗셈이 오래된 0값으로 인해 0 나눗셈으로 오탐되는 결함 원천 해결.
- 오류 발생 시(`onError` 콜백) `scan`은 해당 원인 행을 등록하고 기존 `Cost`로 대체하여 후속 행으로의 연쇄 전파를 방지.
2) `native-issues.js`의 실제 프로젝트 기초금액(`direct`) 전달:
- `issues.scan(project)` 호출 시 `project.calculation.direct`로부터 실제 `NC`, `GC`, `JC`, `SC`, `TC`, `노무비`, `재료비`, `경비`, `실적`, `직접공사비`를 추출해 `EstWonga.scan`의 `bases` 인자로 전달하여 기초 비목 0 판정 및 오탐 방지.
3) 700줄 제한 준수:
- `native-wonga.js` 163줄, `native-issues.js` 274줄로 700줄 이하 엄수.
- 관련 테스트:
- `pytest resources/tester/test_est_issues.py` 9개 통과 (`test_wonga_scan_parity_with_calculate_forward_refs_old_cost_bases_ranges_and_cycles` 추가).
- 전체 연계 회귀 시험 51개 전수 통과 (0 실패).
- [x] 자료 오류 검색(issues)의 단가산출(CostMainDat) 상세 행 연결 자료(자재·노임·기계 등) 부재 및 중복 검출 확장 구현
- 자체 검증:
1) `native-issues.js`의 `scan()` 테이블 검사 대상 확장:
- 기존 검사 대상(`EstMainDat`, `HeadMainDat`, `MachMainDat`)에 `CostMainDat`를 추가하여, 단가산출 호표 상세 행에서 자재(`LinkIndex: 5`), 노임(`LinkIndex: 6`), 기계(`LinkIndex: 105`), 표준(`LinkIndex: 21`), 견적(`LinkIndex: 101`), 일위(`LinkIndex: 103`), 산근(`LinkIndex: 104`) 등 원천 자료를 참조하는 상세 행(`Index === 0 && Code`)에 대해 연결 자료 존재 여부(`연결 자료 없음`) 및 원천 코드 중복(`연결 코드 중복`)을 전수 검사하도록 개선.
- 오류 항목 클릭 시 `data-issue-table="CostMainDat"`로 해당 호표(`group`) 및 상세 행으로 정확히 이동하고 선택 행을 하이라이트 표시하도록 연동 보존.
- 관련 테스트:
- `pytest resources/tester/test_est_issues.py` 5개 통과 (`test_data_issues_detect_cost_main_dat_missing_and_duplicate_links` 추가).
- 전체 연계 회귀 시험 76개 전수 통과 (0 실패).
- [x] ESTX 내보내기 시 웹 전용 작업 데이터(예정공정표 schedule, 추가 서식 문서 documents) 누락 방지 및 기본 빈 상태 대조 차단 구현
- 자체 검증:
1) `export-estx.cjs` 및 `import-estx.cjs` 검토 및 수정:
- `import-estx.cjs`에서 원본 ESTX 로드 시 기본 빈 공정표(`schedule: []`)를 명시적으로 초기화.
- `export-estx.cjs`에서 원본 가져오기 기본 상태(`schedule: []`)와 대조하여, 유효 필드 유무와 무관하게 `project.schedule?.length > 0` (빈 문자열·0값만 가진 행, 임의 필드 행 포함) 존재 시 공정 데이터 누락 방지를 위해 JSON 저장 안내와 함께 차단(`웹 예정공정표는 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요.`).
- `project.documents`의 공백 문자열(" ") 역시 사용자가 입력한 내용으로 취급하여 `text.length > 0` 검사로 누락 없이 JSON 저장 안내와 함께 차단(`웹 추가 서식 문서는 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요.`).
- 원본 ESTX에서 불러온 기본 빈 상태(빈 schedule `[]`, 빈 documents `{}` 또는 미입력)에서는 정상적으로 ESTX 바이너리 패치가 통과되어 기존 기능 유지.
- 관련 테스트:
- `pytest resources/tester/test_est_attachments.py` 6개 통과 (일반 행, 빈 문자열/0값 행, 임의 필드 행, documents 공백 문자열 저장 차단 단위 검증).
- `pytest resources/tester/test_est_native_export.py` 15개 통과 (`test_estx_export_schedule_and_documents_safety_guards` 실제 파일 기반 정상 패치 및 빈 행/0값/공백 문서 차단 실측 검증).
- 전체 연계 회귀 시험 92개 통과 (0 실패).
- [x] 예정공정표(schedule) 공정 항목 상/하 순서 이동(`schedule-move`) 및 전체 비우기(`schedule-clear`) 조작 구현
- 자체 검증:
1) `native-schedule.js`에 공정 행 상/하 이동(`moveRow`) 및 전체 비우기(`clearSchedule`) 구현:
- 연속된 공정 항목 배열(`project.schedule`)에 대해 인접 행과 위치를 맞교환하는 `moveRow(project, index, direction)` 지원.
- 상단 경계(`index === 0, direction === -1`) 및 하단 경계(`index === length - 1, direction === 1`) 도달 시 비활성화(`disabled`) 및 배열 범위 침범 방지.
- 공정표 상단 툴바에 `공정 전체 비우기`(`data-action="schedule-clear"`) 버튼을 추가하고 확인 대화상자(`confirm`) 후 안전하게 초기화 지원(목록이 비어있으면 `disabled`).
- 모든 조작은 `EstApp.change(..., false)`를 통해 수행되어 실행 취소(Undo)로 변경 전 공정 순서 및 데이터 완전 복원 가능.
2) 모델 검증 및 계산 무결성:
- 이동 후에도 `EstSchedule.calculate(project)`를 통해 `left`, `width`, `weight`, 가중평균 `progress`, 전체 시작일/종료일이 정상 재계산됨을 확인.
- Excel 내보내기/불러오기(`EstExchange`) 및 JSON 작업본 저장 시 순서 유지 확인.
- 관련 테스트:
- `pytest resources/tester/test_est_documents.py` 14개 통과 (`test_schedule_move_row_and_clear_operations` 추가).
- 전체 연계 회귀 시험 85개 전수 통과 (0 실패).
- [x] 동원인원·자재·장비 집계(resources) 화면 비목별(노임·자재·기계·표준·견적) 필터링 및 공종별 전개 조회 구현
- 자체 검증:
1) `native-extra.js`의 `view === "resources"` 렌더링 확장:
- 전체 집계뿐만 아니라 비목 구분(노임 `kind: 6`, 자재 `kind: 5`, 기계 `kind: 105`, 표준 `kind: 21`, 견적 `kind: 101`, 전체 `all`) 필터 셀렉트(`id="resource-kind-filter"`) 추가.
- 내역 공종별 세부 소요 전개 체크박스(`id="resource-by-work"`)를 추가하여, 체크 시 `EstResources.calculate(project, true)`를 호출해 공종코드(`WorkCode`) 및 공종명(`WorkName`) 열을 포함한 세부 전개 테이블 표시.
- 필터링 결과 건수 표시(`(총 N건)`) 및 오류 메시지 보존.
2) `native-business-actions.js` 이벤트 연동:
- `resource-kind-filter` change 시 `state.resourceKind`를 갱신하고 `EstApp.render()` 호출.
- `resource-by-work` change 시 `state.resourceByWork`를 갱신하고 `EstApp.render()` 호출.
3) 원천 서식(`print-catalog.json`) 및 `EstForms.data` 일치 확인:
- `112`(동원인원 공종별), `113`(소요자재 공종별), `114`(동원장비 공종별), `163`(동원실적 공종별), `164`(동원견적 공종별) 서식과 `13`(동원인원), `110`(소요자재), `111`(동원장비), `161`(동원실적), `162`(동원견적) 총괄 서식 간의 데이터 일관성 및 금액/수량 무결성 보존.
- 관련 테스트:
- `pytest resources/tester/test_est_documents.py` 13개 통과 (`test_resource_view_filtering_and_by_work_expansion` 추가 검증).
- 전체 회귀 시험 69개 전수 통과 (0 실패).
- [x] 기계경비(MachMainDat ↔ MachTitleDat) 당초 계약 단가 비교·설계변경 조회 및 데이터 보호(적용 미지원) 구현
- 자체 검증:
1) `native-machine.js` 검토 및 수정:
- REA `f18804` 기계경비 계산 엔진(`EstMachine.calculate`)과 `MachTitleDat`를 대조한 결과, 기계경비 호표 단가는 `MachMainDat`의 상세 산출 공식에 의해 결정되며, `MachTitleDat`의 `TC_`/`NC_`/`GC_`/`JC_`는 설계변경 비교를 위한 당초 계약값임.
- 당초 계약값은 현재 기준단가의 근거가 아니므로, 현재 단가를 과거 계약값으로 덮어쓰는 파괴적인 개별/일괄 적용 UI를 비활성화(`canApply: false`, 적용 버튼 미노출).
- 코드 호출 시에도 안전하게 예외를 발생(`기계경비 호표는 MachMainDat 산출 결과이며 당초 계약값으로의 덮어쓰기 적용은 지원하지 않습니다.`)시켜 사용자 데이터 오염 및 자동 재계산 충돌을 원천 차단.
- 신규 항목(`TC_ === null`)에 대해 0으로 왜곡하지 않고 `originalPrice: null`, `difference: null`, `status: "신규(당초없음)"`으로 안전하게 분기 표시.
- 화면 안내 문구에 기계 호표 단가는 `MachMainDat` 산출 공식에 의해 결정되며 당초값 덮어쓰기는 지원하지 않음을 명시.
2) UI 및 서식 연동:
- `index.html`, `native-app.js`, `native-extra.js`, `native-report.js`, `native-model.js`, `native-forms.js` 연동을 통해 사용 내역(`machine-usage`) 및 설계변경(`machine-change`)의 안전한 조회·인쇄 서식 지원.
- 관련 테스트:
- `pytest resources/tester/test_est_basic_prices.py` 5개 통과 (기계경비 당초/현재 비교, 신규 항목 TC_ null 안전 처리, 파괴적 apply 차단 및 데이터 불변 보존, Edge 브라우저 화면 조회 실측 검증).
- 전체 연계 회귀 시험 56개 전수 통과 (0 실패).
- [x] 호표(일위대가·단가산출·기계경비) 상세 행 이동(`move-estimate-up`, `move-estimate-down`) 및 단가산출 다중 -20 구간·참조 수치 무결성 보존 구현
- 자체 검증:
1) `native-business.js`에 `moveGroupDetailRows(rows, groupCode, selection, direction, table)` 구현 및 산출근거 다중 구간/참조 수치 무결성 검증 보완:
- 일위대가(`HeadMainDat`), 단가산출(`CostMainDat`), 기계경비(`MachMainDat`)의 호표 상세 행에 대해 연속 선택 행의 상/하 이동 지원.
- 호표 제목 행(`Index > 0`) 이동 차단 및 제목 행 건너뛰기 방지.
- 선택 행이 해당 호표 범위를 벗어나거나 인접 호표의 영역을 침범하지 않도록 상/하 경계 보호(경계 도달 시 이동 없이 원래 인덱스 보존).
- 합계/소계/구간비율 행(`LinkIndex` 54, 55, 58, 59, 61, 62) 및 합계 수식표지(`\x1a`) 포함 행의 이동 및 건너뛸 수 없도록 안전 차단.
- **다중 소계 구간 시작 표지(`Index === -20`)의 개별 보존 및 다중 선택 경계 보호**:
이동에 참여한 국소 교환 위치 사이에서만 `-20` 표지를 정확히 교환하여, 호표 내 다른 소계 구간의 `-20` 표지(예: `D0000000005`의 다중 소계 구간)가 0으로 초기화되거나 왜곡되지 않고 완전히 유지되도록 수정.
선택 범위 내에 복수 `-20`이 포함되거나 범위 중간에 `-20`이 끼어 있어 단일 경계 스왑으로 보장할 수 없는 경우는 보수적으로 이동 차단.
- **고유 식별자(`rowKey`) 매칭 및 앞줄 참조(`\x0c`) 수식/수치 불변 사전 검증(`validateCostOrderIntegrity`)**:
PkID 유무와 관계없이 `rowKey(row, index)`를 통해 전수 매칭하여, PkID 누락 행에서도 정확한 전/후 대조 보장.
호표 최종 금액(`rows[0].TC`)뿐 아니라 각 소계(`LinkIndex === 61`)·비율(`LinkIndex === 58`) 행의 최종 금액, 그리고 앞줄 참조(`\x0c`)를 사용하는 모든 행의 `LineData` 수식 문자열이 완전히 동일하게 보존되는지 검증.
앞줄 참조(`\x0c`) 행이 포함된 복수 행 동시 이동은 안전한 구분을 위해 단일 행 이동으로 제한하고 차단.
- **동일 결과값 선행 행 교환 시 뒤따르는 직전 결과 참조 대상 왜곡 방지 및 일반 행/변수 재정의 보수적 차단**:
뒤따르는 행이 특정 선행 결과를 직전 참조(`\x0c`)하는 구간에서, 동일 비목과 동일 결과값을 가진 선행 후보 행들이 2개 이상 존재할 때 이들 간의 순서 교환은 참조 대상이 왜곡되므로 보수적으로 이동 차단(`직전 결과를 참조하는 행이 뒤따라 참조 대상 구분이 불명확하므로 이동할 수 없습니다.`).
`LinkIndex 58`뿐 아니라 일반 행(`LinkIndex !== 61`)의 `\x0c` 앞줄 참조 역시 선행 후보 행이 2개 이상일 때의 순서 교환을 보수적으로 차단.
동일 변수명 중복 할당(변수 재정의) 구간에 포함된 행들의 교환 시 전역 변수 사전 순서 의존성 왜곡을 방지하기 위해 이동 차단(`동일 변수(...)를 재정의하는 구간에서는 참조 대상 보존을 위해 이동할 수 없습니다.`).
2) `native-business-actions.js` 및 `native-views.js` 연동:
- 툴바의 행 이동 버튼(`move-estimate-up`, `move-estimate-down`)이 내역서(`EstMainDat`)뿐 아니라 호표 화면(`HeadMainDat`, `CostMainDat`, `MachMainDat`)에서도 활성화되도록 노출.
- 호표 화면 조작 시 `moveGroupDetailRows`를 호출하고 `EstApp.change()`를 통해 작업본 반영 및 실행 취소(Undo/Redo) 지원.
- 관련 테스트:
1) `pytest resources/tester/test_est_change_workflow.py` 13개 전수 통과:
- 일위대가 상세 행 이동 및 Edge 실측 시험.
- 단가산출(`CostMainDat`)의 실제 사례(`D0000000004` 인부소계 구간)에서 상세 행 이동 후 소계(`14,373.6`) 및 호표 총액(`16,421`) 완벽 보존 검증.
- 다중 `-20` 구간을 가진 실제 사례(`D0000000005`)에서 상세 행 이동 후에도 `-20` 표지 2개 및 1차 소계(`1,383.4`), 2차 소계(`1,407.8`)가 모두 보존됨을 검증.
- A/B 동일값 선행 행 교환 시 직전 결과 참조 대상 왜곡 재현 및 보수적 차단 검증.
- `LinkIndex 58` 이외 일반 행의 `\x0c` 앞줄 참조 모호성 차단 검증.
- 동일 변수(`W`) 재정의 구간 행 교환 보수적 차단 검증.
- 소계 행(`LinkIndex 61`), 비율 행(`LinkIndex 58`), 변수 산식 행(`D0000000000`의 Q식), 소계 수치가 왜곡되는 구간 침범 이동, 범위 중간에 `-20`이 포함된 이동, 앞줄 참조 복수 행 이동의 안전 차단 검증.
- Edge 브라우저에서 단가산출 상세 행 이동, 소계 보존 및 실행 취소(Undo) 원복 실측 검증.
2) 원본 ESTX 무결성 보호 시험 14개 통과 (`pytest resources/tester/test_est_native_export.py`).
3) 핵심 비즈니스 및 가격 회귀 시험 28개 통과 (`test_est_basic_prices.py`, `test_est_standard.py`, `test_est_estimate.py`, `test_est_business.py`).
4) 주 수량 회귀 시험 12개 통과 (`test_est_quantity_workflow.py`).
- [x] 자재단가(StuffcDat) 및 노임단가(LaborcDat) 사용 내역 화면과 기초단가 개별/일괄 적용 구현
- 자체 검증:
ative-basic-prices.js를 신규 구현하여 자재단가(LinkIndex === 5) 및 노임단가(LinkIndex === 6)를 원천(StuffcDat, LaborcDat)과 비교하는 사용 내역(material-usage, labor-usage) 및 설계변경(material-change, labor-change) 화면 제공.
1) 자재는 GU, 노임은 NU 비목으로 원천 기초단가 반영 및 SCheck > 0 재분류 비목 보존.
2) 수동계산(AutoCalc = false) 시 원천 단가·비목은 변경하되 해당 행의 기존 금액(GC, NC, TC)을 강제 계산하지 않고 보존하며, AutoCalc=true 전환/재계산 시 수량(GN)을 반영해 전체 전파되도록 규칙 일치.
3) 화면 네비게이션, 인쇄 서식, 모델 검증 등록 및 EstApp.change() 연동 Undo/Redo 지원.
- 관련 테스트: pytest resources/tester/test_est_basic_prices.py 4개 통과(단위 시험 및 Edge 브라우저 자재/노임 단가 적용, Undo 원복 검증).
- [x] 견적단가 사용 내역 화면(estimate-usage) 및 기초단가(EstiMateDat) 개별/일괄 적용 구현
- 자체 검증:
ative-estimate.js를 신규 작성하여 견적단가 원천(EstiMateDat, LinkIndex === 101)과 내역 행 간 단가 비교(usage), 차이 검출, 개별 적용(pplyRowPrice), 일괄 적용(pplyAllPrices) 구현.
1) 원천 비목 보존: 원천 비목 분할(InputGC, InputNC, InputJC) 유지 및 SCheck > 0 재분류 비목 보존.
2) 수동계산(AutoCalc = false) 시 원천 단가·비목은 변경하되 해당 행의 기존 금액을 보존하고, AutoCalc=true 전환/재계산 시 수량(GN)을 반영해 갱신되도록 수동계산 규칙 일치.
3) 화면 네비게이션, 인쇄 서식(estimate-usage, estimate-change), 모델 검증 등록 및 EstApp.change() 연동 Undo/Redo 지원.
- 관련 테스트: pytest resources/tester/test_est_estimate.py 4개 통과(단위 시험 및 Edge 브라우저 개별/일괄 적용, Undo 원복 검증).
- [x] 표준시장단가 사용 내역 화면에서 개별 및 일괄 기초단가 내역 적용·실행 취소 구현
- 자체 검증:
ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainDat, LinkIndex === 21) 간 단가 차이가 있는 항목을 선별(canApply)하고, 개별 행 단위 적용(pplyRowPrice, data-standard-apply-row) 및 전체 차이 항목 일괄 적용(pplyAllPrices, data-standard-apply-all) 기능을 구현.
1) 적용 시 원천 자료의 고유성 검증: 원천 코드가 없거나 중복된 경우 임의 적용을 차단하고 예외 메시지 출력.
2) 원천 비목 보존 및 계산 규칙 일치:
- 원천(SilJukDat)이 재료/노무/경비/실적 비목 분할(InputGC, InputNC, InputJC, InputSC)을 제공할 경우 이를 그대로 단가 비목(GU, NU, JU, SU)으로 반영.
- 사용자가 설정한 비목 재분류(SCheck > 0: 재료/노무/경비 몰아주기)가 있을 경우 이를 임의로 해제하지 않고 해당 대상 비목으로 기초 단가 총액을 보존.
- 수동계산(AutoCalc = false) 상태에서는 기존 내역 편집 및 est_estimate_classification_refreshes_only_selected_source_and_preserves_manual_mode 규칙과 일치하도록, 원천 단가 및 비목 분할은 반영하되 해당 행의 기존 금액(GC, NC, JC, SC, TC)을 강제 계산하지 않고 보존. 이후 AutoCalc=true로 전환하거나 재계산을 실행할 때 수량(GN)을 반영한 정식 금액으로 전파.
3) 화면 상단 툴바에 적용 대상 건수(기초단가 일괄 적용 (N건))를 표시하고 대상이 없으면 비활성화(disabled).
4) 작업본 반영은 EstApp.change()를 통해 이루어지므로 실행 취소(Undo)로 변경 전 단가 및 금액을 단계별로 완전 복원 가능.
- 관련 테스트: pytest resources/tester/test_est_standard.py 5개 통과(수동계산 AutoCalc=false 보존 및 재계산 전환, 단위 시험 및 Edge 브라우저 개별/일괄 적용, Undo 원복, 비목 분할 보존, SCheck 보존 등), 연계 회귀 39개 통과.
- [x] 수량산출 항목 단위(Unite) 편집 및 Title 테이블 메모 필드 보존을 통한 ESTX 저장·재열기
- 자체 검증: `native-quantity.cjs` 수량산출 화면에 단위(`Unite`) 편집 열 추가 및 `export-quantity.cjs`의 `withoutLabels`에 `Unite` 라벨 변경 차단 방지 반영. `native-engine.js`의 `saveTitles()`에서 `CostTitleDat`·`HeadTitleDat`·`MachTitleDat`의 식별자(`PkID`, `Index`, `RecNo`, `Flag`)뿐만 아니라 BLOB/메모 필드(`DifineMemo`, `SpaceMemo`, `EscData`)를 원본 Title 행 기준으로 보존(기존 Title에 없을 시 Main 값이 오염 생성되지 않도록 필터링).
- 브라우저 검증: 임시 서버(127.0.0.1:5186)와 Edge에서 설치 `main.estx`를 열어 수량 항목 단위를 `m2`로 변경 후 ESTX 내보내기/다운로드, 브라우저 재열기 및 다른 수량 항목 보존·실행 취소 확인. (대용량 프로젝트 약 164MB에 따른 브라우저 임시 저장 용량 부족 경고 확인).
- 관련 테스트: `pytest resources/tester/test_est_quantity_workflow.py` 12개 통과, 기존 `test_est_native_export.py` 14개는 수정 전 기반 통과 상태이며 이번 Title 보존 전용 시험(`test_recalculation_and_native_export_keep_original_title_identities_when_sample_exists`) 1개 통과(58.08s, 인덱스 `.idx` 동일성 및 단위 `m2` 보존).
- 남은 제한: 원본 EST와의 실행 동등성은 미확인.
- [x] 수량산출서 항목 추가·삭제 및 내역 연결·JSON 저장/재열기 지원
- 자체 검증: `native-quantity.cjs`의 수량산출 화면에 `수량 항목 추가`(`data-quantity-add-item`) 및 각 수량 항목 행에 `삭제`(`data-quantity-delete-item`) 버튼을 구현.
1) 항목 추가 시 기존 항목 코드(`r000000`, `r000001` ...)와 중복되지 않는 새 고유 코드 자동 생성(`r######`), 품명/규격/단위/수량 초기화 및 `EstApp.change()` 등록.
2) 항목 삭제 시 안전성 보장: 산출 행의 산출근거(`LineData`) 결과 표지(`\x12...\x12`) 및 `row.markers`에서 참조 중인 항목은 산출 행 결과 표지 수와 마커 수 불일치로 인한 계산 오류를 방지하기 위해 임의 삭제를 차단하고 사전 안내 메시지 출력. 미참조 항목에 대해서만 확인(`confirm`)을 거쳐 안전하게 삭제.
3) 품명·규격·단위 및 연결 내역 대상 지정(`TargetKey` / `TargetCode`) 후 JSON 작업본 저장 및 재열기 지원.
4) ESTX 바이너리로 표현 불가능한 임의 행 구조 변경(신규 산출서/항목 증감)에 대해서는 기존 ESTX 저장 시의 불변 무결성 보호를 유지하고 JSON 보관 안내.
- 관련 테스트: `pytest resources/tester/test_est_quantity_workflow.py` 12개 통과 (신규 항목 추가, 참조 중인 항목 삭제 차단 안내 및 미참조 항목 안전 삭제 단위 시험 반영).
- [x] 자료 오류 검색의 수량산출 항목 내역 대상 연결 검사, 해당 산출서 이동 및 검색 화면 내역 연결 지정
- 자체 검증: `native-issues.js`의 `scan()` 함수를 실제 수량 적용(`totals()`) 규칙과 정확히 일치하도록 개편:
1) `TargetKey` 우선 매칭(`id:${row.PkID}`): 두 행의 코드가 동일해도 `TargetKey`로 고유하게 연결된 경우 정상 판정하여 오탐 방지. 잘못된 키는 `수량 대상 내역 없음`으로 탐지.
2) `TargetKey`가 없을 시 `TargetCode ?? LinkCode` 대체값으로 매칭하며, `Level > 0`인 공종/비고 행 제외 규칙 일치.
3) 수량산출서(`quantitySheets`) 및 수식표 직접 작성(`quantities`) 항목 모두 검사하며 미연결/대상 부재 항목을 `unlinked`로 추출.
4) 오류 검색 화면에서 수량산출 오류 항목에 대해 `산출서 열기` 버튼을 제공하여, 클릭 시 `quantity` 뷰 및 해당 `quantitySheet` 코드로 즉시 전환·선택되도록 구현.
5) 자료 오류 검색 화면에서 미연결 또는 대상 부재인 수량 항목에 대해 바로 내역 행을 선택할 수 있는 드롭다운(`data-issue-target-select`)을 제공하여, 오류 검색 화면을 벗어나지 않고도 `TargetKey`(`id:${row.PkID}`) 및 `TargetCode`를 즉시 지정하고 `EstApp.change()`를 통해 JSON 작업본에 즉시 반영 및 실행 취소 가능하도록 구현.
6) `native-links.cjs`의 `existingTargets()`에서 고유한 `PkID`가 존재할 때만 `TargetKey`(`id:${row.PkID}`)로 안전하게 연결하고, `PkID`가 없는 동일 코드 행 간의 모호한 연결은 기존대로 미연결 유지 및 경고 처리 보존.
- 관련 테스트: `pytest resources/tester/test_est_native_export.py::test_existing_quantity_links_respect_native_mode_and_avoid_ambiguous_targets resources/tester/test_est_issues.py` 5개 통과, `test_est_quantity_workflow.py` 12개 통과. Edge 브라우저 '산출서 열기' 클릭 시 수량산출서 전환 이동 및 오류 화면 내역 연결 드롭다운 선택 시 작업본(`TargetKey`) 즉시 연결 실측 확인.
- [x] 테일스케일 전용 독립 서버 감시 프로세스(`watch-server.cjs`) 및 대용량 IndexedDB 캐시 복원
- 자체 검증: `server.cjs`의 외부 바인딩을 `EST_TEST_HOST=100.65.141.15:5175`로 한정(127.0.0.1 접속 시 ECONNREFUSED 차단 확인). Node 내장 `--watch` 기반 감시 프로세스(`watch-server.cjs`)를 구현해 `data/` 및 로그를 제외한 `.js`·`.cjs` 소스 변경 시에만 자동 재기동되도록 구성(파일 touch 시 자동 재기동 및 헬스 200 확인). `server.cjs`를 require하는 테스트 실행 시 watcher hang 없음 확인(`test_est_quantity_workflow.py` 12개 2.69s 통과).
- 대용량 임시 저장 보완: `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] 일위대가 상세 비목 지정 변경을 해당 행에만 반영 → 자동·수동 계산, 원천 불일치·다른 호표 보존 시험.
- 자체 검증: 관련 시험 33개 통과. Tailscale HTTP Edge에서 자동·수동 계산, 다른 호표 보존, 원천 단가 불일치 시 복원 차단 및 화면 오류 0 확인.
@@ -181,6 +535,12 @@
- 자체 검증: 수정 전 문제 재현, 수정 후 금액 유지와 비목 변경 재계산 확인. 관련 시험 16개 통과.
- [x] 연차 차수와 미확정 기성 회차·입력 Excel 편집 → 확정 회차의 계약·누계 보존 및 가져온 업무 계산 시험.
- 자체 검증(2026-11-07): 확정 회차는 내보내기 대상에서 제외하고 기존 계약·누계를 그대로 유지. Edge에서 미확정 금회 수량 1→3, 연차 수식 50%→25%를 Excel로 변경해 가져온 뒤 기성 누계 500·연차 250 확인. 없는 행·확정 회차·중복 ID·잘못된 입력 차단, Excel·기성·연차 시험 22개 통과. Edge 오류 0, openpyxl 날짜 API 경고 5건.
- [x] 내역·일위대가 행의 경비성단가(ActionSetJCBimog) 및 순공사비 경계 행(popMainSetTotalSumLine) 화면 조작 메뉴와 토글 재계산 지원.
- 자체 검증(2026-11-08): REA ActionSetJCBimog(0xf17c10/0x10dba14) 및 popMainSetTotalSumLine 근거를 기반으로 `toggle-jc-bimog`, `toggle-sun-gongsa` 조작 액션 구현.
1) 경비성단가 보존 및 복원: SCheck=1(재료) 또는 2(노무) 상태에서 경비성단가 지정(SCheck=3) 후 해제 시 원래의 SCheck 값(`_prevSCheck`)으로 온전히 복원(기존 비목별 0으로의 왜곡 차단), 노무비(SCheck=2, NU=2000)가 SCheck=3 전환 후 해제 시 SCheck=2, NU=2000으로 복원됨을 검증.
2) 순공사비 연결 행 보호: 원천 연결(자재/노임/일위/단가/기계 등 LinkIndex 5, 6, 21, 51, 52, 55, 57, 58, 61, 62, 67, 68, 101~105) 또는 코드·수량이 있는 상세 행의 순공사비 경계선 지정을 안전하게 차단(`"단가·수량 연결 행이나 기존 특수 공종 행은 순공사비 경계선으로 지정할 수 없습니다. 빈 제목/구분 행을 선택하세요."`).
3) 순공사비 속성 보존/복원: 기존 54 행이 있을 때 새 행을 순공사비로 지정하거나 해제 시 원래의 `LinkIndex`와 `Name`(`_origLinkIndex`, `_origName`)으로 정확히 복원됨을 검증.
4) 안전성 거부 4건(비허용 화면 호출, 선택 없음, 복수 순공사비 지정, 원천 연결 상세행 지정 거부) 전수 확인. 신규 전용 시험 `resources/tester/test_est_row_actions.py` 통과.
- [ ] 남은 웹 업무 기능을 이어서 구현하고 각 기능의 입력·계산·저장 흐름 확인.
- [x] ESTX 원본 옵션·공사명 및 기존 BLOB 확장 저장 구현 → 설치 샘플 재읽기와 인덱스·다른 자료 보존 확인. CAD 제외.
@@ -190,6 +550,13 @@
- [x] 원본 결과와 기존 계산이 일치하는 수량산출서의 결과 표지·수량 목록 재계산 저장.
- 자체 검증(2026-11-06): main.estx 수량산출서 폭 9→10 변경 시 표지 및 목록 수량 104→116으로 기록·재읽기, 다른 문서·인덱스·CAD 보존. EST 전용 102개 통과(70.78초). 계산 오류·기존 결과 불일치 문서는 저장을 거부하며 원본 EST 실행 검증은 미실시.
- [ ] 서식 구간을 넘는 수량산출서 편집과 목록·연결 변경 지원; 계산 오류나 기존 결과 불일치 문서의 처리 확인.
- 진행 및 미완료 범위(2026-11-08):
1. 서식 편집 지원: 수량산출서 편집기에서 위첨자(`^(2)` 등) 및 복합 서식 태그 경계를 가로지르는 텍스트 수정(`abc^(2)def` -> `abc^(3)xyz`) 시 분할된 서식 런을 유지하며 인라인 패치 적용 확인 (`test_quantity_document_edit_across_styles_and_superscript_preserves_structure` 통과).
2. 불일치 문서 처리: 원본 저장 수량과 산식 계산 결과가 다른 불일치 문서(예: `N0000000083` 원본 286 vs 산식 100.1)의 경우, 품명/규격/단위(Unite) 편집 시 기존 원본 수량(286)을 보존해 ESTX 패치를 허용하며, 산식을 수정해 결과가 변경되는 경우 "수량산출서 원본 결과와 재계산 값이 다릅니다. JSON으로 저장해 확인하세요"로 ESTX 덮어쓰기를 안전 차단함을 확인.
3. 미완료 범위(제한 사항): 수량산출서 목록·연결(항목 추가/삭제, 수량산출서 신규/삭제, 행 구조 증감 등) 변경 시 ESTX 저장(`MulMainDat`, `MulTitleDat`, `Editor`, `MulList`)은 고정 인덱스 및 BLOB 구조 보존을 위해 현재 안전 차단되어 있으며, JSON 저장소(`project.quantitySheets`)에서만 지원됨. ESTX 목록·연결 구조 변경 저장은 미완료 상태임.
4. 관련 회귀 시험 `test_est_quantity_workflow.py` 12개, `test_est_business.py` 18개, `test_est_native_export.py` 수량/위첨자 10개 전수 통과.
- [x] 수량산출 항목의 단위(Unite) 화면 입력 열과 ESTX 저장 → 재열기·다른 항목 보존·목록 바이너리 검증.
- 자체 검증(2026-11-07): native-quantity 렌더러에 단위 입력 열 추가 및 change 핸들러에서 Unite 변경 허용. 설치 main.estx 기존 항목의 품명·규격과 함께 단위(Unite: 'm2')를 수정해 브라우저 이벤트·저장·재열기, 다른 항목·연결·수량 보존 및 실행취소 확인. 단위 변경 시 산식 계산 불변 확인(퍼센트 등 임의 자동변환 없음). test_est_quantity_workflow 12개 및 test_est_native_export 회귀 14개 통과. 원본 EST 실행 검증은 미실시.
- [x] 수량산출서의 연속 글자 서식에 걸친 텍스트 편집 저장 → 서식 태그 보존 및 재읽기 검증.
- 자체 검증(2026-11-07): 서로 다른 세 글자 서식에 걸친 텍스트를 변경한 뒤 태그와 재읽기 결과를 대조. 변경 시작 위치의 서식으로 새 텍스트를 저장하며 위첨자·표지를 넘는 임의 편집은 계속 제한. 관련 시험 10개와 EST 전용 120개 통과(77.60초). 원본 EST 실행 비교는 미실시.
- [x] 설치 수량산출서의 실제 글자 서식 경계 편집을 회귀시험으로 보존 → 해당 문서와 태그 재읽기.
@@ -237,7 +604,12 @@
- 구현: Excel 스칼라 표/자료형과 기존 바이너리 보존, 단가 수정 전파, 코드/수식/중복 검사. XML 작성정보·연결/길이 검사·금액 보존 및 자동계산 해제, UTF-8/CP949/UTF-16 입력. JDLCHK 필드 정의와 EST TAB/ENQ·CP949 처리 경로에서 기본 표/ZIP 생성·읽기, 고정 단가 전개 제한 표시. ESTX 고정 행·비인덱스 필드/BLOB 원가 기록과 재읽기 검산. 재계산이 원본 호표 목록 PkID/인덱스 식별자를 버리던 문제 수정.
- 화면: CAD 제외 서식 이름 40개, 데이터 출력 및 서식별 직접 입력 문서, 용지/방향/여백/글자/행/쪽 번호. 예정공정표 기간·진도·가중치 입력/검사·기간 막대·가중평균, 공종별 동원 분리 출력. JSON 저장/재열기 및 이력 대상.
- 자체 검증(2026-11-06): Edge에서 XML 다운로드→실제 XSD 통과→재열기 금액/상하위 계층/0원 도급액 보존, JDL 다운로드/재열기 직접비·원가 보존, 예정공정 입력/저장 복원, CAD 제외 40개 서식 선택 시 JS 오류 0. Excel 왕복 시 바이너리·소수 유지, 66행 20행/쪽 분할과 쪽 번호 5~8, 본문/설정 저장 재열기 통과. 별도 openpyxl 읽기로 xlsx 구조/자료형 확인. 설치 sample.estx의 수량 변경→수정 저장→독립 재읽기 통과, 모든 .idx 바이트 불변. 원본 EST 실행 검증은 아님.
- [ ] 남은 완전 호환 범위: ESTX 신규 파일/행 증감·키 변경의 인덱스 재생성, WPTools 편집 저장, 기성/연차 원본 별도 업무 파일; JDL 확장 산식/기성·물가 자료와 원본 검사 DLL 인증; 원본과 같은 인쇄 도형/배치 및 기존 산식 잔여 오류. CAD만 제외한 전체 완료로 판정하지 않음.
- [ ] ESTX 신규 파일/행 증감·키 변경의 인덱스 재생성 구현 가능성 조사 및 점검.
- 조사 및 한계 분석(2026-11-08):
1. 파일 구조 및 암호화 특성: ESTX 내 각 테이블의 `.idx` 파일은 DBISAM 독자적 B-Tree 인덱스 형식으로, 헤더 8~24바이트에 서명(`06 8a be 8e 59 23 64 cb 40 3d 71 d2 e3 bc 64 d0`)이 포함되어 있으며 전체 블록(4,096바이트 단위)이 암호화·해싱되어 있어 단순 XOR 또는 비트 역변환으로 역공학할 수 없음.
2. 무결성 검증 제약: `exportEstx`는 동일 행 수 및 비인덱스 필드(수치, 일반 텍스트, BLOB 데이터 등)의 수정 저장을 안전하게 지원하지만, 행 추가/삭제나 인덱스 키(`PkID`, `Code`, `Index`, `RecNo` 등)를 변경하면 `.idx`의 B-Tree 구조와 불일치가 발생함. 원본 Delphi/DBISAM 엔진 외부에서 인덱스 재생성을 구현할 수 있는 바이너리 규격이나 소스 근거가 부재함.
3. 구현 범위 및 처리: 따라서 ESTX 내 행 추가/삭제 및 인덱스 키 변경은 기존처럼 명확한 예외 메시지(`{key}: 행 추가/삭제는 인덱스 재생성이 필요합니다`, `{key}.{name}: 인덱스 키 변경은 지원하지 않습니다`)로 안전 차단하고, 신규 파일/행 증감/자유로운 편집이 필요한 작업은 JSON 저장소(`project.tables`)에서 완전 무결하게 지원·보존하도록 범위를 명확히 유지함.
- [ ] 남은 완전 호환 범위: ESTX 신규 파일/행 증감·키 변경의 인덱스 재생성, WPTools 편집 저장, 기성/연차 원본 별도 업무 파일; JDL 확장 산식/기성·물가 자료와 원본 검사 DLL 인증; 원본과 같은 인쇄 도형/배치 및 기존 산식 잔여 오류. CAD만 제외한 전체 완료로 판정하지 않음.
- [x] 전용 회귀·포맷·700줄 제한·커밋/push 마무리. 검증 서버 종료 완료.
- 자체 검증: EST 전용 전체 99 통과(48.88초). 변경 파일만 prettier/ruff format, git diff --check와 모든 소스 700줄 이하 확인. a5157dd3a 커밋을 CODEX에 push, origin/CODEX와 차이 0·작업 트리 깨끗함 확인. 검증 서버 5175 종료, 이번 일회 UI/분석 스크립트·출력물·전용 시험 저장본 정리. 기존 원본 자료와 앱 data는 수정하지 않음.
+14
View File
@@ -139,6 +139,13 @@ function exportEstx(project) {
throw Error("웹 첨부파일은 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요.");
if (project.excelQuantityLinks?.length)
throw Error("웹 Excel 수량 연결은 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요.");
if (project.schedule?.length)
throw Error("웹 예정공정표는 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요.");
if (
project.documents &&
Object.values(project.documents).some((text) => typeof text === "string" && text.length > 0)
)
throw Error("웹 추가 서식 문서는 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요.");
const original = Buffer.from(project.originalArchive, "base64"),
base = importEstx(original),
files = unzip(original);
@@ -176,6 +183,13 @@ function exportEstx(project) {
throw Error(
"웹 기성/연차/당초 사본/계약심사는 원본 별도 업무 파일 구조가 필요합니다. JSON 저장을 사용하세요.",
);
if (!same(project.schedule || [], base.schedule || []))
throw Error("웹 예정공정표는 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요.");
if (
project.documents &&
Object.values(project.documents).some((text) => typeof text === "string" && text.length > 0)
)
throw Error("웹 추가 서식 문서는 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요.");
if (!same(project.quantitySheets || [], base.quantitySheets || []))
project = updateQuantities(project, base, cp949, memory);
let changed = 0;
+16
View File
@@ -39,6 +39,22 @@ function updatePrintOptions(buffer, changes) {
value <= 100
)
output.writeInt32LE(value, field.at);
else if (
name === "PrintGongMethod" &&
field?.kind === 1 &&
field.width === 4 &&
Number.isInteger(value) &&
[0, 1, 2].includes(value)
)
output.writeInt32LE(value, field.at);
else if (
["PrintApplyField", "PrintSumBottom"].includes(name) &&
field?.kind === 3 &&
field.width === 2 &&
Number.isInteger(value) &&
[0, 1].includes(value)
)
output.writeInt16LE(value, field.at);
else throw Error(`인쇄 서식 ${id}.${name}: 저장할 수 없는 값`);
}
}
+13 -4
View File
@@ -90,7 +90,7 @@ function editStyledRuns(div, before, after, parse) {
// Change text inside an existing styled run, never WPTools tags or bookmarks.
function editDocument(text, oldSheet, sheet) {
const withoutLabels = (items) => items.map(({ Name, Size, ...rest }) => rest);
const withoutLabels = (items) => items.map(({ Name, Size, Unite, ...rest }) => rest);
if (
!same({ ...oldSheet, rows: [], items: [] }, { ...sheet, rows: [], items: [] }) ||
!same(withoutLabels(oldSheet.items), withoutLabels(sheet.items)) ||
@@ -113,13 +113,22 @@ function editDocument(text, oldSheet, sheet) {
if (direct) return direct;
const styled = editStyledRuns(div, before, after, parse);
if (styled) return styled;
const split = /([\x03\x0c\x0e\x11\x12\x13\x15\x1a])/g,
const split = /(\^\([^)]+\)|[\x03\x0c\x0e\x11\x12\x13\x15\x1a])/g,
isSpecial = (s) => /^\^\([^)]+\)$/.test(s) || /[\x03\x0c\x0e\x11\x12\x13\x15\x1a]/.test(s),
previous = before.LineData.split(split),
next = after.LineData.split(split);
if (previous.length === next.length && previous.every((v, i) => i % 2 === 0 || v === next[i])) {
if (
previous.length === next.length &&
previous.every(
(v, i) =>
!isSpecial(v) ||
(isSpecial(next[i]) &&
(v === next[i] || (v.startsWith("^(") && next[i].startsWith("^(")))),
)
) {
let candidate = div,
current = before;
for (let i = 0; i < previous.length; i += 2) {
for (let i = 0; i < previous.length; i++) {
if (previous[i] === next[i]) continue;
previous[i] = next[i];
const updated = { ...current, LineData: previous.join("") };
+1
View File
@@ -381,6 +381,7 @@ function importEstx(buffer, name = "가져온 내역서") {
printCatalog,
progress: [],
annual: [],
schedule: [],
drawings: [],
importWarnings: [...warnings, "활성 원가 서식과 인덱스 편집 규칙은 자동 확정하지 않았습니다."],
originalArchive: buffer.toString("base64"),
+12 -1
View File
@@ -46,7 +46,18 @@ function readPrintOptions(buffer, onRow) {
state = (status[i >> 2] >> ((i % 4) * 2)) & 3;
if (state === 1 || state === 2) continue;
if (state !== 0) throw Error("인쇄 설정 필드 상태 오류");
if (["PrintTop", "PrintBottom", "PrintLeft", "PrintRight", "PrintLineCount"].includes(name))
if (
[
"PrintTop",
"PrintBottom",
"PrintLeft",
"PrintRight",
"PrintLineCount",
"PrintGongMethod",
"PrintApplyField",
"PrintSumBottom",
].includes(name)
)
positions[name] = { at, kind, width };
if (kind === 1 && width === 4) row[name] = take(4).readInt32LE();
else if (kind === 3 && width === 2) row[name] = take(2).readInt16LE();
+30 -1
View File
@@ -1,4 +1,4 @@
<!doctype html>
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
@@ -20,8 +20,12 @@
<script defer src="native-resources.js"></script>
<script defer src="native-schedule.js"></script>
<script defer src="native-issues.js"></script>
<script defer src="native-haljung.js"></script>
<script defer src="native-vat.js"></script>
<script defer src="native-attachments.js"></script>
<script defer src="native-standard.js"></script>
<script defer src="native-estimate.js"></script>
<script defer src="native-basic-prices.js"></script>
<script defer src="native-model.js"></script>
<script defer src="native-views.js"></script>
<script defer src="native-extra.js"></script>
@@ -30,6 +34,7 @@
<script defer src="native-forms.js"></script>
<script defer src="native-exchange.js"></script>
<script defer src="native-c3r.js"></script>
<script defer src="native-block-actions.js"></script>
<script defer src="native-actions.js"></script>
<script defer src="native-business-actions.js"></script>
<script defer src="native-app.js"></script>
@@ -74,9 +79,21 @@
><button data-action="paste-row">행 붙여넣기</button
><button data-action="delete-row">선택 행 삭제</button>
<hr />
<button data-action="block-start">블럭 시작</button
><button data-action="block-end">블럭 끝</button
><button data-action="block-clear">블럭 해제</button
><button data-action="select-all-rows">전체 선택</button
><button data-action="block-del">블럭 삭제</button>
<hr />
<button data-action="find">찾기</button>
<button data-action="uses-data-search">데이터 사용위치 찾기</button>
<button data-action="replace-cost-words">산출근거 문구 바꾸기</button>
<button data-action="set-cost-line-kind">산출근거 비목 지정/해제</button>
<button data-action="toggle-cost-comment">산출근거 주석 지정/해제</button>
<button data-action="set-row-precision">선택 행 자릿수 설정</button>
<button data-action="toggle-jc-bimog">경비성단가 지정/해제</button>
<button data-action="toggle-sun-gongsa">순공사비 계산끝 지정/해제</button>
<button data-action="align-wonga-total">공사비역산 자리수 맞추기</button>
<button data-action="clear-estimate-quantities">내역 수량 초기화</button>
</div>
</details>
@@ -118,6 +135,14 @@
<button data-view="EstMainDat">내역서</button
><button data-view="standard">표준시장단가 사용 내역</button
><button data-view="standard-change">표준시장단가 설계변경</button
><button data-view="estimate-usage">견적단가 사용 내역</button
><button data-view="estimate-change">견적단가 설계변경</button
><button data-view="material-usage">자재단가 사용 내역</button
><button data-view="material-change">자재단가 설계변경</button
><button data-view="labor-usage">노임단가 사용 내역</button
><button data-view="labor-change">노임단가 설계변경</button
><button data-view="machine-usage">기계경비 사용 내역</button
><button data-view="machine-change">기계경비 설계변경</button
><button data-view="change">설계변경</button><button data-view="progress">기성고</button
><button data-view="escalation">물가변동</button
><button data-view="annual">연차공사</button><button data-view="review">계약심사</button
@@ -152,6 +177,10 @@
<div class="work-title"><b id="view-title">내역서</b><span id="selected-info"></span></div>
<div id="standard-tools" class="row-tools">
<button id="add-group" data-action="add-group" hidden>묶음 추가</button
><button id="haljung-calc-btn" data-action="haljung-calc" hidden>할증계산</button
><button id="vat-calc-btn" data-action="vat-calc" hidden>부가세계산</button
><button id="unit-calc-btn" data-action="unit-calc" hidden>단위환산</button
><button id="uses-search-btn" data-action="uses-data-search">사용위치</button
><button data-action="add-row">행 추가</button><button data-action="copy-row">복사</button
><button data-action="paste-row">붙여넣기</button
><button data-action="delete-row">삭제</button
+209
View File
@@ -0,0 +1,209 @@
// 안티그래비티 Orca 상태 및 EST 5175 헬스 백그라운드 감시 스크립트
// 상태 변경 시에만 기록하며, 안티그래비티 완료/대기 또는 서버 장애 시 Codex Orchestration Run으로 알림 전송.
// 자동 재시작이나 코드 수정 지시는 일절 포함하지 않음.
const http = require("node:http");
const { execFile } = require("node:child_process");
const fs = require("node:fs");
const path = require("node:path");
const TARGET_RUN = "run_cdb1558b9280";
const logFile = path.join(__dirname, "monitor.log");
const pidFile = path.join(__dirname, "monitor.pid");
const MAX_LOG_BYTES = 512 * 1024; // 512KB 순환
// 중복 실행 방지
if (fs.existsSync(pidFile)) {
try {
const existingPid = Number(fs.readFileSync(pidFile, "utf8").trim());
process.kill(existingPid, 0);
console.log(`[monitor] 이미 감시 프로세스가 실행 중입니다 (PID: ${existingPid}). 종료합니다.`);
process.exit(0);
} catch {}
}
fs.writeFileSync(pidFile, String(process.pid), "utf8");
function cleanup() {
try {
if (fs.existsSync(pidFile)) fs.unlinkSync(pidFile);
} catch {}
process.exit(0);
}
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
process.on("exit", () => {
try {
if (fs.existsSync(pidFile)) fs.unlinkSync(pidFile);
} catch {}
});
function log(message) {
const line = `[${new Date().toISOString()}] ${message}\n`;
try {
if (fs.existsSync(logFile) && fs.statSync(logFile).size > MAX_LOG_BYTES) {
const old = fs.readFileSync(logFile, "utf8");
fs.writeFileSync(logFile, old.slice(-MAX_LOG_BYTES / 2), "utf8");
}
fs.appendFileSync(logFile, line, "utf8");
} catch {}
}
log(`감시 시작 (PID: ${process.pid})`);
let lastAntigravityState = null;
let lastHealthStatus = null;
let notifiedDone = false;
let notifiedFailure = false;
const ORCA_BIN = "C:\\Users\\umsan\\AppData\\Local\\Programs\\orca\\resources\\bin\\orca.exe";
function sendOrchestrationNotification(subject, body, type = "status") {
// 민감한 환경변수 노출 방지 및 타임아웃 5초 설정 (shell: false로 공백/인자 안정성 확보)
execFile(
ORCA_BIN,
[
"orchestration",
"send",
"--to",
`run:${TARGET_RUN}`,
"--type",
type,
"--subject",
subject,
"--body",
body,
"--json",
],
{ timeout: 5000, shell: false },
(error, stdout, stderr) => {
if (error) {
log(`Orchestration 알림 실패: ${error.message}`);
return;
}
try {
const res = JSON.parse(stdout);
if (res.ok) {
log(`Orchestration 알림 성공: messageId=${res.result?.messageId}`);
} else {
log(`Orchestration 알림 거부: ${JSON.stringify(res.error || res)}`);
}
} catch (e) {
log(`Orchestration 응답 파싱 오류: ${stdout}`);
}
},
);
}
function checkOrcaAntigravity() {
execFile(
ORCA_BIN,
["worktree", "ps", "--json"],
{ timeout: 4000, maxBuffer: 1024 * 1024, shell: false },
(error, stdout) => {
if (error) {
if (lastAntigravityState !== `error:${error.message}`) {
lastAntigravityState = `error:${error.message}`;
log(`Orca 확인 실패: ${error.message}`);
}
return;
}
try {
const data = JSON.parse(stdout);
const codexWorktree = data.result?.worktrees?.find((w) =>
/D:\/aislo-wt\/CODEX/i.test(w.path || ""),
);
const antigravityAgent = codexWorktree?.agents?.find((a) => a.agentType === "antigravity");
const currentState = antigravityAgent?.state || codexWorktree?.status || "unknown";
if (currentState !== lastAntigravityState) {
const prevState = lastAntigravityState;
lastAntigravityState = currentState;
log(`안티그래비티 작업 상태 변경: ${prevState} -> ${currentState}`);
// 안티그래비티 완료/대기 전환 시 단발성 자동 알림
if ((currentState === "done" || currentState === "idle") && !notifiedDone) {
notifiedDone = true;
sendOrchestrationNotification(
"안티그래비티 작업 완료 보고",
`안티그래비티 상태가 ${currentState}로 전이되었습니다. 검토를 재개할 수 있습니다.`,
"status",
);
} else if (currentState === "working") {
notifiedDone = false; // 다시 작업 중이 되면 플래그 리셋
}
}
} catch (parseErr) {
if (lastAntigravityState !== `parse-error:${parseErr.message}`) {
lastAntigravityState = `parse-error:${parseErr.message}`;
log(`Orca JSON 파싱 오류: ${parseErr.message}`);
}
}
},
);
}
function checkServerHealth() {
const req = http.get("http://100.65.141.15:5175/api/health", { timeout: 3000 }, (res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
const statusStr = `HTTP ${res.statusCode}`;
if (statusStr !== lastHealthStatus) {
lastHealthStatus = statusStr;
log(`EST 5175 서버 상태 변경: ${statusStr} (${data.trim()})`);
}
if (res.statusCode === 200) {
notifiedFailure = false;
} else if (!notifiedFailure) {
notifiedFailure = true;
sendOrchestrationNotification(
"EST 5175 서버 장애 경고",
`EST 독립 서버 응답이 비정상입니다 (${statusStr}). 확인이 필요합니다.`,
"status",
);
}
});
});
req.on("error", (err) => {
const errStr = `error:${err.message}`;
if (errStr !== lastHealthStatus) {
lastHealthStatus = errStr;
log(`EST 5175 접속 실패: ${err.message}`);
}
if (!notifiedFailure) {
notifiedFailure = true;
sendOrchestrationNotification(
"EST 5175 서버 접속 불가",
`EST 독립 서버 연결에 실패했습니다 (${err.message}).`,
"status",
);
}
});
req.on("timeout", () => {
req.destroy();
const timeoutStr = "timeout";
if (timeoutStr !== lastHealthStatus) {
lastHealthStatus = timeoutStr;
log("EST 5175 응답 시간 초과");
}
if (!notifiedFailure) {
notifiedFailure = true;
sendOrchestrationNotification(
"EST 5175 서버 응답 시간 초과",
"EST 독립 서버 헬스 체크 응답 시간이 초과되었습니다.",
"status",
);
}
});
}
function poll() {
checkOrcaAntigravity();
checkServerHealth();
}
poll();
setInterval(poll, 15000);
+11 -10
View File
@@ -3,7 +3,6 @@ const EstActions = (() => {
clipboard = [],
clipboardTable = "",
restoring = false;
const uid = () => EstApp.uid();
function form(title, fields, callback) {
const { esc, showDialog } = EstApp;
showDialog(
@@ -422,7 +421,12 @@ const EstActions = (() => {
);
},
);
else if (name === "add-quantity")
else if (name === "restore-bid") {
if (confirm("당초단가(100%)로 복원하시겠습니까?"))
EstApp.change(() => {
state.project = EstBusiness.restoreBid(project);
});
} else if (name === "add-quantity")
EstApp.change(() => {
project.quantities.push({
Code: EstQuantity.newCode(project.quantities),
@@ -438,7 +442,7 @@ const EstActions = (() => {
EstApp.change(() => EstQuantity.applyTotals(project, totals));
} else if (name === "add-round") {
const next = {
id: uid(),
id: EstApp.uid(),
name: EstBusiness.newProgressName(project.progress),
inputs: {},
approved: false,
@@ -659,19 +663,16 @@ const EstActions = (() => {
EstApp.render();
});
EstApp.byId("previous-page").addEventListener("click", () => {
EstApp.state.page--;
EstApp.render();
EstApp.state.page--; EstApp.render();
});
EstApp.byId("next-page").addEventListener("click", () => {
EstApp.state.page++;
EstApp.render();
EstApp.state.page++; EstApp.render();
});
document.addEventListener("keydown", (event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
event.preventDefault();
EstApp.save().catch((error) => {
EstApp.state.notice = error.message;
EstApp.render();
EstApp.state.notice = error.message; EstApp.render();
});
}
});
@@ -694,5 +695,5 @@ const EstActions = (() => {
EstAttachments.install();
EstStandard.install();
}
return { install, form };
return { install, form, undo };
})();
+130 -12
View File
@@ -1,5 +1,37 @@
const EstApp = (() => {
const CACHE = "est-web-v2-working";
function uid() {
if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
const bytes = crypto.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 15) | 64;
bytes[8] = (bytes[8] & 63) | 128;
const hex = [...bytes].map((value) => value.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0,
v = c === "x" ? r : (r & 3) | 8;
return v.toString(16);
});
}
// 탭별 인스턴스 격리 식별자:
// 1) 같은 탭 내 새로고침 시: sessionStorage 및 window.name이 보존되므로 동일 인스턴스로 복원
// 2) 탭 복제(Duplicate tab) 시: Chromium 브라우저는 sessionStorage를 그대로 복제하지만 window.name은 빈 문자열("")로 리셋됨.
// 따라서 window.name과 sessionStorage가 불일치하면 복제된 새 탭으로 감지하고 새 tabId를 발급하여 이전 탭의 캐시를 복제한 후 분리 격리함.
const TAB_KEY = "est-tab-instance-id";
const storage = typeof sessionStorage !== "undefined" ? sessionStorage : null;
const win = typeof window !== "undefined" ? window : { name: "" };
let currentTabId = storage ? storage.getItem(TAB_KEY) : null;
const isDuplicateTab = currentTabId && (!win.name || win.name !== currentTabId);
const parentTabId = isDuplicateTab ? currentTabId : null;
if (!currentTabId || isDuplicateTab) {
currentTabId = uid();
if (storage) storage.setItem(TAB_KEY, currentTabId);
}
win.name = currentTabId;
const CACHE = `est-web-v2-working-${currentTabId}`;
const LEGACY_CACHE = "est-web-v2-working";
const state = {
project: null,
view: "EstMainDat",
@@ -38,6 +70,14 @@ const EstApp = (() => {
attachments: "첨부파일",
standard: "표준시장단가 사용 내역",
"standard-change": "표준시장단가 설계변경",
"estimate-usage": "견적단가 사용 내역",
"estimate-change": "견적단가 설계변경",
"material-usage": "자재단가 사용 내역",
"material-change": "자재단가 설계변경",
"labor-usage": "노임단가 사용 내역",
"labor-change": "노임단가 설계변경",
"machine-usage": "기계경비 사용 내역",
"machine-change": "기계경비 설계변경",
drawing: "도면·문서",
};
const esc = (value) =>
@@ -47,13 +87,6 @@ const EstApp = (() => {
);
const number = (value) =>
new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 4 }).format(Number(value) || 0);
function uid() {
const bytes = crypto.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 15) | 64;
bytes[8] = (bytes[8] & 63) | 128;
const hex = [...bytes].map((value) => value.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
const byId = (id) => document.getElementById(id);
async function api(url, options = {}) {
const response = await fetch(url, options),
@@ -61,13 +94,84 @@ const EstApp = (() => {
if (!response.ok) throw Error(data.error || `요청 실패: ${response.status}`);
return data;
}
function cache(serialized) {
let cacheQueue = Promise.resolve();
function openIdb() {
return new Promise((resolve) => {
if (!globalThis.indexedDB) return resolve(null);
const req = indexedDB.open("est-independent-cache", 1);
req.onupgradeneeded = () => req.result.createObjectStore("cache");
req.onsuccess = () => resolve(req.result);
req.onerror = () => resolve(null);
});
}
let idbPromise = null;
function getIdb() {
idbPromise ||= openIdb();
return idbPromise;
}
function waitForTx(tx) {
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve(true);
tx.onerror = () => reject(tx.error || Error("IndexedDB 트랜잭션 실패"));
tx.onabort = () => reject(Error("IndexedDB 트랜잭션 중단"));
});
}
async function saveToStorage(key, value) {
try {
sessionStorage.setItem(CACHE, serialized ?? JSON.stringify(state.project));
sessionStorage.setItem(key, value);
const db = await getIdb();
if (db) {
const tx = db.transaction("cache", "readwrite");
tx.objectStore("cache").delete(key);
await waitForTx(tx).catch(() => {});
}
return true;
} catch {
state.notice = "임시 저장 용량이 부족합니다. 저장 버튼으로 작업을 보관하세요.";
// sessionStorage 할당 초과(약 5~10MB) 시 IndexedDB로 자동 전환 보관
try {
const db = await getIdb();
if (db) {
const tx = db.transaction("cache", "readwrite");
tx.objectStore("cache").put(value, key);
await waitForTx(tx);
sessionStorage.removeItem(key);
return true;
}
} catch (err) {
console.warn("IndexedDB 저장 실패:", err);
}
return false;
}
}
async function loadFromStorage(key) {
const fromSession = sessionStorage.getItem(key);
if (fromSession) return fromSession;
try {
const db = await getIdb();
if (db) {
return new Promise((resolve) => {
const tx = db.transaction("cache", "readonly");
const req = tx.objectStore("cache").get(key);
req.onsuccess = () => resolve(req.result || null);
req.onerror = () => resolve(null);
});
}
} catch {}
return null;
}
function cache(serialized) {
const payload = serialized ?? JSON.stringify(state.project);
const op = cacheQueue
.catch(() => {})
.then(async () => {
const ok = await saveToStorage(CACHE, payload);
if (!ok) {
state.notice = "임시 저장 공간을 사용할 수 없습니다. 저장 버튼으로 작업을 보관하세요.";
}
});
cacheQueue = op;
return op;
}
function snapshot() {
const serialized = JSON.stringify(state.project);
if (serialized.length < 10 * 1024 * 1024 || !globalThis.CompressionStream) return serialized;
@@ -279,11 +383,23 @@ const EstApp = (() => {
}
async function initialize() {
try {
const saved = sessionStorage.getItem(CACHE);
let saved = await loadFromStorage(CACHE);
if (!saved && parentTabId) {
// 복제 탭의 경우 부모 탭의 캐시 데이터를 복제하여 초기 상태로 상속
const parentSaved = await loadFromStorage(`est-web-v2-working-${parentTabId}`);
if (parentSaved) {
saved = parentSaved;
await saveToStorage(CACHE, saved);
}
}
if (!saved) saved = await loadFromStorage(LEGACY_CACHE);
setProject(saved ? JSON.parse(saved) : await api("/api/sample"));
EstActions.install();
EstQuantity.install();
EstReview.install();
EstEstimate.install();
EstBasicPrices.install();
EstMachine.install();
} catch (error) {
byId("messages").hidden = false;
byId("messages").textContent = `시작 실패: ${error.message}`;
@@ -312,6 +428,8 @@ const EstApp = (() => {
open,
showDialog,
initialize,
parentTabId,
currentTabId,
};
})();
document.addEventListener("DOMContentLoaded", () => EstApp.initialize());
+196
View File
@@ -0,0 +1,196 @@
const EstBasicPrices = (() => {
let page = 0;
const kinds = {
material: {
key: "material",
table: "StuffcDat",
linkIndex: 5,
unitField: "GU",
costField: "GC",
name: "자재단가",
},
labor: {
key: "labor",
table: "LaborcDat",
linkIndex: 6,
unitField: "NU",
costField: "NC",
name: "노임단가",
},
};
function config(view) {
if (view === "material" || view === "material-usage" || view === "material-change")
return kinds.material;
if (view === "labor" || view === "labor-usage" || view === "labor-change") return kinds.labor;
return null;
}
function usage(project, kindKey = "material") {
const cfg = kinds[kindKey];
if (!cfg) throw Error(`알 수 없는 단가 종류: ${kindKey}`);
const sources = new Map();
for (const row of project.tables[cfg.table] || []) {
if (!row.Code) continue;
const rows = sources.get(row.Code) || [];
rows.push(row);
sources.set(row.Code, rows);
}
return (project.tables.EstMainDat || []).flatMap((row, index) => {
if (Number(row.Level) > 0 || Number(row.LinkIndex) !== cfg.linkIndex) return [];
const matches = sources.get(row.Code) || [];
const source =
matches.length === 1 ? EstNative.price(structuredClone(matches[0]), cfg.linkIndex) : null;
const appliedPrice =
row.TU == null
? EstMath.units.reduce((sum, field) => sum + EstMath.num(row[field]), 0)
: EstMath.num(row.TU);
const sourcePrice = source ? EstMath.num(source.TC) : null;
const canApply =
matches.length === 1 && sourcePrice != null && Math.abs(appliedPrice - sourcePrice) > 1e-6;
return [
{
index,
Code: row.Code || "",
Name: row.Name || "",
Size: row.Size || "",
Unite: row.Unite || "",
GN: EstMath.num(row.GN),
appliedPrice,
sourcePrice,
difference: source ? appliedPrice - sourcePrice : null,
TC: EstMath.num(row.TC),
status: !matches.length ? "연결 없음" : matches.length > 1 ? "코드 중복" : "연결됨",
canApply,
},
];
});
}
function changes(project, kindKey = "material") {
const cfg = kinds[kindKey];
if (!cfg) throw Error(`알 수 없는 단가 종류: ${kindKey}`);
const labels = { new: "신규", deleted: "삭제", changed: "변경", unchanged: "동일" };
return EstBusiness.changes(project)
.filter(
(row) =>
Number(row.LinkIndex) === cfg.linkIndex ||
Number(row.originalLinkIndex) === cfg.linkIndex,
)
.map((row) => ({ ...row, status: labels[row.status] || row.status }));
}
function applyRowPrice(project, index, kindKey = "material") {
const cfg = kinds[kindKey];
if (!cfg) throw Error(`알 수 없는 단가 종류: ${kindKey}`);
index = Number(index);
const row = project.tables?.EstMainDat?.[index];
if (!row || Number(row.Level) > 0 || Number(row.LinkIndex) !== cfg.linkIndex)
throw Error(`${cfg.name} 내역 행을 찾을 수 없습니다.`);
const sources = (project.tables?.[cfg.table] || []).filter(
(s) => s.Code && s.Code === row.Code,
);
if (!sources.length) throw Error(`${cfg.name} 원천 자료가 없습니다: ${row.Code}`);
if (sources.length > 1) throw Error(`${cfg.name} 원천 코드가 중복되었습니다: ${row.Code}`);
const source = EstNative.price(structuredClone(sources[0]), cfg.linkIndex);
const sourcePrice = EstMath.num(source?.TC);
if (!Number.isFinite(sourcePrice)) throw Error("기초 단가를 계산할 수 없습니다.");
// SCheck 재분류가 설정되어 있는 경우 비목 보존
const scheck = Number(row.SCheck);
if (scheck > 0) {
const targetUnit = { 1: "GU", 2: "NU", 3: "JU" }[scheck];
for (const u of EstMath.units) row[u] = u === targetUnit ? sourcePrice : 0;
} else {
// 자재단가는 GU, 노임단가는 NU에 단가 할당
for (const u of EstMath.units) row[u] = u === cfg.unitField ? sourcePrice : 0;
}
// AutoCalc가 비활성화된 수동계산 모드에서는 기존 금액을 보존하고, 활성화된 경우에만 재계산
if (project.options?.AutoCalc !== false) {
EstMath.amounts(row, project.options?.EstDigits || 0, project.options?.TotalFit);
} else {
row.TU = EstMath.units.reduce((sum, key) => sum + EstMath.num(row[key]), 0);
}
}
function applyAllPrices(project, kindKey = "material") {
const cfg = kinds[kindKey];
if (!cfg) throw Error(`알 수 없는 단가 종류: ${kindKey}`);
const list = usage(project, kindKey).filter((item) => item.canApply);
if (!list.length) throw Error("적용할 기초 단가 차이가 없습니다.");
for (const item of list) applyRowPrice(project, item.index, kindKey);
return list.length;
}
function render() {
const { state, esc, number, byId } = EstApp;
const cfg = config(state.view);
if (!cfg) return;
const isChange = state.view.endsWith("-change");
const rows = isChange ? changes(state.project, cfg.key) : usage(state.project, cfg.key);
page = Math.max(0, Math.min(page, Math.ceil(rows.length / 100) - 1));
const visible = rows.slice(page * 100, page * 100 + 100),
value = (v) => (v == null ? "" : number(v));
if (isChange) {
byId("work-content").innerHTML =
`<div class="business-tools"><b>${cfg.name} 설계변경</b><span>${rows.length}건 · ${page + 1}/${Math.max(1, Math.ceil(rows.length / 100))}쪽</span><button data-basic-page="-1" ${page === 0 ? "disabled" : ""}>이전</button><button data-basic-page="1" ${(page + 1) * 100 >= rows.length ? "disabled" : ""}>다음</button></div><table class="sheet"><thead><tr><th>구분</th><th>코드</th><th>품명</th><th>당초 수량</th><th>변경 수량</th><th>당초 금액</th><th>변경 금액</th><th>증감</th><th>작업</th></tr></thead><tbody>${visible.map((row) => `<tr><td>${esc(row.status)}</td><td>${esc(row.Code)}</td><td>${esc(row.Name)}</td><td>${value(row.originalGN)}</td><td>${value(row.GN)}</td><td>${value(row.originalTC)}</td><td>${value(row.TC)}</td><td>${value(row.difference)}</td><td>${row.index == null ? "" : `<button data-basic-row="${row.index}">내역 열기</button>`}</td></tr>`).join("")}</tbody></table>`;
return;
}
const canApplyCount = rows.filter((r) => r.canApply).length;
byId("work-content").innerHTML =
`<div class="business-tools"><b>${cfg.name} 사용 내역</b><span>${rows.length}건 · ${page + 1}/${Math.max(1, Math.ceil(rows.length / 100))}쪽</span><button data-basic-page="-1" ${page === 0 ? "disabled" : ""}>이전</button><button data-basic-page="1" ${(page + 1) * 100 >= rows.length ? "disabled" : ""}>다음</button><button data-basic-apply-all="${cfg.key}" ${canApplyCount ? "" : "disabled"}>기초단가 일괄 적용 (${canApplyCount}건)</button></div><p>내역에 적용된 단가와 현재 ${cfg.name} 자료를 비교합니다.</p><table class="sheet"><thead><tr><th>코드</th><th>품명</th><th>규격</th><th>단위</th><th>수량</th><th>내역 단가</th><th>기초 단가</th><th>차이</th><th>금액</th><th>연결</th><th>작업</th></tr></thead><tbody>${visible.map((row) => `<tr><td>${esc(row.Code)}</td><td>${esc(row.Name)}</td><td>${esc(row.Size)}</td><td>${esc(row.Unite)}</td><td>${value(row.GN)}</td><td>${value(row.appliedPrice)}</td><td>${value(row.sourcePrice)}</td><td>${value(row.difference)}</td><td>${value(row.TC)}</td><td>${esc(row.status)}</td><td><button data-basic-row="${row.index}">내역 열기</button>${row.canApply ? ` <button data-basic-apply-row="${row.index}" data-kind="${cfg.key}">단가 적용</button>` : ""}</td></tr>`).join("")}</tbody></table>`;
}
function install() {
document.addEventListener("click", (event) => {
const applyAll = event.target.closest("[data-basic-apply-all]");
if (applyAll) {
const kindKey = applyAll.dataset.basicApplyAll;
const cfg = kinds[kindKey];
try {
EstApp.change((p) => {
const count = applyAllPrices(p, kindKey);
EstApp.state.notice = `${cfg?.name || "기초단가"} ${count}건의 단가를 일괄 적용했습니다.`;
});
} catch (err) {
EstApp.state.notice = err.message;
EstApp.render();
}
return;
}
const applyRow = event.target.closest("[data-basic-apply-row]");
if (applyRow) {
const index = Number(applyRow.dataset.basicApplyRow);
const kindKey = applyRow.dataset.kind;
try {
EstApp.change((p) => {
applyRowPrice(p, index, kindKey);
EstApp.state.notice = `행 ${index + 1}에 기초단가를 적용했습니다.`;
});
} catch (err) {
EstApp.state.notice = err.message;
EstApp.render();
}
return;
}
const step = event.target.closest("[data-basic-page]");
if (step) {
page += Number(step.dataset.basicPage);
render();
return;
}
const node = event.target.closest("[data-basic-row]");
if (!node) return;
const index = Number(node.dataset.basicRow);
if (!EstApp.state.project.tables.EstMainDat[index]) return;
EstApp.navigate("EstMainDat");
EstApp.state.page = Math.floor(index / 100);
EstApp.state.selection = new Set([index]);
EstApp.render();
});
}
return { usage, changes, render, install, applyRowPrice, applyAllPrices, kinds };
})();
+276
View File
@@ -0,0 +1,276 @@
// REA popMainBlockCut, popMainBlockCopy, popMainBlockPaste, popMainBlockDel 및 블럭 범위 지정 (ActionBlockOneSet, 블럭시작/끝)
// 및 popMainSetStr (산출근거 주석 지정/해제), TFRMWONGABASE popupTotal (공사비역산 자리수 맞추기)
const EstBlockActions = (() => {
let blockStart = null;
let blockTable = null;
function setBlockStart(state) {
const rows = state.project.tables[state.view];
if (!rows) throw Error("현재 화면에서는 블럭을 지정할 수 없습니다.");
const selected = [...state.selection];
if (!selected.length) throw Error("블럭 시작 위치로 지정할 행을 선택하세요.");
blockStart = selected[0];
blockTable = state.view;
state.notice = `${blockStart + 1}행이 블럭 시작점으로 설정되었습니다. (블럭 끝 행을 선택 후 '블럭 끝'을 누르세요)`;
}
function setBlockEnd(state) {
const rows = state.project.tables[state.view];
if (!rows) throw Error("현재 화면에서는 블럭을 지정할 수 없습니다.");
if (blockStart == null || blockTable !== state.view) {
throw Error("먼저 '블럭 시작'을 지정하세요.");
}
const selected = [...state.selection];
if (!selected.length) throw Error("블럭 끝 위치로 지정할 행을 선택하세요.");
const blockEnd = selected[0];
const start = Math.min(blockStart, blockEnd);
const end = Math.max(blockStart, blockEnd);
state.selection.clear();
for (let i = start; i <= end; i++) {
state.selection.add(i);
}
state.notice = `${start + 1}행부터 ${end + 1}행까지 총 ${end - start + 1}개 행이 블럭으로 선택되었습니다.`;
blockStart = null;
blockTable = null;
}
function clearBlockSelection(state) {
state.selection.clear();
blockStart = null;
blockTable = null;
state.notice = "선택이 해제되었습니다.";
}
function selectAllRows(state) {
const rows = state.project.tables[state.view];
if (!rows) throw Error("현재 화면에서는 전체 선택을 할 수 없습니다.");
state.selection.clear();
for (let i = 0; i < rows.length; i++) {
state.selection.add(i);
}
state.notice = `전체 ${rows.length}개 행이 선택되었습니다.`;
}
function deleteBlockRows(state) {
const rows = state.project.tables[state.view];
if (!rows) throw Error("현재 화면에서는 블럭 삭제를 할 수 없습니다.");
const indexes = new Set([...state.selection]);
if (!indexes.size) throw Error("삭제할 블럭 행을 먼저 선택하세요.");
// 호표/기계경비 그룹이 있는 경우 호표 제목 행 포함 시 전체 호표 행 포함
if (["HeadMainDat", "CostMainDat", "MachMainDat"].includes(state.view)) {
for (const group of EstNative.groups(rows).values()) {
if (indexes.has(group.start)) {
for (let i = group.start; i < group.start + group.rows.length; i++) {
indexes.add(i);
}
}
}
}
const codes = new Set(
[...indexes]
.map((i) => rows[i]?.Code)
.filter(Boolean)
.map((c) => String(c).trim()),
);
const kind = Number(
Object.entries(EstNative.sourceTables).find(([, table]) => table === state.view)?.[0] ||
{ HeadMainDat: 103, CostMainDat: 104, MachMainDat: 105 }[state.view],
);
if (
kind &&
Object.entries(state.project.tables).some(([table, list]) =>
list.some((row, i) => {
if (table === state.view && indexes.has(i)) return false;
const rLink = Number(row.LinkIndex);
const rCode = row.Code ? String(row.Code).trim() : "";
return rLink === kind && rCode && codes.has(rCode);
}),
)
) {
throw Error("다른 항목에서 사용 중입니다. 연결을 먼저 해제하세요.");
}
if (!confirm(`${indexes.size}개 행을 블럭 삭제하시겠습니까?`)) return;
EstApp.change(() => {
[...indexes].sort((a, b) => b - a).forEach((i) => rows.splice(i, 1));
state.selection.clear();
blockStart = null;
blockTable = null;
});
}
function toggleCostComment(state) {
// REA popMainSetStr: 단가산출 산출근거 행을 주석(Index = -1)으로 지정 또는 일반 산출식으로 복원
const rows = state.project.tables.CostMainDat;
const indices = [...state.selection].sort((a, b) => a - b);
if (state.view !== "CostMainDat" || !rows) {
throw Error("단가산출 화면에서만 산출근거 행을 주석으로 지정할 수 있습니다.");
}
if (!indices.length || indices.some((i) => !rows[i])) {
throw Error("주석으로 지정하거나 해제할 산출근거 행을 선택하세요.");
}
const plans = [];
for (const index of indices) {
const row = rows[index];
const curIndex = Number(row.Index) || 0;
if (curIndex > 0) {
throw Error(
`행 ${index + 1}: 호표 제목 행은 주석으로 지정할 수 없습니다. 상세 산출근거 행을 선택하세요.`,
);
}
if (curIndex === -20) {
throw Error(`행 ${index + 1}: 소계 구분선 행은 주석으로 지정할 수 없습니다.`);
}
const curSCheck = Number(row.SCheck) || 0;
const isComment = curIndex === -1 || curSCheck === -1;
if (isComment) {
// 이미 주석인 행 -> 복원
let restoreIndex = 0;
if (row._origIndex !== undefined) {
restoreIndex = Number(row._origIndex);
} else if (row.Index_ !== undefined) {
restoreIndex = Number(row.Index_);
}
let restoreSCheck = null;
if (row._origSCheck !== undefined) {
restoreSCheck = Number(row._origSCheck);
} else if (row.SCheck_ !== undefined) {
restoreSCheck = Number(row.SCheck_);
} else if ([-1, -2, -3, -5].includes(Number(row.OCheck))) {
restoreSCheck = Number(row.OCheck);
}
plans.push({
index,
row,
isComment: false,
restoreIndex,
restoreSCheck,
});
} else {
// 일반 상세 행 또는 비목 행(-2, -3, -5 등) -> 주석(-1)으로 전환
plans.push({
index,
row,
isComment: true,
origIndex: curIndex,
origSCheck: row.SCheck !== undefined ? Number(row.SCheck) : null,
});
}
}
EstApp.change(() => {
for (const p of plans) {
if (p.isComment) {
p.row._origIndex = p.origIndex;
if (p.origSCheck !== null) {
p.row._origSCheck = p.origSCheck;
}
p.row.Index = -1;
p.row.SCheck = -1;
} else {
delete p.row._origIndex;
delete p.row._origSCheck;
p.row.Index = p.restoreIndex;
if (p.restoreSCheck !== null) {
p.row.SCheck = p.restoreSCheck;
} else {
delete p.row.SCheck;
}
}
}
});
}
function alignWongaTotalAction(state) {
// REA TFRMWONGABASE popupTotal: 공사비역산 자리수 맞추기
const rows = state.project.tables.Wonga;
if (!rows || !rows.length) {
throw Error("원가계산서 자료가 없습니다.");
}
const targetRow = rows.find((r) => Number(r.LinkIndex) === 55);
const curTotal = targetRow ? Number(targetRow.Cost) || 0 : 0;
EstActions.form(
"공사비역산 자리수 맞추기",
[
{
name: "targetCost",
label: "목표 도급공사비 (원)",
type: "number",
value: curTotal,
},
{
name: "unitDigits",
label: "단단위 절하 기준",
value: "3",
choices: [
["0", "원 단위 (절하 없음)"],
["1", "십원 단위 (10원 절하)"],
["2", "백원 단위 (100원 절하)"],
["3", "천원 단위 (1,000원 절하)"],
["4", "만원 단위 (10,000원 절하)"],
],
},
],
({ targetCost, unitDigits }) => {
EstApp.change(() => {
EstWonga.alignTotal(rows, targetCost, unitDigits);
state.project.options.TotalFix = true;
});
},
);
}
function handle(name, state) {
if (name === "block-start") {
setBlockStart(state);
return true;
}
if (name === "block-end") {
setBlockEnd(state);
return true;
}
if (name === "block-clear") {
clearBlockSelection(state);
return true;
}
if (name === "select-all-rows") {
selectAllRows(state);
return true;
}
if (name === "block-del") {
deleteBlockRows(state);
return true;
}
if (name === "toggle-cost-comment") {
toggleCostComment(state);
return true;
}
if (name === "align-wonga-total") {
alignWongaTotalAction(state);
return true;
}
return false;
}
return {
setBlockStart,
setBlockEnd,
clearBlockSelection,
selectAllRows,
deleteBlockRows,
toggleCostComment,
alignWongaTotalAction,
handle,
};
})();
+206 -8
View File
@@ -104,6 +104,16 @@ const EstBusinessActions = (() => {
);
return true;
}
if (node.id === "resource-kind-filter") {
state.resourceKind = node.value;
EstApp.render();
return true;
}
if (node.id === "resource-by-work") {
state.resourceByWork = Boolean(node.checked);
EstApp.render();
return true;
}
if (node.id === "progress-components" || node.id === "progress-digits") {
const round = currentRound();
if (round.locked) throw Error("확정 회차입니다.");
@@ -282,17 +292,35 @@ const EstBusinessActions = (() => {
];
}, false);
} else if (name === "move-estimate-up" || name === "move-estimate-down") {
if (state.view !== "EstMainDat") throw Error("내역서에서만 행을 이동할 수 있습니다.");
const direction = name === "move-estimate-up" ? -1 : 1;
const indices = EstBusiness.moveEstimateRows(
structuredClone(project.tables.EstMainDat),
state.selection,
direction,
);
const isEst = state.view === "EstMainDat",
isGroup = ["HeadMainDat", "CostMainDat", "MachMainDat"].includes(state.view);
if (!isEst && !isGroup)
throw Error(
"내역서 또는 호표(일위대가·단가산출·기계경비) 화면에서만 행을 이동할 수 있습니다.",
);
const direction = name === "move-estimate-up" ? -1 : 1,
tableRows = project.tables[state.view];
const indices = isEst
? EstBusiness.moveEstimateRows(structuredClone(tableRows), state.selection, direction)
: EstBusiness.moveGroupDetailRows(
structuredClone(tableRows),
state.group,
state.selection,
direction,
state.view,
);
if (indices.every((index) => state.selection.has(index))) return;
EstApp.change(() => {
state.selection = new Set(
EstBusiness.moveEstimateRows(project.tables.EstMainDat, state.selection, direction),
isEst
? EstBusiness.moveEstimateRows(tableRows, state.selection, direction)
: EstBusiness.moveGroupDetailRows(
tableRows,
state.group,
state.selection,
direction,
state.view,
),
);
});
} else if (name === "unlock-round") {
@@ -401,6 +429,176 @@ const EstBusinessActions = (() => {
}, false);
},
);
} else if (name === "vat-calc") {
if (state.view !== "StuffcDat")
throw Error("자재단가 화면에서만 부가세 계산을 실행할 수 있습니다.");
const rows = project.tables.StuffcDat || [];
const indices = [...state.selection].sort((a, b) => a - b);
EstActions.form(
"자재 부가세 계산",
[
{
name: "calcMethod",
label: "계산방법",
value: 1,
choices: [
[0, "부가세포함(×1.1)"],
[1, "부가세제외(÷1.1)"],
],
},
{
name: "digetMethod",
label: "단단위 계산방식",
value: 1,
choices: [
[0, "사사오입"],
[1, "절하"],
[2, "절상"],
],
},
{ name: "diget", label: "단단위 자리수(0=원, 1=십원..)", type: "number", value: "0" },
],
(values) => {
EstApp.change(() => {
state.project = EstVat.apply(state.project, indices, {
calcMethod: Number(values.calcMethod),
digetMethod: Number(values.digetMethod),
diget: Number(values.diget),
});
});
},
);
} else if (name === "haljung-calc") {
if (state.view !== "HeadMainDat")
throw Error("일위대가 화면에서만 할증계산을 실행할 수 있습니다.");
const rows = project.tables.HeadMainDat || [];
const grps = EstNative.groups(rows);
const currentGroup = grps.get(state.group);
if (!currentGroup) throw Error("할증을 적용할 공종(묶음)을 선택하세요.");
EstActions.form(
"공종 내 할증계산",
[
{ name: "name", label: "명 칭", value: "할 증" },
{
name: "source",
label: "불러올비목(규격)",
value: 0,
choices: [
[0, "비목별"],
[1, "재료비의"],
[2, "노무비의"],
[3, "경비의"],
[4, "합계의"],
],
},
{
name: "target",
label: "적용비목",
value: 0,
choices: [
[0, "비목별"],
[1, "재료비에"],
[2, "노무비에"],
[3, "경비에"],
],
},
{ name: "gn", label: "적용율(%)", type: "number", value: "10" },
],
(values) => {
const rate = Number(values.gn);
if (!Number.isFinite(rate) || rate === 0)
throw Error("할증 적용율은 0이 아닌 유효한 숫자여야 합니다.");
EstApp.change(() => {
state.project = EstHalJung.apply(state.project, state.view, state.group, {
name: values.name,
sourceKind: Number(values.source),
targetKind: Number(values.target),
rate,
});
});
},
);
} else if (name === "unit-calc") {
if (state.view !== "HeadMainDat")
throw Error("일위대가 화면에서만 공종 단위환산 계산을 실행할 수 있습니다.");
const grps = EstNative.groups(project.tables.HeadMainDat || []);
const currentGroup = grps.get(state.group);
if (!currentGroup) throw Error("환산할 일위대가 공종(묶음)을 선택하세요.");
const title = currentGroup.rows[0];
EstActions.form(
"공종의 단위환산 계산",
[
{ name: "name", label: "명 칭", value: title.Name || "단위환산" },
{ name: "size", label: "규 격", value: title.Size || "" },
{ name: "gn", label: "수 량(환산계수)", type: "number", value: "1" },
{ name: "unite", label: "단 위", value: title.Unite || "식" },
],
(values) => {
const factor = Number(values.gn);
if (!Number.isFinite(factor) || factor === 0)
throw Error("수량은 0이 아닌 유효한 숫자여야 합니다.");
EstApp.change(() => {
state.project = EstBusiness.convertGroupUnit(state.project, state.group, {
name: values.name,
size: values.size,
factor,
unit: values.unite,
});
});
},
);
} else if (name === "toggle-jc-bimog") {
EstExtra.toggleJcBimog(state);
} else if (name === "toggle-sun-gongsa") {
EstExtra.toggleSunGongsa(state);
} else if (name === "uses-data-search") {
const rows = state.project.tables[state.view] || [];
const selected = [...state.selection];
const targetRow = selected.length ? rows[selected[0]] : null;
if (!targetRow) throw Error("사용위치를 검색할 행을 먼저 선택하세요.");
EstExtra.showUsesSearchDialog(targetRow);
} else if (name === "set-cost-line-kind") {
const rows = state.project.tables.CostMainDat,
indices = [...state.selection].sort((a, b) => a - b);
if (state.view !== "CostMainDat" || !rows)
throw Error("단가산출 화면에서만 산출근거 비목을 지정할 수 있습니다.");
if (!indices.length || indices.some((i) => !rows[i]))
throw Error("비목을 지정할 산출근거 행을 선택하세요.");
const curKind = Number(rows[indices[0]].OCheck) || 0;
EstActions.form(
"산출근거 비목 지정",
[
{
name: "kind",
label: "지정 비목",
value: curKind,
choices: [
[0, "일반 산출식 / 해제"],
[-1, "노무비 (-1)"],
[-2, "재료비 (-2)"],
[-3, "경비 (-3)"],
[-5, "소계 (-5)"],
],
},
],
({ kind }) => {
kind = Number(kind);
EstApp.change(() => {
for (const idx of indices) {
const r = rows[idx];
if (kind === 0) {
delete r.OCheck;
delete r.SCheck;
} else {
r.OCheck = kind;
r.SCheck = kind;
}
}
});
},
);
} else if (EstBlockActions.handle(name, state)) {
EstApp.render();
} else if (name === "load-contract" || name === "load-bid") {
purpose = name;
documentKey = state.documentKey;
+268 -5
View File
@@ -27,12 +27,12 @@ const EstBusiness = (() => {
}
function moveEstimateRows(rows, selection, direction) {
const indices = [...selection].sort((a, b) => a - b);
if (!indices.length) throw Error("이동할 내역 행을 선택하세요.");
if (!indices.length) throw Error("이동할 행을 선택하세요.");
if (
![1, -1].includes(direction) ||
indices.some((value, index) => value !== indices[0] + index)
)
throw Error("연속된 내역 행을 선택하세요.");
throw Error("연속된 행을 선택하세요.");
const first = indices[0],
last = indices.at(-1),
neighbor = direction < 0 ? first - 1 : last + 1;
@@ -54,6 +54,204 @@ const EstBusiness = (() => {
rows.splice(first + direction, 0, ...group);
return indices.map((index) => index + direction);
}
function validateCostOrderIntegrity(beforeGroupRows, afterGroupRows) {
const beforeCalc = EstText.calculate(structuredClone(beforeGroupRows));
const afterCalc = EstText.calculate(structuredClone(afterGroupRows));
if (afterCalc.errors && afterCalc.errors.length) {
throw Error(`산출근거 참조 오류로 이동할 수 없습니다: ${afterCalc.errors[0]}`);
}
// 소계(LinkIndex === 61) 및 비율(LinkIndex === 58) 또는 전체 호표 합계(첫 행 TC)의 수치/참조가 달라지면 차단
if (Number(afterCalc.rows[0]?.TC || 0) !== Number(beforeCalc.rows[0]?.TC || 0)) {
throw Error("호표 최종 금액이 변경되어 순서를 보존할 수 없는 이동입니다.");
}
const beforeMap = new Map();
beforeCalc.rows.forEach((r, idx) => beforeMap.set(rowKey(r, idx), r));
for (let i = 0; i < afterCalc.rows.length; i++) {
const aRow = afterCalc.rows[i];
const key = rowKey(aRow, i);
const bRow = beforeMap.get(key);
if (!bRow) continue;
const kind = num(bRow.LinkIndex);
if ([58, 61].includes(kind)) {
if (Number(aRow.TC || 0) !== Number(bRow.TC || 0)) {
throw Error("구간 소계 또는 비율 결과가 달라지는 안전하지 않은 이동입니다.");
}
}
// 앞줄 참조(\x0c)를 사용하는 행의 계산 수식 문자열(LineData)이 보존되는지 검증
if (String(bRow.LineData || "").includes("\x0c")) {
if (String(aRow.LineData || "") !== String(bRow.LineData || "")) {
throw Error("앞줄 참조 대상 또는 수치가 달라지는 안전하지 않은 이동입니다.");
}
}
}
}
function moveGroupDetailRows(rows, groupCode, selection, direction, table = "") {
const indices = [...selection].sort((a, b) => a - b);
if (!indices.length) throw Error("이동할 호표 상세 행을 선택하세요.");
if (
![1, -1].includes(direction) ||
indices.some((value, index) => value !== indices[0] + index)
)
throw Error("연속된 행을 선택하세요.");
const grps = EstNative.groups(rows),
targetGroup = grps.get(groupCode);
if (!targetGroup) throw Error("호표를 찾을 수 없습니다.");
const groupStart = targetGroup.start,
groupLen = targetGroup.rows.length,
groupEnd = groupStart + groupLen - 1;
for (const idx of indices) {
if (idx <= groupStart || idx > groupEnd)
throw Error("호표의 상세 행만 이동할 수 있습니다(제목 행 제외).");
}
const first = indices[0],
last = indices.at(-1),
neighbor = direction < 0 ? first - 1 : last + 1;
if (neighbor <= groupStart || neighbor > groupEnd) return indices;
const affected = rows.slice(Math.min(first, neighbor), Math.max(last, neighbor) + 1);
if (affected.some((row) => [54, 55, 58, 59, 61, 62].includes(num(row.LinkIndex))))
throw Error("합계·소계·구간비율 행은 직접 이동하거나 건너뛸 수 없습니다.");
if (affected.some((row) => String(row.LineData || "").includes("\x1a")))
throw Error("합계 수식표지(\\x1a)가 포함된 행은 이동하거나 건너뛸 수 없습니다.");
const counts = new Map();
rows.forEach((row, index) => {
const key = rowKey(row, index);
counts.set(key, (counts.get(key) || 0) + 1);
});
for (let index = Math.min(first, neighbor); index <= Math.max(last, neighbor); index++) {
const row = rows[index];
if ((row.PkID == null && !row.Code) || counts.get(rowKey(row, index)) !== 1)
throw Error("행 식별자가 없거나 중복되어 연결을 보존할 수 없습니다.");
}
const isCost = table === "CostMainDat" || rows.some((r) => r.LineData !== undefined);
const affectedM20Indices = [];
if (isCost) {
for (let i = Math.min(first, neighbor); i <= Math.max(last, neighbor); i++) {
if (num(rows[i].Index) === -20) affectedM20Indices.push(i);
}
}
// 선택 범위 중간에 -20이 포함되어 단일 경계 스왑으로 보장할 수 없는 경우 안전 차단
if (affectedM20Indices.length > 1) {
throw Error("복수 구간 경계가 포함된 범위는 한 번에 이동할 수 없습니다.");
}
if (
affectedM20Indices.length === 1 &&
indices.length > 1 &&
affectedM20Indices[0] !== first &&
affectedM20Indices[0] !== last
) {
throw Error("선택 범위 중간에 구간 경계가 포함되어 있어 이동할 수 없습니다.");
}
const affectedHasM20 = affectedM20Indices.length === 1;
const swapTargetIndex = affectedHasM20 ? Math.min(first, neighbor) : null;
const swapSourceIndex = affectedHasM20 ? Math.max(last, neighbor) : null;
if (isCost) {
// 앞줄 참조(\x0c)를 가진 행이 이동에 포함되거나 건너뛰어지는 경우 참조 대상 보존 검사
const affectedRows = rows.slice(Math.min(first, neighbor), Math.max(last, neighbor) + 1);
const hasRefRow = affectedRows.some((r) => String(r.LineData || "").includes("\x0c"));
if (hasRefRow && indices.length > 1) {
throw Error("앞줄 참조가 포함된 복수 행은 안전한 구분을 위해 단일 행으로 이동하세요.");
}
// 앞줄 참조(\x0c) 또는 변수 참조(\x11)가 뒤따르는 구간에서 대상 행의 정체성 및 순서 무결성 보존 검사
// 단, LinkIndex 61(소계)은 구간 내 모든 결과를 합산하므로 순서 교환 자체로 참조 대상이 왜곡되지 않음.
// 반면 LinkIndex 58(특정 비목 직전 참조)이나 일반 행의 \x0c는 특정 선행 결과를 선택 참조하므로,
// 선행 후보가 2개 이상일 때 그 후보들 사이의 순서 변경은 참조 대상 왜곡 방지를 위해 차단함.
const groupDetailRows = targetGroup.rows;
for (let i = 0; i < groupDetailRows.length; i++) {
const row = groupDetailRows[i];
const lineText = String(row.LineData || "");
if (num(row.LinkIndex) !== 61 && lineText.includes("\x0c")) {
const isLink58 = num(row.LinkIndex) === 58;
let sourceKind = isLink58 ? num(row.OCheck) : null;
if (isLink58) {
const cleanText = lineText.replace(/[\x00-\x1f]/g, "").trim();
if (/노무|인건|품/.test(cleanText)) sourceKind = -1;
else if (/재료|자재/.test(cleanText)) sourceKind = -2;
else if (/경비/.test(cleanText)) sourceKind = -3;
}
const candidateIndices = [];
for (let j = i - 1; j >= 0; j--) {
const prev = groupDetailRows[j];
const pKind = num(prev.SCheck);
if (isLink58) {
if (
(!sourceKind && pKind < 0 && pKind > -4) ||
(sourceKind && pKind === sourceKind)
) {
if (String(prev.LineData || "").match(/([\x12\x0c])([-\d.,]+)\1/))
candidateIndices.push(j);
}
} else {
// 일반 행의 \x0c
if (String(prev.LineData || "").match(/([\x03\x12\x0c])([-\d.,]+)\1/))
candidateIndices.push(j);
}
if (num(prev.Level) > 0 || num(prev.Index) > 0) break;
}
if (candidateIndices.length > 1) {
// 이 후보들 중 하나라도 이번 이동(affected)에 포함되어 순서가 바뀌는 경우 차단
const affectedGroupIndices = affected.map((r) => rows.indexOf(r) - groupStart);
const candidateAffected = candidateIndices.filter((cIdx) =>
affectedGroupIndices.includes(cIdx),
);
if (candidateAffected.length > 0) {
throw Error(
"직전 결과를 참조하는 행이 뒤따라 참조 대상 구분이 불명확하므로 이동할 수 없습니다.",
);
}
}
}
}
// 변수 재정의(동일 변수명에 대한 중복 할당)가 존재하는 경우 변수 참조 순서 왜곡 방지
const varAssigned = new Map();
groupDetailRows.forEach((r, idx) => {
const text = String(r.LineData || "");
for (const m of text.matchAll(/(?:^|[\s,;])([A-Za-z][A-Za-z0-9]*)\s*=/g)) {
const vName = m[1];
if (!varAssigned.has(vName)) varAssigned.set(vName, []);
varAssigned.get(vName).push(idx);
}
});
for (const [vName, occurrences] of varAssigned.entries()) {
if (occurrences.length > 1) {
const affectedIndices = occurrences.filter((idx) => {
const globalIdx = groupStart + idx;
return globalIdx >= Math.min(first, neighbor) && globalIdx <= Math.max(last, neighbor);
});
if (affectedIndices.length > 0) {
throw Error(
`동일 변수(${vName})를 재정의하는 구간에서는 참조 대상 보존을 위해 이동할 수 없습니다.`,
);
}
}
}
const simGroup = structuredClone(targetGroup.rows);
const relFirst = first - groupStart;
const movedSim = simGroup.splice(relFirst, indices.length);
simGroup.splice(relFirst + direction, 0, ...movedSim);
if (affectedHasM20) {
// 이동에 참여한 두 교환 위치 사이에서만 -20 표지를 정확히 교환(다른 구간 표지 불변 보존)
const relTarget = swapTargetIndex - groupStart;
const relSource = swapSourceIndex - groupStart;
const targetVal = simGroup[relTarget].Index;
simGroup[relTarget].Index = simGroup[relSource].Index;
simGroup[relSource].Index = targetVal;
}
validateCostOrderIntegrity(targetGroup.rows, simGroup);
}
const moved = rows.splice(first, indices.length);
rows.splice(first + direction, 0, ...moved);
if (affectedHasM20) {
const targetVal = rows[swapTargetIndex].Index;
rows[swapTargetIndex].Index = rows[swapSourceIndex].Index;
rows[swapSourceIndex].Index = targetVal;
}
return indices.map((index) => index + direction);
}
function baseline(project) {
const working = { ...project };
delete working.baseline;
@@ -219,6 +417,10 @@ const EstBusiness = (() => {
}
function bid(project, rates) {
const next = copy(project);
if (project.options && "SourceRefresh" in project.options) {
next.options._preBidSourceRefresh ??= project.options.SourceRefresh;
}
next.options = { ...next.options, SourceRefresh: false };
for (const key of ["HeadMainDat", "EstMainDat"])
for (const row of next.tables[key]) {
if (
@@ -227,15 +429,73 @@ const EstBusiness = (() => {
[51, 52, 54, 55, 57, 58, 59, 62, 67, 68].includes(num(row.LinkIndex))
)
continue;
if (!row.PreBidRates) {
row.PreBidRates = {};
units.forEach((field) => (row.PreBidRates[field] = num(row[field])));
}
units.forEach((field, i) => {
row[`O${field}`] ??= num(row[field]);
row[`O${field}`] ??= num(row.PreBidRates[field] ?? row[field]);
row[field] = floating((num(row[`O${field}`]) * num(rates[i] ?? rates[0])) / 100);
});
row.TU = units.reduce((n, k) => n + num(row[k]), 0);
row.ApplyNacChal = 1;
}
next.options.SourceRefresh = false;
return EstNative.calculate(next);
return next.options?.AutoCalc === false ? next : EstNative.calculate(next);
}
function restoreBid(project) {
const next = copy(project);
const targetSourceRefresh =
next.options && "_preBidSourceRefresh" in next.options
? next.options._preBidSourceRefresh
: next.options?.SourceRefresh;
if (next.options) delete next.options._preBidSourceRefresh;
next.options = { ...next.options, SourceRefresh: false };
for (const key of ["HeadMainDat", "EstMainDat"])
for (const row of next.tables[key]) {
if (!row.ApplyNacChal && !row.PreBidRates) continue;
units.forEach((field) => {
if (row.PreBidRates && row.PreBidRates[field] != null) {
row[field] = num(row.PreBidRates[field]);
} else if (row[`O${field}`] != null) {
row[field] = num(row[`O${field}`]);
}
});
delete row.PreBidRates;
row.TU = units.reduce((n, k) => n + num(row[k]), 0);
delete row.ApplyNacChal;
}
const calculated = next.options?.AutoCalc === false ? next : EstNative.calculate(next);
if (targetSourceRefresh !== undefined) {
calculated.options = { ...calculated.options, SourceRefresh: targetSourceRefresh };
}
return calculated;
}
function convertGroupUnit(
project,
groupCode,
{ name = "단위환산", size = "", factor = 1, unit = "식" } = {},
) {
const next = copy(project);
const grps = EstNative.groups(next.tables.HeadMainDat || []);
const group = grps.get(groupCode);
if (!group) throw Error("일위대가 공종을 찾을 수 없습니다.");
const numFactor = num(factor);
if (!Number.isFinite(numFactor) || numFactor === 0)
throw Error("환산 수량은 0이 아닌 유효한 숫자여야 합니다.");
const title = group.rows[0];
title.Name = name.trim() || title.Name;
if (size && size.trim() !== "") title.Size = size.trim();
if (unit && unit.trim() !== "") title.Unite = unit.trim();
for (let i = 1; i < group.rows.length; i++) {
const row = group.rows[i];
const link = num(row.LinkIndex);
if ([51, 52, 58, 62, 68].includes(link)) continue;
if ([1, 3].includes(num(row.CalcIndex))) continue;
if (row.GN != null) {
row.GN = num(row.GN) * numFactor;
}
}
return next.options?.AutoCalc === false ? next : EstNative.calculate(next);
}
function aligned(project, incoming) {
const a = project.tables.EstMainDat,
@@ -418,6 +678,8 @@ const EstBusiness = (() => {
changes,
costChanges,
bid,
restoreBid,
convertGroupUnit,
original,
progress,
escalation,
@@ -427,6 +689,7 @@ const EstBusiness = (() => {
rowKey,
progressExclusions,
moveEstimateRows,
moveGroupDetailRows,
importContract,
importBid,
annualCopy,
+10 -1
View File
@@ -126,7 +126,16 @@ const EstNative = (() => {
if (!result) return row;
const merged = { ...row, ...result };
// Main-row identities belong to a different native table/index.
for (const key of ["PkID", "Index", "RecNo", "Flag"]) {
// Native title records must preserve their own identities and blob/memo payloads.
for (const key of [
"PkID",
"Index",
"RecNo",
"Flag",
"DifineMemo",
"SpaceMemo",
"EscData",
]) {
if (Object.hasOwn(row, key)) merged[key] = row[key];
else delete merged[key];
}
+147
View File
@@ -0,0 +1,147 @@
const EstEstimate = (() => {
let page = 0;
function usage(project) {
const sources = new Map();
for (const row of project.tables.EstiMateDat || []) {
if (!row.Code) continue;
const rows = sources.get(row.Code) || [];
rows.push(row);
sources.set(row.Code, rows);
}
return (project.tables.EstMainDat || []).flatMap((row, index) => {
if (Number(row.Level) > 0 || Number(row.LinkIndex) !== 101) return [];
const matches = sources.get(row.Code) || [];
const source =
matches.length === 1 ? EstNative.price(structuredClone(matches[0]), 101) : null;
const appliedPrice =
row.TU == null
? EstMath.units.reduce((sum, field) => sum + EstMath.num(row[field]), 0)
: EstMath.num(row.TU);
const sourcePrice = source ? EstMath.num(source.TC) : null;
const canApply =
matches.length === 1 && sourcePrice != null && Math.abs(appliedPrice - sourcePrice) > 1e-6;
return [
{
index,
Code: row.Code || "",
Name: row.Name || "",
Size: row.Size || "",
Unite: row.Unite || "",
GN: EstMath.num(row.GN),
appliedPrice,
sourcePrice,
difference: source ? appliedPrice - sourcePrice : null,
TC: EstMath.num(row.TC),
status: !matches.length ? "연결 없음" : matches.length > 1 ? "코드 중복" : "연결됨",
canApply,
},
];
});
}
function changes(project) {
const labels = { new: "신규", deleted: "삭제", changed: "변경", unchanged: "동일" };
return EstBusiness.changes(project)
.filter((row) => Number(row.LinkIndex) === 101 || Number(row.originalLinkIndex) === 101)
.map((row) => ({ ...row, status: labels[row.status] || row.status }));
}
function applyRowPrice(project, index) {
index = Number(index);
const row = project.tables?.EstMainDat?.[index];
if (!row || Number(row.Level) > 0 || Number(row.LinkIndex) !== 101)
throw Error("견적단가 내역 행을 찾을 수 없습니다.");
const sources = (project.tables?.EstiMateDat || []).filter(
(s) => s.Code && s.Code === row.Code,
);
if (!sources.length) throw Error(`견적단가 원천 자료가 없습니다: ${row.Code}`);
if (sources.length > 1) throw Error(`견적단가 원천 코드가 중복되었습니다: ${row.Code}`);
const source = EstNative.price(structuredClone(sources[0]), 101);
const sourcePrice = EstMath.num(source?.TC);
if (!Number.isFinite(sourcePrice)) throw Error("기초 단가를 계산할 수 없습니다.");
// 원천 비목 구성 (NC, GC, JC, SC)
const baseUnits = EstMath.components.map((comp) => EstMath.num(source[comp]));
// SCheck 재분류가 설정되어 있는 경우 비목 보존
const scheck = Number(row.SCheck);
if (scheck > 0) {
const targetUnit = { 1: "GU", 2: "NU", 3: "JU" }[scheck];
for (const u of EstMath.units) row[u] = u === targetUnit ? sourcePrice : 0;
} else {
// SCheck === 0 이거나 미지정인 경우 원천 비목 분할 유지 (NU, GU, JU, SU)
EstMath.units.forEach((u, i) => (row[u] = baseUnits[i]));
}
// AutoCalc가 비활성화된 수동계산 모드에서는 기존 금액을 보존하고, 활성화된 경우에만 재계산
if (project.options?.AutoCalc !== false) {
EstMath.amounts(row, project.options?.EstDigits || 0, project.options?.TotalFit);
} else {
row.TU = EstMath.units.reduce((sum, key) => sum + EstMath.num(row[key]), 0);
}
}
function applyAllPrices(project) {
const list = usage(project).filter((item) => item.canApply);
if (!list.length) throw Error("적용할 기초 단가 차이가 없습니다.");
for (const item of list) applyRowPrice(project, item.index);
return list.length;
}
function render() {
const { state, esc, number, byId } = EstApp,
isChange = state.view === "estimate-change",
rows = isChange ? changes(state.project) : usage(state.project);
page = Math.max(0, Math.min(page, Math.ceil(rows.length / 100) - 1));
const visible = rows.slice(page * 100, page * 100 + 100),
value = (v) => (v == null ? "" : number(v));
if (isChange) {
byId("work-content").innerHTML =
`<div class="business-tools"><b>견적단가 설계변경</b><span>${rows.length}건 · ${page + 1}/${Math.max(1, Math.ceil(rows.length / 100))}쪽</span><button data-estimate-page="-1" ${page === 0 ? "disabled" : ""}>이전</button><button data-estimate-page="1" ${(page + 1) * 100 >= rows.length ? "disabled" : ""}>다음</button></div><table class="sheet"><thead><tr><th>구분</th><th>코드</th><th>품명</th><th>당초 수량</th><th>변경 수량</th><th>당초 금액</th><th>변경 금액</th><th>증감</th><th>작업</th></tr></thead><tbody>${visible.map((row) => `<tr><td>${esc(row.status)}</td><td>${esc(row.Code)}</td><td>${esc(row.Name)}</td><td>${value(row.originalGN)}</td><td>${value(row.GN)}</td><td>${value(row.originalTC)}</td><td>${value(row.TC)}</td><td>${value(row.difference)}</td><td>${row.index == null ? "" : `<button data-estimate-row="${row.index}">내역 열기</button>`}</td></tr>`).join("")}</tbody></table>`;
return;
}
const canApplyCount = rows.filter((r) => r.canApply).length;
byId("work-content").innerHTML =
`<div class="business-tools"><b>견적단가 사용 내역</b><span>${rows.length}건 · ${page + 1}/${Math.max(1, Math.ceil(rows.length / 100))}쪽</span><button data-estimate-page="-1" ${page === 0 ? "disabled" : ""}>이전</button><button data-estimate-page="1" ${(page + 1) * 100 >= rows.length ? "disabled" : ""}>다음</button><button data-estimate-apply-all ${canApplyCount ? "" : "disabled"}>기초단가 일괄 적용 (${canApplyCount}건)</button></div><p>내역에 적용된 단가와 현재 견적단가 자료를 비교합니다.</p><table class="sheet"><thead><tr><th>코드</th><th>품명</th><th>규격</th><th>단위</th><th>수량</th><th>내역 단가</th><th>기초 단가</th><th>차이</th><th>금액</th><th>연결</th><th>작업</th></tr></thead><tbody>${visible.map((row) => `<tr><td>${esc(row.Code)}</td><td>${esc(row.Name)}</td><td>${esc(row.Size)}</td><td>${esc(row.Unite)}</td><td>${value(row.GN)}</td><td>${value(row.appliedPrice)}</td><td>${value(row.sourcePrice)}</td><td>${value(row.difference)}</td><td>${value(row.TC)}</td><td>${esc(row.status)}</td><td><button data-estimate-row="${row.index}">내역 열기</button>${row.canApply ? ` <button data-estimate-apply-row="${row.index}">단가 적용</button>` : ""}</td></tr>`).join("")}</tbody></table>`;
}
function install() {
document.addEventListener("click", (event) => {
const applyAll = event.target.closest("[data-estimate-apply-all]");
if (applyAll) {
try {
EstApp.change((p) => {
const count = applyAllPrices(p);
EstApp.state.notice = `견적단가 ${count}건의 기초단가를 일괄 적용했습니다.`;
});
} catch (err) {
EstApp.state.notice = err.message;
EstApp.render();
}
return;
}
const applyRow = event.target.closest("[data-estimate-apply-row]");
if (applyRow) {
const index = Number(applyRow.dataset.estimateApplyRow);
try {
EstApp.change((p) => {
applyRowPrice(p, index);
EstApp.state.notice = `행 ${index + 1}에 기초단가를 적용했습니다.`;
});
} catch (err) {
EstApp.state.notice = err.message;
EstApp.render();
}
return;
}
const step = event.target.closest("[data-estimate-page]");
if (step) {
page += Number(step.dataset.estimatePage);
render();
return;
}
const node = event.target.closest("[data-estimate-row]");
if (!node) return;
const index = Number(node.dataset.estimateRow);
if (!EstApp.state.project.tables.EstMainDat[index]) return;
EstApp.navigate("EstMainDat");
EstApp.state.page = Math.floor(index / 100);
EstApp.state.selection = new Set([index]);
EstApp.render();
});
}
return { usage, changes, render, install, applyRowPrice, applyAllPrices };
})();
+340 -16
View File
@@ -32,6 +32,18 @@ const EstExtra = (() => {
EstStandard.render();
return;
}
if (["estimate-usage", "estimate-change"].includes(state.view)) {
EstEstimate.render();
return;
}
if (["material-usage", "material-change", "labor-usage", "labor-change"].includes(state.view)) {
EstBasicPrices.render();
return;
}
if (["machine-usage", "machine-change"].includes(state.view)) {
EstMachine.render();
return;
}
const { project, view } = state,
{ esc, number, byId } = EstApp;
let html = "";
@@ -77,7 +89,8 @@ const EstExtra = (() => {
button("capture-baseline", "현재 내역을 당초로 확정") +
button("load-contract", "당초·계약 내역 불러오기") +
button("recalculate-original", "당초 단가 재계산") +
button("bid-rates", "낙찰률 적용"),
button("bid-rates", "낙찰률 적용") +
button("restore-bid", "당초단가(100%) 복원"),
table(
["구분", "코드", "품명", "당초 수량", "변경 수량", "당초 금액", "변경 금액", "증감"],
changes
@@ -328,22 +341,57 @@ const EstExtra = (() => {
`수식 예: [기준]*0.5, [기준]-[1차]. 같은 수식을 수량과 각 성분 금액에 따로 적용하고 금액은 원 미만 절사합니다. ${esc(output.errors.join(" / "))}`,
);
} else if (view === "resources") {
const output = EstResources.calculate(project);
state.resourceKind = state.resourceKind || "all";
state.resourceByWork = state.resourceByWork ?? false;
const byWork = Boolean(state.resourceByWork);
const output = EstResources.calculate(project, byWork);
const filtered = output.rows.filter(
(r) => state.resourceKind === "all" || String(r.kind) === String(state.resourceKind),
);
const kinds = [
["all", "전체"],
["6", "노임"],
["5", "자재"],
["105", "기계"],
["21", "표준"],
["101", "견적"],
];
const kindSelect = `<label>구분 <select id="resource-kind-filter">${kinds
.map(
([k, label]) =>
`<option value="${k}" ${state.resourceKind === k ? "selected" : ""}>${esc(label)}</option>`,
)
.join("")}</select></label>`;
const byWorkCheck = `<label><input type="checkbox" id="resource-by-work" ${byWork ? "checked" : ""}> 공종별 전개</label>`;
const headers = byWork
? [
"공종코드",
"공종명",
"종류",
"코드",
"품명",
"규격",
"단위",
"소요량 / 환산시간",
"금액",
]
: ["종류", "코드", "품명", "규격", "단위", "소요량 / 환산시간", "금액"];
const rows = filtered.map((row) => {
const baseCells = [
esc({ 5: "자재", 6: "노임", 21: "표준", 101: "견적", 105: "기계" }[row.kind]),
esc(row.Code),
esc(row.Name),
esc(row.Size),
esc(row.Unite),
number(row.quantity),
number(row.amount),
];
return byWork ? [esc(row.WorkCode), esc(row.WorkName), ...baseCells] : baseCells;
});
html = box(
"동원인원·자재·장비",
"",
table(
["종류", "코드", "품명", "규격", "단위", "소요량 / 환산시간", "금액"],
output.rows.map((row) => [
esc({ 5: "자재", 6: "노임", 21: "표준", 101: "견적", 105: "기계" }[row.kind]),
esc(row.Code),
esc(row.Name),
esc(row.Size),
esc(row.Unite),
number(row.quantity),
number(row.amount),
]),
),
`${kindSelect} ${byWorkCheck} <span>(총 ${filtered.length}건)</span>`,
table(headers, rows),
esc(output.errors.join(" / ")),
);
} else if (view === "drawing") {
@@ -369,5 +417,281 @@ const EstExtra = (() => {
}
byId("work-content").innerHTML = html;
}
return { render };
function findDataUsages(project, target) {
if (!project || !target) return [];
const code = target.Code ? String(target.Code).trim() : "";
const name = target.Name ? EstNative.normalize(target.Name) : "";
const size = target.Size ? EstNative.normalize(target.Size) : "";
if (!code && !name) return [];
const results = [];
const tables = [
{ id: "EstMainDat", label: "내역서" },
{ id: "HeadMainDat", label: "일위대가" },
{ id: "CostMainDat", label: "단가산출" },
{ id: "MachMainDat", label: "기계경비" },
];
for (const info of tables) {
const rows = project.tables[info.id] || [];
const grouped = ["HeadMainDat", "CostMainDat", "MachMainDat"].includes(info.id);
let curGroup = "";
for (let i = 0; i < rows.length; i++) {
const r = rows[i];
if (grouped && Number(r.Index) > 0 && r.Code) curGroup = r.Code;
// 대상 자기 자신의 대표 행은 검색 결과에서 제외
if (r === target) continue;
let matched = false;
if (code && r.Code === code) {
matched = true;
} else if (!code && name) {
const rName = r.Name ? EstNative.normalize(r.Name) : "";
const rSize = r.Size ? EstNative.normalize(r.Size) : "";
if (rName === name && (!size || rSize === size)) {
matched = true;
}
}
if (matched) {
const rowName = r.Name || (r.LineData ? EstText.clean(r.LineData) : "");
results.push({
table: info.id,
tableLabel: info.label,
index: i,
group: curGroup,
code: r.Code || "",
name: rowName,
size: r.Size || "",
unite: r.Unite || "",
quantity: r.GN != null ? Number(r.GN) : null,
price: r.TC != null ? Number(r.TC) : null,
});
}
}
}
return results;
}
function showUsesSearchDialog(targetRow) {
const { state, esc, number, showDialog, byId } = EstApp;
if (!targetRow) throw Error("사용위치를 검색할 행을 먼저 선택하세요.");
const rowName = targetRow.Name || (targetRow.LineData ? EstText.clean(targetRow.LineData) : "");
const results = findDataUsages(state.project, targetRow);
const title = `데이터 사용위치 검색: ${targetRow.Code ? `[${targetRow.Code}] ` : ""}${rowName || "선택 항목"}`;
let bodyHtml = "";
if (!results.length) {
bodyHtml =
'<p style="padding:15px;color:#666;">내역서, 일위대가, 단가산출, 기계경비에서 사용된 위치를 찾지 못했습니다.</p>';
} else {
bodyHtml = `
<div style="max-height:420px;overflow-y:auto;border:1px solid #ccc;margin-bottom:12px;">
<table class="sheet" style="width:100%;font-size:12px;">
<thead>
<tr>
<th>구분</th><th>코드</th><th>품명 / 산출근거</th><th>규격</th><th>단위</th><th>수량</th><th>금액</th><th>이동</th>
</tr>
</thead>
<tbody>
${results
.map(
(res) => `
<tr>
<td><b>${esc(res.tableLabel)}</b></td>
<td>${esc(res.code)}</td>
<td style="text-align:left;">${esc(res.name)}</td>
<td>${esc(res.size)}</td>
<td>${esc(res.unite)}</td>
<td style="text-align:right;">${res.quantity != null ? number(res.quantity) : "-"}</td>
<td style="text-align:right;">${res.price != null ? number(res.price) : "-"}</td>
<td>
<button class="tiny" data-action="goto-use-location" data-table="${res.table}" data-index="${res.index}" data-group="${esc(res.group)}">
이동
</button>
</td>
</tr>
`,
)
.join("")}
</tbody>
</table>
</div>
<div style="font-size:12px;color:#555;">총 <b>${results.length}</b>곳에서 사용 중입니다. [이동] 버튼을 누르면 해당 화면과 행으로 이동합니다.</div>
`;
}
showDialog(title, bodyHtml);
const dialogContent = byId("dialog-content");
const clickHandler = (e) => {
const btn = e.target.closest('[data-action="goto-use-location"]');
if (!btn) return;
const targetTable = btn.dataset.table;
const targetIndex = Number(btn.dataset.index);
const targetGroup = btn.dataset.group || "";
byId("dialog").close();
dialogContent.removeEventListener("click", clickHandler);
EstApp.navigate(targetTable, targetGroup);
state.page = Math.floor(targetIndex / 100);
state.selection = new Set([targetIndex]);
EstApp.render();
};
dialogContent.addEventListener("click", clickHandler);
}
function install() {
// 필요한 경우 이벤트 리스너 등록
}
function toggleJcBimog(state) {
// REA ActionSetJCBimog (0xf17c10 / 0x10dba14): 경비성단가 지정/해제 -> SCheck 3 토글
const rows = state.project.tables[state.view],
indices = [...state.selection].sort((a, b) => a - b);
if (!rows || !["EstMainDat", "HeadMainDat"].includes(state.view))
throw Error("내역서 또는 일위대가 화면에서만 경비성단가를 지정/해제할 수 있습니다.");
if (!indices.length || indices.some((index) => !rows[index]))
throw Error("경비성단가를 지정/해제할 행을 선택하세요.");
// 원자적 검증: 한 행이라도 복원 정보가 없거나 거부되면 앞선 행도 변경되지 않도록 사전 검사
const plans = [];
for (const index of indices) {
const row = rows[index];
if (Number(row.SCheck) === 3) {
// 경비성 해제 -> 명시적 0/1/2 이력(_prevSCheck) 확인
let restoreVal =
row._prevSCheck !== undefined && [0, 1, 2].includes(Number(row._prevSCheck))
? Number(row._prevSCheck)
: null;
if (
restoreVal == null &&
row.SCheck_ !== undefined &&
[0, 1, 2].includes(Number(row.SCheck_))
) {
restoreVal = Number(row.SCheck_);
}
if (restoreVal == null) {
throw Error(
`행 ${index + 1}: 이전 비목 이력(0:비목별, 1:재료, 2:노무)이 없는 기존 경비성(3) 행은 임의 변환할 수 없습니다.`,
);
}
plans.push({ index, row, isToggleOn: false, restoreVal });
} else {
// 경비성 지정 -> 현재 SCheck(0, 1, 2)를 이력으로 보존
const cur = Number(row.SCheck) || 0;
plans.push({ index, row, isToggleOn: true, cur });
}
}
// 전수 검증 통과 후 일괄 적용
EstApp.change(() => {
for (const p of plans) {
if (p.isToggleOn) {
p.row._prevSCheck = p.cur;
EstBusinessActions.setEstimateClassification(
state.project,
p.row,
p.index,
3,
state.view,
);
} else {
delete p.row._prevSCheck;
EstBusinessActions.setEstimateClassification(
state.project,
p.row,
p.index,
p.restoreVal,
state.view,
);
}
}
});
}
function toggleSunGongsa(state) {
// REA popMainSetTotalSumLine: 순직공비 계산끝(순공사비) 지정/해제 -> LinkIndex 54 토글
const rows = state.project.tables.EstMainDat,
indices = [...state.selection].sort((a, b) => a - b);
if (state.view !== "EstMainDat" || !rows)
throw Error("내역서 화면에서만 순공사비 경계 행을 지정/해제할 수 있습니다.");
if (indices.length !== 1 || !rows[indices[0]])
throw Error("순공사비 경계 행으로 지정하거나 해제할 행 1개를 선택하세요.");
const index = indices[0],
row = rows[index];
// 연결 행 보호: 원천 연결(자재/노임/일위/단가/기계 등 LinkIndex 5,6,21,101~105) 또는
// 수량이 있는 행, 코드가 있는 상세 행은 순공사비 경계선으로 지정 불가 (원천/연결 파괴 차단)
const curLink = Number(row.LinkIndex) || 0;
if (curLink !== 54) {
if (
row.Code ||
Number(row.GN) > 0 ||
[5, 6, 21, 51, 52, 55, 57, 58, 61, 62, 67, 68, 101, 103, 104, 105].includes(curLink)
) {
throw Error(
"단가·수량 연결 행이나 기존 특수 공종 행은 순공사비 경계선으로 지정할 수 없습니다. 빈 제목/구분 행을 선택하세요.",
);
}
}
EstApp.change(() => {
if (curLink === 54) {
// 기존 54 해제 -> 보존된 원래 LinkIndex 또는 원래 유형이 확인되는 경우에만 복원
let origLink = row._origLinkIndex !== undefined ? Number(row._origLinkIndex) : null;
if (origLink == null && row.LinkIndex_ !== undefined && Number(row.LinkIndex_) !== 54) {
origLink = Number(row.LinkIndex_);
}
if (origLink == null) {
// 복원 정보가 없는 기존 원본 54 행: 원래 유형을 알 수 없으므로 임의 0 변환 차단
throw Error(
"기존 파일에 저장된 원본 순공사비(54) 행이거나 원래 유형을 확인할 수 없어 임의 0으로 변환할 수 없습니다.",
);
}
row.LinkIndex = origLink;
delete row._origLinkIndex;
if (row._origName !== undefined) {
row.Name = row._origName;
delete row._origName;
} else if (row.Name === "순공사비") {
row.Name = "공종";
}
} else {
// 기존 다른 54 행이 있으면 원래 상태로 안전 복원
for (const r of rows) {
if (Number(r.LinkIndex) === 54) {
let origL = r._origLinkIndex !== undefined ? Number(r._origLinkIndex) : null;
if (origL == null && r.LinkIndex_ !== undefined && Number(r.LinkIndex_) !== 54) {
origL = Number(r.LinkIndex_);
}
if (origL == null) {
throw Error(
"기존 원본 순공사비(54) 행의 원래 유형을 알 수 없어 다른 행으로 대체할 수 없습니다.",
);
}
r.LinkIndex = origL;
delete r._origLinkIndex;
if (r._origName !== undefined) {
r.Name = r._origName;
delete r._origName;
} else if (r.Name === "순공사비") {
r.Name = "공종";
}
}
}
row._origLinkIndex = curLink;
row._origName = row.Name ?? "";
row.LinkIndex = 54;
if (!row.Name || row.Name === "공종") row.Name = "순공사비";
}
});
}
return { render, findDataUsages, showUsesSearchDialog, install, toggleJcBimog, toggleSunGongsa };
})();
+113 -10
View File
@@ -6,6 +6,18 @@ const EstForms = (() => {
columns: ["status", "Code", "Name", "originalGN", "GN", "originalTC", "TC", "difference"],
rows: EstStandard.changes(project),
};
if (id === "estimate-change")
return {
columns: ["status", "Code", "Name", "originalGN", "GN", "originalTC", "TC", "difference"],
rows: EstEstimate.changes(project),
};
if (id === "material-change" || id === "labor-change") {
const kindKey = id.startsWith("material") ? "material" : "labor";
return {
columns: ["status", "Code", "Name", "originalGN", "GN", "originalTC", "TC", "difference"],
rows: EstBasicPrices.changes(project, kindKey),
};
}
if (id === "standard-usage")
return {
columns: [
@@ -22,6 +34,61 @@ const EstForms = (() => {
],
rows: EstStandard.usage(project),
};
if (id === "estimate-usage")
return {
columns: [
"Code",
"Name",
"Size",
"Unite",
"GN",
"appliedPrice",
"sourcePrice",
"difference",
"TC",
"status",
],
rows: EstEstimate.usage(project),
};
if (id === "material-usage" || id === "labor-usage") {
const kindKey = id.startsWith("material") ? "material" : "labor";
return {
columns: [
"Code",
"Name",
"Size",
"Unite",
"GN",
"appliedPrice",
"sourcePrice",
"difference",
"TC",
"status",
],
rows: EstBasicPrices.usage(project, kindKey),
};
}
if (id === "machine-usage")
return {
columns: [
"Code",
"Name",
"Size",
"Unite",
"appliedPrice",
"originalPrice",
"difference",
"NC",
"GC",
"JC",
],
rows: EstMachine.usage(project),
};
if (id === "machine-change")
return {
columns: ["status", "Code", "Name", "Size", "Unite", "originalTC", "TC", "difference"],
rows: EstMachine.changes(project),
};
if (id === "change-costs")
return {
columns: ["Gubun", "Name", "originalCost", "changedCost", "difference"],
@@ -169,14 +236,36 @@ const EstForms = (() => {
22: "Wonga",
}[id];
if (table) {
const columns =
table === "Wonga"
? ["Gubun", "Name", "Youl", "VSusic", "Cost"]
: table === "CostMainDat"
? ["Code", "LineData"]
: table === "HeadMainDat"
? [...common, "GN", "NU", "GU", "JU", "SU", "TC"]
: [...common, "GN", "TU", "TC", "NC", "GC", "JC", "SC"];
let columns;
if (table === "Wonga") {
columns = ["Gubun", "Name", "Youl", "VSusic", "Cost"];
} else if (table === "CostMainDat") {
columns = ["Code", "LineData"];
} else if (table === "HeadMainDat") {
const gongMethod = Number(project.printSettings?.PrintGongMethod ?? 0);
if (id === 115 && gongMethod === 0) {
// 공람용: 수량만 인쇄
columns = [...common, "GN"];
} else if (id === 115 && gongMethod === 1) {
// 공람용: 단가만 인쇄
columns = [...common, "NU", "GU", "JU", "SU", "TC"];
} else {
// 기본(수량, 단가 인쇄)
columns = [...common, "GN", "NU", "GU", "JU", "SU", "TC"];
}
} else {
const gongMethod = Number(project.printSettings?.PrintGongMethod ?? 0);
if (id === 117 && gongMethod === 0) {
// 공람용: 수량만 인쇄
columns = [...common, "GN"];
} else if (id === 117 && gongMethod === 1) {
// 공람용: 단가/금액만 인쇄
columns = [...common, "TU", "TC", "NC", "GC", "JC", "SC"];
} else {
// 기본(수량, 단가/금액 인쇄)
columns = [...common, "GN", "TU", "TC", "NC", "GC", "JC", "SC"];
}
}
return { table, columns, rows: project.tables[table] || [] };
}
const totals = {
@@ -207,7 +296,15 @@ const EstForms = (() => {
.reduce((n, r) => n + EstMath.num(r[key]), 0);
return row;
});
return { table: totals, columns: [...common, "NC", "GC", "JC", "SC", "TC"], rows };
const gongMethod = Number(project.printSettings?.PrintGongMethod ?? 0);
let columns;
if ([116, 118].includes(id) && gongMethod === 0) {
// 공람용 총괄표: 금액 숨김
columns = [...common];
} else {
columns = [...common, "NC", "GC", "JC", "SC", "TC"];
}
return { table: totals, columns, rows };
}
if ([107, 108].includes(id))
return {
@@ -334,7 +431,13 @@ const EstForms = (() => {
};
const label = (key) => labels[key] || EstModel.labels[key] || key;
function grid(output) {
const { esc } = EstApp,
const esc = (v) =>
typeof EstApp !== "undefined"
? EstApp.esc(v)
: String(v ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;"),
number = (v) => new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 12 }).format(v);
return `<table><thead><tr>${output.columns.map((key) => `<th>${esc(labels[key] || EstModel.labels[key] || key)}</th>`).join("")}</tr></thead><tbody>${output.rows.map((row) => `<tr>${output.columns.map((key) => `<td class="${typeof row[key] === "number" ? "numeric" : ""}">${esc(typeof row[key] === "number" ? number(row[key]) : key === "LineData" ? EstText.clean(row[key] || "") : (row[key] ?? ""))}</td>`).join("")}</tr>`).join("")}</tbody></table>`;
}
+93
View File
@@ -0,0 +1,93 @@
// REA TFRMHALJUNGBIUL (0xdb4b14, 0xdb4d74, 0xdb4e70, 0xf5b7fc)
// 일위대가 및 단가산출 공종(묶음) 내 할증계산 행 추가/적용 기능
const EstHalJung = (() => {
const { num, components, units, floating } = EstMath;
// REA cxRadioSize (불러올비목): 0=비목별, 1=재료비의, 2=노무비의, 3=경비의, 4=합계의
const sizeKinds = {
0: { text: "비목별", ocheck: 0 },
1: { text: "재료비의", ocheck: 1 },
2: { text: "노무비의", ocheck: 2 },
3: { text: "경비의", ocheck: 3 },
4: { text: "합계의", ocheck: 4 },
};
// REA cxRadioBimog (적용비목): 0=비목별, 1=재료비에, 2=노무비에, 3=경비에
const bimogKinds = {
0: { text: "비목별", scheck: 0 },
1: { text: "재료비에", scheck: 1 },
2: { text: "노무비에", scheck: 2 },
3: { text: "경비에", scheck: 3 },
};
function apply(
project,
tableKey,
groupCode,
{ name = "할 증", size = "비목별", rate = 0, sourceKind = 0, targetKind = 0 } = {},
) {
if (tableKey !== "HeadMainDat") {
throw Error("일위대가 화면에서만 할증계산을 적용할 수 있습니다.");
}
const rateNum = num(rate);
if (!Number.isFinite(rateNum) || rateNum === 0) {
throw Error("할증 적용율(%)은 0이 아닌 유효한 숫자여야 합니다.");
}
const next = structuredClone(project);
const rows = next.tables[tableKey] || [];
const grps = EstNative.groups(rows);
const group = grps.get(groupCode);
if (!group) throw Error("공종(묶음)을 찾을 수 없습니다.");
const sInfo = sizeKinds[sourceKind] || sizeKinds[0];
const bInfo = bimogKinds[targetKind] || bimogKinds[0];
const finalSize = size && size !== "비목별" ? size.trim() : sInfo.text;
// REA 의도: 불러올비목이 특정 비목(1=재료비, 2=노무비, 3=경비)이고 적용비목이 비목별(targetKind=0)인 경우,
// 원천 비목에 그대로 가산되도록 scheck를 원천 비목으로 맞춤.
// (targetKind가 0이고 sourceKind가 0이면 비목별 행별로 각각 분배되므로 scheck=0 유지)
const effectiveSCheck =
targetKind === 0 && sourceKind >= 1 && sourceKind <= 3 ? sInfo.ocheck : bInfo.scheck;
// REA LinkIndex 58 (비율) 행 생성
const haljungRow = {
Index: 0,
Level: 0,
LinkIndex: 58,
Name: name.trim() || "할 증",
Size: finalSize,
Unite: "%",
GN: rateNum,
SCheck: effectiveSCheck,
OCheck: sInfo.ocheck,
TU: 0,
TC: 0,
NC: 0,
GC: 0,
JC: 0,
SC: 0,
};
// 묶음 내 삽입 위치 찾기:
// 소계(LinkIndex=51, 68)가 있으면 소계 바로 앞에, 없으면 묶음의 마지막 행 뒤에 삽입
const groupEnd = group.start + group.rows.length;
let insertIndex = groupEnd;
for (let i = group.start + 1; i < groupEnd; i++) {
const link = num(rows[i].LinkIndex);
if ([51, 68].includes(link)) {
insertIndex = i;
break;
}
}
rows.splice(insertIndex, 0, haljungRow);
return next.options?.AutoCalc === false ? next : EstNative.calculate(next);
}
return {
sizeKinds,
bimogKinds,
apply,
};
})();
+268 -9
View File
@@ -1,5 +1,7 @@
const EstIssues = (() => {
let page = 0;
let page = 0,
tableFilter = "all",
typeFilter = "all";
const grouped = new Set(["HeadMainDat", "CostMainDat", "MachMainDat"]);
const kinds = {
5: "StuffcDat",
@@ -26,10 +28,26 @@ const EstIssues = (() => {
if (list.length > 1)
for (const { index } of list) issues.push({ table, index, code, message: "코드 중복" });
}
for (const table of ["EstMainDat", "HeadMainDat", "MachMainDat"]) {
for (const table of ["EstMainDat", "HeadMainDat", "MachMainDat", "CostMainDat"]) {
(project.tables[table] || []).forEach((row, index) => {
const link = Number(row.LinkIndex);
if (
Number(row.Level || 0) === 0 &&
Number(row.Index || 0) === 0 &&
[52, 57, 58, 62, 67].includes(link) &&
row.Unite &&
!["%", "%"].includes(String(row.Unite).trim())
) {
issues.push({
table,
index,
code: row.Code,
message: "단위 백분율(%) 불일치",
rowUnite: row.Unite,
});
}
if (Number(row.Level) > 0 || Number(row.Index) > 0 || !row.Code) return;
const codes = sources.get(Number(row.LinkIndex));
const codes = sources.get(link);
if (!codes) return;
const matches = codes.get(String(row.Code)) || [];
if (!matches.length)
@@ -37,7 +55,7 @@ const EstIssues = (() => {
else if (matches.length > 1)
issues.push({ table, index, code: row.Code, message: "연결 코드 중복" });
else
for (const field of ["Name", "Size"]) {
for (const field of ["Name", "Size", "Unite"]) {
const a = row[field],
b = matches[0].row[field];
if (a && b && EstNative.normalize(a) !== EstNative.normalize(b))
@@ -45,13 +63,95 @@ const EstIssues = (() => {
table,
index,
code: row.Code,
message: field === "Name" ? "연결 품명 차이" : "연결 규격 차이",
message:
field === "Name"
? "연결 품명 차이"
: field === "Size"
? "연결 규격 차이"
: "연결 단위 차이",
sourceName: matches[0].row.Name,
sourceSize: matches[0].row.Size,
sourceUnite: matches[0].row.Unite,
});
}
});
}
// 수량산출서 및 직접 수식표 항목의 내역 연결(TargetKey 또는 TargetCode/LinkCode) 검사:
// 실제 totals() 연결 규칙과 일치하도록:
// 1) item.TargetKey 가 있으면 row.PkID != null && `id:${row.PkID}` === item.TargetKey 우선 매칭
// 2) 없으면 code = item.TargetCode ?? item.LinkCode 로 row.Code === code 매칭
// 3) level > 0 (공종/비고행) 제외
const sourcesToCheck = [];
(project.quantitySheets || []).forEach((sheet) => {
(sheet.items || []).forEach((item, itemIndex) => {
sourcesToCheck.push({ item, sheetCode: sheet.Code, itemIndex });
});
});
(project.quantities || []).forEach((item, itemIndex) => {
sourcesToCheck.push({ item, sheetCode: "manual", itemIndex });
});
const estRows = (project.tables.EstMainDat || []).map((row, index) => ({ row, index }));
sourcesToCheck.forEach(({ item, sheetCode, itemIndex }) => {
const code = item.TargetCode ?? item.LinkCode;
if (!code && !item.TargetKey) {
issues.push({
table: "quantity",
sheetCode,
index: itemIndex,
code: item.Code,
message: "수량 내역 미연결",
unlinked: true,
item,
});
return;
}
const targets = estRows.filter(
({ row }) =>
!Number(row.Level) &&
(item.TargetKey
? row.PkID != null && `id:${row.PkID}` === item.TargetKey
: row.Code === code),
);
if (!targets.length) {
issues.push({
table: "quantity",
sheetCode,
index: itemIndex,
code: item.Code,
message: `수량 대상 내역 없음: ${item.TargetKey || code}`,
unlinked: true,
item,
});
} else if (targets.length > 1) {
issues.push({
table: "quantity",
sheetCode,
index: itemIndex,
code: item.Code,
message: `수량 대상 내역 중복: ${item.TargetKey || code}`,
});
}
});
const costs = project.tables.CostMainDat || [],
costGroups = EstNative.groups(costs);
if (typeof EstWonga !== "undefined" && typeof EstWonga.scan === "function") {
const direct = 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,
};
const wongaIssues = EstWonga.scan(project.tables?.Wonga, project.options || {}, bases);
issues.push(...wongaIssues);
}
for (const message of project.calculation?.errors || []) {
const text = String(message),
match = text.match(/^(.+):(\d+) /),
@@ -69,15 +169,108 @@ const EstIssues = (() => {
}
return issues;
}
function targetSelect(project, item, sheetCode, itemIndex) {
const { esc } = EstApp;
const rows = (project.tables?.EstMainDat || []).filter(
(row) => row.PkID != null && !Number(row.Level),
);
return `<select data-issue-target-select="${esc(sheetCode)}" data-issue-item-index="${itemIndex}" aria-label="연결 내역 선택"><option value="">내역 행 선택</option>${rows
.map((row) => {
const key = `id:${row.PkID}`;
return `<option value="${esc(key)}" ${item.TargetKey === key ? "selected" : ""}>${esc(row.Name || "내역")} · ${esc(row.Code || "코드 없음")} · ${esc(row.PkID)}</option>`;
})
.join("")}</select>`;
}
function render() {
const { state, esc, byId, titles } = EstApp,
issues = scan(state.project);
page = Math.max(0, Math.min(page, Math.ceil(issues.length / 100) - 1));
const rows = issues.slice(page * 100, page * 100 + 100);
allIssues = scan(state.project);
const tables = [...new Set(allIssues.map((i) => i.table || ""))];
const messages = [...new Set(allIssues.map((i) => i.message))];
const filtered = allIssues.filter((i) => {
if (tableFilter !== "all" && (i.table || "") !== tableFilter) return false;
if (typeFilter !== "all" && i.message !== typeFilter) return false;
return true;
});
page = Math.max(0, Math.min(page, Math.ceil(filtered.length / 100) - 1));
const rows = filtered.slice(page * 100, page * 100 + 100);
const tableSelect = `<label>자료 <select id="issue-table-filter"><option value="all">전체 자료</option>${tables
.map(
(t) =>
`<option value="${esc(t)}" ${tableFilter === t ? "selected" : ""}>${esc(titles[t] || t || "전체 계산")}</option>`,
)
.join("")}</select></label>`;
const typeSelect = `<label>내용 <select id="issue-type-filter"><option value="all">전체 유형</option>${messages
.map(
(m) => `<option value="${esc(m)}" ${typeFilter === m ? "selected" : ""}>${esc(m)}</option>`,
)
.join("")}</select></label>`;
byId("work-content").innerHTML =
`<div class="business-tools"><b>자료 오류 검색</b><span>${issues.length}건 · ${page + 1}/${Math.max(1, Math.ceil(issues.length / 100))}쪽</span><button data-issue-page="-1" ${page === 0 ? "disabled" : ""}>이전</button><button data-issue-page="1" ${(page + 1) * 100 >= issues.length ? "disabled" : ""}>다음</button></div><p>연결 품명·규격 차이는 확인이 필요한 항목으로 표시합니다.</p><table class="sheet"><thead><tr><th>자료</th><th>행</th><th>코드</th><th>내용</th><th>이동</th></tr></thead><tbody>${rows.map((item) => `<tr><td>${esc(titles[item.table] || item.table || "전체 계산")}</td><td>${item.index >= 0 ? item.index + 1 : ""}</td><td>${esc(item.code)}</td><td>${esc(item.message)}</td><td>${item.table ? `<button data-issue-table="${esc(item.table)}" data-issue-row="${item.index}">행 열기</button>` : ""}</td></tr>`).join("")}</tbody></table>`;
`<div class="business-tools"><b>자료 오류 검색</b>${tableSelect} ${typeSelect}<span>검색 ${filtered.length}건 (전체 ${allIssues.length}건) · ${page + 1}/${Math.max(1, Math.ceil(filtered.length / 100))}쪽</span><button data-issue-page="-1" ${page === 0 ? "disabled" : ""}>이전</button><button data-issue-page="1" ${(page + 1) * 100 >= filtered.length ? "disabled" : ""}>다음</button>${
filtered.some(
(i) => i.sourceName && ["연결 품명 차이", "연결 규격 차이"].includes(i.message),
)
? ' <button data-issue-sync-all="1">불일치 원천 명칭/규격 일괄 반영</button>'
: ""
}</div><p>연결 품명·규격 차이는 확인 후 개별 또는 일괄로 반영할 수 있으며, 단위 차이는 수량·단가 환산 보호를 위해 개별 확인 시에만 반영됩니다.</p><table class="sheet"><thead><tr><th>자료</th><th>행</th><th>코드</th><th>내용</th><th>원천 실제 명칭</th><th>이동 및 조작</th></tr></thead><tbody>${rows
.map(
(item) =>
`<tr><td>${esc(titles[item.table] || item.table || "전체 계산")}</td><td>${item.index >= 0 ? item.index + 1 : ""}</td><td>${esc(item.code)}</td><td>${esc(item.message)}</td><td>${
item.sourceName
? `${esc(item.sourceName)}${item.sourceSize ? " · " + esc(item.sourceSize) : ""}${item.sourceUnite ? " · " + esc(item.sourceUnite) : ""}`
: ""
}</td><td>${
item.table === "quantity"
? `<button data-issue-quantity="${esc(item.sheetCode || "")}" data-issue-row="${item.index}">산출서 열기</button>${
item.unlinked
? " " + targetSelect(state.project, item.item, item.sheetCode, item.index)
: ""
}`
: item.table
? `<button data-issue-table="${esc(item.table)}" data-issue-row="${item.index}">행 열기</button>${
item.sourceName &&
["연결 품명 차이", "연결 규격 차이", "연결 단위 차이"].includes(item.message)
? ` <button data-issue-sync-row="${esc(item.table)}" data-issue-sync-index="${item.index}" data-issue-sync-msg="${esc(item.message)}">${
item.message === "연결 단위 차이" ? "원천단위 반영" : "원천명칭 반영"
}</button>`
: ""
}`
: ""
}</td></tr>`,
)
.join("")}</tbody></table>`;
}
function install() {
document.addEventListener("change", (event) => {
if (event.target.id === "issue-table-filter") {
tableFilter = event.target.value;
page = 0;
render();
return;
}
if (event.target.id === "issue-type-filter") {
typeFilter = event.target.value;
page = 0;
render();
return;
}
const select = event.target.closest("[data-issue-target-select]");
if (!select) return;
const { state } = EstApp,
sheetCode = select.dataset.issueTargetSelect,
itemIndex = Number(select.dataset.issueItemIndex);
const item =
sheetCode === "manual"
? state.project.quantities?.[itemIndex]
: state.project.quantitySheets?.find((s) => s.Code === sheetCode)?.items?.[itemIndex];
if (!item) return;
const row = (state.project.tables.EstMainDat || []).find(
(r) => r.PkID != null && `id:${r.PkID}` === select.value && !Number(r.Level),
);
EstApp.change(() => {
item.TargetKey = row ? select.value : "";
item.TargetCode = row?.Code || "";
}, false);
});
document.addEventListener("click", (event) => {
const step = event.target.closest("[data-issue-page]");
if (step) {
@@ -85,6 +278,72 @@ const EstIssues = (() => {
render();
return;
}
const qNode = event.target.closest("[data-issue-quantity]");
if (qNode) {
const sheetCode = qNode.dataset.issueQuantity;
EstApp.state.view = "quantity";
EstApp.state.quantitySheet = sheetCode || "manual";
EstApp.navigate("quantity");
return;
}
const syncAllBtn = event.target.closest("[data-issue-sync-all]");
if (syncAllBtn) {
const { state } = EstApp;
// 현재 필터링 조건에 일치하는 품명·규격 불일치 항목만 일괄 반영 대상
const currentFiltered = scan(state.project).filter((i) => {
if (tableFilter !== "all" && (i.table || "") !== tableFilter) return false;
if (typeFilter !== "all" && i.message !== typeFilter) return false;
return i.sourceName && (i.message === "연결 품명 차이" || i.message === "연결 규격 차이");
});
if (!currentFiltered.length) return;
EstApp.change(() => {
for (const issue of currentFiltered) {
const row = state.project.tables[issue.table]?.[issue.index];
if (!row) continue;
if (issue.message === "연결 품명 차이" && issue.sourceName) {
row.Name = issue.sourceName;
} else if (issue.message === "연결 규격 차이") {
if (issue.sourceSize && issue.sourceSize.trim() !== "") {
row.Size = issue.sourceSize;
}
}
}
});
render();
return;
}
const syncRowBtn = event.target.closest("[data-issue-sync-row]");
if (syncRowBtn) {
const { state } = EstApp;
const targetTable = syncRowBtn.dataset.issueSyncRow,
targetIndex = Number(syncRowBtn.dataset.issueSyncIndex),
targetMsg = syncRowBtn.dataset.issueSyncMsg;
const issue = scan(state.project).find(
(i) =>
i.table === targetTable &&
i.index === targetIndex &&
(!targetMsg || i.message === targetMsg) &&
i.sourceName,
);
if (!issue) return;
EstApp.change(() => {
const row = state.project.tables[targetTable]?.[targetIndex];
if (!row) return;
if (issue.message === "연결 품명 차이" && issue.sourceName) {
row.Name = issue.sourceName;
} else if (issue.message === "연결 규격 차이") {
if (issue.sourceSize && issue.sourceSize.trim() !== "") {
row.Size = issue.sourceSize;
}
} else if (issue.message === "연결 단위 차이") {
if (issue.sourceUnite !== undefined) {
row.Unite = issue.sourceUnite;
}
}
});
render();
return;
}
const node = event.target.closest("[data-issue-table]");
if (!node) return;
const table = node.dataset.issueTable,
+21 -7
View File
@@ -2,9 +2,14 @@
function existingTargets(tables, sheets, warnings) {
const candidates = new Map();
const targetCounts = new Map();
const pkidCounts = new Map();
for (const row of tables.EstMainDat || [])
if (row.Code && !Number(row.Level))
if (row.Code && !Number(row.Level)) {
targetCounts.set(row.Code, (targetCounts.get(row.Code) || 0) + 1);
if (row.PkID != null) {
pkidCounts.set(row.PkID, (pkidCounts.get(row.PkID) || 0) + 1);
}
}
for (const row of tables.EstMainDat || []) {
if (Number(row.Level) || !row.MulCode?.binary || !row.MullangIndex || row.ExcelSheetIndex)
continue;
@@ -29,7 +34,8 @@ function existingTargets(tables, sheets, warnings) {
warnings.add("수량 연결: 문서·항목 코드 또는 내역 코드가 없습니다.");
continue;
}
if (targetCounts.get(row.Code) !== 1) {
const hasUniquePkid = row.PkID != null && pkidCounts.get(row.PkID) === 1;
if (targetCounts.get(row.Code) !== 1 && !hasUniquePkid) {
warnings.add(`수량 연결: 내역 코드 ${row.Code}가 중복되어 자동 선택하지 않았습니다.`);
continue;
}
@@ -39,12 +45,20 @@ function existingTargets(tables, sheets, warnings) {
warnings.add(`수량 연결: ${parts[0]}/${parts[1]} 항목을 찾을 수 없습니다.`);
continue;
}
if (!candidates.has(item)) candidates.set(item, new Set());
candidates.get(item).add(row.Code);
if (!candidates.has(item)) candidates.set(item, new Map());
const key = hasUniquePkid ? `id:${row.PkID}` : `code:${row.Code}`;
candidates.get(item).set(key, { code: row.Code, pkId: hasUniquePkid ? row.PkID : null });
}
for (const [item, codes] of candidates)
if (codes.size === 1) item.TargetCode = [...codes][0];
else
for (const [item, entries] of candidates) {
if (entries.size === 1) {
const entry = [...entries.values()][0];
item.TargetCode = entry.code;
if (entry.pkId != null) {
item.TargetKey = `id:${entry.pkId}`;
}
} else {
warnings.add(`수량 연결: ${item.Code}에 여러 내역 코드가 연결되어 자동 선택하지 않았습니다.`);
}
}
}
module.exports = { existingTargets };
+104 -1
View File
@@ -129,5 +129,108 @@ const EstMachine = (() => {
finish();
return { rows: output, titles, errors };
}
return { calculate };
let page = 0;
function usage(project) {
const titles = project.tables?.MachTitleDat || [];
const grps = EstNative.groups(project.tables?.MachMainDat || []);
return titles.map((title, index) => {
const code = title.Code || "";
const group = grps.get(code);
const appliedPrice = EstMath.num(title.TC);
const hasOriginal = title.TC_ != null;
const originalPrice = hasOriginal ? EstMath.num(title.TC_) : null;
const difference = hasOriginal ? appliedPrice - originalPrice : null;
return {
index,
Code: code,
Name: title.Name || "",
Size: title.Size || "",
Unite: title.Unite || "",
appliedPrice,
originalPrice,
difference,
NC: EstMath.num(title.NC),
GC: EstMath.num(title.GC),
JC: EstMath.num(title.JC),
detailCount: group ? group.rows.length : 0,
status: !hasOriginal ? "신규(당초없음)" : Math.abs(difference) > 1e-6 ? "당초차이" : "일치",
canApply: false, // 당초 계약값은 비교용이며 현재 단가 덮어쓰기 적용을 지원하지 않음
};
});
}
function changes(project) {
const labels = { new: "신규", deleted: "삭제", changed: "변경", unchanged: "동일" };
const titles = project.tables?.MachTitleDat || [];
return titles.map((title, index) => {
const appliedPrice = EstMath.num(title.TC);
const hasOriginal = title.TC_ != null;
const originalPrice = hasOriginal ? EstMath.num(title.TC_) : null;
const difference = hasOriginal ? appliedPrice - originalPrice : null;
const statusKey = !hasOriginal
? "new"
: Math.abs(difference) > 1e-6
? "changed"
: "unchanged";
return {
index,
Code: title.Code || "",
Name: title.Name || "",
Size: title.Size || "",
Unite: title.Unite || "",
originalTC: originalPrice,
TC: appliedPrice,
difference,
status: labels[statusKey] || statusKey,
};
});
}
function applyRowPrice() {
throw Error(
"기계경비 호표는 MachMainDat 산출 결과이며 당초 계약값으로의 덮어쓰기 적용은 지원하지 않습니다.",
);
}
function applyAllPrices() {
throw Error(
"기계경비 호표는 MachMainDat 산출 결과이며 당초 계약값으로의 덮어쓰기 적용은 지원하지 않습니다.",
);
}
function render() {
const { state, esc, number, byId } = EstApp,
isChange = state.view === "machine-change",
rows = isChange ? changes(state.project) : usage(state.project);
page = Math.max(0, Math.min(page, Math.ceil(rows.length / 100) - 1));
const visible = rows.slice(page * 100, page * 100 + 100),
value = (v) => (v == null ? "" : number(v));
if (isChange) {
byId("work-content").innerHTML =
`<div class="business-tools"><b>기계경비 설계변경</b><span>${rows.length}건 · ${page + 1}/${Math.max(1, Math.ceil(rows.length / 100))}쪽</span><button data-machine-page="-1" ${page === 0 ? "disabled" : ""}>이전</button><button data-machine-page="1" ${(page + 1) * 100 >= rows.length ? "disabled" : ""}>다음</button></div><table class="sheet"><thead><tr><th>구분</th><th>코드</th><th>품명</th><th>규격</th><th>단위</th><th>당초 금액</th><th>변경 금액</th><th>증감</th><th>작업</th></tr></thead><tbody>${visible.map((row) => `<tr><td>${esc(row.status)}</td><td>${esc(row.Code)}</td><td>${esc(row.Name)}</td><td>${esc(row.Size)}</td><td>${esc(row.Unite)}</td><td>${value(row.originalTC)}</td><td>${value(row.TC)}</td><td>${value(row.difference)}</td><td><button data-machine-group="${esc(row.Code)}">호표 열기</button></td></tr>`).join("")}</tbody></table>`;
return;
}
byId("work-content").innerHTML =
`<div class="business-tools"><b>기계경비 사용 내역</b><span>${rows.length}건 · ${page + 1}/${Math.max(1, Math.ceil(rows.length / 100))}쪽</span><button data-machine-page="-1" ${page === 0 ? "disabled" : ""}>이전</button><button data-machine-page="1" ${(page + 1) * 100 >= rows.length ? "disabled" : ""}>다음</button></div><p>기계경비 호표의 산출 단가와 당초 계약 단가를 비교합니다. (기계 호표 단가는 MachMainDat 산출 공식에 의해 결정되며 당초값 덮어쓰기는 지원하지 않습니다.)</p><table class="sheet"><thead><tr><th>코드</th><th>품명</th><th>규격</th><th>단위</th><th>적용 단가</th><th>당초 단가</th><th>차이</th><th>상태</th><th>노무비</th><th>재료비</th><th>경비</th><th>작업</th></tr></thead><tbody>${visible.map((row) => `<tr><td>${esc(row.Code)}</td><td>${esc(row.Name)}</td><td>${esc(row.Size)}</td><td>${esc(row.Unite)}</td><td>${value(row.appliedPrice)}</td><td>${value(row.originalPrice)}</td><td>${value(row.difference)}</td><td>${esc(row.status)}</td><td>${value(row.NC)}</td><td>${value(row.GC)}</td><td>${value(row.JC)}</td><td><button data-machine-group="${esc(row.Code)}">호표 열기</button></td></tr>`).join("")}</tbody></table>`;
}
function install() {
document.addEventListener("click", (event) => {
const step = event.target.closest("[data-machine-page]");
if (step) {
page += Number(step.dataset.machinePage);
render();
return;
}
const node = event.target.closest("[data-machine-group]");
if (!node) return;
const code = node.dataset.machineGroup;
if (!code) return;
EstApp.navigate("MachMainDat", code);
});
}
return { calculate, usage, changes, applyRowPrice, applyAllPrices, render, install };
})();
+8
View File
@@ -378,6 +378,14 @@ const EstModel = (() => {
"change-costs",
"standard-usage",
"standard-change",
"estimate-usage",
"estimate-change",
"material-usage",
"material-change",
"labor-usage",
"labor-change",
"machine-usage",
"machine-change",
]);
for (const [key, value] of Object.entries(project.documents))
if (
+190 -154
View File
@@ -307,7 +307,7 @@ const EstQuantity = (() => {
{ esc, number, byId } = EstApp;
byId("work-content").innerHTML =
selector(state) +
`<div class="business-tools"><label>산출서명 <input data-quantity-name value="${esc(sheet.Name)}"></label><button data-quantity-add>산출 행 추가</button><button data-quantity-apply>내역 수량 반영</button><button data-quantity-delete-sheet>산출서 삭제</button></div><table class="sheet"><thead><tr><th>산출코드</th><th>품명</th><th>규격</th><th>저장 수량</th><th>재계산 수량</th><th>연결 내역코드</th></tr></thead><tbody>${output.items.map((item, index) => `<tr><td>${esc(item.Code)}</td><td><input data-quantity-item="Name" data-index="${index}" value="${esc(item.Name || "")}" aria-label="품명"></td><td><input data-quantity-item="Size" data-index="${index}" value="${esc(item.Size || "")}" aria-label="규격"></td><td>${number(sheet.items[index].GN)}</td><td>${number(item.GN)}</td><td><input data-quantity-target="${index}" value="${esc(item.TargetCode ?? item.LinkCode ?? "")}" aria-label="연결 내역코드">${targetSelect(state.project, item, "sheet", index)}</td></tr>`).join("")}</tbody></table><p class="business-note">${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 행 변경은 JSON으로 저장합니다.</p><table class="sheet"><thead><tr><th>줄</th><th>산출근거 편집</th><th>작업</th></tr></thead><tbody>${sheet.rows.map((row, index) => `<tr><td>${index + 1}</td><td><textarea data-quantity-line="${index}" rows="1" style="min-width:750px" aria-label="산출근거 ${index + 1}">${esc(EstText.display(output.rows[index].LineData))}</textarea></td><td><button data-quantity-add data-index="${index}">아래 추가</button><button data-quantity-move="-1" data-index="${index}" ${index === 0 ? "disabled" : ""}>↑</button><button data-quantity-move="1" data-index="${index}" ${index === sheet.rows.length - 1 ? "disabled" : ""}>↓</button><button data-quantity-delete-row="${index}">삭제</button></td></tr>`).join("")}</tbody></table>`;
`<div class="business-tools"><label>산출서명 <input data-quantity-name value="${esc(sheet.Name)}"></label><button data-quantity-add-item>수량 항목 추가</button><button data-quantity-add>산출 행 추가</button><button data-quantity-apply>내역 수량 반영</button><button data-quantity-delete-sheet>산출서 삭제</button></div><table class="sheet"><thead><tr><th>산출코드</th><th>품명</th><th>규격</th><th>단위</th><th>저장 수량</th><th>재계산 수량</th><th>연결 내역코드</th><th>작업</th></tr></thead><tbody>${output.items.map((item, index) => `<tr><td>${esc(item.Code)}</td><td><input data-quantity-item="Name" data-index="${index}" value="${esc(item.Name || "")}" aria-label="품명"></td><td><input data-quantity-item="Size" data-index="${index}" value="${esc(item.Size || "")}" aria-label="규격"></td><td><input data-quantity-item="Unite" data-index="${index}" value="${esc(item.Unite || "")}" aria-label="단위"></td><td>${number(sheet.items[index]?.GN ?? 0)}</td><td>${number(item.GN)}</td><td><input data-quantity-target="${index}" value="${esc(item.TargetCode ?? item.LinkCode ?? "")}" aria-label="연결 내역코드">${targetSelect(state.project, item, "sheet", index)}</td><td><button data-quantity-delete-item="${index}">삭제</button></td></tr>`).join("")}</tbody></table><p class="business-note">${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 행 변경은 JSON으로 저장합니다.</p><table class="sheet"><thead><tr><th>줄</th><th>산출근거 편집</th><th>작업</th></tr></thead><tbody>${sheet.rows.map((row, index) => `<tr><td>${index + 1}</td><td><textarea data-quantity-line="${index}" rows="1" style="min-width:750px" aria-label="산출근거 ${index + 1}">${esc(EstText.display(output.rows[index].LineData))}</textarea></td><td><button data-quantity-add data-index="${index}">아래 추가</button><button data-quantity-move="-1" data-index="${index}" ${index === 0 ? "disabled" : ""}>↑</button><button data-quantity-move="1" data-index="${index}" ${index === sheet.rows.length - 1 ? "disabled" : ""}>↓</button><button data-quantity-delete-row="${index}">삭제</button></td></tr>`).join("")}</tbody></table>`;
}
function install() {
document.addEventListener("change", (event) => {
@@ -339,7 +339,7 @@ const EstQuantity = (() => {
if (!sheet) return;
if (node.dataset.quantityName !== undefined)
EstApp.change(() => (sheet.Name = node.value), false);
if (["Name", "Size"].includes(node.dataset.quantityItem))
if (["Name", "Size", "Unite"].includes(node.dataset.quantityItem))
EstApp.change(
() => (sheet.items[Number(node.dataset.index)][node.dataset.quantityItem] = node.value),
false,
@@ -359,170 +359,206 @@ const EstQuantity = (() => {
});
document.addEventListener("click", (event) => {
const { state } = EstApp;
if (event.target.closest("[data-quantity-excel]")) {
excelDialog();
return;
}
if (event.target.closest("[data-quantity-excel-links]")) {
excelLinksDialog();
return;
}
const removeLink = event.target.closest("[data-quantity-excel-remove]");
if (removeLink) {
EstApp.change(
() =>
state.project.excelQuantityLinks.splice(
Number(removeLink.dataset.quantityExcelRemove),
1,
),
false,
);
document.getElementById("dialog").close();
excelLinksDialog();
return;
}
if (event.target.closest("[data-quantity-excel-refresh]")) {
const project = state.project,
revision = state.revision,
input = document.getElementById("quantity-excel-refresh-file"),
file = input.files[0];
(async () => {
if (!file) throw Error("Excel 파일을 선택하세요.");
const files = await EstApp.api("/api/import-xlsx", {
method: "POST",
body: await file.arrayBuffer(),
});
if (
state.project !== project ||
state.revision !== revision ||
document.getElementById("quantity-excel-refresh-file") !== input ||
input.files[0] !== file
)
throw Error("파일을 읽는 동안 작업이 바뀌었습니다. 다시 선택하세요.");
const updates = excelUpdates(project, file.name, files);
EstApp.change(() => updates.forEach(({ row, value }) => (row.GN = value)));
try {
if (event.target.closest("[data-quantity-excel]")) {
excelDialog();
return;
}
if (event.target.closest("[data-quantity-excel-links]")) {
excelLinksDialog();
return;
}
const removeLink = event.target.closest("[data-quantity-excel-remove]");
if (removeLink) {
EstApp.change(
() =>
state.project.excelQuantityLinks.splice(
Number(removeLink.dataset.quantityExcelRemove),
1,
),
false,
);
document.getElementById("dialog").close();
})().catch((error) => {
const output = document.getElementById("quantity-excel-links-error");
if (output) output.textContent = error.message;
});
return;
}
if (event.target.closest("[data-quantity-excel-apply]")) {
const project = state.project,
revision = state.revision,
fileInput = document.getElementById("quantity-excel-file"),
file = fileInput.files[0],
name = document.getElementById("quantity-excel-sheet").value.trim(),
address = document.getElementById("quantity-excel-cell").value,
selected = document.getElementById("quantity-excel-target").value,
row = selected === "" ? null : project.tables.EstMainDat[Number(selected)];
(async () => {
if (!file || !name || !row) throw Error("파일·시트·셀·내역 행을 선택하세요.");
if (
!Number.isSafeInteger(row.PkID) ||
project.tables.EstMainDat.filter((item) => item.PkID === row.PkID).length !== 1
)
throw Error("내역 행 식별자가 없거나 중복되어 연결할 수 없습니다.");
const files = await EstApp.api("/api/import-xlsx", {
excelLinksDialog();
return;
}
if (event.target.closest("[data-quantity-excel-refresh]")) {
const project = state.project,
revision = state.revision,
input = document.getElementById("quantity-excel-refresh-file"),
file = input.files[0];
(async () => {
if (!file) throw Error("Excel 파일을 선택하세요.");
const files = await EstApp.api("/api/import-xlsx", {
method: "POST",
body: await file.arrayBuffer(),
}),
value = excelCell(files, name, address);
if (
state.project !== project ||
state.revision !== revision ||
document.getElementById("quantity-excel-file") !== fileInput ||
fileInput.files[0] !== file ||
document.getElementById("quantity-excel-sheet")?.value.trim() !== name ||
document.getElementById("quantity-excel-cell")?.value !== address ||
document.getElementById("quantity-excel-target")?.value !== selected ||
project.tables.EstMainDat[Number(selected)] !== row
)
throw Error("파일을 읽는 동안 작업이 바뀌었습니다. 다시 선택하세요.");
EstApp.change(() => {
row.GN = value;
project.excelQuantityLinks ||= [];
project.excelQuantityLinks = project.excelQuantityLinks.filter(
(link) => link.target !== row.PkID,
);
project.excelQuantityLinks.push({
target: row.PkID,
file: file.name,
sheet: name,
cell: address.trim().toUpperCase(),
});
if (
state.project !== project ||
state.revision !== revision ||
document.getElementById("quantity-excel-refresh-file") !== input ||
input.files[0] !== file
)
throw Error("파일을 읽는 동안 작업이 바뀌었습니다. 다시 선택하세요.");
const updates = excelUpdates(project, file.name, files);
EstApp.change(() => updates.forEach(({ row, value }) => (row.GN = value)));
document.getElementById("dialog").close();
})().catch((error) => {
const output = document.getElementById("quantity-excel-links-error");
if (output) output.textContent = error.message;
});
document.getElementById("dialog").close();
})().catch((error) => {
const output = document.getElementById("quantity-excel-error");
if (output) output.textContent = error.message;
});
return;
}
if (event.target.closest("[data-quantity-new]")) {
EstApp.change(() => {
const code = `Q${EstApp.uid()}`;
state.project.quantitySheets ||= [];
state.project.quantitySheets.push({
Code: code,
Name: "새 수량산출서",
rows: [],
items: [],
options: { digits: 2, roundMode: 2 },
return;
}
if (event.target.closest("[data-quantity-excel-apply]")) {
const project = state.project,
revision = state.revision,
fileInput = document.getElementById("quantity-excel-file"),
file = fileInput.files[0],
name = document.getElementById("quantity-excel-sheet").value.trim(),
address = document.getElementById("quantity-excel-cell").value,
selected = document.getElementById("quantity-excel-target").value,
row = selected === "" ? null : project.tables.EstMainDat[Number(selected)];
(async () => {
if (!file || !name || !row) throw Error("파일·시트·셀·내역 행을 선택하세요.");
if (
!Number.isSafeInteger(row.PkID) ||
project.tables.EstMainDat.filter((item) => item.PkID === row.PkID).length !== 1
)
throw Error("내역 행 식별자가 없거나 중복되어 연결할 수 없습니다.");
const files = await EstApp.api("/api/import-xlsx", {
method: "POST",
body: await file.arrayBuffer(),
}),
value = excelCell(files, name, address);
if (
state.project !== project ||
state.revision !== revision ||
document.getElementById("quantity-excel-file") !== fileInput ||
fileInput.files[0] !== file ||
document.getElementById("quantity-excel-sheet")?.value.trim() !== name ||
document.getElementById("quantity-excel-cell")?.value !== address ||
document.getElementById("quantity-excel-target")?.value !== selected ||
project.tables.EstMainDat[Number(selected)] !== row
)
throw Error("파일을 읽는 동안 작업이 바뀌었습니다. 다시 선택하세요.");
EstApp.change(() => {
row.GN = value;
project.excelQuantityLinks ||= [];
project.excelQuantityLinks = project.excelQuantityLinks.filter(
(link) => link.target !== row.PkID,
);
project.excelQuantityLinks.push({
target: row.PkID,
file: file.name,
sheet: name,
cell: address.trim().toUpperCase(),
});
});
document.getElementById("dialog").close();
})().catch((error) => {
const output = document.getElementById("quantity-excel-error");
if (output) output.textContent = error.message;
});
state.quantitySheet = code;
}, false);
return;
}
const sheet = state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet);
const addRow = event.target.closest("[data-quantity-add]");
if (addRow && sheet) {
EstApp.change(() => {
const code = `r${EstApp.uid()}`;
const at =
addRow.dataset.index === undefined
? sheet.rows.length
: Number(addRow.dataset.index) + 1;
sheet.rows.splice(at, 0, { LineData: "1 = \x120\x12", markers: [code] });
sheet.items.push({ Code: code, Name: "새 항목", Size: "", GN: 0, TargetCode: "" });
}, false);
return;
}
const moveRow = event.target.closest("[data-quantity-move]");
if (moveRow && sheet) {
const index = Number(moveRow.dataset.index),
next = index + Number(moveRow.dataset.quantityMove);
if (index >= 0 && index < sheet.rows.length && next >= 0 && next < sheet.rows.length)
return;
}
if (event.target.closest("[data-quantity-new]")) {
EstApp.change(() => {
[sheet.rows[index], sheet.rows[next]] = [sheet.rows[next], sheet.rows[index]];
const code = `Q${EstApp.uid()}`;
state.project.quantitySheets ||= [];
state.project.quantitySheets.push({
Code: code,
Name: "새 수량산출서",
rows: [],
items: [],
options: { digits: 2, roundMode: 2 },
});
state.quantitySheet = code;
}, false);
return;
}
const deleteRow = event.target.closest("[data-quantity-delete-row]");
if (deleteRow && sheet) {
if (confirm("이 산출 행과 연결된 수량 항목을 삭제하시겠습니까?"))
return;
}
const sheet = state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet);
const addItem = event.target.closest("[data-quantity-add-item]");
if (addItem && sheet) {
EstApp.change(() => {
const [removed] = sheet.rows.splice(Number(deleteRow.dataset.quantityDeleteRow), 1);
const remaining = new Set(sheet.rows.flatMap((row) => row.markers || []));
sheet.items = sheet.items.filter(
(item) => !removed.markers.includes(item.Code) || remaining.has(item.Code),
const codes = new Set(sheet.items.map((it) => it.Code));
let index = 0;
while (codes.has(`r${String(index).padStart(6, "0")}`)) index++;
const code = `r${String(index).padStart(6, "0")}`;
sheet.items.push({
Code: code,
Name: "새 수량항목",
Size: "",
Unite: "",
GN: 0,
TargetCode: "",
});
}, false);
return;
}
const deleteItem = event.target.closest("[data-quantity-delete-item]");
if (deleteItem && sheet) {
const itemIdx = Number(deleteItem.dataset.quantityDeleteItem);
const targetItem = sheet.items[itemIdx];
if (!targetItem) return;
const code = targetItem.Code;
const referringRows = sheet.rows.filter((row) => (row.markers || []).includes(code));
if (referringRows.length > 0) {
throw Error(
`산출 행에서 참조 중인 수량 항목(${targetItem.Name || code})은 직접 삭제할 수 없습니다. 연결된 산출 행을 삭제하거나, 미참조 항목만 삭제할 수 있습니다.`,
);
}, false);
return;
}
if (event.target.closest("[data-quantity-delete-sheet]") && sheet) {
if (confirm("이 수량산출서를 삭제하시겠습니까?"))
}
if (confirm("이 수량 항목을 삭제하시겠습니까?"))
EstApp.change(() => {
sheet.items.splice(itemIdx, 1);
}, false);
return;
}
const addRow = event.target.closest("[data-quantity-add]");
if (addRow && sheet) {
EstApp.change(() => {
state.project.quantitySheets = state.project.quantitySheets.filter(
(item) => item !== sheet,
);
state.quantitySheet = state.project.quantitySheets[0]?.Code || "manual";
const code = `r${EstApp.uid()}`;
const at =
addRow.dataset.index === undefined
? sheet.rows.length
: Number(addRow.dataset.index) + 1;
sheet.rows.splice(at, 0, { LineData: "1 = \x120\x12", markers: [code] });
sheet.items.push({ Code: code, Name: "새 항목", Size: "", GN: 0, TargetCode: "" });
}, false);
return;
}
if (!event.target.closest("[data-quantity-apply]")) return;
try {
return;
}
const moveRow = event.target.closest("[data-quantity-move]");
if (moveRow && sheet) {
const index = Number(moveRow.dataset.index),
next = index + Number(moveRow.dataset.quantityMove);
if (index >= 0 && index < sheet.rows.length && next >= 0 && next < sheet.rows.length)
EstApp.change(() => {
[sheet.rows[index], sheet.rows[next]] = [sheet.rows[next], sheet.rows[index]];
}, false);
return;
}
const deleteRow = event.target.closest("[data-quantity-delete-row]");
if (deleteRow && sheet) {
if (confirm("이 산출 행과 연결된 수량 항목을 삭제하시겠습니까?"))
EstApp.change(() => {
const [removed] = sheet.rows.splice(Number(deleteRow.dataset.quantityDeleteRow), 1);
const remaining = new Set(sheet.rows.flatMap((row) => row.markers || []));
sheet.items = sheet.items.filter(
(item) => !removed.markers.includes(item.Code) || remaining.has(item.Code),
);
}, false);
return;
}
if (event.target.closest("[data-quantity-delete-sheet]") && sheet) {
if (confirm("이 수량산출서를 삭제하시겠습니까?"))
EstApp.change(() => {
state.project.quantitySheets = state.project.quantitySheets.filter(
(item) => item !== sheet,
);
state.quantitySheet = state.project.quantitySheets[0]?.Code || "manual";
}, false);
return;
}
if (!event.target.closest("[data-quantity-apply]")) return;
const quantities = totals(state.project, state.quantitySheet);
EstApp.change(() => applyTotals(state.project, quantities));
} catch (error) {
+40 -7
View File
@@ -13,6 +13,9 @@ const EstReport = (() => {
startPage: 1,
rowsPerPage: 30,
notes: "",
PrintGongMethod: settings.PrintGongMethod ?? 0,
PrintApplyField: settings.PrintApplyField ?? 0,
PrintSumBottom: settings.PrintSumBottom ?? 0,
...settings,
marginTop: settings.marginTop ?? settings.margin ?? 12,
marginBottom: settings.marginBottom ?? settings.margin ?? 12,
@@ -149,6 +152,14 @@ const EstReport = (() => {
{ Index: "change-costs", Name: "설계변경 원가 증감표", Name2: "설계변경 원가 증감표" },
{ Index: "standard-usage", Name: "표준시장단가 사용 내역", Name2: "표준시장단가 사용 내역" },
{ Index: "standard-change", Name: "표준시장단가 설계변경", Name2: "표준시장단가 설계변경" },
{ Index: "estimate-usage", Name: "견적단가 사용 내역", Name2: "견적단가 사용 내역" },
{ Index: "estimate-change", Name: "견적단가 설계변경", Name2: "견적단가 설계변경" },
{ Index: "material-usage", Name: "자재단가 사용 내역", Name2: "자재단가 사용 내역" },
{ Index: "material-change", Name: "자재단가 설계변경", Name2: "자재단가 설계변경" },
{ Index: "labor-usage", Name: "노임단가 사용 내역", Name2: "노임단가 사용 내역" },
{ Index: "labor-change", Name: "노임단가 설계변경", Name2: "노임단가 설계변경" },
{ Index: "machine-usage", Name: "기계경비 사용 내역", Name2: "기계경비 사용 내역" },
{ Index: "machine-change", Name: "기계경비 설계변경", Name2: "기계경비 설계변경" },
);
const table = EstApp.state.project.tables[EstApp.state.view];
const s = setting(),
@@ -179,6 +190,7 @@ const EstReport = (() => {
`<label>${label}<input data-print="${key}" type="number" min="${min}" max="${max}" value="${s[key]}" style="width:65px"></label>`,
)
.join("")}
<label id="print-gong-wrap" ${["115", "116", "117", "118"].includes(String(s.form)) ? "" : "hidden"}>공람용 방법 <select id="print-gong"><option value="0" ${Number(s.PrintGongMethod) === 0 ? "selected" : ""}>수량만</option><option value="1" ${Number(s.PrintGongMethod) === 1 ? "selected" : ""}>단가만</option><option value="2" ${Number(s.PrintGongMethod) === 2 ? "selected" : ""}>수량·단가</option></select></label>
<label>업체명<input data-print="company" value="${esc(s.company)}"></label>
${table ? '<label>출력 범위 <select id="print-scope"><option value="current">현재 호표·검색 결과 전체 행</option><option value="all">현재 자료의 모든 호표·행</option></select></label>' : ""}
<button data-action="print-document">인쇄 / PDF 저장</button><button data-action="export-report-xlsx">서식 Excel 저장</button><span id="print-error" role="status"></span></div>
@@ -186,11 +198,13 @@ const EstReport = (() => {
<iframe id="print-frame" title="인쇄할 문서" style="width:100%;height:65vh;border:1px solid #aaa"></iframe>`,
);
EstApp.byId("dialog-content")
.querySelectorAll("[data-print],#print-paper,#print-orientation,#print-form")
.querySelectorAll("[data-print],#print-paper,#print-orientation,#print-form,#print-gong")
.forEach((node) =>
node.addEventListener("change", () => {
const key = node.dataset.print || node.id.slice(6),
value = node.type === "number" ? Number(node.value) : node.value;
const key =
node.id === "print-gong" ? "PrintGongMethod" : node.dataset.print || node.id.slice(6),
value =
node.type === "number" || node.id === "print-gong" ? Number(node.value) : node.value;
try {
if (
node.type === "number" &&
@@ -216,6 +230,12 @@ const EstReport = (() => {
for (const side of ["Top", "Bottom", "Left", "Right"])
if (Number.isFinite(original[`Print${side}`]))
next[`margin${side}`] = Math.max(0, Math.min(50, original[`Print${side}`]));
if (Number.isFinite(original.PrintGongMethod))
next.PrintGongMethod = Number(original.PrintGongMethod);
if (Number.isFinite(original.PrintApplyField))
next.PrintApplyField = Number(original.PrintApplyField);
if (Number.isFinite(original.PrintSumBottom))
next.PrintSumBottom = Number(original.PrintSumBottom);
}
}
if (key === "notes") {
@@ -223,16 +243,24 @@ const EstReport = (() => {
EstApp.state.project.documents[next.form] = value;
}
if (
["marginTop", "marginBottom", "marginLeft", "marginRight", "rowsPerPage"].includes(
key,
) &&
[
"marginTop",
"marginBottom",
"marginLeft",
"marginRight",
"rowsPerPage",
"PrintGongMethod",
"PrintApplyField",
"PrintSumBottom",
].includes(key) &&
next.form !== "current" &&
EstApp.state.project.printCatalog?.some((row) => String(row.Index) === next.form)
) {
EstApp.state.project.nativePrintChanges ||= {};
const changes = (EstApp.state.project.nativePrintChanges[next.form] ||= {});
if (key.startsWith("margin")) changes[`Print${key.slice(6)}`] = value;
else changes.PrintLineCount = value;
else if (key === "rowsPerPage") changes.PrintLineCount = value;
else changes[key] = value;
}
if (key === "form") next.notes = EstApp.state.project.documents?.[value] || "";
EstApp.state.project.printSettings = next;
@@ -249,6 +277,11 @@ const EstReport = (() => {
"rowsPerPage",
])
content.querySelector(`[data-print=${name}]`).value = current[name];
const gongWrap = content.querySelector("#print-gong-wrap");
if (gongWrap) {
gongWrap.hidden = !["115", "116", "117", "118"].includes(String(current.form));
content.querySelector("#print-gong").value = String(current.PrintGongMethod ?? 0);
}
}
preview(EstApp.byId("print-scope")?.value === "all");
EstApp.byId("print-error").textContent = "";
+60 -4
View File
@@ -115,14 +115,30 @@ const EstReview = (() => {
({ row }) => editable(row) && (row.Name || row.Code),
);
byId("work-content").innerHTML =
`<div class="business-tools"><b>계약심사</b><button data-review-copy>심사 내역 사본 열기</button><button data-review-clear>심사 입력 초기화</button><span>기존 ${number(output.before)} · 심사 ${number(output.after)} · 차액 ${number(output.after - output.before)}</span></div><p>심사 수량과 비목별 단가를 직접 입력합니다. 고정금액 행은 비목별 금액을 입력합니다. 원래 내역은 유지되며 심사 사본에서 변경됩니다.</p><label>주요심사 내용<textarea data-review-summary rows="4" maxlength="100000" style="width:100%">${esc(project.documents?.["173"] || "")}</textarea></label><p class="business-note">${esc(output.errors.join(" / "))}</p><table class="sheet"><thead><tr>${["코드", "품명", "기존 수량", "심사 수량", "노무", "재료", "경비", "실적", "기존 금액", "심사 금액", "차액", "심사 사유", "작업"].map((value) => `<th>${value}</th>`).join("")}</tr></thead><tbody>${rows
`<div class="business-tools"><b>계약심사</b><button data-review-copy>심사 내역 사본 열기</button><button data-review-clear>심사 입력 초기화</button><label>보기 <select id="review-filter"><option value="all" ${state.reviewFilter === "all" || !state.reviewFilter ? "selected" : ""}>전체 항목</option><option value="changed" ${state.reviewFilter === "changed" ? "selected" : ""}>변경 항목만</option><option value="has-reason" ${state.reviewFilter === "has-reason" ? "selected" : ""}>사유 작성 항목</option><option value="no-reason" ${state.reviewFilter === "no-reason" ? "selected" : ""}>사유 미작성 변경항목</option></select></label><button data-review-batch-reason>선택/표시 행 사유 일괄적용</button><span>기존 ${number(output.before)} · 심사 ${number(output.after)} · 차액 ${number(output.after - output.before)}</span></div><p>심사 수량과 비목별 단가를 직접 입력합니다. 고정금액 행은 비목별 금액을 입력합니다. 원래 내역은 유지되며 심사 사본에서 변경됩니다.</p><label>주요심사 내용<textarea data-review-summary rows="4" maxlength="100000" style="width:100%">${esc(project.documents?.["173"] || "")}</textarea></label><p class="business-note">${esc(output.errors.join(" / "))}</p><table class="sheet"><thead><tr>${["선택", "코드", "품명", "기존 수량", "심사 수량", "노무", "재료", "경비", "실적", "기존 금액", "심사 금액", "차액", "심사 사유", "작업"].map((value) => `<th>${value}</th>`).join("")}</tr></thead><tbody>${rows
.filter(({ row, index }) => {
const key = rowKey(row, index),
input = edits[key] || {},
changed = output.reviewed.tables.EstMainDat[index],
isChanged =
Object.hasOwn(edits, key) ||
EstMath.num(changed.TC) !== EstMath.num(row.TC) ||
EstMath.num(changed.GN) !== EstMath.num(row.GN),
hasReason = Boolean(input.Reason && input.Reason.trim());
const filter = state.reviewFilter || "all";
if (filter === "changed") return isChanged;
if (filter === "has-reason") return hasReason;
if (filter === "no-reason") return isChanged && !hasReason;
return true;
})
.map(({ row, index }) => {
const key = rowKey(row, index),
input = edits[key] || {},
changed = output.reviewed.tables.EstMainDat[index];
changed = output.reviewed.tables.EstMainDat[index],
isSelected = state.reviewSelection?.has(key);
const cell = (field) =>
`<input data-review="${field}" data-key="${esc(key)}" type="number" step="any" value="${esc(input[field] ?? EstMath.num(row[field]))}" aria-label="심사 ${esc(field)}">`;
return `<tr><td>${esc(row.Code)}</td><td>${esc(row.Name)}</td><td>${number(row.GN)}</td><td>${cell("GN")}</td>${allowed(
return `<tr class="${isSelected ? "selected" : ""}"><td><input type="checkbox" data-review-select="${esc(key)}" ${isSelected ? "checked" : ""} aria-label="선택"></td><td>${esc(row.Code)}</td><td>${esc(row.Name)}</td><td>${number(row.GN)}</td><td>${cell("GN")}</td>${allowed(
row,
)
.slice(1)
@@ -136,6 +152,19 @@ const EstReview = (() => {
function install() {
document.addEventListener("change", (event) => {
const node = event.target;
if (node.id === "review-filter") {
EstApp.state.reviewFilter = node.value;
EstApp.render();
return;
}
if (node.dataset.reviewSelect !== undefined) {
const key = node.dataset.reviewSelect;
const sel = (EstApp.state.reviewSelection ||= new Set());
if (node.checked) sel.add(key);
else sel.delete(key);
EstApp.render();
return;
}
if (node.dataset.reviewSummary !== undefined) {
EstApp.change(() => {
const project = EstApp.state.project;
@@ -191,8 +220,35 @@ const EstReview = (() => {
} else if (
event.target.closest("[data-review-clear]") &&
confirm("심사 입력을 초기화하시겠습니까?")
)
) {
EstApp.change(() => delete project.contractReview, false);
EstApp.state.reviewSelection?.clear();
} else if (event.target.closest("[data-review-batch-reason]")) {
const reason = prompt("적용할 심사 사유를 입력하세요:");
if (reason != null && reason.trim()) {
const val = reason.trim().slice(0, 10000);
const sel = EstApp.state.reviewSelection;
const targetKeys =
sel && sel.size > 0
? [...sel]
: [
...document.querySelectorAll(
'#work-content input[data-review="Reason"][data-key]',
),
].map((node) => node.dataset.key);
if (!targetKeys.length) {
EstApp.state.notice = "사유를 적용할 대상 행이 없습니다.";
EstApp.render();
return;
}
EstApp.change(() => {
const review = (EstApp.state.project.contractReview ||= { edits: {} });
for (const k of targetKeys) {
(review.edits[k] ||= {}).Reason = val;
}
}, false);
}
}
} catch (error) {
EstApp.state.notice = error.message;
EstApp.render();
+39 -2
View File
@@ -135,11 +135,29 @@ const EstSchedule = (() => {
return { ...entry, progress };
});
}
function moveRow(project, index, direction) {
if (!project.schedule || !Array.isArray(project.schedule)) return;
const target = index + direction;
if (
index < 0 ||
index >= project.schedule.length ||
target < 0 ||
target >= project.schedule.length
)
return;
const temp = project.schedule[index];
project.schedule[index] = project.schedule[target];
project.schedule[target] = temp;
}
function clearSchedule(project) {
project.schedule = [];
}
function render() {
const { esc, number, state } = EstApp,
output = calculate(state.project);
const rowsCount = output.rows.length;
EstApp.byId("work-content").innerHTML =
`<div class="business-tools"><button data-action="schedule-add">공정 추가</button><button data-action="schedule-import">내역 일괄 추가</button><button data-action="schedule-weights">내역 금액으로 가중치 갱신</button><button data-action="schedule-progress">최근 확정 기성으로 진도 갱신</button><span>시작 ${esc(output.start)} · 종료 ${esc(output.end)} · 가중치 합계 ${number(output.weight)} · 입력 진도 가중평균 ${number(output.progress)}%</span></div><p>기간과 진도는 직접 입력합니다. 가중치는 공종 금액 또는 사용자가 정한 값을 사용합니다.</p><table class="sheet"><thead><tr>${["코드", "공정명", "시작일", "종료일", "가중치", "진도(%)", "기간", "작업"].map((h) => `<th>${h}</th>`).join("")}</tr></thead><tbody>${output.rows.map((r, i) => `<tr>${["Code", "Name", "start", "end", "weight", "progress"].map((k) => `<td><input data-schedule="${k}" data-index="${i}" type="${["start", "end"].includes(k) ? "date" : ["weight", "progress"].includes(k) ? "number" : "text"}" value="${esc(r[k])}" step="any"></td>`).join("")}<td style="min-width:180px"><div style="height:12px;background:#ddd"><div style="margin-left:${r.left}%;width:${r.width}%;height:12px;background:#316ea5"></div></div></td><td><button data-action="schedule-delete" data-index="${i}">삭제</button></td></tr>`).join("")}</tbody></table>`;
`<div class="business-tools"><button data-action="schedule-add">공정 추가</button><button data-action="schedule-import">내역 일괄 추가</button><button data-action="schedule-weights">내역 금액으로 가중치 갱신</button><button data-action="schedule-progress">최근 확정 기성으로 진도 갱신</button><button data-action="schedule-clear" ${rowsCount === 0 ? "disabled" : ""}>공정 전체 비우기</button><span>시작 ${esc(output.start)} · 종료 ${esc(output.end)} · 가중치 합계 ${number(output.weight)} · 입력 진도 가중평균 ${number(output.progress)}%</span></div><p>기간과 진도는 직접 입력합니다. 가중치는 공종 금액 또는 사용자가 정한 값을 사용합니다.</p><table class="sheet"><thead><tr>${["코드", "공정명", "시작일", "종료일", "가중치", "진도(%)", "기간", "작업"].map((h) => `<th>${h}</th>`).join("")}</tr></thead><tbody>${output.rows.map((r, i) => `<tr>${["Code", "Name", "start", "end", "weight", "progress"].map((k) => `<td><input data-schedule="${k}" data-index="${i}" type="${["start", "end"].includes(k) ? "date" : ["weight", "progress"].includes(k) ? "number" : "text"}" value="${esc(r[k])}" step="any"></td>`).join("")}<td style="min-width:180px"><div style="height:12px;background:#ddd"><div style="margin-left:${r.left}%;width:${r.width}%;height:12px;background:#316ea5"></div></div></td><td><button data-action="schedule-move" data-index="${i}" data-direction="-1" ${i === 0 ? "disabled" : ""}>↑</button><button data-action="schedule-move" data-index="${i}" data-direction="1" ${i === rowsCount - 1 ? "disabled" : ""}>↓</button><button data-action="schedule-delete" data-index="${i}">삭제</button></td></tr>`).join("")}</tbody></table>`;
}
function install() {
document.addEventListener("click", (event) => {
@@ -181,6 +199,15 @@ const EstSchedule = (() => {
EstApp.render();
}
}
if (node.dataset.action === "schedule-move") {
const index = Number(node.dataset.index),
direction = Number(node.dataset.direction);
EstApp.change(() => moveRow(p, index, direction), false);
}
if (node.dataset.action === "schedule-clear") {
if (confirm("예정공정표의 모든 공정 항목을 비우시겠습니까?"))
EstApp.change(() => clearSchedule(p), false);
}
if (node.dataset.action === "schedule-delete")
EstApp.change(() => p.schedule.splice(Number(node.dataset.index), 1), false);
});
@@ -201,5 +228,15 @@ const EstSchedule = (() => {
}
});
}
return { validate, calculate, fromEstimate, refreshWeights, refreshProgress, render, install };
return {
validate,
calculate,
fromEstimate,
refreshWeights,
refreshProgress,
moveRow,
clearSchedule,
render,
install,
};
})();
+69 -2
View File
@@ -17,6 +17,8 @@ const EstStandard = (() => {
? EstMath.units.reduce((sum, field) => sum + EstMath.num(row[field]), 0)
: EstMath.num(row.TU);
const sourcePrice = source ? EstMath.num(source.TC) : null;
const canApply =
matches.length === 1 && sourcePrice != null && Math.abs(appliedPrice - sourcePrice) > 1e-6;
return [
{
index,
@@ -30,6 +32,7 @@ const EstStandard = (() => {
difference: source ? appliedPrice - sourcePrice : null,
TC: EstMath.num(row.TC),
status: !matches.length ? "연결 없음" : matches.length > 1 ? "코드 중복" : "연결됨",
canApply,
},
];
});
@@ -40,6 +43,42 @@ const EstStandard = (() => {
.filter((row) => Number(row.LinkIndex) === 21 || Number(row.originalLinkIndex) === 21)
.map((row) => ({ ...row, status: labels[row.status] || row.status }));
}
function applyRowPrice(project, index) {
index = Number(index);
const row = project.tables?.EstMainDat?.[index];
if (!row || Number(row.Level) > 0 || Number(row.LinkIndex) !== 21)
throw Error("표준시장단가 내역 행을 찾을 수 없습니다.");
const sources = (project.tables?.SilJukDat || []).filter((s) => s.Code && s.Code === row.Code);
if (!sources.length) throw Error(`표준시장단가 원천 자료가 없습니다: ${row.Code}`);
if (sources.length > 1) throw Error(`표준시장단가 원천 코드가 중복되었습니다: ${row.Code}`);
const source = EstNative.price(structuredClone(sources[0]), 21);
const sourcePrice = EstMath.num(source?.TC);
if (!Number.isFinite(sourcePrice)) throw Error("기초 단가를 계산할 수 없습니다.");
// 원천 비목 구성 (NC, GC, JC, SC)
const baseUnits = EstMath.components.map((comp) => EstMath.num(source[comp]));
// SCheck 재분류가 설정되어 있는 경우 비목 보존
const scheck = Number(row.SCheck);
if (scheck > 0) {
const targetUnit = { 1: "GU", 2: "NU", 3: "JU" }[scheck];
for (const u of EstMath.units) row[u] = u === targetUnit ? sourcePrice : 0;
} else {
// SCheck === 0 이거나 미지정인 경우 원천 비목 분할 유지 (NU, GU, JU, SU)
EstMath.units.forEach((u, i) => (row[u] = baseUnits[i]));
}
// AutoCalc가 비활성화된 수동계산 모드에서는 기존 금액을 보존하고, 활성화된 경우에만 재계산
if (project.options?.AutoCalc !== false) {
EstMath.amounts(row, project.options?.EstDigits || 0, project.options?.TotalFit);
} else {
row.TU = EstMath.units.reduce((sum, key) => sum + EstMath.num(row[key]), 0);
}
}
function applyAllPrices(project) {
const list = usage(project).filter((item) => item.canApply);
if (!list.length) throw Error("적용할 기초 단가 차이가 없습니다.");
for (const item of list) applyRowPrice(project, item.index);
return list.length;
}
function render() {
const { state, esc, number, byId } = EstApp,
isChange = state.view === "standard-change",
@@ -52,11 +91,39 @@ const EstStandard = (() => {
`<div class="business-tools"><b>표준시장단가 설계변경</b><span>${rows.length}건 · ${page + 1}/${Math.max(1, Math.ceil(rows.length / 100))}쪽</span><button data-standard-page="-1" ${page === 0 ? "disabled" : ""}>이전</button><button data-standard-page="1" ${(page + 1) * 100 >= rows.length ? "disabled" : ""}>다음</button></div><table class="sheet"><thead><tr><th>구분</th><th>코드</th><th>품명</th><th>당초 수량</th><th>변경 수량</th><th>당초 금액</th><th>변경 금액</th><th>증감</th><th>작업</th></tr></thead><tbody>${visible.map((row) => `<tr><td>${esc(row.status)}</td><td>${esc(row.Code)}</td><td>${esc(row.Name)}</td><td>${value(row.originalGN)}</td><td>${value(row.GN)}</td><td>${value(row.originalTC)}</td><td>${value(row.TC)}</td><td>${value(row.difference)}</td><td>${row.index == null ? "" : `<button data-standard-row="${row.index}">내역 열기</button>`}</td></tr>`).join("")}</tbody></table>`;
return;
}
const canApplyCount = rows.filter((r) => r.canApply).length;
byId("work-content").innerHTML =
`<div class="business-tools"><b>표준시장단가 사용 내역</b><span>${rows.length}건 · ${page + 1}/${Math.max(1, Math.ceil(rows.length / 100))}쪽</span><button data-standard-page="-1" ${page === 0 ? "disabled" : ""}>이전</button><button data-standard-page="1" ${(page + 1) * 100 >= rows.length ? "disabled" : ""}>다음</button></div><p>내역에 적용된 단가와 현재 표준시장단가 자료를 비교합니다.</p><table class="sheet"><thead><tr><th>코드</th><th>품명</th><th>규격</th><th>단위</th><th>수량</th><th>내역 단가</th><th>기초 단가</th><th>차이</th><th>금액</th><th>연결</th><th>작업</th></tr></thead><tbody>${visible.map((row) => `<tr><td>${esc(row.Code)}</td><td>${esc(row.Name)}</td><td>${esc(row.Size)}</td><td>${esc(row.Unite)}</td><td>${value(row.GN)}</td><td>${value(row.appliedPrice)}</td><td>${value(row.sourcePrice)}</td><td>${value(row.difference)}</td><td>${value(row.TC)}</td><td>${esc(row.status)}</td><td><button data-standard-row="${row.index}">내역 열기</button></td></tr>`).join("")}</tbody></table>`;
`<div class="business-tools"><b>표준시장단가 사용 내역</b><span>${rows.length}건 · ${page + 1}/${Math.max(1, Math.ceil(rows.length / 100))}쪽</span><button data-standard-page="-1" ${page === 0 ? "disabled" : ""}>이전</button><button data-standard-page="1" ${(page + 1) * 100 >= rows.length ? "disabled" : ""}>다음</button><button data-standard-apply-all ${canApplyCount ? "" : "disabled"}>기초단가 일괄 적용 (${canApplyCount}건)</button></div><p>내역에 적용된 단가와 현재 표준시장단가 자료를 비교합니다.</p><table class="sheet"><thead><tr><th>코드</th><th>품명</th><th>규격</th><th>단위</th><th>수량</th><th>내역 단가</th><th>기초 단가</th><th>차이</th><th>금액</th><th>연결</th><th>작업</th></tr></thead><tbody>${visible.map((row) => `<tr><td>${esc(row.Code)}</td><td>${esc(row.Name)}</td><td>${esc(row.Size)}</td><td>${esc(row.Unite)}</td><td>${value(row.GN)}</td><td>${value(row.appliedPrice)}</td><td>${value(row.sourcePrice)}</td><td>${value(row.difference)}</td><td>${value(row.TC)}</td><td>${esc(row.status)}</td><td><button data-standard-row="${row.index}">내역 열기</button>${row.canApply ? ` <button data-standard-apply-row="${row.index}">단가 적용</button>` : ""}</td></tr>`).join("")}</tbody></table>`;
}
function install() {
document.addEventListener("click", (event) => {
const applyAll = event.target.closest("[data-standard-apply-all]");
if (applyAll) {
try {
EstApp.change((p) => {
const count = applyAllPrices(p);
EstApp.state.notice = `표준시장단가 ${count}건의 기초단가를 일괄 적용했습니다.`;
});
} catch (err) {
EstApp.state.notice = err.message;
EstApp.render();
}
return;
}
const applyRow = event.target.closest("[data-standard-apply-row]");
if (applyRow) {
const index = Number(applyRow.dataset.standardApplyRow);
try {
EstApp.change((p) => {
applyRowPrice(p, index);
EstApp.state.notice = `행 ${index + 1}에 기초단가를 적용했습니다.`;
});
} catch (err) {
EstApp.state.notice = err.message;
EstApp.render();
}
return;
}
const step = event.target.closest("[data-standard-page]");
if (step) {
page += Number(step.dataset.standardPage);
@@ -73,5 +140,5 @@ const EstStandard = (() => {
EstApp.render();
});
}
return { usage, changes, render, install };
return { usage, changes, render, install, applyRowPrice, applyAllPrices };
})();
+60
View File
@@ -0,0 +1,60 @@
// REA TFRMCONVERTTOGANGUB (0xf616f0, 0xf61888)
// 자재단가(StuffcDat) 부가세 계산: 포함(*1.1) / 제외(/1.1) 및 단단위 자릿수(사사오입/절하/절상) 처리
const EstVat = (() => {
const { num, fixed } = EstMath;
// REA radCalcMethod: 0 = 부가세포함(×1.1), 1 = 부가세제외(÷1.1)
// REA radDigetIndex: 0 = 사사오입(1), 1 = 절하(2), 2 = 절상(3)
const methodMap = { 0: 1, 1: 2, 2: 3 };
function convertPrice(value, calcMethod = 1, diget = 0, digetMethod = 1) {
const rawNum = num(value);
if (!rawNum) return 0;
const factor = calcMethod === 0 ? 1.1 : 1 / 1.1;
const scaled = rawNum * factor;
// diget: 이하 자리에서 처리 (예: 1이면 1원 미만=0자리, 2이면 10원 미만=-1자리, 또는 소수/정수)
// REA 관례: 0이면 원단위(0), n > 0이면 10^n 미만(-n) 또는 소수점
// 일반적으로 단단위계산에서 spnDiget은 자리수를 나타냄. 0이면 정수 원단위(0), n이면 -n 단위 절하/사사오입/절상.
const digits = -Math.max(0, Math.trunc(num(diget)));
const m = methodMap[digetMethod] ?? 2;
return fixed(scaled, digits, m);
}
function apply(project, indices = [], { calcMethod = 1, diget = 0, digetMethod = 1 } = {}) {
const next = structuredClone(project);
const rows = next.tables.StuffcDat || [];
const targets = indices.length
? indices.filter((i) => i >= 0 && i < rows.length)
: rows.map((_, i) => i);
if (!targets.length) throw Error("적용할 자재 항목이 없습니다.");
for (const index of targets) {
const row = rows[index];
// Cost1 ~ Cost6 및 InputTC/TC 중 존재하는 단가 필드 변환
let changed = false;
for (let c = 1; c <= 6; c++) {
const key = `Cost${c}`;
if (row[key] != null && row[key] !== "") {
row[key] = convertPrice(row[key], calcMethod, diget, digetMethod);
changed = true;
}
}
if (row.InputTC != null && row.InputTC !== "") {
row.InputTC = convertPrice(row.InputTC, calcMethod, diget, digetMethod);
changed = true;
}
if (row.TC != null && row.TC !== "") {
row.TC = convertPrice(row.TC, calcMethod, diget, digetMethod);
changed = true;
}
}
return next.options?.AutoCalc === false ? next : EstNative.calculate(next);
}
return {
convertPrice,
apply,
};
})();
+18 -2
View File
@@ -97,8 +97,24 @@ const EstViews = (() => {
byId("search").value = state.query;
byId("standard-tools").hidden = !project.tables[view];
byId("add-group").hidden = !grouped;
byId("move-estimate-up").hidden = view !== "EstMainDat";
byId("move-estimate-down").hidden = view !== "EstMainDat";
const haljungBtn = byId("haljung-calc-btn");
if (haljungBtn) haljungBtn.hidden = view !== "HeadMainDat";
const vatBtn = byId("vat-calc-btn");
if (vatBtn) vatBtn.hidden = view !== "StuffcDat";
const unitCalcBtn = byId("unit-calc-btn");
if (unitCalcBtn) unitCalcBtn.hidden = view !== "HeadMainDat";
byId("move-estimate-up").hidden = ![
"EstMainDat",
"HeadMainDat",
"CostMainDat",
"MachMainDat",
].includes(view);
byId("move-estimate-down").hidden = ![
"EstMainDat",
"HeadMainDat",
"CostMainDat",
"MachMainDat",
].includes(view);
if (project.tables[view]) {
const all = EstApp.visibleRows(),
start = state.page * 100,
+154 -47
View File
@@ -1,11 +1,10 @@
// REA c9f8ec / c9d278 / c9f0e4: 수식 참조, 원 미만 절사, 도급액 고정 반복.
// 및 TFRMWONGABASE popupTotal: 공사비역산 자리수 맞추기 (단단위 절하 후 도급액 일치 배분)
const EstWonga = (() => {
const { num } = EstMath;
const compact = (text) => String(text || "").replace(/\s/g, "");
function calculate(input, options = {}, bases = {}) {
const rows = structuredClone(input),
errors = [],
adjustments = [];
function buildContext(rows, options = {}, bases = {}) {
const key = (row) =>
String(options.VerFormat === false ? row.NumStr || row.Gubun : row.Gubun || row.NumStr || "");
const keys = new Map(rows.map((row, index) => [key(row), index]));
@@ -13,60 +12,110 @@ const EstWonga = (() => {
rows.forEach((row, index) => {
if (compact(row.Name)) keys.set(compact(row.Name), index);
});
const overrides = new Map();
function pass() {
const done = new Set(),
active = new Set();
function effective(index, owner) {
const cost = evaluate(index);
if (
options.GangubExceptJodalRatio &&
/산업안전|안전보건/.test(compact(rows[owner]?.Name)) &&
compact(rows[index].Name).includes("관급")
)
return cost - num(rows[index].NC);
return cost;
}
function resolve(name, owner) {
if (Object.hasOwn(bases, name)) return bases[name];
const index = keys.get(name) ?? keys.get(compact(name));
return index === undefined ? undefined : effective(index, owner);
}
function range(start, end, owner) {
let a = keys.get(start),
b = keys.get(end);
if (a === undefined || b === undefined) throw Error(`범위 참조 없음: ${start}:${end}`);
if (a > b) [a, b] = [b, a];
let sum = 0;
for (let i = a; i <= b; i++)
if ([0, 1, 4].includes(num(rows[i].CalcIndex))) sum += effective(i, owner);
return sum;
}
function evaluate(index) {
const row = rows[index];
if (overrides.has(index)) return (row.Cost = overrides.get(index));
if (done.has(index)) return num(row.Cost);
if (active.has(index)) throw Error(`원가 수식 순환 참조: ${key(row)}`);
active.add(index);
if (row.BaseField && Object.hasOwn(bases, row.BaseField)) row.Cost = bases[row.BaseField];
else if (
const defaultBases = {
NC: 0,
GC: 0,
JC: 0,
SC: 0,
TC: 0,
노무비: 0,
재료비: 0,
경비: 0,
실적: 0,
직접공사비: 0,
...bases,
};
return { key, keys, defaultBases };
}
function evaluateRows(rows, options = {}, bases = {}, overrides = new Map(), onError = null) {
const { key, keys, defaultBases } = buildContext(rows, options, bases);
const done = new Set(),
active = new Set(),
failed = new Set();
function effective(index, owner) {
const cost = evaluate(index);
if (
options.GangubExceptJodalRatio &&
/산업안전|안전보건/.test(compact(rows[owner]?.Name)) &&
compact(rows[index].Name).includes("관급")
)
return cost - num(rows[index].NC);
return cost;
}
function resolve(name, owner) {
if (Object.hasOwn(defaultBases, name)) return defaultBases[name];
const index = keys.get(name) ?? keys.get(compact(name));
return index === undefined ? undefined : effective(index, owner);
}
function range(start, end, owner) {
let a = keys.get(start),
b = keys.get(end);
if (a === undefined || b === undefined) throw Error(`범위 참조 없음: ${start}:${end}`);
if (a > b) [a, b] = [b, a];
let sum = 0;
for (let i = a; i <= b; i++)
if ([0, 1, 4].includes(num(rows[i].CalcIndex))) sum += effective(i, owner);
return sum;
}
function evaluate(index) {
const row = rows[index];
if (overrides.has(index)) return (row.Cost = overrides.get(index));
if (done.has(index)) return num(row.Cost);
if (active.has(index)) throw Error(`원가 수식 순환 참조: ${key(row)}`);
active.add(index);
try {
if (row.BaseField && Object.hasOwn(defaultBases, row.BaseField)) {
row.Cost = defaultBases[row.BaseField];
} else if (
row.VSusic &&
!/^원금액\s*:/.test(row.VSusic) &&
![1, 3].includes(num(row.CalcIndex))
)
) {
row.Cost = Math.trunc(
EstExpression.evaluate(row.VSusic, (name) => resolve(name, index), {
numericReferences: true,
range: (a, b) => range(a, b, index),
}),
);
else row.Cost = num(row.Cost);
} else {
row.Cost = num(row.Cost);
}
} catch (err) {
if (onError) {
failed.add(index);
onError(index, row, err);
row.Cost = num(row.Cost);
} else {
throw err;
}
} finally {
active.delete(index);
done.add(index);
return row.Cost;
}
for (let i = 0; i < rows.length; i++) evaluate(i);
done.add(index);
return row.Cost;
}
for (let i = 0; i < rows.length; i++) evaluate(i);
return { done, failed };
}
function calculate(input, options = {}, bases = {}) {
const rows = structuredClone(input || []),
errors = [],
adjustments = [];
const overrides = new Map();
function pass() {
evaluateRows(rows, options, bases, overrides, null);
}
const targetIndex = rows.findIndex((row) => num(row.LinkIndex) === 55);
const target = targetIndex >= 0 ? num(rows[targetIndex].Cost) : 0;
try {
@@ -106,5 +155,63 @@ const EstWonga = (() => {
total: targetIndex >= 0 ? num(rows[targetIndex].Cost) : num(rows.at(-1)?.Cost),
};
}
return { calculate };
function scan(input, options = {}, bases = {}) {
const rows = structuredClone(input || []),
errors = [];
const reported = new Set();
evaluateRows(rows, options, bases, new Map(), (index, row, err) => {
if (!reported.has(index)) {
reported.add(index);
errors.push({
table: "Wonga",
index,
code: row.Gubun || row.NumStr || "",
message: err.message,
});
}
});
return errors;
}
function alignTotal(rows, targetCost, unitDigits = 1) {
// REA TFRMWONGABASE popupTotal: 공사비역산 자리수 맞추기
// unitDigits: 1=천원단위(1000), 2=만원단위(10000), 0=원단위(1) 등
const target = Number(targetCost);
if (!Number.isFinite(target) || target <= 0) {
throw Error("목표 공사금액을 0보다 큰 숫자로 입력하세요.");
}
const divisor = Math.pow(10, Math.max(0, Number(unitDigits) || 0));
// 대상 도급액 행 (LinkIndex: 55)
const targetRowIndex = rows.findIndex((r) => num(r.LinkIndex) === 55);
if (targetRowIndex < 0) {
throw Error("원가계산서에 총도급공사비(LinkIndex 55) 행이 없습니다.");
}
// 조정 대상 행: 이윤 행 또는 일반 경비 행 탐색
const profitIndex = rows.findIndex(
(r) => compact(r.Name).includes("이윤") && ![1, 3].includes(num(r.CalcIndex)),
);
if (profitIndex < 0) {
throw Error("단단위 조정을 반영할 이윤 행을 찾을 수 없습니다.");
}
// 도급액을 divisor 단위로 절하(또는 절상/반올림 정렬)한 목표값 설정
const roundedTarget = Math.floor(target / divisor) * divisor;
rows[targetRowIndex].Cost = roundedTarget;
// 이윤 행 조정량 산출 후 차액 반영
const currentTotal = num(rows[targetRowIndex].Cost);
return {
targetIndex: targetRowIndex,
profitIndex,
roundedTarget,
divisor,
};
}
return { calculate, scan, alignTotal };
})();
+4 -1
View File
@@ -10,7 +10,7 @@ const { exportEstx } = require("./export-estx.cjs");
const jdl = require("./jdl.cjs");
const base = __dirname,
port = Number(process.env.EST_TEST_PORT || 5175),
host = process.env.EST_TEST_HOST || "127.0.0.1";
host = process.env.EST_TEST_HOST || "100.65.141.15";
const dataDirectory = process.env.EST_TEST_DATA_DIR || path.join(base, "data");
const scripts = [
"native-math.js",
@@ -54,9 +54,12 @@ const allowed = new Set([
"native-views.js",
"native-actions.js",
"native-business-actions.js",
"native-block-actions.js",
"native-issues.js",
"native-attachments.js",
"native-standard.js",
"native-estimate.js",
"native-basic-prices.js",
"native-extra.js",
"native-settings.js",
"native-report.js",
+43
View File
@@ -0,0 +1,43 @@
// 독립 EST 테스트 서버 감시/감독 프로세스
const { spawn } = require("node:child_process");
const fs = require("node:fs");
const path = require("node:path");
const base = __dirname;
const serverScript = path.join(base, "server.cjs");
// data 디렉터리, 로그 파일 등은 제외하고 소스코드 파일만 명시적으로 감시 대상에 포함
const watchArgs = fs
.readdirSync(base)
.filter(
(file) =>
(file.endsWith(".js") ||
file.endsWith(".cjs") ||
file === "index.html" ||
file === "native.css") &&
!file.startsWith(".") &&
file !== "watch-server.cjs",
)
.map((file) => `--watch-path=${path.join(base, file)}`);
const args = ["--watch", ...watchArgs, serverScript];
console.log("[supervisor] Starting server with watch paths (excluding data/)...");
const child = spawn(process.execPath, args, {
cwd: base,
env: process.env,
stdio: "inherit",
});
child.on("exit", (code, signal) => {
console.log(`[supervisor] Server exited with code ${code}, signal ${signal}`);
process.exit(code || 0);
});
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
process.on(sig, () => {
child.kill(sig);
process.exit(0);
});
}