feat(est-test): 엑셀물량 파일목록(추가·삭제·열기), 셀 연결에서 목록 파일 쓰기

- 공사에 엑셀 물량 파일 담기(같은 이름 거부), 삭제, 내려받기
- 셀 수량 연결 창에서 목록 파일 고르기, 새 파일 연결 때 목록 등록

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 19:36:53 +09:00
co-authored by Claude Opus 5.5
parent b1c7d3dbc3
commit 3879de8b34
9 changed files with 156 additions and 7 deletions
+1
View File
@@ -40,6 +40,7 @@ for (const file of [
"native-print-range.js",
"native-work-mode.js",
"native-dongwon-check.js",
"native-excel-files.js",
"native-business-actions.js",
"native-forms.js",
"native-excel-to-est.js",
+26
View File
@@ -0,0 +1,26 @@
"""엑셀물량 파일목록: 추가(같은 이름 거부·번호)·삭제."""
from test_est_rea_parity import run
def test_add_remove_and_duplicate_name():
result = run(
"math",
"""(()=>{
const p=EstNative.empty(),F=EstExcelFiles;
F.add(p,'a.xlsx','QUJD');F.add(p,'b.xls','REVG');
let dup=null;try{F.add(p,'a.xlsx','')}catch(e){dup=e.message}
const idx=p.excelFiles.map(f=>f.Index);
F.remove(p,'a.xlsx');const after=p.excelFiles.map(f=>f.FileName);
F.add(p,'c.xlsx','');const next=p.excelFiles.at(-1).Index;
F.remove(p,'b.xls');F.remove(p,'c.xlsx');
return {dup,idx,after,next,gone:'excelFiles' in p};
})()""",
)
assert result == {
"dup": "같은 이름의 파일이 내역서에 포함되어 있습니다.",
"idx": [1, 2],
"after": ["b.xls"],
"next": 3,
"gone": False,
}
+2
View File
@@ -508,6 +508,8 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
- 검증완료: 작업모드 시험 2개·ESTX 저장 시험 34개 통과. ORCA 브라우저에서 표준내역서로 바꿈 → 표준 양식 30종 → 열기, 내역서로 돌아오면 원래 원가계산서 31행 복원, 계약심사목록 창 확인, 원복.
- [x] 동원 확인·내역서 탭(TfrmDongWon Button1~4 · spec_part §4) — 동원 화면 단추 확인 장비·단산·일위: 호표마다 1 단위를 펼친 항목 금액 합과 호표 금액을 대조해 10원 넘게 어긋나면 표시(어긋난 것만 보기), 행을 고르면 당초값·계산값(노무·재료·경비·실적)·항목 목록. 내역서: 내역 줄마다 펼친 항목. 웹 동원 계산은 기계경비를 더 쪼개지 않아 장비 확인은 늘 맞음.
- 검증완료: 확인 시험 1개·동원 관련 시험 35개 통과. ORCA 브라우저에서 일위 8건 모두 일치·당초값=계산값, 항목 3개 표시.
- [x] 엑셀물량 파일목록(TfrmExcelMullang btnNewProject·btnDelete · spec_pa §7) — 공사에 딸린 엑셀 물량 파일을 작업에 담음(Index·파일명). 추가 = 파일 골라 목록에(같은 이름 거부 — 원본은 엑셀에 띄우기만, 웹은 엑셀이 없어 여기서 등록) · 삭제 = 확인 뒤 목록·담은 파일 지움(셀 연결은 남음) · 열기 = 내려받기. 셀 수량 연결 창에서 목록 파일을 골라 쓰고, 새 파일로 연결하면 목록에 들어감. ESTX 저장은 JSON 안내.
- 검증완료: 파일목록 시험 1개·수량산출 시험 6개 통과. ORCA 브라우저에서 목록 등록 → 목록 파일로 B2 셀 연결(수량 12.5)·연결 수 1 → 삭제 확인, 원복.
- [x] 다른 공사 불러오기 창(원본 barbtnSelfLoad → TfrmOtherLoad, 찾기 popSerch) — 저장된 다른 작업을 골라 같은 표를 띄우고 찾기(품명·규격, A&B 둘 다)·고른 줄을 하위 자료와 함께 가져오기(내역서는 고른 줄 아래에 줄, 나머지는 자료·호표 합침, 같은 코드는 이 공사 것). 검증완료: 바인딩 통과, ORCA 브라우저에서 산불진화임도 작업의 일위대가 3개 → 「취수장」 1개 → 가져오기 호표 8→9·하위 41건, 샘플 복구.
- [x] 사용위치 검색 창(원본 TfrmUsesDataSerch btnSerch·btnAllDisplay) — 현재 표 항목을 품명·규격(빈칸·|·괄호 무시)으로 거르고 골라 사용위치 보기. 검증완료: 바인딩 통과, ORCA 브라우저에서 노임 9개 → 「보 통」 1개 → 보통인부 사용위치 11곳.
- [x] 인쇄양식 불러오기·저장하기(원본 mnuPrintFormatLoad·Save 0x10ed318·0x10ed000 — spec_pc §3) — .pfot(인쇄정보표 MIDAS 이진) 읽기·쓰기 `print-format.cjs`(설명 BLOB·빈 칸 표지까지 보존, 원본 파일 그대로 다시 쓰면 바이트 같음). 불러오면 공사 인쇄표를 그 값으로, 저장은 지금 인쇄표를 원본 설치 main.pfot 필드 설명자 위에(`EST_PFOT_TEMPLATE`). .gfot(cxGrid 열 배치)는 웹 격자와 호환되지 않아 하지 않음.
+2
View File
@@ -164,6 +164,8 @@ function exportEstx(project) {
);
if (project.attachments?.length)
throw Error("웹 첨부파일은 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요.");
if (project.excelFiles?.length)
throw Error("웹 엑셀물량 파일목록은 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요.");
if (project.excelQuantityLinks?.length)
throw Error("웹 Excel 수량 연결은 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요.");
if (project.schedule?.length)
+2
View File
@@ -28,6 +28,7 @@
<script defer src="native-print-range.js"></script>
<script defer src="native-work-mode.js"></script>
<script defer src="native-dongwon-check.js"></script>
<script defer src="native-excel-files.js"></script>
<script defer src="native-actions-more.js"></script>
<script defer src="native-wonga.js"></script>
<script defer src="native-jungsang.js"></script>
@@ -126,6 +127,7 @@
><button data-action="pfot-save">인쇄양식 저장하기(.pfot)</button>
<button data-action="export-xlsx">전체 자료 Excel 내보내기</button>
<button data-action="import-xlsx">Excel 표 불러오기</button>
<button data-action="excel-files">엑셀물량 파일목록</button>
<button data-action="excel-map-open">Excel 내역 열 맞춰 가져오기</button>
<button data-action="xml-check">조달청 XML 검사·자리수 자동 수정</button>
<button data-action="export-xml">조달청 XML 내보내기</button>
+1
View File
@@ -13,6 +13,7 @@ const EstActionsMore = (() => {
typeof EstPrintRange !== "undefined" && EstPrintRange,
typeof EstWorkMode !== "undefined" && EstWorkMode,
typeof EstDongwonCheck !== "undefined" && EstDongwonCheck,
typeof EstExcelFiles !== "undefined" && EstExcelFiles,
].filter(Boolean);
function handle(name, state, node) {
for (const module of modules()) if (module.handle(name, state, node)) return true;
+101
View File
@@ -0,0 +1,101 @@
// 원본 「엑셀물량 파일목록」(TfrmExcelMullang — spec_pa §7 · spec_small §8.3): 공사에 딸린 엑셀 물량 파일 목록(Index·FileName).
// 원본은 파일을 공사 폴더에 두고, 웹은 작업(project.excelFiles)에 파일 내용을 담는다. 추가(btnNewProject) = 파일을 골라 목록에
// 넣음(원본은 엑셀에 띄우기만 하고 셀 연결 때 등록 — 웹은 엑셀이 없어 여기서 등록) · 같은 이름은 거부. 삭제(btnDelete) = 목록과
// 담은 파일을 지움(원본도 공사 폴더의 파일을 지움) — 그 파일 셀 연결은 남아 다음 갱신 때 못 찾음으로 드러남. 열기 = 내려받기.
// 셀 수량 연결 창은 목록의 파일을 골라 쓸 수 있고, 새 파일로 연결하면 목록에 들어간다(0xc7328c).
const EstExcelFiles = (() => {
const list = (project) => project.excelFiles || [];
function add(project, name, data) {
const files = (project.excelFiles ||= []);
if (files.some((f) => f.FileName === name))
throw Error("같은 이름의 파일이 내역서에 포함되어 있습니다.");
const entry = {
Index: files.reduce((m, f) => Math.max(m, Number(f.Index) || 0), 0) + 1,
FileName: name,
data,
};
files.push(entry);
return entry;
}
function remove(project, name) {
const files = list(project),
at = files.findIndex((f) => f.FileName === name);
if (at < 0) throw Error("삭제할 엑셀파일을 선택하지 않았습니다.");
files.splice(at, 1);
if (!files.length) delete project.excelFiles;
}
const toBase64 = (buffer) => {
const bytes = new Uint8Array(buffer);
let out = "";
for (let i = 0; i < bytes.length; i += 0x8000)
out += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
return btoa(out);
};
const toBuffer = (data) => Uint8Array.from(atob(data), (c) => c.charCodeAt(0)).buffer;
let picked = "";
function dialog(state) {
const { esc } = EstApp,
files = list(state.project),
links = state.project.excelQuantityLinks || [];
EstApp.showDialog(
"엑셀물량 파일목록",
`<button data-action="excel-files-add" title="엑셀파일을 내역서에 추가합니다.">추 가</button> <button data-action="excel-files-delete" title="선택한 엑셀파일을 파일목록에서 제외시킵니다.">삭 제</button> <button data-action="excel-files-open">열 기</button> <span id="excel-files-error" role="alert"></span><table class="sheet" style="margin-top:6px"><thead><tr><th></th><th>파 일 명</th><th>셀 연결</th></tr></thead><tbody>${
files
.map(
(f) =>
`<tr><td><input type="radio" name="excel-file-pick" value="${esc(f.FileName)}" ${f.FileName === picked ? "checked" : ""}></td><td style="text-align:left">${esc(f.FileName)}</td><td>${links.filter((l) => l.file === f.FileName).length}</td></tr>`,
)
.join("") || '<tr><td colspan="3">등록된 엑셀 파일이 없습니다.</td></tr>'
}</tbody></table>`,
);
}
const chosen = () =>
document.querySelector('#dialog-content [name="excel-file-pick"]:checked')?.value || "";
async function run(name, state) {
if (name === "excel-files") return dialog(state);
if (name === "excel-files-add") {
const file = await new Promise((resolve) => {
const picker = document.createElement("input");
picker.type = "file";
picker.accept = ".xls,.xlsx";
picker.addEventListener("change", () => resolve(picker.files[0]));
picker.click();
});
if (!file) return;
const data = toBase64(await file.arrayBuffer());
EstApp.change((project) => add(project, file.name, data), false);
picked = file.name;
return dialog(state);
}
const target = chosen();
if (name === "excel-files-delete") {
if (!target.trim()) throw Error("삭제할 엑셀파일을 선택하지 않았습니다.");
if (!confirm(`${target}를현재작업목록에서 삭제하시겠습니까?`)) return;
EstApp.change((project) => remove(project, target), false);
return dialog(state);
}
if (name === "excel-files-open") {
const file = list(state.project).find((f) => f.FileName === target);
if (!file) throw Error("열 엑셀파일을 선택하세요.");
EstApp.download(file.FileName, new Blob([toBuffer(file.data)]), "application/octet-stream");
}
}
function handle(name, state) {
if (
!["excel-files", "excel-files-add", "excel-files-delete", "excel-files-open"].includes(name)
)
return false;
run(name, state).catch((error) => {
const note = EstApp.byId("excel-files-error");
if (note) note.textContent = error.message;
else {
state.notice = error.message;
EstApp.render();
}
});
return true;
}
return { add, remove, list, toBase64, toBuffer, handle };
})();
+20 -7
View File
@@ -255,7 +255,7 @@ const EstQuantity = (() => {
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) => (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>`,
`<div class="form-fields"><label>Excel 파일 <input type="file" accept=".xlsx" id="quantity-excel-file"></label><label>또는 엑셀물량 파일목록 <select id="quantity-excel-stored"><option value="">(고르지 않음)</option>${(state.project.excelFiles || []).map((f) => `<option>${esc(f.FileName)}</option>`).join("")}</select></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) {
@@ -486,7 +486,16 @@ const EstQuantity = (() => {
const project = state.project,
revision = state.revision,
fileInput = document.getElementById("quantity-excel-file"),
file = fileInput.files[0],
uploaded = fileInput.files[0] || null,
stored = (project.excelFiles || []).find(
(f) => f.FileName === document.getElementById("quantity-excel-stored")?.value,
),
file =
fileInput.files[0] ||
(stored && {
name: stored.FileName,
arrayBuffer: async () => EstExcelFiles.toBuffer(stored.data),
}),
name = document.getElementById("quantity-excel-sheet").value.trim(),
address = document.getElementById("quantity-excel-cell").value,
selected = document.getElementById("quantity-excel-target").value,
@@ -498,16 +507,14 @@ const EstQuantity = (() => {
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(),
}),
const bytes = await file.arrayBuffer(),
files = await EstApp.api("/api/import-xlsx", { method: "POST", body: bytes }),
value = excelCell(files, name, address);
if (
state.project !== project ||
state.revision !== revision ||
document.getElementById("quantity-excel-file") !== fileInput ||
fileInput.files[0] !== file ||
(fileInput.files[0] || null) !== uploaded ||
document.getElementById("quantity-excel-sheet")?.value.trim() !== name ||
document.getElementById("quantity-excel-cell")?.value !== address ||
document.getElementById("quantity-excel-target")?.value !== selected ||
@@ -526,6 +533,12 @@ const EstQuantity = (() => {
sheet: name,
cell: address.trim().toUpperCase(),
});
// 셀 연결 때 엑셀물량 파일목록에 없으면 등록(0xc7328c)
if (
typeof EstExcelFiles !== "undefined" &&
!(project.excelFiles || []).some((f) => f.FileName === file.name)
)
EstExcelFiles.add(project, file.name, EstExcelFiles.toBase64(bytes));
});
document.getElementById("dialog").close();
})().catch((error) => {
+1
View File
@@ -112,6 +112,7 @@ const allowed = new Set([
"native-print-range.js",
"native-work-mode.js",
"native-dongwon-check.js",
"native-excel-files.js",
"native-actions-more.js",
"native-wonga-tools.js",
"native-estimate-print.js",