feat(est): manage quantity calculation sheets
This commit is contained in:
@@ -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"]
|
||||
|
||||
@@ -27,6 +27,8 @@ Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요
|
||||
|
||||
수량산출서에서 내역에 수량을 반영할 때 연결 코드에 해당하는 상세 내역이 정확히 하나여야 합니다. 코드가 없거나 중복되면 다른 항목을 포함해 반영을 중단하고 오류를 표시합니다.
|
||||
|
||||
수량 화면에서 새 산출서를 만들고 이름과 산출 항목의 품명·규격을 편집할 수 있습니다. `산출 행 추가`로 결과 표지가 연결된 행과 항목을 함께 만든 뒤 수식을 입력하고, 연결 내역코드를 지정해 계산 수량을 내역에 반영합니다. 행 아래 삽입, 위·아래 이동과 산출 행·산출서 삭제도 가능하며, 이 구조 변경은 JSON 저장과 다시 열기를 지원합니다. 기존 연결은 내역코드를 비워 해제할 수 있습니다.
|
||||
|
||||
## 검증과 남은 범위
|
||||
|
||||
기계 성분·합계 48건, 일위 41건, 내역 37건, 산출 결과 118건과 호표 8건, 원가 수식 23건을 대조합니다. 원가 도급액 고정 반복 차이 `324 → -32 → 3 → 0`을 재현합니다. 단가 갱신 후 전체 재계산과 반복 계산의 안정성도 시험합니다.
|
||||
|
||||
@@ -114,9 +114,8 @@ const EstQuantity = (() => {
|
||||
};
|
||||
}
|
||||
function selector(state) {
|
||||
if (!state.project.quantitySheets?.length) return "";
|
||||
const { esc } = EstApp;
|
||||
return `<div class="business-tools"><label>수량산출서 <select id="quantity-sheet-choice"><option value="manual">수식표 직접 작성</option>${state.project.quantitySheets.map((sheet) => `<option value="${esc(sheet.Code)}" ${state.quantitySheet === sheet.Code ? "selected" : ""}>${esc(sheet.Name || sheet.Code)}</option>`).join("")}</select></label></div>`;
|
||||
return `<div class="business-tools"><label>수량산출서 <select id="quantity-sheet-choice"><option value="manual">수식표 직접 작성</option>${(state.project.quantitySheets || []).map((sheet) => `<option value="${esc(sheet.Code)}" ${state.quantitySheet === sheet.Code ? "selected" : ""}>${esc(sheet.Name || sheet.Code)}</option>`).join("")}</select></label><button data-quantity-new>새 산출서</button></div>`;
|
||||
}
|
||||
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) +
|
||||
`<div class="business-tools"><b>${esc(sheet.Name)}</b><button data-quantity-apply>내역 수량 반영</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>${esc(item.Name)}</td><td>${esc(item.Size)}</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="연결 내역코드"></td></tr>`).join("")}</tbody></table><p class="business-note">${esc(output.errors.join(" / "))} 원본 결과 표지의 이름을 유지해 수량을 연결합니다. ESTX 저장 시 원본 결과가 계산과 일치하면 편집 결과와 수량 목록을 갱신합니다.</p><table class="sheet"><thead><tr><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></tr>`).join("")}</tbody></table>`;
|
||||
`<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="연결 내역코드"></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) => {
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user