From c33f6837f2940ff507043af4ebe642fe6c7f09a9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 16:13:34 +0900 Subject: [PATCH] =?UTF-8?q?fix(B09=20=EC=84=A4=EA=B3=84=EB=82=B4=EC=97=AD?= =?UTF-8?q?=EC=84=9C):=20=EC=97=91=EC=85=80=20=EB=82=B4=EC=97=AD=20?= =?UTF-8?q?=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=ED=9D=A0=20=E2=80=94=20=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EB=A7=8C=20=EB=B9=88=EC=B9=B8=20=C2=B7=20=EC=9A=B4=EB=B0=98=20?= =?UTF-8?q?=EC=A4=84=20=EC=98=A4=EB=B6=99=EC=9D=8C=20=C2=B7=20=EC=B0=A8?= =?UTF-8?q?=EB=A1=80=20=C2=B7=20=ED=91=9C=20=EB=86=92=EC=9D=B4=20(PLAN=207?= =?UTF-8?q?07-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 단가만 갈래 = 붙인 자동 줄에 B08 수량이 없으면 엑셀 수량(빈칸 금액 막음) - 운반 자동 줄(「모래」 · 「시멘트」 = 자재대 줄 이름) = 붙이지 않음 · 지우기만 - 불러온 공종 = 관급자재대 앞(엑셀 차례 · 표 끝에 묻히지 않음) - 결과 창 = 표를 다시 그린 뒤 따로 엶(열린 창 가림막을 표 아래 자리로 재어 표가 한 줄로 줄던 것) · 어긋난 줄 표 칸 폭 줄임 - 못 읽은 파일(빈 · DRM · 내역 시트 없음) = 프로젝트 import 폴더에 안 둠 Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Bill_Import.py | 27 +++++++++----- .../B09_Estimation_Router_Import.py | 10 +++-- B09_Estimation/B09_Estimation_UI_Import.ts | 37 +++++++++++-------- resources/tester/test_707_1_bill_import.py | 21 ++++++++--- 4 files changed, 60 insertions(+), 35 deletions(-) diff --git a/B09_Estimation/B09_Estimation_Bill_Import.py b/B09_Estimation/B09_Estimation_Bill_Import.py index b2b8b6f2b..077c3fb62 100644 --- a/B09_Estimation/B09_Estimation_Bill_Import.py +++ b/B09_Estimation/B09_Estimation_Bill_Import.py @@ -1,15 +1,16 @@ """B09 경쟁사 내역 줄 불러오기 짜기(PLAN 707-1) — 읽은 내역(`Bill_Excel.read_bill`) → 설계내역서 덮개 v2(`user.bill` · 계약 = `B09_Estimation_Calc_Bill` 머리). 파일 · 저장 안 만짐(부르는 쪽 몫). -먼저 싣고 나중 잇기 — 빠짐 0: +먼저 싣고 나중 잇기 — 빠짐 0 · 실은 줄 = 표 차례에서 관급자재대(`sec:G`) 앞: 묶음 줄 → 새 묶음(`hand:x{표지}-{행}` · 제외 맨 위 = 관급자재대 `sec:G` 아래) 항목 줄 → 새 줄 + 고정 단가 칸 `price`(엑셀 단가) + `src_qty`(엑셀 수량) + `note`(엑셀 비고) · 연결 = 같은 책 일위대가 사본(`links` {대가 번: ref}) — 없으면 고정 단가로 금액 같은 파일을 다시 부르면(같은 표지) 앞서 실은 줄을 갈아 끼움. 수량 갈래 — `take`(가 넘겨받기) 붙인 자동 줄 수량 = 엑셀(B08 = `auto_qty` 로 나란히) · - `price`(나 단가만) 붙인 자동 줄 수량 = B08 · 엑셀 수량 = 비교 칸만. 새 줄 수량 = 늘 엑셀. + `price`(나 단가만) 붙인 자동 줄 수량 = B08(B08 수량 없으면 엑셀) · 엑셀 수량 = 비교 칸. + 새 줄 수량 = 늘 엑셀. 두 번 셈 막음 — `attach` = 이름(빈칸 뺌)이 같은 자동 항목 줄에 붙임(그 줄 = 불러온 자리로 옮기고 - 단가 = 연결 · 고정 단가 칸 · 자재 줄 · 조달수수료 뺌) · + 단가 = 연결 · 고정 단가 칸 · 자재 줄 · 운반 줄(이름이 자재와 같음 「모래」) · 조달수수료 뺌) · `drop` = 안 붙은 자동 항목 줄 지움(`gone`). 조달수수료율 = 사용자 값이 비었을 때만 엑셀 율. """ @@ -24,6 +25,8 @@ from typing import Any MODES = ("take", "price") FIELDS = ("name", "spec", "unit", "qty", "ref") PARTS = ("labor", "material", "expense") +#: 붙이지도 지우지도 않는 자동 줄 머리 — 자재(대비표 주인) · 조달수수료(운반 = 지움만) +SKIP = ("mat", "fee") def tag_of(data: bytes) -> str: @@ -73,19 +76,21 @@ def _price(line: dict[str, Any]) -> dict[str, float]: return {p: float(line["단가"].get(p) or 0) for p in PARTS} -def _autos(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: - """붙일 수 있는 자동 항목 줄 — 자재 줄 · 조달수수료 뺌.""" +def _autos(rows: list[dict[str, Any]], attach: bool = False) -> list[dict[str, Any]]: + """지울 수 있는 자동 항목 줄(자재 · 조달수수료 뺌) · + `attach` = 붙일 수 있는 줄(운반도 뺌 — 「모래」 = 자재대 줄 이름).""" + skip = (*SKIP, "haul") if attach else SKIP return [ r for r in rows - if r["kind"] == "item" and r.get("auto") and r["key"] != "fee" and r["key"][:4] != "mat:" + if r["kind"] == "item" and r.get("auto") and r["key"].split(":")[0] not in skip ] def matches(parsed: dict[str, Any], rows: list[dict[str, Any]]) -> dict[str, str]: """{불러온 줄 행(글): 자동 줄 키} — 이름(빈칸 뺌)이 같은 첫 남은 자동 줄 · 제외 줄 뺌.""" free: dict[str, list[str]] = {} - for row in _autos(rows): + for row in _autos(rows, attach=True): free.setdefault(_flat(row["name"]), []).append(row["key"]) out = {} for line in parsed["줄"]: @@ -144,6 +149,7 @@ def apply( kept.append(e) bill["rows"] = kept pairs = matches(parsed, rows) if attach else {} + by_key = {r["key"]: r for r in rows} keys = {line["행"]: f"{head}{line['행']:05d}" for line in parsed["줄"]} put, report = [], {"실음": 0, "붙임": 0, "지움": 0, "이음": 0, "줄": {}} for line in parsed["줄"]: @@ -162,8 +168,8 @@ def apply( else: bill["rows"].remove(entry) entry.update({**cells, "ref": link}) - if mode == "take": - entry["qty"] = line["수량"] + if mode == "take" or by_key[auto].get("auto_qty", by_key[auto]["qty"]) is None: + entry["qty"] = line["수량"] # 단가만 · B08 수량 없음 = 엑셀 entry.pop("parent", None) put.append({**entry, **({"parent": parent} if parent else {})}) report["붙임"] += 1 @@ -185,7 +191,8 @@ def apply( bill["gone"].append(row["key"]) report["지움"] += 1 bill["rows"] = [e for e in bill["rows"] if e["key"] not in bill["gone"]] - bill["rows"] += put + at = next((n for n, e in enumerate(bill["rows"]) if e["key"] == "sec:G"), len(bill["rows"])) + bill["rows"][at:at] = put # 불러온 공종 = 관급자재대 앞(엑셀 차례) out = {**user, "bill": bill} estimate = dict(user.get("estimate") or {}) if estimate.get("procurement_fee_pct") is None and parsed.get("조달수수료율") is not None: diff --git a/B09_Estimation/B09_Estimation_Router_Import.py b/B09_Estimation/B09_Estimation_Router_Import.py index 181dca087..259bb39cf 100644 --- a/B09_Estimation/B09_Estimation_Router_Import.py +++ b/B09_Estimation/B09_Estimation_Router_Import.py @@ -66,9 +66,9 @@ def _keep(root: Path, name: str, data: bytes) -> str: return key -def _parse(path: Path) -> dict[str, Any]: +def _parse(name: str, data: bytes) -> dict[str, Any]: try: - return read_bill(path.name, path.read_bytes()) + return read_bill(name, data) except BillExcelError as error: raise HTTPException(status_code=400, detail=str(error)) from error @@ -137,11 +137,13 @@ async def import_bill( data = await file.read(MAX_BYTES + 1) if len(data) > MAX_BYTES: raise HTTPException(status_code=400, detail="파일이 너무 큼(50MB 넘음)") + parsed = await asyncio.to_thread(_parse, file.filename or "", data) # 읽힌 파일만 둠 file_key = await asyncio.to_thread(_keep, root, file.filename or "", data) if not file_key: raise HTTPException(status_code=400, detail="파일을 고름") path = _saved(root, file_key) - parsed = await asyncio.to_thread(_parse, path) + if file is None: + parsed = await asyncio.to_thread(_parse, path.name, path.read_bytes()) rows = (await payload(project_id, root, _draft(user), True))["sheets"]["bill"]["rows"] found = await asyncio.to_thread(_found, session, path.name, _numbers(parsed)) return { @@ -170,7 +172,7 @@ async def apply_bill( raise HTTPException(status_code=400, detail="수량 갈래는 take · price 중 하나") root = await _project_root(project_id) path = _saved(root, body.file_key) - parsed = await asyncio.to_thread(_parse, path) + parsed = await asyncio.to_thread(_parse, path.name, path.read_bytes()) draft = _draft(body.user) or await asyncio.to_thread(store.read_user, root) rows = (await payload(project_id, root, draft, True))["sheets"]["bill"]["rows"] found = await asyncio.to_thread(_found, session, path.name, _numbers(parsed)) diff --git a/B09_Estimation/B09_Estimation_UI_Import.ts b/B09_Estimation/B09_Estimation_UI_Import.ts index 27e6f2d85..93151d2ae 100644 --- a/B09_Estimation/B09_Estimation_UI_Import.ts +++ b/B09_Estimation/B09_Estimation_UI_Import.ts @@ -51,11 +51,11 @@ function merged(user: Row, made: Row): Row { function differences(rows: Row[]): HTMLElement { return createDataTable({ columns: [ - { key: "행", label: "엑셀 행", kind: "number", width: 56 }, - { key: "이름", label: "명칭", width: 200 }, - { key: "엑셀", label: "엑셀 금액", kind: "number", width: 110 }, - { key: "우리", label: "우리 금액", kind: "number", width: 110 }, - { key: "까닭", label: "까닭", width: 160 }, + { key: "행", label: "엑셀 행", kind: "number", width: 48 }, + { key: "이름", label: "명칭", width: 150 }, + { key: "엑셀", label: "엑셀 금액", kind: "number", width: 96 }, + { key: "우리", label: "우리 금액", kind: "number", width: 96 }, + { key: "까닭", label: "까닭", width: 130 }, ], rows, }).root; @@ -123,20 +123,27 @@ export function createBillImport( showLoadingOverlay(); try { const got = await post(projectId, "bill/apply", JSON.stringify(ask), true); + // 창 닫고 다시 그림 — 표 높이(fillViewport)가 열린 창 가림막을 아래 자리로 셈 + close(); done(got.payload, merged(draft, got.user)); const n = got.알림 || {}; const head = `새 줄 ${n.실음} · 붙임 ${n.붙임} · 지움 ${n.지움} · 이음 ${n.이음} — [저장]해야 남음`; const off: Row[] = got.어긋남 || []; - if (!off.length) { - close(); - return void showToast(`${head} · 엑셀 금액과 모두 같음`, "success"); - } - run.remove(); - body.replaceChildren( - createText(head, "success", 14), - createText(`엑셀 금액과 다른 줄 ${off.length}`, "warning", 14), - differences(off), - ); + if (!off.length) return void showToast(`${head} · 엑셀 금액과 모두 같음`, "success"); + // 어긋난 줄 = 표 높이를 잰 뒤(다음 화면) 보기 창 + const result = (): void => + void openModal({ + title: "엑셀 내역 불러오기 — 결과", + closeLabel: "닫기", + width: "40rem", + mount: (box) => + box.append( + createText(head, "success", 14), + createText(`엑셀 금액과 다른 줄 ${off.length}`, "warning", 14), + differences(off), + ), + }); + requestAnimationFrame(() => requestAnimationFrame(result)); } catch (error) { showToast(`불러오지 못함 — ${error instanceof Error ? error.message : error}`, "error"); } finally { diff --git a/resources/tester/test_707_1_bill_import.py b/resources/tester/test_707_1_bill_import.py index e726f8a3d..c558fb7c8 100644 --- a/resources/tester/test_707_1_bill_import.py +++ b/resources/tester/test_707_1_bill_import.py @@ -45,6 +45,10 @@ ITEMS = [ "ref": {"tab": "sg_list", "key": "D01341"}}, {"key": "earth:9", "section": "1.6", "name": "노면 정리X", "unit": "㎡", "qty": 3, "ref": {"tab": "sg_list", "key": "D01341"}}, + {"key": "earth:6", "section": "1.2", "name": "암발파", "unit": "㎥", "qty": None, + "ref": {"tab": "sg_list", "key": "D01341"}}, + {"key": "haul:3", "section": "3", "name": "모래", "unit": "㎥", "qty": 2, + "ref": {"tab": "sg_list", "key": "D01341"}}, ] # fmt: skip INPUTS = {"materials": [], "bill": {"items": ITEMS}, "hopyo": {"D01341": { "labor": 1, "material": 0, "expense": 0}}} # fmt: skip @@ -102,14 +106,17 @@ def test_DRM_읽을_수_없음(): def test_가_넘겨받기_붙인_자동_줄_수량_엑셀(parsed): made, report, bill = _load(parsed["기번3"], INPUTS, mode="take") rows = {r["key"]: r for r in bill["rows"]} - assert report["붙임"] == 1 # 절토(토사) 첫 줄만(1공구) + assert report["붙임"] == 2 # 절토(토사) 첫 줄만(1공구) · 암발파 · 운반 「모래」 = 안 붙음 cut = rows["earth:4"] assert (cut["qty"], cut["auto_qty"], cut["src_qty"]) == (5675, 10, 5675) assert cut["fixed"] and cut["sum"] == 11003825 # 엑셀 단가 · 연결 덮개 null assert cut["parent"].startswith("hand:xabcd1234-") # 불러온 자리로 옮김 assert sum(1 for r in bill["rows"] if r["name"] == "절토(토사)") == 3 # 두 번 셈 없음 - # 붙인 줄 금액 = 엑셀 · 안 붙은 자동 줄(노면 정리X) 그대로 = 직접비 = 엑셀 + 3 - assert bill["totals"]["direct"]["sum"] == 710359136 + 3 + # 붙인 줄 금액 = 엑셀 · 안 붙은 자동 줄(노면 정리X 3 · 운반 모래 2) 그대로 = 직접비 = 엑셀 + 5 + assert bill["totals"]["direct"]["sum"] == 710359136 + 5 + assert "haul:3" in rows and rows["haul:3"]["parent"] == "sec:3" + tops = [r["key"] for r in bill["rows"] if r["parent"] is None] + assert tops.index("hand:xabcd1234-00006") < tops.index("sec:G") # 1공구 = 관급자재대 앞 def test_나_단가만_B08_이김_비교_칸(parsed): @@ -117,21 +124,23 @@ def test_나_단가만_B08_이김_비교_칸(parsed): cut = {r["key"]: r for r in bill["rows"]}["earth:4"] assert (cut["qty"], cut["src_qty"], "auto_qty" in cut) == (10, 5675, False) assert cut["sum"] == 1939 * 10 # 엑셀 단가 × B08 수량 + blast = {r["key"]: r for r in bill["rows"]}["earth:6"] + assert (blast["qty"], blast["sum"]) == (3889, 43700693) # B08 수량 없음 = 엑셀 수량 diff = imp.differences(parsed["기번3"], report, bill["rows"]) assert [d["까닭"] for d in diff] == ["수량 = B08(단가만)"] def test_붙이지_않음_지우기_다시_부르기(parsed): _, report, bill = _load(parsed["기번3"], INPUTS, attach=False, drop=True) - assert (report["붙임"], report["지움"]) == (0, 2) + assert (report["붙임"], report["지움"]) == (0, 4) # 운반 줄도 지움 assert bill["totals"]["direct"]["sum"] == 710359136 made, _, _ = _load(parsed["기번3"], INPUTS) made["bill"]["rows"].append({"key": "hand:mine", "parent": "hand:xabcd1234-00007", "name": "내 줄", "qty": 1, "ref": None}) # fmt: skip again, report, bill = _load(parsed["기번3"], INPUTS, user=made) keys = [e["key"] for e in again["bill"]["rows"]] - assert len(keys) == len(set(keys)) and report["붙임"] == 1 - assert bill["totals"]["direct"]["sum"] == 710359136 + 3 # 갈아 끼움 · 두 번 안 셈 + assert len(keys) == len(set(keys)) and report["붙임"] == 2 + assert bill["totals"]["direct"]["sum"] == 710359136 + 5 # 갈아 끼움 · 두 번 안 셈 mine = next(e for e in again["bill"]["rows"] if e["key"] == "hand:mine") assert "parent" not in mine # 내 줄 = 남음(맨 위)