feat(est): persist and refresh Excel quantity links
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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 저장·복원 확인.
|
||||
|
||||
@@ -54,7 +54,7 @@ Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요
|
||||
|
||||
수량산출서와 수식표에서 내역 행을 직접 선택해 연결할 수 있습니다. 내역 행 선택을 사용하면 코드가 없거나 중복되어도 해당 행에만 수량을 반영합니다. 코드를 직접 입력할 때는 해당 상세 내역이 정확히 하나여야 하며, 대상이 없거나 중복되면 다른 항목을 포함해 반영을 중단하고 오류를 표시합니다.
|
||||
|
||||
수량 화면의 `Excel 셀 수량 반영`은 `.xlsx` 파일과 시트·셀·내역 행을 선택해 저장된 숫자 값을 한 번 반영합니다. 수식 셀은 파일에 저장된 계산값을 사용하며, 저장된 값이 없거나 숫자가 아니면 반영하지 않습니다. Excel 파일이 바뀌면 다시 반영해야 합니다.
|
||||
수량 화면의 `Excel 셀 수량 연결`은 `.xlsx` 파일과 시트·셀·내역 행을 연결하고 저장된 숫자 값을 반영합니다. 연결 정보는 작업 JSON에 저장되며, `Excel 연결 관리`에서 같은 이름의 파일을 다시 선택해 해당 파일의 연결을 일괄 갱신하거나 연결을 해제할 수 있습니다. 어느 연결에 오류가 있어도 갱신 전체를 중단합니다. 수식 셀은 파일에 저장된 계산값을 사용하며, 저장된 값이 없거나 숫자가 아니면 반영하지 않습니다. Excel 파일은 자동 감시하지 않습니다. 웹 Excel 연결은 원본 ESTX에 저장할 수 없어 JSON으로 보관합니다.
|
||||
|
||||
수량 화면에서 새 산출서를 만들고 이름과 산출 항목의 품명·규격을 편집할 수 있습니다. `산출 행 추가`로 결과 표지가 연결된 행과 항목을 함께 만든 뒤 수식을 입력하고, 연결 내역코드를 지정해 계산 수량을 내역에 반영합니다. 행 아래 삽입, 위·아래 이동과 산출 행·산출서 삭제도 가능하며, 이 구조 변경은 JSON 저장과 다시 열기를 지원합니다. 기존 연결은 내역코드를 비워 해제할 수 있습니다.
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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("문서 본문 형식 오류");
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user