diff --git a/B09_Estimation/B09_Estimation_Router_Import.py b/B09_Estimation/B09_Estimation_Router_Import.py index b51db8407..e2868fde1 100644 --- a/B09_Estimation/B09_Estimation_Router_Import.py +++ b/B09_Estimation/B09_Estimation_Router_Import.py @@ -18,6 +18,9 @@ {file_key, name, sheets, counts, linked, missing, patch{labor_cover, machine_cover, materials}} file_key = 세 길 같은 표지(`_keep` · `_saved`) — 같은 파일을 다시 올리지 않음 · + .estx(EST 원본 · 707-5 `B09_Estimation_Estx`) = 이름 끝으로 갈림 — 내역 줄 · 단가표 같은 그릇 · + 요율 = 400 「같은 공사 엑셀에서」 · + 미리 보기 `호표` = 대가 · 산근 목록 수 + 코드로 가리킨 줄(이음 후보만) 화면은 `patch` 를 캐시에 얹고 [저장] 때 서버가 정본으로 다시 셈. `B09_Estimation_Router` 가 끼워 넣음(같은 머리 · 같은 권한). """ @@ -37,6 +40,7 @@ from pydantic import BaseModel, Field from B08_Quantity.B08_Quantity_Router_Hydro import _project_root from B09_Estimation import B09_Estimation_Bill_Import as importer from B09_Estimation import B09_Estimation_Bill_Library as library +from B09_Estimation import B09_Estimation_Estx as estx from B09_Estimation import B09_Estimation_Import_Prices as import_prices from B09_Estimation import B09_Estimation_Import_Rates as import_rates from B09_Estimation import B09_Estimation_Prices as prices @@ -82,13 +86,28 @@ def _keep(root: Path, name: str, data: bytes) -> str: return key +def _is_estx(name: str) -> bool: + return name.lower().endswith(".estx") + + def _parse(name: str, data: bytes) -> dict[str, Any]: try: - return read_bill(name, data) + return (estx.read_bill if _is_estx(name) else read_bill)(name, data) except BillExcelError as error: raise HTTPException(status_code=400, detail=str(error)) from error +def _hopyo(name: str, data: bytes, parsed: dict[str, Any]) -> dict[str, int] | None: + """.estx 호표 목록 수 · 내역 줄이 코드로 가리킨 수(이음 후보 — 구성 줄은 `estx.UNKNOWN`).""" + if not _is_estx(name): + return None + got = estx.read_hopyo(name, data) + codes = {(k, x["코드"]) for k, v in got.items() for x in v} + hits = [h for line in parsed["줄"] if (h := line.get("호표"))] + return {**{k: len(v) for k, v in got.items()}, + "가리킴": sum((h["종류"], h["코드"]) in codes for h in hits)} # fmt: skip + + def _draft(raw: Any) -> dict[str, Any] | None: if raw in (None, ""): return None @@ -158,8 +177,10 @@ async def import_bill( if not file_key: raise HTTPException(status_code=400, detail="파일을 고름") path = _saved(root, file_key) + data = path.read_bytes() if file is None: - parsed = await asyncio.to_thread(_parse, path.name, path.read_bytes()) + parsed = await asyncio.to_thread(_parse, path.name, data) + hopyo = await asyncio.to_thread(_hopyo, path.name, data, parsed) rows = (await payload(project_id, root, _draft(user), True))["sheets"]["bill"]["rows"] found = await asyncio.to_thread(_found, session, path.name, _numbers(parsed)) return { @@ -167,6 +188,7 @@ async def import_bill( "파일": path.name, **importer.preview(parsed, rows), "이음": len(found), + "호표": hopyo, } @@ -234,6 +256,8 @@ async def import_rate_sheet( root = await _project_root(project_id) key, name, data = await _file(file, file_key, root) draft = _draft(user) + if _is_estx(name): + raise HTTPException(400, "원가계산서 요율은 .estx 에서 못 읽음 — 같은 공사 엑셀에서 불러옴") mine = (draft if draft is not None else await asyncio.to_thread(store.read_user, root))[ "estimate" ] @@ -262,8 +286,9 @@ async def import_price_sheets( draft = _draft(user) mine = draft if draft is not None else await asyncio.to_thread(store.read_user, root) try: - read = await asyncio.to_thread(import_prices.read_prices, name, data) - except ExcelError as error: + reader = estx.read_prices if _is_estx(name) else import_prices.read_prices + read = await asyncio.to_thread(reader, name, data) + except (ExcelError, BillExcelError) as error: raise HTTPException(400, str(error)) from error keys = await main._demand(root) # 화면 단가 목록(GET)과 같은 수요 — 사본 전부 b08 = await main._b08_for(project_id, [], True) diff --git a/B09_Estimation/B09_Estimation_UI_Import.ts b/B09_Estimation/B09_Estimation_UI_Import.ts index 56e1efe3e..889de4691 100644 --- a/B09_Estimation/B09_Estimation_UI_Import.ts +++ b/B09_Estimation/B09_Estimation_UI_Import.ts @@ -14,6 +14,9 @@ * 길 = `B09_Estimation_Router_Import.py` import/rates · import/prices(file | file_key · user = 화면 캐시) → * 확인 창(짝 표 · 식 다름 · 짝 없음 n) → [적용] = 답 `patch` 를 캐시에 얹음 · [저장] 때 서버 정본 * 올린 파일 = 내역 줄 · 요율 · 단가표 세 길이 같은 표지(file_key · `last`) — 같은 파일 다시 안 올림 + * + * EST 원본(.estx · PLAN 707-5) = 같은 단추 · 같은 창 — 내역 줄 + [단가표도 같이](받은 patch 를 캐시에 함께) · + * 요율 = 서버 400 「같은 공사 엑셀에서」 안내 · 호표 목록 = 수만(이음 후보 · 구성 줄 못 읽음) * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; @@ -81,11 +84,17 @@ export function createBillImport( const total = seen.합계 || {}; const lines = [ `${seen.파일} · ${seen.꼴} 「${seen.시트}」 · 공종 ${seen.묶음} · 항목 ${seen.항목}`, - `엑셀 직접비 합 ${won(total.직접)} 원 · 제외(관급 등) ${won(total.제외)} 원` + + `${seen.꼴 === "estx" ? ".estx" : "엑셀"} 직접비 합 ${won(total.직접)} 원 · 제외(관급 등) ${won(total.제외)} 원` + (seen.조달수수료율 != null ? ` · 조달수수료율 ${seen.조달수수료율} %` : ""), seen.이음 ? `같은 파일로 들여온 일위대가 ${seen.이음} 호표 — 그 줄은 일위대가에 이음` : "같은 파일로 들여온 일위대가 없음 — 모든 줄 = 엑셀 단가(고정)", + ...(seen.호표 + ? [ + `호표 목록 대가 ${seen.호표.대가} · 산근 ${seen.호표.산근} — 내역 줄 ${seen.호표.가리킴} 개가 코드로 가리킴(이음 후보 · 구성 줄은 못 읽음)`, + "원가계산서 요율은 .estx 에서 못 읽음 — 같은 공사 엑셀에서(총괄 · 원가계산서 탭)", + ] + : []), ]; const take = createCheckbox("넘겨받기 — 불러온 수량을 씀(수량 산출 값은 곁에)", { radio: "b09-import-mode", @@ -107,6 +116,11 @@ export function createBillImport( const drop = createCheckbox(`그 밖 자동 줄 ${seen.남는자동} 개 지움`, { disabled: !seen.남는자동, }); + const withPrices = seen.호표 + ? createCheckbox("단가표도 같이 — 노임 · 자재 · 기계 단가를 이 공사 단가 책에 덮음", { + checked: true, + }) + : null; const run = createButton({ label: "불러오기", variant: "filled" }); run.dataset.b09ImportRun = ""; openModal({ @@ -118,6 +132,7 @@ export function createBillImport( ...lines.map((t) => createText(t, "muted", 14)), el("div", { className: "b09-import__choices", children: [take.root, price.root] }), el("div", { className: "b09-import__choices", children: [attach.root, drop.root] }), + ...(withPrices ? [withPrices.root] : []), ); actions.append(run); run.addEventListener("click", async () => { @@ -133,12 +148,22 @@ export function createBillImport( showLoadingOverlay(); try { const got = await post(projectId, "bill/apply", JSON.stringify(ask), true); + let made = merged(draft, got.user); + let tail = ""; + if (withPrices?.input.checked) { + const sheet = await postSheet("prices", made, null); + made = { ...made, ...sheet.patch }; + const c: Row = sheet.counts || {}; + tail = ` · 단가표 이음 ${Object.entries(PART) + .map(([k, name]) => `${name} ${c[k]?.linked ?? 0}`) + .join(" · ")}`; + } // 창 닫고 다시 그림 — 표 높이(fillViewport)가 열린 창 가림막을 아래 자리로 셈 close(); - done(got.payload, merged(draft, got.user)); + done(got.payload, made); void openBillLink(user(), done, mode.value); // 707-4 다음: 우리 공종으로 잇기 const n = got.알림 || {}; - const head = `새 줄 ${n.실음} · 붙임 ${n.붙임} · 지움 ${n.지움} · 이음 ${n.이음} — [저장]해야 남음`; + const head = `새 줄 ${n.실음} · 붙임 ${n.붙임} · 지움 ${n.지움} · 이음 ${n.이음}${tail} — [저장]해야 남음`; const off: Row[] = got.어긋남 || []; if (!off.length) return void showToast(`${head} · 엑셀 금액과 모두 같음`, "success"); // 어긋난 줄 = 표 높이를 잰 뒤(다음 화면) 보기 창 @@ -169,7 +194,7 @@ export function createBillImport( label: "엑셀 내역 불러오기", variant: "ghost", onClick: () => - pickFiles({ accept: ".xls,.xlsx" }, ([file]) => { + pickFiles({ accept: ".xls,.xlsx,.estx" }, ([file]) => { const form = new FormData(); form.append("file", file); form.append("user", JSON.stringify(user())); @@ -208,11 +233,11 @@ const PART: Record = { labor: "노임", material: "자재", mach /** 마지막에 올린 파일(서버 기억 열쇠) — 같은 파일 다시 씀 */ let last: { key: string; name: string } | null = null; -async function postSheet(kind: Kind, ctx: TabContext, file: File | null): Promise { +async function postSheet(kind: Kind, user: Row, file: File | null): Promise { const form = new FormData(); if (file) form.append("file", file); else if (last) form.append("file_key", last.key); - form.append("user", JSON.stringify(ctx.user)); + form.append("user", JSON.stringify(user)); const url = `${API_BASE_URL}/projects/${currentProjectId()}/estimation/import/${kind}`; const response = await fetch(url, { method: "POST", body: form, credentials: "include" }); const body = (await response.json().catch(() => null)) as Row | null; @@ -334,8 +359,9 @@ const fail = (error: unknown): void => showToast(`불러오지 못함 — ${Stri function pick(kind: Kind, ctx: TabContext): void { pickFiles( - { accept: ".xlsx,.xls" }, - ([file]) => void postSheet(kind, ctx, file).then((got) => openResult(kind, ctx, got), fail), + { accept: ".xlsx,.xls,.estx" }, + ([file]) => + void postSheet(kind, ctx.user, file).then((got) => openResult(kind, ctx, got), fail), ); } @@ -349,9 +375,12 @@ export function importTools(tab: TabId, ctx: TabContext): HTMLElement[] { size: "sm", onClick: () => last - ? void postSheet(kind, ctx, null).then( + ? void postSheet(kind, ctx.user, null).then( (got) => openResult(kind, ctx, got), - () => pick(kind, ctx), + (error: unknown) => { + fail(error); // 지난 파일이 .estx(요율) · 없음 = 까닭 알리고 다시 고름 + pick(kind, ctx); + }, ) : pick(kind, ctx), }); diff --git a/resources/tester/helper_202_1_fill_below.cjs b/resources/tester/helper_202_1_fill_below.cjs index 73fd59feb..190b7517c 100644 --- a/resources/tester/helper_202_1_fill_below.cjs +++ b/resources/tester/helper_202_1_fill_below.cjs @@ -44,17 +44,19 @@ const sty = (n) => ({ paddingBottom: n.css.paddingBottom ?? "0px", borderBottomWidth: n.css.borderBottomWidth ?? "0px", marginBottom: n.css.marginBottom ?? "0px", + position: n.css.position ?? "static", }); const box = { exports: {} }; -new Function("exports", "module", "require", "window", "document", "getComputedStyle", "ResizeObserver", js)( - box.exports, - box, - (p) => (p.endsWith(".css") ? {} : require(p)), - win, - { body: null }, - sty, - RO, -); +new Function( + "exports", + "module", + "require", + "window", + "document", + "getComputedStyle", + "ResizeObserver", + js, +)(box.exports, box, (p) => (p.endsWith(".css") ? {} : require(p)), win, { body: null }, sty, RO); const m = box.exports; // 문서: body(padding-bottom 8) > card(padding-bottom 12 · gap 8) > [root][avg 20][info 30][hint 40] @@ -81,4 +83,9 @@ hint.height = 60; observers.forEach((o) => o.fn()); out.afterHintGrow = parseFloat(root.style.maxHeight); out.firstMinusGrow = out.first - 20; + +// 열린 창 가림막(body 끝 · position fixed · 창 높이) = 아래 자리에 안 셈(707-5) +link(body, card, el("backdrop", { position: "fixed" }, 900)); +out.belowWithModal = m.spaceBelow(root); +out.belowAfterGrow = 3 * 8 + 20 + 30 + 60 + 12 + 8; console.log(JSON.stringify(out)); diff --git a/resources/tester/helper_280_1_combo_pick.cjs b/resources/tester/helper_280_1_combo_pick.cjs index 9b4ffd3f9..d0669661c 100644 --- a/resources/tester/helper_280_1_combo_pick.cjs +++ b/resources/tester/helper_280_1_combo_pick.cjs @@ -147,6 +147,7 @@ const { loadTs } = makeLoader({ root: ROOT, requireFor: (full) => (request) => { if (request in STUBS) return STUBS[request]; + if (request.endsWith(".css")) return {}; // 부품 글꼴 import(ui_template_icons.css 등) if (request.startsWith(".")) return loadTs(`${path.resolve(path.dirname(full), request)}.ts`); return require(request); }, @@ -178,7 +179,7 @@ const flush = () => new Promise((resolve) => setTimeout(resolve, 20)); const out = {}; void openItemPicker({ current: null, canCopy: false }); await flush(); - const [, cols] = modal.body.children; + const [, , cols] = modal.body.children; // 위 표 · 규격 표(704-4) · 열 const [search, layerBar, treeHost] = cols.children[0].children; const searchInput = search.children[0]; const active = () => treeHost.all().find((n) => n.classList.contains("is-active"))?.dataset.id; diff --git a/resources/tester/helper_b05_structures_form_summary.cjs b/resources/tester/helper_b05_structures_form_summary.cjs index 4c7ff7c35..62fd8d5b6 100644 --- a/resources/tester/helper_b05_structures_form_summary.cjs +++ b/resources/tester/helper_b05_structures_form_summary.cjs @@ -119,6 +119,7 @@ const { loadTs } = makeLoader({ requireFor: (full) => (request) => { if (CATALOG.isCatalogRequest(request)) return CATALOG; if (STUBS[request]) return STUBS[request]; + if (request.endsWith(".css")) return {}; // 부품 글꼴 import(ui_template_icons.css 등) — 트랜스파일 못 함 // 화면 공용 부품(@ui/…) — 이 시험이 안 쓰는 것이라 뭘 불러도 아무 일 안 하는 함수로 막아 둠 if (request.startsWith("@ui/")) return new Proxy({}, { get: () => () => {} }); // 공용 계산(@util/…) — tsconfig 별칭대로 common_util 폴더에서 읽음 diff --git a/resources/tester/test_202_1_table_hint_bottom.py b/resources/tester/test_202_1_table_hint_bottom.py index 5d89460f6..f79393036 100644 --- a/resources/tester/test_202_1_table_hint_bottom.py +++ b/resources/tester/test_202_1_table_hint_bottom.py @@ -177,3 +177,4 @@ def test_창_끝까지_셈에_아래_안내_줄_포함(): assert r["below"] == r["belowExpected"] == 134 # 아래 줄 · 안내 줄 둘 + 여백 assert r["bottom"] == r["viewport"] # 문서 맨 아래 = 창 끝 → 페이지 스크롤 0 assert r["afterHintGrow"] == r["firstMinusGrow"] # 안내 줄이 늘면 표 칸이 그만큼 줄어듦 + assert r["belowWithModal"] == r["belowAfterGrow"] # 열린 창 가림막(fixed) = 아래 자리 아님 diff --git a/resources/tester/test_224_1_picker_top_table.py b/resources/tester/test_224_1_picker_top_table.py index e9258b32c..c9a5cae3a 100644 --- a/resources/tester/test_224_1_picker_top_table.py +++ b/resources/tester/test_224_1_picker_top_table.py @@ -117,7 +117,7 @@ def test_모달_배선_고르면_보이고_편집_화면에서는_숨김(): ts = _text(UI / "ui_template_item_picker.ts") assert "createComboTable((colId) => pickColumn(colId))" in ts # 280-2 표 고름 → 나무 assert "paintTable();" in ts # 고른 항목 · 층이 바뀔 때마다(280-2) - assert "body.append(tableWrap, cols, editHost," in ts # 맨 위 + assert "body.append(tableWrap, specHost, cols, editHost," in ts # 맨 위(704-4 규격 표 뒤) assert ts.count("tableWrap.hidden = true;") == 1 # 편집 화면 열 때 assert ts.count("tableWrap.hidden = false;") == 1 # 돌아올 때 assert "comboTable.destroy();" in ts # 닫을 때 표 거둠 diff --git a/resources/tester/test_707_5_estx.py b/resources/tester/test_707_5_estx.py index 887c2101c..0c9410248 100644 --- a/resources/tester/test_707_5_estx.py +++ b/resources/tester/test_707_5_estx.py @@ -125,3 +125,17 @@ def test_깨진_파일_표_없음_막힘(): z.writestr("Other.dat", b"") with pytest.raises(ex.EstxError, match="EstMainDat"): ex.read_bill("a.estx", buf.getvalue()) + + +def test_불러오기_길_estx_갈림(data): + """707-5 고리 — 이름 끝 .estx = 원본 읽기 · 호표 = 목록 수 + 코드로 가리킨 줄(이음 후보).""" + from B09_Estimation import B09_Estimation_Router_Import as route + + name = "거창.ESTX" + parsed = route._parse(name, data["거창"]) + assert parsed["꼴"] == "estx" and parsed["합계"]["직접"] == 384916227 + assert route._hopyo(name, data["거창"], parsed) == {"대가": 14, "산근": 61, "가리킴": 48} + assert route._hopyo("a.xlsx", data["거창"], parsed) is None + with pytest.raises(route.HTTPException) as got: + route._parse("a.estx", b"not a zip") + assert got.value.status_code == 400 diff --git a/resources/tester/test_m02_precombo_scope.py b/resources/tester/test_m02_precombo_scope.py index 77a4ce3db..fe9bd01fa 100644 --- a/resources/tester/test_m02_precombo_scope.py +++ b/resources/tester/test_m02_precombo_scope.py @@ -77,7 +77,7 @@ def test_사전조합_층_단추는_페이지를_다시_안_세움(): # 구조물 조합 층 단추는 그대로 해시 이동 한 벌 assert page.count('layerBar("') == 1 assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page - assert "precombo: { scope: () => pre, bar: preBar }," in page + assert "precombo: {\n scope: () => pre,\n bar: preBar," in page # 706-1 more 덧붙음 def test_고름에_owner_권한_서고_길에_명시_owner(): diff --git a/resources/tester/test_structures_summary_snapshot.py b/resources/tester/test_structures_summary_snapshot.py index 2a3910762..8a0c0ac54 100644 --- a/resources/tester/test_structures_summary_snapshot.py +++ b/resources/tester/test_structures_summary_snapshot.py @@ -300,5 +300,7 @@ def test_스냅샷이_없으면_알림(world): def test_초기화는_templates_를_안_건드리고_복제는_통째로(): assert not any(tree.startswith("templates") for tree in initial._FILE_TREES) - tool = (config_system.PROJECT_ROOT / "db_management/tools_clone_project.py").read_text("utf-8") + tool = (config_system.PROJECT_ROOT / "common_util/common_util_project_copy.py").read_text( + "utf-8" + ) # 복제 도구 본체(tools_clone_project = CLI 껍데기) assert '"robocopy",' in tool and '"/E",' in tool # 프로젝트 폴더 전체(templates 포함) diff --git a/ui_template/ui_template_data_table.ts b/ui_template/ui_template_data_table.ts index 6fd8c5c2d..7ffe3a975 100644 --- a/ui_template/ui_template_data_table.ts +++ b/ui_template/ui_template_data_table.ts @@ -410,7 +410,8 @@ export function spaceBelow(el: HTMLElement): number { for (let s = node.nextElementSibling; s; s = s.nextElementSibling) { const h = s.getBoundingClientRect().height; const m = getComputedStyle(s); // 203 형제 위아래 바깥 여백도(B09 안내 글 margin-top) - if (h) sum += h + gap + px(m.marginTop) + px(m.marginBottom); + const out = m.position === "fixed" || m.position === "absolute"; // 열린 창 가림막 · 알림 = 자리 안 씀 + if (h && !out) sum += h + gap + px(m.marginTop) + px(m.marginBottom); } sum += px(own.marginBottom) + px(around.paddingBottom) + px(around.borderBottomWidth); }