From 56ebe76e7fb0be6426b4c1a43f8159046a106da8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 16:59:53 +0900 Subject: [PATCH] =?UTF-8?q?feat(B09=20=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0?= =?UTF-8?q?):=20.estx=20=EA=B3=A0=EB=A6=AC=20=E2=80=94=20=EB=82=B4?= =?UTF-8?q?=EC=97=AD=20=EC=A4=84=20=C2=B7=20=EB=8B=A8=EA=B0=80=ED=91=9C=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EA=B8=B8=20=C2=B7=20=EC=9A=94=EC=9C=A8=20?= =?UTF-8?q?=3D=20=EC=97=91=EC=85=80=20=EC=95=88=EB=82=B4=20=C2=B7=20?= =?UTF-8?q?=ED=98=B8=ED=91=9C=20=EB=AA=A9=EB=A1=9D=20=3D=20=EC=9D=B4?= =?UTF-8?q?=EC=9D=8C=20=ED=9B=84=EB=B3=B4=20=EC=88=98=20(PLAN=20707-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 길: 이름 끝 .estx = `B09_Estimation_Estx` read_bill · read_prices(같은 그릇) · 요율 = 400 「같은 공사 엑셀에서」 - 미리 보기 `호표` = 대가 · 산근 목록 수 + 내역 줄이 코드로 가리킨 수(구성 줄 귀속은 안 함) - 창: 파일 고르기 *.estx · [단가표도 같이](받은 patch 를 캐시에 함께) · 지난 파일 요율 실패 = 까닭 알림 뒤 다시 고름 - 시험 test_707_5 8(길 갈림 · 호표 수 · 깨진 파일 400) Co-Authored-By: Claude Opus 5.5 --- .../B09_Estimation_Router_Import.py | 33 +++++++++++-- B09_Estimation/B09_Estimation_UI_Import.ts | 49 +++++++++++++++---- resources/tester/test_707_5_estx.py | 14 ++++++ 3 files changed, 82 insertions(+), 14 deletions(-) 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 cfe1070fe..24b46fcaa 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"; @@ -80,11 +83,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", @@ -106,6 +115,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({ @@ -117,6 +131,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 () => { @@ -132,11 +147,21 @@ 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); 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"); // 어긋난 줄 = 표 높이를 잰 뒤(다음 화면) 보기 창 @@ -167,7 +192,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())); @@ -206,11 +231,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; @@ -332,8 +357,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), ); } @@ -347,9 +373,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/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