diff --git a/resources/tester/test_est_quantity_workflow.py b/resources/tester/test_est_quantity_workflow.py index dd480185d..2b888dacd 100644 --- a/resources/tester/test_est_quantity_workflow.py +++ b/resources/tester/test_est_quantity_workflow.py @@ -11,13 +11,14 @@ def test_quantity_apply_requires_one_detail_row_per_code(): const sheet={Code:'Q',rows:[],items,options:{}}; const state={quantitySheet:'Q',project:{quantitySheets:[sheet],tables:{EstMainDat:rows}}}; globalThis.EstApp={state,change:fn=>fn(),render:()=>{}}; - EstQuantity.install();handlers.click({target:{closest:()=>true}}); + EstQuantity.install();handlers.click({target:{closest:selector=>selector==='[data-quantity-apply]'?{}:null}}); return {rows,notice:state.notice||''}; } return {duplicate:apply([{Code:'X',Level:0,GN:1},{Code:'X',Level:0,GN:2}]), heading:apply([{Code:'X',Level:1,GN:1}]), valid:apply([{Code:'X',Level:1,GN:1},{Code:'X',Level:0,GN:2}]), - partial:apply([{Code:'X',Level:0,GN:1}],[{Code:'r1',GN:7,TargetCode:'X'},{Code:'r2',GN:4,TargetCode:'missing'}])}; + partial:apply([{Code:'X',Level:0,GN:1}],[{Code:'r1',GN:7,TargetCode:'X'},{Code:'r2',GN:4,TargetCode:'missing'}]), + disconnected:apply([{Code:'X',Level:0,GN:1}],[{Code:'r1',GN:7,TargetCode:'',LinkCode:'X'}])}; })()""", ) assert [r["GN"] for r in result["duplicate"]["rows"]] == [1, 2] @@ -28,3 +29,76 @@ def test_quantity_apply_requires_one_detail_row_per_code(): assert result["valid"]["notice"] == "" assert result["partial"]["rows"][0]["GN"] == 1 assert result["partial"]["notice"] + assert result["disconnected"]["rows"][0]["GN"] == 1 + assert result["disconnected"]["notice"] + + +def test_create_edit_apply_and_delete_quantity_sheet(): + result = run( + "math", + """(()=>{ + const handlers={};let id=0; + globalThis.document={addEventListener:(name,fn)=>handlers[name]=fn}; + globalThis.crypto={randomUUID:()=>String(++id)};globalThis.confirm=()=>true; + const state={quantitySheet:'manual',project:{quantitySheets:[],tables:{EstMainDat:[{Code:'X',Level:0,GN:1}]}}}; + globalThis.EstApp={state,change:fn=>fn(),render:()=>{}}; + EstQuantity.install(); + const click=(selector,dataset={})=>handlers.click({target:{closest:s=>s===selector?{dataset}:null}}); + const change=(dataset,value)=>handlers.change({target:{dataset,value}}); + click('[data-quantity-new]');click('[data-quantity-add]'); + change({quantityName:''},'Earthwork'); + change({quantityItem:'Name',index:'0'},'excavation'); + change({quantityTarget:'0'},'X'); + change({quantityLine:'0'},'2*3 = ⟦결과⟧0⟦결과⟧'); + const saved=JSON.parse(JSON.stringify(state.project)); + click('[data-quantity-apply]'); + const applied=state.project.tables.EstMainDat[0].GN; + const sheet=state.project.quantitySheets[0]; + const computed=EstQuantity.calculate(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}; + })()""", + ) + assert result["saved"]["quantitySheets"][0]["Name"] == "Earthwork" + assert result["saved"]["quantitySheets"][0]["items"][0]["Name"] == "excavation" + assert result["computed"]["errors"] == [] + assert result["computed"]["items"][0]["GN"] == 6 + assert result["applied"] == 6 + assert result["empty"] == [0, 0] + assert result["remaining"] == 0 + assert result["selection"] == "manual" + + +def test_insert_and_move_quantity_rows_keeps_item_results_linked(): + result = run( + "math", + """(()=>{ + const handlers={};let id=0; + globalThis.document={addEventListener:(name,fn)=>handlers[name]=fn}; + globalThis.crypto={randomUUID:()=>String(++id)}; + const state={quantitySheet:'manual',project:{quantitySheets:[],tables:{EstMainDat:[]}}}; + globalThis.EstApp={state,change:fn=>fn(),render:()=>{}};EstQuantity.install(); + const click=(selector,dataset={})=>handlers.click({target:{closest:s=>s===selector?{dataset}:null}}); + click('[data-quantity-new]');click('[data-quantity-add]');click('[data-quantity-add]'); + const sheet=state.project.quantitySheets[0]; + sheet.rows[0].LineData='1+2 = \\x120\\x12';sheet.rows[1].LineData='4+5 = \\x120\\x12'; + click('[data-quantity-add]',{index:'0'}); + sheet.rows[1].LineData='6+7 = \\x120\\x12'; + const before=EstQuantity.calculate(sheet).items.map(x=>x.GN); + click('[data-quantity-move]',{index:'2',quantityMove:'-1'}); + const after=EstQuantity.calculate(sheet).items.map(x=>x.GN); + const order=sheet.rows.map(x=>x.LineData); + sheet.rows[0].LineData='A=2 = \\x120\\x12';sheet.rows[1].LineData='A*3 = \\x120\\x12'; + const linked=EstQuantity.calculate(sheet); + click('[data-quantity-move]',{index:'1',quantityMove:'-1'}); + const forward=EstQuantity.calculate(sheet); + return {before,after,order,linked,forward}; + })()""", + ) + assert result["before"] == result["after"] == [3, 9, 13] + assert result["order"][1].startswith("4+5") + assert result["linked"]["errors"] == [] + assert result["linked"]["items"][1]["GN"] == 6 + assert result["forward"]["errors"] diff --git a/tools/est-test/README.md b/tools/est-test/README.md index a945b35d5..506cb91ac 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -27,6 +27,8 @@ Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요 수량산출서에서 내역에 수량을 반영할 때 연결 코드에 해당하는 상세 내역이 정확히 하나여야 합니다. 코드가 없거나 중복되면 다른 항목을 포함해 반영을 중단하고 오류를 표시합니다. +수량 화면에서 새 산출서를 만들고 이름과 산출 항목의 품명·규격을 편집할 수 있습니다. `산출 행 추가`로 결과 표지가 연결된 행과 항목을 함께 만든 뒤 수식을 입력하고, 연결 내역코드를 지정해 계산 수량을 내역에 반영합니다. 행 아래 삽입, 위·아래 이동과 산출 행·산출서 삭제도 가능하며, 이 구조 변경은 JSON 저장과 다시 열기를 지원합니다. 기존 연결은 내역코드를 비워 해제할 수 있습니다. + ## 검증과 남은 범위 기계 성분·합계 48건, 일위 41건, 내역 37건, 산출 결과 118건과 호표 8건, 원가 수식 23건을 대조합니다. 원가 도급액 고정 반복 차이 `324 → -32 → 3 → 0`을 재현합니다. 단가 갱신 후 전체 재계산과 반복 계산의 안정성도 시험합니다. diff --git a/tools/est-test/native-quantity.cjs b/tools/est-test/native-quantity.cjs index 257bd9265..43d428cea 100644 --- a/tools/est-test/native-quantity.cjs +++ b/tools/est-test/native-quantity.cjs @@ -114,9 +114,8 @@ const EstQuantity = (() => { }; } function selector(state) { - if (!state.project.quantitySheets?.length) return ""; const { esc } = EstApp; - return `
`; + return `
`; } function render(state) { state.quantitySheet ||= state.project.quantitySheets[0]?.Code; @@ -128,7 +127,7 @@ const EstQuantity = (() => { { esc, number, byId } = EstApp; byId("work-content").innerHTML = selector(state) + - `
${esc(sheet.Name)}
${output.items.map((item, index) => ``).join("")}
산출코드품명규격원본 저장 수량재계산 수량연결 내역코드
${esc(item.Code)}${esc(item.Name)}${esc(item.Size)}${number(sheet.items[index].GN)}${number(item.GN)}

${esc(output.errors.join(" / "))} 원본 결과 표지의 이름을 유지해 수량을 연결합니다. ESTX 저장 시 원본 결과가 계산과 일치하면 편집 결과와 수량 목록을 갱신합니다.

${sheet.rows.map((row, index) => ``).join("")}
줄산출근거 편집
${index + 1}
`; + `
${output.items.map((item, index) => ``).join("")}
산출코드품명규격저장 수량재계산 수량연결 내역코드
${esc(item.Code)}${number(sheet.items[index].GN)}${number(item.GN)}

${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 행 변경은 JSON으로 저장합니다.

${sheet.rows.map((row, index) => ``).join("")}
줄산출근거 편집작업
${index + 1}
`; } function install() { document.addEventListener("change", (event) => { @@ -141,6 +140,13 @@ const EstQuantity = (() => { } const sheet = state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet); if (!sheet) return; + if (node.dataset.quantityName !== undefined) + EstApp.change(() => (sheet.Name = node.value), false); + if (["Name", "Size"].includes(node.dataset.quantityItem)) + EstApp.change( + () => (sheet.items[Number(node.dataset.index)][node.dataset.quantityItem] = node.value), + false, + ); if (node.dataset.quantityLine !== undefined) EstApp.change( () => @@ -154,16 +160,76 @@ const EstQuantity = (() => { ); }); document.addEventListener("click", (event) => { + const { state } = EstApp; + if (event.target.closest("[data-quantity-new]")) { + EstApp.change(() => { + const code = `Q${crypto.randomUUID()}`; + state.project.quantitySheets ||= []; + state.project.quantitySheets.push({ + Code: code, + Name: "새 수량산출서", + rows: [], + items: [], + options: { digits: 2, roundMode: 2 }, + }); + 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${crypto.randomUUID()}`; + 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) + 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; try { - const { state } = EstApp, - sheet = state.project.quantitySheets.find((s) => s.Code === state.quantitySheet), - output = calculate(sheet); + const output = calculate(sheet); if (output.errors.length) throw Error(output.errors.join(" / ")); const quantities = new Map(); for (const item of output.items) - if (item.TargetCode || item.LinkCode) { - const code = item.TargetCode || item.LinkCode; + if (item.TargetCode ?? item.LinkCode) { + const code = item.TargetCode ?? item.LinkCode; const targets = state.project.tables.EstMainDat.filter( (r) => r.Code === code && !Number(r.Level), );