From c93554d331da08a1ab293870b4d0238963f2a3d4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 20:21:15 +0900 Subject: [PATCH] =?UTF-8?q?fix(B09=20=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0)?= =?UTF-8?q?:=20=EC=BD=94=EB=93=9C=EB=A1=9C=EB=A7=8C=20=EA=B0=80=EB=A6=AC?= =?UTF-8?q?=ED=82=A8=20=EB=82=B4=EC=97=AD=20=EC=A4=84=EB=8F=84=20=EC=9D=BC?= =?UTF-8?q?=EC=9C=84=EB=8C=80=EA=B0=80=EC=97=90=20=EC=9D=B4=EC=9D=8C=20?= =?UTF-8?q?=C2=B7=20=EB=81=9D=EC=88=98=20=EC=A4=84=20=EA=BC=AC=EB=A6=AC?= =?UTF-8?q?=ED=91=9C=20=C2=B7=20=EC=9A=94=EC=9C=A8=20=EC=97=86=EC=9D=8C=20?= =?UTF-8?q?=EC=B4=9D=EA=B3=B5=EC=82=AC=EB=B9=84=20=EC=88=A8=EA=B9=80=20(70?= =?UTF-8?q?7-7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 영월 꼴(STmate 엑셀 내역 줄 = 호표 코드만) = 일위대가표 같은 코드 호표 번 채움 → 서고 사본 21 · 이음 34 - 이은 줄 차가 비목마다 1 원/단위 안 = 「끝수」(STmate 하위 호표 반올림) · 끝 알림 한 줄 「엑셀 합계와 차 n 원 = 하위 호표 반올림 끝수 m 줄」(브레인 정함 = 끝수 줄도 이음) - 요율 못 읽은 공사(.estx · .STC) = 총공사비 칸 안 보임 - 시험 test_707_7 11(영월 끝수 · 일위대가 시트 없음 까닭) Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Bill_Hopyo.py | 15 ++++++++ B09_Estimation/B09_Estimation_Bill_Import.py | 6 ++- .../B09_Estimation_Router_Import.py | 14 ++++--- B09_Estimation/B09_Estimation_UI_Import.ts | 17 ++++++++- resources/tester/test_707_7_whole_import.py | 37 +++++++++++++++---- 5 files changed, 71 insertions(+), 18 deletions(-) diff --git a/B09_Estimation/B09_Estimation_Bill_Hopyo.py b/B09_Estimation/B09_Estimation_Bill_Hopyo.py index 6555be977..66fda419b 100644 --- a/B09_Estimation/B09_Estimation_Bill_Hopyo.py +++ b/B09_Estimation/B09_Estimation_Bill_Hopyo.py @@ -45,6 +45,21 @@ def read(name: str, data: bytes) -> dict[str, Any]: _PARTS = {"labor": "노무비", "material": "재료비", "expense": "경비"} +def numbered(bill: dict[str, Any], book: dict[str, Any]) -> dict[str, Any]: + """번 없이 코드로만 가리킨 대가 줄(STmate 엑셀 영월 꼴) → + 일위대가표 같은 코드 호표 번을 채운 사본.""" + by_code = {h.get("코드"): int(h["번"]) for h in book.get("호표") or [] if h.get("코드")} + + def fill(line: dict[str, Any]) -> dict[str, Any]: + hopyo = line.get("호표") or {} + no = by_code.get(hopyo.get("코드")) + if hopyo.get("종류") != "대가" or "번" in hopyo or no is None: + return line + return {**line, "호표": {**hopyo, "번": no}} + + return {**bill, "줄": [fill(line) for line in bill["줄"]]} + + def agree(bill: dict[str, Any], book: dict[str, Any]) -> tuple[set[int], list[dict[str, Any]]]: """내역 줄 대가 번 가운데 내역 단가 = 일위대가표 호표 금액(비목마다 1 원 안)인 번만 이음 · 다른 줄 = 내역 단가 그대로(경쟁사 금액 재현) → diff --git a/B09_Estimation/B09_Estimation_Bill_Import.py b/B09_Estimation/B09_Estimation_Bill_Import.py index 077c3fb62..9a31f916f 100644 --- a/B09_Estimation/B09_Estimation_Bill_Import.py +++ b/B09_Estimation/B09_Estimation_Bill_Import.py @@ -204,7 +204,8 @@ def apply( def differences( parsed: dict[str, Any], report: dict[str, Any], rows: list[dict[str, Any]] ) -> list[dict[str, Any]]: - """불러온 직후 엑셀 금액 ≠ 우리 금액인 줄 [{행, 이름, 엑셀, 우리, 까닭}].""" + """불러온 직후 엑셀 금액 ≠ 우리 금액인 줄 [{행, 이름, 엑셀, 우리, 까닭}] · + 이은 줄 차가 비목마다 1 원/단위 안 = 「끝수」(STmate 하위 호표 반올림 · 707-7).""" by_key = {r["key"]: r for r in rows} out = [] for line in parsed["줄"]: @@ -219,7 +220,8 @@ def differences( elif row.get("qty") != line["수량"]: why = "수량 = B08(단가만)" elif not row.get("fixed"): - why = "이음 단가 = 우리 셈" + near = abs(ours - excel) <= len(PARTS) * abs(line["수량"] or 0) + 1 + why = "끝수" if near else "이음 단가 = 우리 셈" else: why = "끝수" out.append( diff --git a/B09_Estimation/B09_Estimation_Router_Import.py b/B09_Estimation/B09_Estimation_Router_Import.py index e9e07762c..1bce2e96e 100644 --- a/B09_Estimation/B09_Estimation_Router_Import.py +++ b/B09_Estimation/B09_Estimation_Router_Import.py @@ -168,22 +168,24 @@ def _linked(root: Path, folders: dict[int, str]) -> dict[int, dict[str, Any]]: def _own_links( root: Path, name: str, data: bytes, parsed: dict[str, Any], tag: str, who: Any -) -> tuple[dict[int, dict[str, Any]], dict[str, Any]]: +) -> tuple[dict[int, dict[str, Any]], dict[str, Any], dict[str, Any]]: """707-7 — 파일 일위대가 · 단가산출 → 이 공사 서고 사본(층 쓰기 0) → - (줄 연결, 단계 수 · 까닭).""" + (줄 연결, 단계 수 · 까닭, 내역 — 코드로만 가리킨 줄에 호표 번 채움).""" if _raw(name): - return {}, {"까닭": f"{Path(name).suffix} 구성 줄은 못 읽음 — 같은 공사 엑셀에서"} + return {}, {"까닭": f"{Path(name).suffix} 구성 줄은 못 읽음 — 같은 공사 엑셀에서"}, parsed try: book = bill_hopyo.read(name, data) except ExcelError as error: - return {}, {"까닭": str(error)} + return {}, {"까닭": str(error)}, parsed built = bill_hopyo.build(book, name, tag, who) + parsed = bill_hopyo.numbered(parsed, book) numbers, other = bill_hopyo.agree(parsed, book) folders = { no: got for no in sorted(numbers) if (got := bill_hopyo.copy(root, built, tag, no, who)) } links = _linked(root, folders) - return links, {**built["수"], "사본": len(links), "실패": built["실패"], "단가다름": other} + step = {**built["수"], "사본": len(links), "실패": built["실패"], "단가다름": other} + return links, step, parsed def _numbers(parsed: dict[str, Any]) -> set[int]: @@ -258,7 +260,7 @@ async def apply_bill( steps: dict[str, Any] = {} if body.hopyo: args = (root, path.name, data, parsed, body.file_key, who_of(session)) - links, steps["일위대가"] = await asyncio.to_thread(_own_links, *args) + links, steps["일위대가"], parsed = await asyncio.to_thread(_own_links, *args) else: found = await asyncio.to_thread(_found, session, path.name, _numbers(parsed)) links = await asyncio.to_thread(_links, root, found, who_of(session)) diff --git a/B09_Estimation/B09_Estimation_UI_Import.ts b/B09_Estimation/B09_Estimation_UI_Import.ts index a9abf99aa..7a93ac418 100644 --- a/B09_Estimation/B09_Estimation_UI_Import.ts +++ b/B09_Estimation/B09_Estimation_UI_Import.ts @@ -120,9 +120,15 @@ function report(got: Row, user: () => Row, done: (payload: Row, user: Row) => vo .map(([k, name]) => `${name} 이음 ${s[k]?.linked ?? 0}`) .join(" · "), ), - `${pair("직접비", sum.엑셀직접, sum.우리직접)} · ${pair("총공사비", sum.엑셀총, sum.우리총)}`, + pair("직접비", sum.엑셀직접, sum.우리직접) + + (sum.우리총 == null ? "" : ` · ${pair("총공사비", sum.엑셀총, sum.우리총)}`), // 요율 없음 = 셈 없음 ].filter((t): t is string => !!t); const off: Row[] = got.어긋남 || []; + const tail = off.filter((x) => x.까닭 === "끝수").length; // 이은 줄 1 원/단위 안 = 하위 호표 반올림 + if (tail) { + const gap = Math.abs(Number(sum.엑셀직접 || 0) - Number(sum.우리직접 || 0)); + lines.push(`엑셀 합계와 차 ${won(gap)} 원 = 하위 호표 반올림 끝수 ${tail} 줄(줄당 1 원/단위)`); + } const other: Row[] = hp?.단가다름 || []; const next = "다음 — 붙인 고정 줄은 펼친 일위대가에서 이 공사 단가로 다시 잇기 · 우리 공종 잇기는 줄 우클릭 · [저장]해야 남음"; @@ -168,7 +174,14 @@ function report(got: Row, user: () => Row, done: (payload: Row, user: Row) => vo ] : []), ...(off.length - ? [createText(`엑셀 금액과 다른 줄 ${off.length}`, "warning", 14), differences(off)] + ? [ + createText( + `엑셀 금액과 다른 줄 ${off.length}${tail ? `(끝수 ${tail})` : ""}`, + "warning", + 14, + ), + differences(off), + ] : []), createText(next, "muted", 14), ); diff --git a/resources/tester/test_707_7_whole_import.py b/resources/tester/test_707_7_whole_import.py index 93eee2469..5c6ee1442 100644 --- a/resources/tester/test_707_7_whole_import.py +++ b/resources/tester/test_707_7_whole_import.py @@ -31,12 +31,14 @@ FILES = { "장수": "02.산불 장수/01.내역서/00.단가변경_26년_산불_무주(장수,장수,덕산,산54-1).estx", "기번3": "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/2024년 간선임도사업(기번3).STC", # noqa: E501 } # fmt: skip -#: (호표, 줄, 고정, 하위, 이음, 사본, 내역 이음, 엑셀 직접비) +#: (호표, 줄, 고정, 하위, 이음, 사본, 내역 이음, 엑셀 직접비, 끝수 줄 · 차) — +#: 영월 = 내역 줄이 코드로만 가리킴(번 채움) · STmate 하위 호표 반올림 끝수 28 줄 +#: (줄당 1 원/단위 · 브레인 정함 = 그래도 이음) WANT = { - "거창": (14, 68, 56, 12, 11, 8, 8, 384916227), - "대흥": (34, 171, 143, 28, 0, 26, 45, 710359136), - "소광": (25, 117, 98, 19, 0, 0, 0, 173311386), - "영월": (23, 138, 135, 3, 0, 0, 0, 767148700), + "거창": (14, 68, 56, 12, 11, 8, 8, 384916227, 0, 0), + "대흥": (34, 171, 143, 28, 0, 26, 45, 710359136, 0, 0), + "소광": (25, 117, 98, 19, 0, 0, 0, 173311386, 0, 0), + "영월": (23, 138, 135, 3, 0, 21, 34, 767148700, 28, 2951), } URL = "/api/projects/8b98ca75-c572-4002-8468-2adae4323414/estimation/import/bill" ALL = {"hopyo": True, "rates": True, "prices": True} @@ -98,14 +100,14 @@ def _libs(root): @pytest.mark.parametrize("name", list(WANT)) def test_실무_엑셀_통째_빠짐_0_합계_같음_층_쓰기_0(world, name): got = world["run"](name) - hopyo, lines, fixed, kids, links, copies, billed, direct = WANT[name] + hopyo, lines, fixed, kids, links, copies, billed, direct, tail, gap = WANT[name] step = got["단계"]["일위대가"] assert (step["호표"], step["줄"], step["고정"], step["호표참조"], step["이음"]) == ( hopyo, lines, fixed, kids, links) # fmt: skip assert step["빠짐"] == 0 and step["실패"] == [] and step["사본"] == copies assert got["알림"]["이음"] == billed - assert got["합계"]["엑셀직접"] == direct and got["합계"]["우리직접"] == direct - assert got["어긋남"] == [] + assert got["합계"]["엑셀직접"] == direct and got["합계"]["우리직접"] == direct - gap + assert [x["까닭"] for x in got["어긋남"]] == ["끝수"] * tail libs = _libs(got["root"]) assert len(libs) == copies assert all(re.fullmatch(rf"precombo@project@-@{got['file_key']}-\d+", x) for x in libs) @@ -185,3 +187,22 @@ def test_내_자료집에_저장_하위_호표_같이_한_번(world, monkeypatch assert len(copied) == len({json.dumps(x, sort_keys=True) for x in copied}) # 같은 하위 한 번 for p in (folder / "precombo").glob("*.json"): # 다음 시험에 층 쓰기 안 남김 p.unlink() + + +def test_일위대가_시트_없음_까닭_나머지_그대로(tmp_path): + import io + + import openpyxl + + from B09_Estimation import B09_Estimation_Router_Import as ri + from B09_Estimation.B09_Estimation_Bill_Excel import read_bill + + path = K / FILES["거창"] + book = openpyxl.load_workbook(path) + del book["일위대가"] + out = io.BytesIO() + book.save(out) + data = out.getvalue() + parsed = read_bill(path.name, data) + links, step, same = ri._own_links(tmp_path, path.name, data, parsed, "abcd1234", None) + assert links == {} and "일위대가 시트 없음" in step["까닭"] and same is parsed