feat(est): persist and refresh Excel quantity links

This commit is contained in:
2026-11-07 15:56:04 +09:00
parent aac94d6508
commit fd6e2e6933
7 changed files with 165 additions and 12 deletions
+12
View File
@@ -47,3 +47,15 @@ def test_estx_export_rejects_web_attachments_instead_of_dropping_them():
timeout=10,
)
assert "JSON" in result.stdout.decode("utf-8")
def test_estx_export_rejects_web_excel_links_instead_of_dropping_them():
script = """try{require('./tools/est-test/export-estx.cjs').exportEstx({originalArchive:'AA==',excelQuantityLinks:[{}]});process.exit(1)}catch(e){process.stdout.write(e.message)}"""
result = subprocess.run(
["node", "-e", script],
cwd=Path(__file__).resolve().parents[2],
capture_output=True,
check=True,
timeout=10,
)
assert "JSON" in result.stdout.decode("utf-8")
+22 -1
View File
@@ -16,10 +16,31 @@ def test_excel_quantity_cell_rejects_invalid_address_or_non_numeric_value():
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++}
}
type='n';value='8';const p={tables:{EstMainDat:[{PkID:1,GN:2},{PkID:2,GN:3}]},excelQuantityLinks:[{target:1,file:'a.xlsx',sheet:'Data',cell:'B2'},{target:2,file:'a.xlsx',sheet:'Data',cell:'B2'}]};
const updates=EstQuantity.excelUpdates(p,'a.xlsx',files).map(x=>x.value),before=p.tables.EstMainDat.map(x=>x.GN);let bad=0;
p.excelQuantityLinks[1].cell='B3';try{EstQuantity.excelUpdates(p,'a.xlsx',files)}catch{bad++}
p.tables.EstMainDat.push({PkID:1});try{EstQuantity.excelUpdates(p,'a.xlsx',files)}catch{bad++}
try{EstQuantity.excelUpdates(p,'missing.xlsx',files)}catch{bad++}
return {valid,rejected,updates,before,bad};
})()""",
)
assert result == {"valid": 6.25, "rejected": 6, "updates": [8, 8], "before": [2, 3], "bad": 3}
def test_excel_quantity_links_validate_and_survive_project_validation():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.excelQuantityLinks=[{target:1,file:'a.xlsx',sheet:'Data',cell:'B2'}];
const valid=EstModel.validate(p).excelQuantityLinks;let rejected=0;
for(const bad of [[valid[0],valid[0]],[{...valid[0],target:'1'}],[{...valid[0],cell:'wrong'}],[{...valid[0],cell:'XFE1'}],[{...valid[0],cell:'A1048577'}]]){
const q=EstNative.empty();q.excelQuantityLinks=bad;try{EstModel.validate(q)}catch{rejected++}
}
return {valid,rejected};
})()""",
)
assert result == {"valid": 6.25, "rejected": 6}
assert result["valid"] == [{"target": 1, "file": "a.xlsx", "sheet": "Data", "cell": "B2"}]
assert result["rejected"] == 5
def test_apply_all_quantities_sums_sheets_and_rejects_errors_atomically():
+2
View File
@@ -1,4 +1,6 @@
## Codex · EST 독립 테스트 앱 (2026-11-04)
- [x] Excel 셀과 내역 행 연결을 JSON에 보관하고 다시 선택한 파일로 일괄 갱신 → 중복·누락·오류 시 전체 변경 차단.
- 자체 검증: Tailscale HTTP Edge에서 2행 연결·JSON 재열기·일괄 갱신·실행 취소·연결 해제 확인. 한 셀 오류 시 두 행 모두 유지, ESTX 저장에서 연결 누락 차단. 수량·첨부·독립 서버 시험 25개 통과.
- [x] Excel 숫자 셀을 선택한 내역 행의 수량으로 반영 → 파일·시트·셀 오류 차단, 계산·저장·취소 확인.
- 자체 검증: Tailscale HTTP Edge에서 실제 xlsx B2=6.25 반영·금액 625·취소·JSON 저장, 문자·미계산 수식·누락·범위 오류 차단 확인. 수량·Excel 시험 23개 통과, openpyxl 경고 7건. 자동 파일 감시는 없음.
- [x] Tailscale HTTP에서 수량산출서와 산출 행 생성 → 생성·JSON 저장·복원 확인.
+1 -1
View File
@@ -54,7 +54,7 @@ Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요
수량산출서와 수식표에서 내역 행을 직접 선택해 연결할 수 있습니다. 내역 행 선택을 사용하면 코드가 없거나 중복되어도 해당 행에만 수량을 반영합니다. 코드를 직접 입력할 때는 해당 상세 내역이 정확히 하나여야 하며, 대상이 없거나 중복되면 다른 항목을 포함해 반영을 중단하고 오류를 표시합니다.
수량 화면의 `Excel 셀 수량 반영`은 `.xlsx` 파일과 시트·셀·내역 행을 선택해 저장된 숫자 값을 한 번 반영합니다. 수식 셀은 파일에 저장된 계산값을 사용하며, 저장된 값이 없거나 숫자가 아니면 반영하지 않습니다. Excel 파일이 바뀌면 다시 반영해야 합니다.
수량 화면의 `Excel 셀 수량 연결`은 `.xlsx` 파일과 시트·셀·내역 행을 연결하고 저장된 숫자 값을 반영합니다. 연결 정보는 작업 JSON에 저장되며, `Excel 연결 관리`에서 같은 이름의 파일을 다시 선택해 해당 파일의 연결을 일괄 갱신하거나 연결을 해제할 수 있습니다. 어느 연결에 오류가 있어도 갱신 전체를 중단합니다. 수식 셀은 파일에 저장된 계산값을 사용하며, 저장된 값이 없거나 숫자가 아니면 반영하지 않습니다. Excel 파일은 자동 감시하지 않습니다. 웹 Excel 연결은 원본 ESTX에 저장할 수 없어 JSON으로 보관합니다.
수량 화면에서 새 산출서를 만들고 이름과 산출 항목의 품명·규격을 편집할 수 있습니다. `산출 행 추가`로 결과 표지가 연결된 행과 항목을 함께 만든 뒤 수식을 입력하고, 연결 내역코드를 지정해 계산 수량을 내역에 반영합니다. 행 아래 삽입, 위·아래 이동과 산출 행·산출서 삭제도 가능하며, 이 구조 변경은 JSON 저장과 다시 열기를 지원합니다. 기존 연결은 내역코드를 비워 해제할 수 있습니다.
+2
View File
@@ -137,6 +137,8 @@ function exportEstx(project) {
throw Error("원본 ESTX를 가져온 작업에서만 ESTX 수정 저장이 가능합니다.");
if (project.attachments?.length)
throw Error("웹 첨부파일은 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요.");
if (project.excelQuantityLinks?.length)
throw Error("웹 Excel 수량 연결은 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요.");
const original = Buffer.from(project.originalArchive, "base64"),
base = importEstx(original),
files = unzip(original);
+20
View File
@@ -345,6 +345,26 @@ const EstModel = (() => {
if (s[key] != null && (typeof s[key] !== "string" || s[key].length > 100000))
throw Error("인쇄 문구 형식 오류");
}
if (project.excelQuantityLinks != null) {
if (!Array.isArray(project.excelQuantityLinks)) throw Error("Excel 수량 연결 형식 오류");
const targets = new Set();
for (const link of project.excelQuantityLinks) {
if (
!link ||
!Number.isSafeInteger(link.target) ||
targets.has(link.target) ||
typeof link.file !== "string" ||
!link.file ||
link.file.length > 255 ||
typeof link.sheet !== "string" ||
!link.sheet ||
link.sheet.length > 255 ||
!EstQuantity.validExcelAddress(link.cell)
)
throw Error("Excel 수량 연결 오류");
targets.add(link.target);
}
}
if (project.documents != null) {
if (typeof project.documents !== "object" || Array.isArray(project.documents))
throw Error("문서 본문 형식 오류");
+106 -10
View File
@@ -210,7 +210,15 @@ 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><button data-quantity-excel>Excel 셀 수량 반영</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><button data-quantity-excel-links>Excel 연결 관리</button></div>`;
}
function validExcelAddress(address) {
const parts = typeof address === "string" && address.match(/^([A-Z]{1,3})([1-9]\d*)$/);
return Boolean(
parts &&
[...parts[1]].reduce((n, c) => n * 26 + c.charCodeAt(0) - 64, 0) <= 16384 &&
Number(parts[2]) <= 1048576,
);
}
function excelCell(files, name, address) {
const parse = (text) => {
@@ -220,13 +228,7 @@ const EstQuantity = (() => {
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");
if (!validExcelAddress(address)) throw Error("Excel 셀 주소를 확인하세요. 예: B12");
const workbook = parse(files["xl/workbook.xml"]),
sheet = [...workbook.getElementsByTagNameNS("*", "sheet")].find(
(s) => s.getAttribute("name") === name,
@@ -256,12 +258,43 @@ const EstQuantity = (() => {
throw Error(`${name} ${address}: 저장된 숫자 값이 없습니다.`);
return Number(value);
}
function excelTarget(row) {
return (
row &&
!Number(row.Level) &&
![51, 52, 54, 55, 57, 58, 59, 62, 67, 68].includes(Number(row.LinkIndex)) &&
!["%", "%"].includes(row.Unite)
);
}
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>`,
`<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) => (excelTarget(row) ? `<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>파일명·시트·셀 연결은 작업 JSON에 저장합니다. 저장된 숫자 값을 사용하므로 Excel 파일이 바뀌면 연결 관리에서 다시 선택하세요.</p>`,
);
}
function excelUpdates(project, filename, files) {
const links = (project.excelQuantityLinks || []).filter((link) => link.file === filename);
if (!links.length) throw Error("선택한 파일에 연결된 내역이 없습니다.");
return links.map((link) => {
const rows = project.tables.EstMainDat.filter((row) => row.PkID === link.target);
if (rows.length !== 1 || !excelTarget(rows[0]))
throw Error(`연결 내역이 없거나 중복되었거나 상세 행이 아닙니다: ${link.target}`);
return { row: rows[0], value: excelCell(files, link.sheet, link.cell) };
});
}
function excelLinksDialog() {
const { state, esc, showDialog } = EstApp,
links = state.project.excelQuantityLinks || [];
showDialog(
"Excel 수량 연결 관리",
`<p>연결된 파일을 다시 선택하면 해당 파일의 모든 연결을 갱신합니다.</p><div class="form-fields"><label>Excel 파일 <input type="file" accept=".xlsx" id="quantity-excel-refresh-file"></label><button data-quantity-excel-refresh>연결 수량 갱신</button><span id="quantity-excel-links-error" role="alert"></span></div><table class="sheet"><thead><tr><th>파일</th><th>시트</th><th>셀</th><th>내역</th><th>작업</th></tr></thead><tbody>${links
.map((link, index) => {
const row = state.project.tables.EstMainDat.find((r) => r.PkID === link.target);
return `<tr><td>${esc(link.file)}</td><td>${esc(link.sheet)}</td><td>${esc(link.cell)}</td><td>${esc(row?.Name || link.target)}</td><td><button data-quantity-excel-remove="${index}">연결 해제</button></td></tr>`;
})
.join("")}</tbody></table>`,
);
}
function render(state) {
@@ -330,6 +363,50 @@ const EstQuantity = (() => {
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
)
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;
});
return;
}
if (event.target.closest("[data-quantity-excel-apply]")) {
const project = state.project,
revision = state.revision,
@@ -341,6 +418,11 @@ const EstQuantity = (() => {
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(),
@@ -353,7 +435,19 @@ const EstQuantity = (() => {
project.tables.EstMainDat[Number(selected)] !== row
)
throw Error("파일을 읽는 동안 작업이 바뀌었습니다. 다시 선택하세요.");
EstApp.change(() => (row.GN = value));
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");
@@ -441,6 +535,8 @@ const EstQuantity = (() => {
allTotals,
applyTotals,
excelCell,
validExcelAddress,
excelUpdates,
targetSelect,
newCode,
selector,