feat(est): apply quantity from an Excel cell

This commit is contained in:
2026-11-07 15:49:57 +09:00
parent c788960bf2
commit aac94d6508
4 changed files with 114 additions and 1 deletions
@@ -1,6 +1,27 @@
from test_est_rea_parity import run
def test_excel_quantity_cell_rejects_invalid_address_or_non_numeric_value():
result = run(
"math",
"""(()=>{
let type='n',value='6.25';
const sheet={getAttribute:k=>k==='name'?'Data':null,getAttributeNS:()=> 'r1'};
const rel={getAttribute:k=>({Id:'r1',Target:'/xl/worksheets/sheet1.xml'})[k]};
const cell={getAttribute:k=>({r:'B2',t:type})[k],getElementsByTagNameNS:()=>[{textContent:value}]};
const doc=items=>({querySelector:()=>null,getElementsByTagNameNS:()=>items});
globalThis.DOMParser=class{parseFromString(x){return x==='workbook'?doc([sheet]):x==='rels'?doc([rel]):doc([cell])}};
const files={'xl/workbook.xml':'workbook','xl/_rels/workbook.xml.rels':'rels','xl/worksheets/sheet1.xml':'sheet'};
const valid=EstQuantity.excelCell(files,'Data','b2');let rejected=0;
for(const [name,address,t,v] of [['Other','B2','n','1'],['Data','XFE1','n','1'],['Data','B3','n','1'],['Data','B2','s','1'],['Data','B2','n',''],['Data','B2','n','1e100']]){
type=t;value=v;try{EstQuantity.excelCell(files,name,address)}catch{rejected++}
}
return {valid,rejected};
})()""",
)
assert result == {"valid": 6.25, "rejected": 6}
def test_apply_all_quantities_sums_sheets_and_rejects_errors_atomically():
result = run(
"math",
+2
View File
@@ -1,4 +1,6 @@
## Codex · EST 독립 테스트 앱 (2026-11-04)
- [x] Excel 숫자 셀을 선택한 내역 행의 수량으로 반영 → 파일·시트·셀 오류 차단, 계산·저장·취소 확인.
- 자체 검증: Tailscale HTTP Edge에서 실제 xlsx B2=6.25 반영·금액 625·취소·JSON 저장, 문자·미계산 수식·누락·범위 오류 차단 확인. 수량·Excel 시험 23개 통과, openpyxl 경고 7건. 자동 파일 감시는 없음.
- [x] Tailscale HTTP에서 수량산출서와 산출 행 생성 → 생성·JSON 저장·복원 확인.
- 자체 검증: Tailscale HTTP Edge에서 산출서·행 생성, JSON 저장·재열기 확인. 수량·독립 서버 시험 19개 통과.
- [x] 기존 계산 불일치 수량산출서도 품명·규격만 바꿀 때 원본 수량 유지 → 실자료 재열기 확인.
+2
View File
@@ -54,6 +54,8 @@ Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요
수량산출서와 수식표에서 내역 행을 직접 선택해 연결할 수 있습니다. 내역 행 선택을 사용하면 코드가 없거나 중복되어도 해당 행에만 수량을 반영합니다. 코드를 직접 입력할 때는 해당 상세 내역이 정확히 하나여야 하며, 대상이 없거나 중복되면 다른 항목을 포함해 반영을 중단하고 오류를 표시합니다.
수량 화면의 `Excel 셀 수량 반영`은 `.xlsx` 파일과 시트·셀·내역 행을 선택해 저장된 숫자 값을 한 번 반영합니다. 수식 셀은 파일에 저장된 계산값을 사용하며, 저장된 값이 없거나 숫자가 아니면 반영하지 않습니다. Excel 파일이 바뀌면 다시 반영해야 합니다.
수량 화면에서 새 산출서를 만들고 이름과 산출 항목의 품명·규격을 편집할 수 있습니다. `산출 행 추가`로 결과 표지가 연결된 행과 항목을 함께 만든 뒤 수식을 입력하고, 연결 내역코드를 지정해 계산 수량을 내역에 반영합니다. 행 아래 삽입, 위·아래 이동과 산출 행·산출서 삭제도 가능하며, 이 구조 변경은 JSON 저장과 다시 열기를 지원합니다. 기존 연결은 내역코드를 비워 해제할 수 있습니다.
원본 ESTX의 기존 수량산출 항목은 품명·규격을 수정해 ESTX에 저장할 수 있습니다. 연결 변경과 새 항목 추가·삭제는 계속 JSON으로 보관합니다.
+89 -1
View File
@@ -210,7 +210,59 @@ const EstQuantity = (() => {
}
function selector(state) {
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><button data-quantity-new>새 산출서</button></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><button data-quantity-excel>Excel 셀 수량 반영</button></div>`;
}
function excelCell(files, name, address) {
const parse = (text) => {
if (!text || /<!DOCTYPE|<!ENTITY/i.test(text)) throw Error("Excel XML 형식 오류");
const doc = new DOMParser().parseFromString(text, "application/xml");
if (doc.querySelector("parsererror")) throw Error("Excel XML 형식 오류");
return doc;
};
address = address.trim().toUpperCase();
const parts = address.match(/^([A-Z]{1,3})([1-9]\d*)$/);
if (
!parts ||
[...parts[1]].reduce((n, c) => n * 26 + c.charCodeAt(0) - 64, 0) > 16384 ||
Number(parts[2]) > 1048576
)
throw Error("Excel 셀 주소를 확인하세요. 예: B12");
const workbook = parse(files["xl/workbook.xml"]),
sheet = [...workbook.getElementsByTagNameNS("*", "sheet")].find(
(s) => s.getAttribute("name") === name,
);
if (!sheet) throw Error(`Excel 시트가 없습니다: ${name}`);
const id = sheet.getAttributeNS(
"http://schemas.openxmlformats.org/officeDocument/2006/relationships",
"id",
),
rels = parse(files["xl/_rels/workbook.xml.rels"]),
rel = [...rels.getElementsByTagNameNS("*", "Relationship")].find(
(r) => r.getAttribute("Id") === id,
),
target = rel?.getAttribute("Target") || "",
path = target.startsWith("/") ? target.slice(1) : `xl/${target}`;
const doc = parse(files[path]),
cell = [...doc.getElementsByTagNameNS("*", "c")].find((c) => c.getAttribute("r") === address);
if (!cell || (cell.getAttribute("t") && cell.getAttribute("t") !== "n"))
throw Error(`${name} ${address}: 숫자 셀이 아닙니다.`);
const value = cell.getElementsByTagNameNS("*", "v")[0]?.textContent;
if (
value == null ||
!value.trim() ||
!Number.isFinite(Number(value)) ||
Math.abs(Number(value)) > Number.MAX_SAFE_INTEGER
)
throw Error(`${name} ${address}: 저장된 숫자 값이 없습니다.`);
return Number(value);
}
function excelDialog() {
const { state, esc, showDialog } = EstApp;
const rows = state.project.tables.EstMainDat;
showDialog(
"Excel 셀 수량 반영",
`<div class="form-fields"><label>Excel 파일 <input type="file" accept=".xlsx" id="quantity-excel-file"></label><label>시트 이름 <input id="quantity-excel-sheet"></label><label>셀 주소 <input id="quantity-excel-cell" placeholder="B12"></label><label>내역 행 <select id="quantity-excel-target"><option value="">내역 행 선택</option>${rows.map((row, index) => (!Number(row.Level) && ![51, 52, 54, 55, 57, 58, 59, 62, 67, 68].includes(Number(row.LinkIndex)) && !["%", "%"].includes(row.Unite) ? `<option value="${index}">${esc(row.Code || "코드 없음")} · ${esc(row.Name || "내역")} · ${index + 1}행</option>` : "")).join("")}</select></label><button data-quantity-excel-apply>수량 반영</button><span id="quantity-excel-error" role="alert"></span></div><p>저장된 숫자 값을 한 번 반영합니다. Excel에서 값이 바뀌면 다시 반영하세요.</p>`,
);
}
function render(state) {
state.quantitySheet ||= state.project.quantitySheets[0]?.Code;
@@ -274,6 +326,41 @@ 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-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("파일·시트·셀·내역 행을 선택하세요.");
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 ||
project.tables.EstMainDat[Number(selected)] !== row
)
throw Error("파일을 읽는 동안 작업이 바뀌었습니다. 다시 선택하세요.");
EstApp.change(() => (row.GN = value));
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()}`;
@@ -353,6 +440,7 @@ const EstQuantity = (() => {
totals,
allTotals,
applyTotals,
excelCell,
targetSelect,
newCode,
selector,