From 5dd72cfa38a69dfed240e04b4d2386b10b454f7a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 19:26:40 +0900 Subject: [PATCH] =?UTF-8?q?feat(b09):=20=EC=82=B0=EA=B7=BC=20=ED=85=8D?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=8B=9D=20=EC=B9=B8=20=C2=B7=20?= =?UTF-8?q?=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=ED=99=94=20=EA=B8=80=20?= =?UTF-8?q?=C2=B7=20%=20=EB=8D=A7=EC=A4=84=20=ED=91=9C=EA=B8=B0(155-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 산근 블록마다 서버가 글 지음(sub4 render_sangeun_text · B09 굳힌 단가) — head · 줄마다 text · foot(계) - 단가산출근거 탭 = 엑셀 꼴(공종 · 산출내역 · 합계 · 노무비 · 재료비 · 경비 · 비고) · 제목 · 총계 굵게 - 중기 = 노 · 재 · 경 세 줄(서버 글 줄 수와 같게) - % 덧줄 = 규격 「노무비의 %」 · 수량 = 율 · 단위 %(sub3 pct_extras) - 산근목록 안내 글 = 단가산출 줄 · 사전조합 안 줄 · 일위대가화 줄의 하위 로직(「단가승격」 글 지움) - 시험: 145-1 화면 글 대조 새 갈래 · test_155_b09_sources · test_155_b09_tabs 덧붙임 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X --- B09_Estimation/B09_Estimation_Sangeun.py | 53 ++++++++++- .../B09_Estimation_UI_Tabs_Sangeun.ts | 90 ++++++++++--------- resources/tester/test_145_1_b09_sangeun.py | 3 +- resources/tester/test_155_b09_sources.py | 24 +++++ resources/tester/test_155_b09_tabs.py | 46 +++++++++- 5 files changed, 168 insertions(+), 48 deletions(-) diff --git a/B09_Estimation/B09_Estimation_Sangeun.py b/B09_Estimation/B09_Estimation_Sangeun.py index 322692ab3..4be914331 100644 --- a/B09_Estimation/B09_Estimation_Sangeun.py +++ b/B09_Estimation/B09_Estimation_Sangeun.py @@ -20,6 +20,9 @@ + 「연결 없음」. 조합 밖 토공 · 운반 · 사토 산근 · 준비공 = 원천 없음(만들지 않음 · 목록 안내 한 줄). 대가(145-2) · 내역서(145-3)가 읽는 길 = `sangeun_key` + `price_of`. +텍스트 수식(155-5) = `B09_Estimation_Sangeun_Text`(같은 B09 단가) — `head`(머리 · 입력 · 중간) · + 줄 `text`(줄마다 · 중기 = 노 · 재 · 경 세 줄) · `foot`(계) · % 덧줄 = 규격 「노무비의 %」 · + 수량 = 율 · 단위 %(149-4 `pct_extras`). """ from __future__ import annotations @@ -35,6 +38,7 @@ from B09_Estimation.B09_Estimation_Calc_Base import gap, num, round_half, roundd from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store_Combo as combo_store from M02_MasterTemplete import M02_Item_Prices as item_prices +from M02_MasterTemplete import M02_Labor_Doc as labor_doc from M02_MasterTemplete.M02_Item_Snapshot import ( PICKS_FILE, VALUES_FILE, @@ -297,6 +301,7 @@ class Solver: return self._stop(code, name, str(e) or type(e).__name__) sums = block_totals(lines) why = next((ln["why"] for ln in lines if ln.get("why")), None) + head, foot = self._text(whole, src, times, lines) return { "logic": code, "name": name, @@ -306,12 +311,47 @@ class Solver: "lines": lines, **sums, "why": why, + "head": head, + "foot": foot, } @staticmethod def _stop(code: str, name: str, why: str) -> dict[str, Any]: return {"logic": code, "name": name, "spec": "", "unit": "", "source": "", "lines": [], - **dict.fromkeys(PARTS), "sum": None, "why": why} # fmt: skip + **dict.fromkeys(PARTS), "sum": None, "why": why, + "head": [], "foot": ""} # fmt: skip + + def price_of(self, key: str) -> Any: + """글 짓기 단가(`Sangeun_Text` 꼴) — 노무 · 자재 = 수 · 중기 · 산근 = 비목 단가 묶음.""" + if key in self.prices.labor: + return self.prices.labor[key] + if key in self.prices.material: + return self.prices.material[key] + if key in self.prices.machine: + return {p: num(self.prices.machine[key].get(p)) for p in PARTS} + block = self.blocks.get(key) + return {k: block.get(k) for k in (*PARTS, "sum", "no")} if block else None + + def _text(self, whole, src, times, lines) -> tuple[list[str], str]: + """텍스트 수식(155-5) → (머리 줄들, 계) · 줄마다 `text` 를 얹음(중기 = 세 줄 · 그 밖 한 줄). + 줄 수가 안 맞으면 모두 머리에(값 칸과 어긋나게 얹지 않음).""" + from B09_Estimation.B09_Estimation_Sangeun_Text import render_sangeun_text + + try: + text = render_sangeun_text(whole, src["code"], src["given"], self.price_of, + times=times, rate=src.get("rate"), + picks=src.get("picks")) # fmt: skip + except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError): + return [], "" + counts = [3 if ln["kind"] == "machine" and not ln.get("why") else 1 for ln in lines] + start = len(text) - 1 - sum(counts) + if start < 1: + return text, "" + at = start + for ln, n in zip(lines, counts): + ln["text"] = text[at : at + n] + at += n + return text[:start], text[-1] def _blank(self, item: dict, why: str) -> dict[str, Any]: part = COST_PART.get(item.get("비목"), "material") @@ -382,12 +422,19 @@ class Solver: "price": price, **{p: _money(price[p], q) for p in PARTS}, "why": why} # fmt: skip def _extras(self, whole, row, env, lines) -> None: - """% 줄(덧줄) — 엔진 식 그대로 · 밑수 = 그때까지 B09 비목 합 · 줄 금액 = 이름별.""" - for extra in row.get("덧줄", []): + """% 줄(덧줄) — 엔진 식 그대로 · 밑수 = 그때까지 B09 비목 합 · 줄 금액 = 이름별 · + 「X * Y / 100」 덧줄 = 규격 「노무비의 %」 · 수량 = 율 · 단위 %(149-4 `pct_extras`).""" + heads = [{} for _ in row.get("호표", [])] + pcts = labor_doc.pct_extras( + whole, row, heads + [{} for _ in row.get("덧줄", [])], dict(env) + ) + for extra, pct in zip(row.get("덧줄", []), pcts[len(heads) :]): sums = {name: total([ln[p] for ln in lines]) for name, p in COST_PART.items()} part = COST_PART.get(extra.get("비목"), "material") line = {"kind": "extra", "name": extra["이름"], "spec": extra["식"], "qty": 1.0, "unit": "식", "ref": None, **dict.fromkeys(PARTS, 0.0)} # fmt: skip + if "율" in pct: + line.update(spec=pct.get("규격") or extra["식"], qty=float(pct["율"]), unit="%") if any(v is None for v in sums.values()): lines.append({**line, part: None, "price": {}, "why": "밑수 빈칸"}) continue diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts index 57d6d8848..2b269a626 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts @@ -4,8 +4,10 @@ * (서버 단독 · 이 탭엔 조작 없음 · [저장] · [단가 갱신] 때 서버가 다시 셈). * * 목록표 = 산근 호표 한 줄(합계 · 노무비 · 재료비 · 경비 · 1 단위) · 줄 누름 = 그 블록 - * 풀이표 = 블록 머리(제 n 호표) · 줄(노무 · 중기 노 · 재 · 경 · 자재 · 하위 산근 · % 줄) · 총계 - * 줄 누름 = 단가를 낸 탭 · 줄(노무비목록표 · 중기목록표 · 자재단가대비표 · 하위 산근) + * 풀이표(155-5 · 엑셀 단가산출근거 꼴) = 공종 · 산출내역(서버가 지은 텍스트 수식 한 칸) · 합계 · + * 노무비 · 재료비 · 경비 — 머리(제 n 호표 · 출처 · 입력 · 중간 Q) → 줄(중기 = 노 · 재 · 경 + * 세 줄) → 총계(계) · 줄 누름 = 단가를 낸 탭 · 줄(노무비목록표 · 중기목록표 · 자재단가대비표 · + * 하위 산근) * ========================================================================== */ import type { DataRow } from "@ui/ui_template_data_table"; @@ -50,7 +52,7 @@ export const buildSgList: TabBuild = ({ sheets }): TabTable => { rows, frozen: 2, hint: - "산근 = 연결된 구조물 조합 사본의 「단가승격」 로직 줄 · 같은 로직 + 같은 입력값 = 한 호표 · 합계 = 블록 총계 · 줄 누름 = 그 블록" + + "산근 = 연결된 구조물 조합 사본의 단가산출 로직 줄 · 사전조합 안 줄 · 일위대가화 줄의 하위 로직(노무 · 자재 · 중기 줄은 각 목록표) · 같은 로직 + 같은 입력값 = 한 호표 · 합계 = 블록 총계 · 줄 누름 = 그 블록" + notes(sheets), link: (i) => (rows[i] ? { tab: "sg", key: String(rows[i].key) } : null), }; @@ -63,79 +65,85 @@ const LINE_TAB: Record = { sg: "sg", }; +/** 공종 칸(엑셀 A 열) — 줄 꼴 이름 · 하위 산근 = 「산근 n호표」 */ +const KIND_NAME: Record = { labor: "노무", machine: "중기", material: "자재" }; + export const buildSg: TabBuild = ({ sheets }): TabTable | string => { const rows: DataRow[] = []; const links: Array = []; - const push = (row: DataRow, link: Link | null = null): void => { + const titles = new Set(); + const push = (row: DataRow, link: Link | null = null, title = false): void => { + if (title) titles.add(rows.length); rows.push(row); links.push(link); }; - for (const b of (sheets.sg as Row[]) || []) { - push({ - key: b.key, - name: `제 ${b.no} 호표 ${b.name}`, - spec: b.spec, - unit: b.unit, - sum: b.sum, - labor: b.labor, - material: b.material, - expense: b.expense, - }); - if (b.source) push({ key: `${b.key}|출처`, name: ` ${b.source}` }); + const blocks = (sheets.sg as Row[]) || []; + const noOf: Record = Object.fromEntries(blocks.map((b) => [b.key, b.no])); + for (const b of blocks) { + const head: string[] = b.head?.length ? b.head : [`${b.name} / ${b.unit}`]; + push({ key: b.key, kind: `제 ${b.no} 호표`, text: head[0], note: b.spec }, null, true); + head.slice(1).forEach((line, n) => push({ key: `${b.key}|머리${n}`, text: ` ${line}` })); (b.lines as Row[]).forEach((ln, n) => { const link = ln.ref?.key ? { tab: LINE_TAB[ln.ref.tab] ?? "compare", key: ln.ref.key } : null; - // 중기 = 노 · 재 · 경 세 줄(엑셀 꼴) · 그 밖 = 한 줄 - const parts = ln.kind === "machine" ? PARTS.filter((p) => ln.price?.[p] !== 0) : [null]; - for (const part of parts) { + const kind = + ln.kind === "sangeun" && noOf[ln.ref?.key] + ? `산근 ${noOf[ln.ref.key]}호표` + : (KIND_NAME[ln.kind] ?? ""); + // 중기 = 노 · 재 · 경 세 줄(엑셀 꼴 · 서버 글 줄과 같은 수) · 그 밖 = 한 줄 + const parts = ln.kind === "machine" && !ln.why ? PARTS : [null]; + const texts: string[] = ln.text || []; + parts.forEach((part, k) => { const at = (p: string): number | null => (part === null || part === p ? ln[p] : 0); - const price = part ? ln.price?.[part] : (Object.values(ln.price || {})[0] ?? null); + const fallback = `${ln.name}${part ? ` ${PART_NAME[part]}` : ""} · ${ln.qty ?? ""} ${ln.unit ?? ""}`; push( { key: `${b.key}|${n}${part ? `|${part}` : ""}`, - name: ` ${ln.name}${part ? ` ${PART_NAME[part]}` : ""}`, - spec: ln.why ? `${ln.spec ? `${ln.spec} · ` : ""}${ln.why}` : ln.spec, - qty: ln.qty, - unit: ln.unit, - price: price as number | null, + kind: k === 0 ? kind : "", + text: ` ${texts[k] ?? fallback}`, sum: PARTS.some((p) => at(p) === null) ? null : PARTS.reduce((s, p) => s + (at(p) as number), 0), labor: at("labor"), material: at("material"), expense: at("expense"), + note: ln.why ?? "", }, link, ); - } - }); - push({ - key: `${b.key}|총계`, - name: " 총 계", - spec: b.why ?? "", - sum: b.sum, - labor: b.labor, - material: b.material, - expense: b.expense, + }); }); + push( + { + key: `${b.key}|총계`, + kind: "총 계", + text: b.foot || "", + sum: b.sum, + labor: b.labor, + material: b.material, + expense: b.expense, + note: b.why ?? "", + }, + null, + true, + ); } if (!rows.length) return ((sheets.sg_notes as string[]) || []).join(" · ") || "산근 없음"; return { columns: [ - text("name", "공종 · 산출내역", 260), - text("spec", "규격 · 까닭", 180), - qty("qty", "수량"), - text("unit", "단위", 56), - money("price", "단가", undefined, 1), + text("kind", "공종", 90), + text("text", "산출내역", 520), money("sum", "합계", undefined, 1), money("labor", "노무비", undefined, 1), money("material", "재료비", undefined, 1), money("expense", "경비", undefined, 1), + text("note", "비고 · 까닭", 180), ], rows, frozen: 1, hint: - "수량 = 로직 품(1 단위 · 작업량 Q = ROUND 2) · 단가 = 굳힌 B09 단가 · 줄 ROUNDDOWN 1 · 총계 비목마다 ROUNDDOWN 0 · 자재 적용 단가를 고치면 [저장] 뒤 반영 · 줄 누름 = 단가를 낸 탭" + + "산출내역 = 값이 든 식(B09 굳힌 단가 * 수량 = 금액 · 작업량 Q = ROUND 2) · 줄 ROUNDDOWN 1 · 총계 비목마다 ROUNDDOWN 0 · 자재 적용 단가를 고치면 [저장] 뒤 반영 · 줄 누름 = 단가를 낸 탭" + notes(sheets), link: (i: number) => links[i] ?? null, + rowClass: (i: number) => (titles.has(i) ? "is-title" : null), }; }; diff --git a/resources/tester/test_145_1_b09_sangeun.py b/resources/tester/test_145_1_b09_sangeun.py index 9871698d2..3c1af96ab 100644 --- a/resources/tester/test_145_1_b09_sangeun.py +++ b/resources/tester/test_145_1_b09_sangeun.py @@ -183,4 +183,5 @@ def test_화면_탭_둘_서버_산근_그대로(): assert "gaps: [...base.gaps, ...(server.sg_gaps || [])" in shell # 뒤에 대가(145-2) gaps 이음 tabs = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Sangeun.ts").read_text("utf-8") assert 'link: (i) => (rows[i] ? { tab: "sg", key: String(rows[i].key) } : null),' in tabs - assert "PARTS.filter((p) => ln.price?.[p] !== 0)" in tabs # 중기 = 노 · 재 · 경 세 줄 + # 155-5 — 중기 = 노 · 재 · 경 세 줄(서버 글 줄 수와 같게 · 값 0 줄도 둠) + assert 'const parts = ln.kind === "machine" && !ln.why ? PARTS : [null];' in tabs diff --git a/resources/tester/test_155_b09_sources.py b/resources/tester/test_155_b09_sources.py index 9609cce6e..449d9c637 100644 --- a/resources/tester/test_155_b09_sources.py +++ b/resources/tester/test_155_b09_sources.py @@ -107,3 +107,27 @@ def test_155_3_비고_산근_n호표_거울(): ln.get("ref_no") for b in py["daega_blocks"] for ln in b["lines"] if ln["kind"] == "sangeun" ] assert notes and notes[0] == "산근 1호표" and all(n and n.startswith("산근 ") for n in notes) + + +def test_155_5_산근_글_줄마다_덧줄_퍼센트_표기(tmp_path): + lines = [{"로직": "GF000143", "수량": {"손": 1}}, {"로직": PILE, "수량": {"손": 20}}] + root = t145._project(tmp_path / "p", [(100, "SS000901")]) + lines = [{"로직": "GF000143", "수량": {"손": 1}}, {"로직": PILE, "수량": {"손": 20}}] + t145._snap(root, "pipe@100.0", "SS000901", lines, {}) + inputs, sheets = _sheets_of(root) + crane, pile = sheets["sg"] + assert crane["head"][:2] == ["케이블 크레인 운반대 설치 / 1기", "산림품셈 10-8-2 적용"] + extra = crane["lines"][-1] + assert (extra["kind"], extra["spec"], extra["qty"], extra["unit"]) == ( + "extra", "재료비의 %", 20.0, "%", + ) # fmt: skip + assert extra["text"][0].startswith("기타비용 (재료비의 %) : ") + for b in (crane, pile): + for ln in b["lines"]: + money = sum(ln[p] or 0 for p in sg.PARTS) + assert ln["text"][-1].endswith(f"= {money:,.1f}") or ln["kind"] == "machine" + assert b["foot"] == ( + f"계 : 노무비 {b['labor']:,.0f} + 재료비 {b['material']:,.0f}" + f" + 경비 {b['expense']:,.0f} = {b['sum']:,.0f}" + ) + assert " / 10 = " in pile["lines"][0]["text"][0] # 배 10 로직 꼬리 diff --git a/resources/tester/test_155_b09_tabs.py b/resources/tester/test_155_b09_tabs.py index 8ca2bd81b..4f58a2581 100644 --- a/resources/tester/test_155_b09_tabs.py +++ b/resources/tester/test_155_b09_tabs.py @@ -1,4 +1,4 @@ -"""155 B09 탭 화면 꼴 — 일위대가표 줄 꼴(155-2). +"""155 B09 탭 화면 꼴 — 일위대가표 줄 꼴(155-2) · 단가산출근거 텍스트 수식 칸(155-5). `B09_Estimation_UI_Tabs.ts` + `B09_Estimation_UI_Tabs_Labor.ts` 를 Node(타입 지움)로 돌려 견본 블록 하나(머리 · 품셈 인용 · 줄 둘 · 합계)의 표 줄 글 · 줄 꼴을 판정. @@ -34,10 +34,10 @@ BLOCK = { } # fmt: skip -def _node(js_body): +def _node(js_body, tab="UI_Tabs_Labor"): src = "".join( IMPORTS.sub("", (ROOT / "B09_Estimation" / f"B09_Estimation_{n}.ts").read_text("utf-8")) - for n in ("UI_Tabs", "UI_Tabs_Labor") + for n in ("UI_Tabs", tab) ) result = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", @@ -62,3 +62,43 @@ def test_155_2_일위대가표_제목_굵게_하위_ㄴ(): " 합 계", ] assert out["cls"] == ["is-title", None, None, None, "is-title"] + + +SG = { + "key": "SG:A", "no": 1, "name": "뒷채움", "unit": "m3", "spec": "규격=150", + "head": ["뒷채움 / m3", "산림품셈 13-3-2 적용", "규격 : 150 mm"], + "lines": [ + {"kind": "labor", "name": "보통인부", "ref": {"tab": "labor", "key": "LB2"}, "why": None, + "labor": 2979.8, "material": 0.0, "expense": 0.0, + "text": ["보통인부 : 165,545 * 0.018 = 2,979.8"]}, + {"kind": "machine", "name": "굴착기", "ref": {"tab": "machine", "key": "EQ1"}, "why": None, + "labor": 3899.0, "material": 538.6, "expense": 912.8, + "text": ["굴착기 노무비 : 55,700 * 0.07 = 3,899.0", "굴착기 재료비 : 7,695 * 0.07 = 538.6", + "굴착기 경비 : 13,041 * 0.07 = 912.8"]}, + ], + "labor": 6878, "material": 538, "expense": 912, "sum": 8328, "why": None, + "foot": "계 : 노무비 6,878 + 재료비 538 + 경비 912 = 8,328", +} # fmt: skip + + +def test_155_5_단가산출근거_엑셀_꼴_칸(): + out = _node( + f"\nconst t = buildSg({{ sheets: {{ sg: [{json.dumps(SG, ensure_ascii=False)}] }} }});" + "\nconsole.log(JSON.stringify({ rows: t.rows, cols: t.columns.map((c) => c.key)," + " cls: t.rows.map((_, i) => t.rowClass(i) ?? null) }));", + "UI_Tabs_Sangeun", + ) + assert out["cols"] == ["kind", "text", "sum", "labor", "material", "expense", "note"] + rows = out["rows"] + assert [(r.get("kind"), r["text"]) for r in rows] == [ + ("제 1 호표", "뒷채움 / m3"), + (None, " 산림품셈 13-3-2 적용"), + (None, " 규격 : 150 mm"), + ("노무", " 보통인부 : 165,545 * 0.018 = 2,979.8"), + ("중기", " 굴착기 노무비 : 55,700 * 0.07 = 3,899.0"), + ("", " 굴착기 재료비 : 7,695 * 0.07 = 538.6"), + ("", " 굴착기 경비 : 13,041 * 0.07 = 912.8"), + ("총 계", "계 : 노무비 6,878 + 재료비 538 + 경비 912 = 8,328"), + ] + assert [r.get("sum") for r in rows[3:7]] == [2979.8, 3899.0, 538.6, 912.8] + assert out["cls"] == ["is-title"] + [None] * 6 + ["is-title"]