From 82a3cb5e7d85f21fb61509df2d0ff23eed0b7803 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 19:20:39 +0900 Subject: [PATCH] =?UTF-8?q?feat(b09):=20=ED=98=B8=ED=91=9C=20=EA=B0=88?= =?UTF-8?q?=EB=9E=98=20=EB=92=A4=EC=A7=91=EA=B8=B0=20=E2=80=94=20=EC=9D=BC?= =?UTF-8?q?=EC=9C=84=EB=8C=80=EA=B0=80=ED=99=94=20=EC=A4=84=20=3D=20?= =?UTF-8?q?=EB=8C=80=EA=B0=80=20=C2=B7=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EC=A4=84=20=3D=20=EC=82=B0=EA=B7=BC(155-3=20=C2=B7?= =?UTF-8?q?=20155-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 일위대가목록 = 구조물 조합 + 사전조합 + 일위대가화(승격 · 옛 SP) 줄 · 단가산출 줄 = 산근 줄 - 산근 줄 수량 = 수량 그대로(배 ≠ 1 로직 금액 1/배 흠) · 대가 줄만 ÷ 배 - 품셈 블록 로직 줄 판정 = 산근 풀이와 같은 call_of(기계경비 = 중기 · 그 밖 = 하위 산근) - 비고 「산근 n호표」(Calc_Labor 파이썬 · TS 짝) · 산근 줄 누름 = 단가산출근거 탭 - 산근목록 원천 = 단가산출 줄 + 사전조합 안 줄 + 일위대가화 줄 하위 로직(노무 · 자재 · 중기 줄 제외) - 산근 자재 자리표시 키(MO:경유:…) = 자재단가대비표 키로 - 시험: 145-1 다섯 · 145-2 셋 새 갈래로(머리에 근거 한 줄) · 새 test_155_b09_sources Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X --- B09_Estimation/B09_Estimation_Calc_Labor.py | 4 + B09_Estimation/B09_Estimation_Calc_Labor.ts | 5 + B09_Estimation/B09_Estimation_Labor_Source.py | 75 +++++---- B09_Estimation/B09_Estimation_Sangeun.py | 157 +++++++++++++----- .../B09_Estimation_UI_Tabs_Labor.ts | 2 +- resources/tester/test_145_1_b09_sangeun.py | 37 +++-- resources/tester/test_145_2_b09_labor_calc.py | 58 ++++--- resources/tester/test_155_b09_sources.py | 109 ++++++++++++ 8 files changed, 328 insertions(+), 119 deletions(-) create mode 100644 resources/tester/test_155_b09_sources.py diff --git a/B09_Estimation/B09_Estimation_Calc_Labor.py b/B09_Estimation/B09_Estimation_Calc_Labor.py index f5e4cf273..3b0d2c951 100644 --- a/B09_Estimation/B09_Estimation_Calc_Labor.py +++ b/B09_Estimation/B09_Estimation_Calc_Labor.py @@ -9,6 +9,7 @@ 노무 줄 = 노무비목록표 단가 · 재료 줄 = 재료비목록표 단가 · 중기 줄 = 중기목록표(노무 · 재료 · 경비) 대가 줄 = 부른 블록 합계(노무 · 재료 · 경비) · 산근 줄 = 145-1 산근 단가(`sangeun` {키: 값}) + 비고 = 대가 줄 「대가n」 · 산근 줄 「산근 n호표」(산근 목록 번호) % 줄 = 밑수(그 블록 노무 줄 금액 합) × % / 100 끝수 = 엑셀 자리 그대로 — 줄 금액 = ROUNDDOWN(단가 × 수량, 1) · % 줄 = ROUNDDOWN(밑수 × % / 100, 1) · 관급 · 별도계상 자재 = 대가 안 0원 · @@ -159,6 +160,9 @@ def compute_labor( for line in got["lines"]: if line["kind"] == "daega" and line.get("key") in no: line["ref_no"] = f"대가{no[line['key']]}" + sg_no = (books["sangeun"].get(line.get("key")) or {}).get("no") + if line["kind"] == "sangeun" and sg_no is not None: + line["ref_no"] = f"산근 {sg_no}호표" # 155-3 비고(엑셀 꼴) out_blocks.append(got) listing = [ { diff --git a/B09_Estimation/B09_Estimation_Calc_Labor.ts b/B09_Estimation/B09_Estimation_Calc_Labor.ts index 5a1044be5..48cf7835f 100644 --- a/B09_Estimation/B09_Estimation_Calc_Labor.ts +++ b/B09_Estimation/B09_Estimation_Calc_Labor.ts @@ -8,6 +8,7 @@ * * 노무 줄 = 노무비목록표 · 재료 줄 = 재료비목록표 · 중기 줄 = 중기목록표 · 대가 줄 = 부른 블록 합계 · * 산근 줄 = 145-1 산근 단가 · % 줄 = 밑수(노무 줄 금액 합) × % / 100 + * 비고 = 대가 줄 「대가n」 · 산근 줄 「산근 n호표」(산근 목록 번호) * 줄 금액 ROUNDDOWN(단가 × 수량, 1) · 블록 비목 합 ROUNDDOWN(Σ, 0) · 합계 = 세 비목 합 * 관급 · 별도계상 자재 = 대가 안 0원(자재대 · 관급 단락에서 셈) * 빈칸 = 원천이 없으면 null · 그 칸을 쓰는 합도 null · 「연결 없음」 목록(gaps) @@ -147,6 +148,10 @@ export function computeLabor(daega: Row | null | undefined, sheets: Row, sangeun const got: Row = { ...done[b.id], no: no[b.id] }; for (const line of got.lines) if (line.kind === "daega" && line.key in no) line.ref_no = `대가${no[line.key]}`; + for (const line of got.lines) { + const sgNo = (books.sangeun[line.key] || {}).no; + if (line.kind === "sangeun" && sgNo != null) line.ref_no = `산근 ${sgNo}호표`; // 155-3 비고 + } return got; }); const listing = outBlocks.map((b) => ({ diff --git a/B09_Estimation/B09_Estimation_Labor_Source.py b/B09_Estimation/B09_Estimation_Labor_Source.py index d79c9a1e6..72a9ad8ef 100644 --- a/B09_Estimation/B09_Estimation_Labor_Source.py +++ b/B09_Estimation/B09_Estimation_Labor_Source.py @@ -5,11 +5,14 @@ 조합 구조물 조합 사본 하나 = 블록(엑셀 「조합」 꼴) · 줄 = 담은 로직 줄 · 자재 줄 사전조합 참조 SC = 겹 대가(굳힌 `참조사본`) · 줄 = 그 안 로직 · 참조 줄(수량 = 손) - 품셈 승격 아닌 로직 줄 = 로직마다 따로(노무 · 재료 · 중기 · % 줄) — 조합이 수량만큼 부름 - 산근 승격 줄 · 옛 참조 SP = 산근 호표 참조(키 = 145-1 `sangeun_key` · 없으면 자리만) + 품셈 일위대가화(승격) 줄 · 옛 참조 SP = 로직마다 따로(노무 · 재료 · 중기 · 산근 · % 줄) + — 조합이 수량만큼 부름 · 기계 = 기계경비 로직 줄 · 다른 돈 로직 줄 = 하위 산근 + 산근 단가산출 줄(일위대가화 아닌 로직 줄) = 산근 호표 참조(키 = 145-1 `sangeun_key`) + (155-3 · 사용자 뜻 = 일위대가화 줄 = 대가 · 단가산출 줄 = 산근 · 145 정함 뒤집음) 같은 블록 = 같은 꼴 → 한 호표(id = 내용 해시 · 같은 원본 + 같은 셈 단가 = 하나) · -줄 수량 = 사본 셈 수량(손 · 면적 알약 칸) ÷ 배 = 부르는 호표 1 단위 몫. +줄 수량 = 사본 셈 수량(손 · 면적 알약 칸) — 대가 줄만 ÷ 배(부르는 호표 1 단위 몫 · +산근 단가는 이미 1 단위). 단가 · 금액 · 끝수는 셈 짝(`B09_Estimation_Calc_Labor`)이 B09 프로젝트 한 벌 단가로 냄. """ @@ -22,6 +25,7 @@ from decimal import Decimal from pathlib import Path from typing import Any +from B09_Estimation import B09_Estimation_Sangeun as sg from M02_MasterTemplete import M02_Item_Prices as item_prices from M02_MasterTemplete import M02_Labor_Doc as labor_doc from M02_MasterTemplete import M02_Labor_Ref as refs @@ -38,8 +42,6 @@ mf = item_prices.mf #: 엔진 비목 → 블록 비목 PART = {"노무비": "labor", "재료비": "material", "경비": "expense"} -#: 기계 로직 줄 요소 — `로직(GC…, 기계='0201-0070', …)` 의 원문번호 -MACHINE = re.compile(r"기계\s*=\s*['\"]([^'\"]+)['\"]") #: % 덧줄 밑수 — `노무비 * …` · `줄.'이름' * …` · `(석공 * LB… + …) * …`(인력 줄) BASE_PART = re.compile(r"^\s*(노무비|재료비|경비)\s*\*") BASE_LINE = re.compile(r"^\s*줄\.'([^']+)'\s*\*") @@ -62,12 +64,8 @@ def _digest(value: Any) -> str: def sangeun_ref(code: str, given: Any, rate: Any, picks: Any) -> str: - """산근 호표 키 — 145-1(`B09_Estimation_Sangeun.sangeun_key`)이 들어오면 그 키 · 전엔 자리만.""" - try: - from B09_Estimation.B09_Estimation_Sangeun import sangeun_key - except ImportError: - return f"SG:{code}:{_digest([given or {}, rate, picks or {}])}" - return sangeun_key(code, given or {}, rate, picks or {}) + """산근 호표 키 — 145-1 `B09_Estimation_Sangeun.sangeun_key`.""" + return sg.sangeun_key(code, given or {}, rate, picks or {}) class Builder: @@ -84,7 +82,7 @@ class Builder: return bid # ── 품셈(로직 하나) ───────────────────────────────────────────────── - def poomsem(self, whole: Any, one: dict[str, Any]) -> str | None: + def poomsem(self, whole: Any, one: dict[str, Any], title: str = "") -> str | None: code = str(one.get("로직") or "") given, rate, picks = one.get("입력값") or {}, one.get("켜쌓기율"), one.get("고름") try: @@ -98,15 +96,20 @@ class Builder: lines = [] engine = got.get("줄") or [] bases = _engine_bases(engine[: len(items)], items) + calls = sg.line_calls(whole, code, given, picks) for n, x in enumerate(engine): item = items[n] if n < len(items) else None - lines.append(self._engine_line(whole, item, x, logic, n - len(items), bases)) + lines.append(self._engine_line(item, x, logic, n - len(items), bases, calls.get(n))) + eq = {str(r.get("키")): r for r in (whole.numbers.get("EQ") or {}).values()} + for line in lines: + if line["kind"] == "machine": + line["spec"] = (eq.get(line["key"]) or {}).get("규격") or "" unit = str(logic.get("결과단위") or "").replace("원/", "") spec = " · ".join(f"{k} {v}" for k, v in sorted(given.items())) return self.put( { "kind": "품셈", - "name": logic.get("이름") or code, + "name": title or logic.get("이름") or code, "spec": spec, "unit": unit, "cite": logic.get("출처") or "", @@ -116,7 +119,7 @@ class Builder: ) def _engine_line( - self, whole: Any, item: dict | None, x: dict, logic: dict, extra: int, bases: dict + self, item: dict | None, x: dict, logic: dict, extra: int, bases: dict, call: dict | None ) -> dict[str, Any]: head = {"name": x.get("이름") or "", "spec": "", "unit": x.get("단위") or ""} qty = _f(x.get("수량")) @@ -140,24 +143,20 @@ class Builder: return {**head, "kind": "blank", "qty": qty, "why": x.get("까닭") or "품목 없음"} return {**head, "kind": "material", "key": goods["키"], "spec": goods.get("규격") or "", "qty": qty} # fmt: skip - hit = MACHINE.search(str(item.get("요소") or "")) - row = whole.numbers.get("EQ", {}).get(hit.group(1)) if hit else None - if row is None: - # ponytail: 기계 아닌 하위 로직 줄은 B09 단가로 못 펼침 — 줄만 두고 빈칸 - return { - **head, - "kind": "blank", - "qty": qty, - "why": "하위 로직 줄 — 대가 단가로 못 펼침", - } - return {**head, "kind": "machine", "key": row["키"], "spec": row.get("규격") or "", - "qty": qty} # fmt: skip + # 로직 줄 — `Sangeun.call_of` 판정: 기계경비 로직 = 중기 · 그 밖 = 하위 산근 + if call and call.get("machine"): + return {**head, "kind": "machine", "key": call["machine"], "qty": qty} + if call and call.get("code"): + key = sg.sangeun_key(call["code"], call["given"]) + return {**head, "kind": "sangeun", "key": key, "qty": qty} + return {**head, "kind": "blank", "qty": qty, "why": "하위 로직 줄 — 못 펼침"} # ── 조합 · 사전조합 줄 ────────────────────────────────────────────── def line_of( self, whole: Any, one: dict, qty: Any, times: Any, name: str, copies: dict, depth: int = 0 ) -> dict[str, Any]: - """담은 로직 줄 하나 → 블록 줄 — 수량 = 수량 ÷ 배(부르는 호표 1 단위 몫).""" + """담은 로직 줄 하나 → 블록 줄 — 대가 줄 수량 = 수량 ÷ 배(부르는 호표 1 단위 몫) · + 산근 줄 수량 = 수량 그대로(산근 단가 = 결과단위 1 단위).""" q = _f(qty) t = _f(times) or 1.0 each = None if q is None else q / t @@ -171,20 +170,24 @@ class Builder: "qty": each, "why": f"없는 참조 「{ref.get('키')}」", } - if refs.kind_of(ref.get("키")) == "promote": # 옛 단가승격 참조 = 산근 + if refs.kind_of(ref.get("키")) == "promote": # 옛 단가승격 참조 = 일위대가화 = 대가 first = (doc.get("담은로직") or [{}])[0] - key = sangeun_ref(str(first.get("로직") or ""), first.get("입력값"), - first.get("켜쌓기율"), first.get("고름")) # fmt: skip - return {"kind": "sangeun", "key": key, "name": doc.get("이름") or name, + bid = self.poomsem(whole, first, doc.get("이름") or name) + if bid is None: + return {"kind": "blank", "name": name, "qty": each, "why": "돈 아닌 로직"} + return {"kind": "daega", "key": bid, "name": self.blocks[bid]["name"], "unit": doc.get("단위") or "", "qty": each} # fmt: skip bid = self.precombo(whole, ref, doc, copies, depth + 1) return {"kind": "daega", "key": bid, "name": doc.get("이름") or name, "unit": doc.get("단위") or "", "qty": each} # fmt: skip code = str(one.get("로직") or "") - if one.get("승격") is True: + if one.get("승격") is not True: # 단가산출 줄 = 산근 + if not sg.is_money(whole, code): + return {"kind": "blank", "name": name or code, "qty": q, "why": "돈 아닌 로직"} key = sangeun_ref(code, one.get("입력값"), one.get("켜쌓기율"), one.get("고름")) - return {"kind": "sangeun", "key": key, "name": name, "qty": each} - bid = self.poomsem(whole, one) + return {"kind": "sangeun", "key": key, "name": name or code, + "unit": sg.unit_of(code), "qty": q} # fmt: skip + bid = self.poomsem(whole, one, str(one.get("별칭") or "") or name) if bid is None: return { "kind": "blank", @@ -312,7 +315,7 @@ def _plain(value: Any) -> Any: def daega_inputs(project_root: str | Path) -> dict[str, Any]: """대가 셈 입력 — `{blocks: [...], owners: {구조물 id: 블록 id}}` · 사본 없으면 빈 것. - 차례 = 조합(구조물 id 차례) → 사전조합 → 품셈(처음 본 차례) — 호표 번호 = 이 차례. + 차례 = 조합(구조물 id 차례) → 사전조합 → 일위대가화(품셈 · 처음 본 차례) — 호표 번호 = 이 차례. """ base = structures_dir(project_root) build = Builder() diff --git a/B09_Estimation/B09_Estimation_Sangeun.py b/B09_Estimation/B09_Estimation_Sangeun.py index 3fcfd78d0..322692ab3 100644 --- a/B09_Estimation/B09_Estimation_Sangeun.py +++ b/B09_Estimation/B09_Estimation_Sangeun.py @@ -1,8 +1,10 @@ """B09 단가산출근거 · 단가산출근거목록표(PLAN 145-1) — 서버 단독(③ · 화면 짝 없음 · 저장 안 함). -산근 호표 = 연결된 사본(`linked_snapshots` · 143-1)의 일위대가 로직 줄 중 - `승격: true` 인 줄(134-1 · 입력값 = 사본 `입력값.json` · 빈 입력 = 자동값) + - 옛 단가승격 참조 줄(134-2 ③ 로 남은 SP · 입력값 = 굳힌 `참조사본`) +산근 호표(155-4 · 엑셀 꼴 · 145 정함 뒤집음) = 연결된 사본(`linked_snapshots` · 143-1)의 + 단가산출 줄(일위대가화 아닌 돈 로직 줄 · 입력값 = 사본 `입력값.json` · 빈 입력 = 자동값) + + 사전조합(SC 참조 · 굳힌 `참조사본`) 안 단가산출 줄(입력값 = 그 문서 값) + + 일위대가화 줄(승격 · 옛 SP)의 하위 로직 줄(기계경비 밖 · 입력 = 부르는 식 값) + — 노무 · 자재 · 중기 줄은 각 목록표(산근 아님) · 일위대가화 줄 자체 = 대가(145-2 블록) 같은 로직 + 같은 입력값(+ 켜쌓기율 · 고른 자재) = 산근 하나(`sangeun_key`) · 번호 = 첫 등장 차례 · 로직 줄이 부르는 다른 돈 로직(기계경비 밖) = 하위 산근(그 산근도 목록에 · 산근 → 산근). @@ -47,7 +49,7 @@ COST_PART = {"노무비": "labor", "재료비": "material", "경비": "expense"} DEPTH = 4 # 하위 산근 깊이 상한 _Q = re.compile(r"Q\d*") # 작업량 중간값 이름(산림품셈 09장 토공 · 07장 집재 …) NO_SOURCE = "조합 밖 토공 · 운반 · 사토 산근 · 준비공 = 원천 없음(연결 없음 · 만들지 않음)" -EMPTY = "연결된 구조물 조합 사본에 단가승격 줄 없음 — 연결 없음" +EMPTY = "연결된 구조물 조합 사본에 단가산출 줄 없음 — 연결 없음" # ── 산근 정체 ───────────────────────────────────────────────────────── @@ -81,42 +83,57 @@ def _values(values: dict[str, Any], code: str) -> dict[str, Any]: return {name: (one or {}).get("값") for name, one in (values.get(code) or {}).items()} -def rows_of(folder: Path) -> list[dict[str, Any]]: - """사본 하나의 산근 원천 줄 — 승격 로직 줄 · 옛 SP 참조 줄(차례대로).""" +def rows_of(folder: Path, whole: Any = None) -> list[dict[str, Any]]: + """사본 하나의 산근 원천 줄(차례대로) — 단가산출 줄 · 사전조합 안 단가산출 줄 · + 일위대가화 줄(승격 · 옛 SP)의 하위 로직 줄. 키 = 대가 블록 산근 줄 키(`Labor_Source`)와 같음.""" from M02_MasterTemplete import M02_Labor_Ref as refs labor = read_json(folder / LABOR_FILE) if not isinstance(labor, dict): return [] + if whole is None: + whole = item_prices.load_prices(read_json(folder / PRICE_FILE) or {}) values = read_json(folder / VALUES_FILE) or {} picks = read_json(folder / PICKS_FILE) or {} copies = labor.get("참조사본") or {} - out = [] - for one in (labor.get("줄") or {}).get("담은로직") or []: - if not isinstance(one, dict): - continue - ref = one.get("참조") - if one.get("승격") is True and one.get("로직"): - code = str(one["로직"]) - got = { - "code": code, - "given": _values(values, code), - "rate": one.get("켜쌓기율"), - "picks": picks.get(code) or one.get("고름"), - } - elif isinstance(ref, dict) and refs.kind_of(ref.get("키")) == "promote": - line = ((copies.get(refs.ref_id(ref)) or {}).get("담은로직") or [None])[0] - if not isinstance(line, dict) or not line.get("로직"): + out: list[dict[str, Any]] = [] + + def logic_line(code: str, given: Any, rate: Any, chosen: Any, promoted: bool) -> None: + if not is_money(whole, code): + return + if not promoted: + out.append({"code": code, "given": _clean_given(given), "rate": rate, "picks": chosen}) + return + for call in line_calls(whole, code, given, chosen).values(): + if call and call.get("code"): + out.append({"code": call["code"], "given": call["given"], "rate": None, + "picks": None}) # fmt: skip + + def walk(lines: list, inner: bool, depth: int) -> None: + for one in lines: + if not isinstance(one, dict): continue - got = { - "code": str(line["로직"]), - "given": line.get("입력값") or {}, - "rate": line.get("켜쌓기율"), - "picks": line.get("고름"), - } - else: - continue - out.append({**got, "given": _clean_given(got["given"])}) + ref = one.get("참조") + if isinstance(ref, dict): + doc = copies.get(refs.ref_id(ref)) + if not isinstance(doc, dict) or depth >= refs.DEPTH: + continue + if refs.kind_of(ref.get("키")) == "promote": # 옛 SP = 일위대가화 + first = (doc.get("담은로직") or [None])[0] + if isinstance(first, dict) and first.get("로직"): + logic_line(str(first["로직"]), first.get("입력값"), + first.get("켜쌓기율"), first.get("고름"), True) # fmt: skip + continue + walk(doc.get("담은로직") or [], True, depth + 1) # 사전조합 = 굳힌 값 + continue + if not one.get("로직"): + continue + code = str(one["로직"]) + given = one.get("입력값") if inner else _values(values, code) + chosen = one.get("고름") if inner else (picks.get(code) or one.get("고름")) + logic_line(code, given, one.get("켜쌓기율"), chosen, one.get("승격") is True) + + walk((labor.get("줄") or {}).get("담은로직") or [], False, 0) return out @@ -136,6 +153,11 @@ def _unit(code: str) -> tuple[str, Decimal]: return str(pill.get("단위") or ""), Decimal(str(pill.get("배") or 1)) +def unit_of(code: str) -> str: + """산근 1 단위 이름(「원/10㎡」 = ㎡).""" + return _unit(code)[0] + + def _env(whole: Any, row: dict, given: dict) -> dict: """입력(빈 입력 = 자동값) + 중간값 · 작업량 Q = ROUND(식, 2)(엑셀 자리).""" auto = {k: v for k, v in m01.mau.values(whole, row).items() if v is not None} @@ -152,6 +174,53 @@ def _env(whole: Any, row: dict, given: dict) -> dict: return env +def is_money(whole: Any, code: str) -> bool: + """돈 로직(호표 줄 꼴)인지 — 없는 로직 · 흐름 · 결과 로직 = 아님.""" + try: + row = whole.logic(code) + except mf.FormulaError: + return False + return not (isinstance(row.get("흐름"), dict) or "결과" in row) + + +def call_of(whole: Any, item: dict, env: dict) -> dict[str, Any] | None: + """로직 호표 줄(종류 로직) → 부른 것 — 기계경비 로직 `{machine: EQ 키 | None}` · + 다른 돈 로직 `{code, given}`(하위 산근) · 못 펼치면 None. + 대가 품셈 줄(`B09_Estimation_Labor_Source`)과 산근 풀이가 같은 판정을 씀.""" + node = mf.parse(item["요소"]) + called = node[1] if node and node[0] == "logic" else None + try: + target = whole.logic(called) if called else None + except mf.FormulaError: + target = None + if not target: + return None + if any(s.get("이름") == "기계" for s in target.get("입력", [])): + keys = _captured(whole, node, env) + return {"machine": keys[0] if keys else None} + given = {name: mf.evaluate(expr, env, whole, 1) for name, expr in node[2]} + return {"code": called, "given": _clean_given(given)} + + +def line_calls(whole: Any, code: str, given: Any, picks: Any = None) -> dict[int, dict | None]: + """로직 하나의 로직 호표 줄마다 `call_of`(호표 차례 n → 부른 것) — 못 셈 줄 = None.""" + try: + row = whole.logic(code) + env = _env(whole, row, _clean_given(given)) + except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError): + return {} + out: dict[int, dict | None] = {} + with mf.mp.choosing(picks): + for n, item in enumerate(row.get("호표", [])): + if item.get("종류") != "로직": + continue + try: + out[n] = call_of(whole, item, env) + except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError): + out[n] = None + return out + + def _captured(whole: Any, node: Any, env: dict) -> list[str]: """식을 돌리며 부른 기계 키(EQ) — 기계경비 로직 줄의 기계.""" seen: list[str] = [] @@ -278,6 +347,11 @@ class Solver: key = str( goods.get("키") or (source or "").split(".")[0] or (ref if isinstance(ref, str) else "") ) + if key not in self.prices.material: # 자리표시 글(「MO:경유:전국평균」) = 대비표 키로 + try: + key = str((whole.get(key) or {}).get("키") or key) + except (mf.FormulaError, KeyError, TypeError, ValueError): + pass part = COST_PART.get(item.get("비목"), "material") price = self.prices.material.get(key) if q == 0: @@ -289,14 +363,9 @@ class Solver: "price": {part: price}, part: _money(price, q), "why": why} # fmt: skip def _logic_line(self, whole, item, env, q, base, zero, depth) -> dict[str, Any]: - node = mf.parse(item["요소"]) - called = node[1] if node and node[0] == "logic" else None - try: - target = whole.logic(called) if called else None - except mf.FormulaError: - target = None - if target and any(s.get("이름") == "기계" for s in target.get("입력", [])): - keys = _captured(whole, node, env) + call = call_of(whole, item, env) + if call and "machine" in call: + keys = [call["machine"]] if call["machine"] else [] m = self.prices.machine.get(keys[0]) if keys else None price = {p: num((m or {}).get(p)) for p in PARTS} why = None if m else f"중기목록표에 없음 「{keys[0] if keys else item.get('이름')}」" @@ -304,10 +373,9 @@ class Solver: ref = {"tab": "machine", "key": keys[0] if keys else ""} return {**base, "kind": "machine", "ref": ref, "price": price, **amounts, "why": why} # fmt: skip - if not target or depth >= DEPTH: + if not call or depth >= DEPTH: return {**self._blank(item, "하위 로직을 못 펼침"), **base} - given = {name: mf.evaluate(expr, env, whole, 1) for name, expr in node[2]} - sub = self.add(whole, {"code": called, "given": _clean_given(given)}, None, depth + 1) + sub = self.add(whole, {"code": call["code"], "given": call["given"]}, None, depth + 1) price = {p: sub.get(p) for p in PARTS} why = None if sub.get("sum") is not None else f"하위 산근 {sub['no']} 빈칸" return {**base, "kind": "sangeun", "ref": {"tab": "sg", "key": sub["key"]}, @@ -342,11 +410,8 @@ def collect(entries: list[tuple[str, Path]], sheets: dict[str, Any]) -> dict[str """`entries` = [(구조물 id, 사본 폴더)] → 블록 · 목록 · 「연결 없음」.""" solver = Solver(sheets) for owner, folder in entries: - rows = rows_of(folder) - if not rows: - continue whole = item_prices.load_prices(read_json(folder / PRICE_FILE) or {}) - for src in rows: + for src in rows_of(folder, whole): solver.add(whole, src, owner) blocks = sorted(solver.blocks.values(), key=lambda b: b["no"]) listing = [ diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts index 7e3635ca0..94d8e491f 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts @@ -32,7 +32,7 @@ const LINE_TAB: Record = { material: "material", machine: "machine", daega: "dg", - sangeun: "sg_list", + sangeun: "sg", }; export const buildDaegaList: TabBuild = ({ sheets }): TabTable | string => { diff --git a/resources/tester/test_145_1_b09_sangeun.py b/resources/tester/test_145_1_b09_sangeun.py index a1d99c70f..9871698d2 100644 --- a/resources/tester/test_145_1_b09_sangeun.py +++ b/resources/tester/test_145_1_b09_sangeun.py @@ -1,6 +1,7 @@ -"""145-1 B09 단가산출근거 · 목록표 — 승격 줄 → 산근 블록 · 엑셀 검산 · 같은 산근 하나 · 빈 표. +"""145-1 B09 단가산출근거 · 목록표 — 단가산출 줄 → 산근 블록 · 엑셀 검산 · 같은 산근 하나 · 빈 표. -- 산근 = 연결된 사본 일위대가 로직 줄 중 `승격: true` + 옛 SP 참조 줄(134-2 ③) · 승격 아닌 줄은 안 담음 +- 산근 = 155-4 갈래(사용자 뜻 · 145 정함 뒤집음) — 일위대가 로직 줄 중 단가산출 줄(승격 아님) + + 사전조합 안 단가산출 줄 · 일위대가화(승격 · 옛 SP) 줄 자체는 산근 아님(대가 블록) - 줄 금액 = ROUNDDOWN(B09 굳힌 단가 × 1 단위 수량, 1) · 총계 = 비목마다 ROUNDDOWN 0 — 엑셀 단가산출근거 제 6호표(뒷채움) 줄 값 · 총계 8,328(6,878 · 538 · 912)과 같음 - 같은 로직 + 같은 입력값 = 산근 하나(쓰는 구조물이 늘어남) · 입력이 다르면 둘 · 번호 = 첫 등장 @@ -29,6 +30,7 @@ STONE = "GF000219" # 돌쌓기 메쌓기(인력) — 석공 0.195 · 보통인 NET = "GC000337" # 낙석방지망 — 노무 · 크레인(중기) · 자재 셋 NET_IN = {"구분": "기계식", "방지망": "포켓식", "지역": "전국평균", "크레인": "2104-0010"} SP_REF = {"층": "system", "주인": None, "키": "SP-000001"} +SC_REF = {"층": "system", "주인": None, "키": "SC-000001"} def _snapshot(folder: Path, rows, values=None, copies=None) -> Path: @@ -52,8 +54,8 @@ def _sheets(*folders: Path) -> dict: return compute_base(prices.build_inputs(None, list(dict.fromkeys(keys))), {}) -def test_승격_줄만_산근_블록_단가는_B09_표(tmp_path): - a = _snapshot(tmp_path / "a", [{"로직": STONE, "승격": True}, {"로직": NET}], {NET: NET_IN}) +def test_단가산출_줄만_산근_블록_단가는_B09_표(tmp_path): + a = _snapshot(tmp_path / "a", [{"로직": STONE}, {"로직": NET, "승격": True}], {NET: NET_IN}) sheets = _sheets(a) got = sg.collect([("s1", a)], sheets) (block,) = got["sg"] @@ -75,7 +77,7 @@ def test_승격_줄만_산근_블록_단가는_B09_표(tmp_path): def test_중기는_중기목록표_노_재_경_자재는_적용_단가(tmp_path): - a = _snapshot(tmp_path / "a", [{"로직": NET, "승격": True}], {NET: NET_IN}) + a = _snapshot(tmp_path / "a", [{"로직": NET}], {NET: NET_IN}) sheets = _sheets(a) (block,) = sg.collect([("s1", a)], sheets)["sg"] kinds = [ln["kind"] for ln in block["lines"]] @@ -104,21 +106,26 @@ def test_엑셀_6호표_뒷채움_검산(): assert sg.block_totals([labor, {**machine, "material": None}])["sum"] is None # 빈칸 = 빈칸 -def test_같은_로직_같은_입력은_하나_다르면_둘_옛_SP_도_같은_산근(tmp_path): - a = _snapshot(tmp_path / "a", [{"로직": NET, "승격": True}], {NET: NET_IN}) - b = _snapshot(tmp_path / "b", [{"로직": NET, "승격": True}], {NET: {**NET_IN, "": ""}}) +def test_같은_로직_같은_입력은_하나_다르면_둘_사전조합_안_줄도_같은_산근(tmp_path): + a = _snapshot(tmp_path / "a", [{"로직": NET}], {NET: NET_IN}) + b = _snapshot(tmp_path / "b", [{"로직": NET}], {NET: {**NET_IN, "": ""}}) other = {**NET_IN, "방지망": "비포켓식"} - c = _snapshot(tmp_path / "c", [{"로직": NET, "승격": True}], {NET: other}) - sp = {"담은로직": [{"로직": NET, "입력값": NET_IN}], "배": 1} + c = _snapshot(tmp_path / "c", [{"로직": NET}], {NET: other}) + sc = {"이름": "사전조합", "담은로직": [{"로직": NET, "입력값": NET_IN}]} + sp = {"담은로직": [{"로직": NET, "입력값": other}], "배": 1} # 옛 SP = 일위대가화 = 산근 아님 d = _snapshot( tmp_path / "d", - [{"로직": "", "참조": SP_REF}, {"로직": STONE, "승격": True}], - copies={"참조|system||SP-000001": sp}, + [{"로직": "", "참조": SC_REF}, {"로직": "", "참조": SP_REF}, {"로직": STONE}], + copies={"참조|system||SC-000001": sc, "참조|system||SP-000001": sp}, ) got = sg.collect([("s1", a), ("s2", b), ("s3", c), ("s4", d)], _sheets(a, c, d)) blocks = got["sg"] assert [(x["no"], x["logic"], x["uses"]) for x in blocks] == [ - (1, NET, ["s1", "s2", "s4"]), # 빈 입력 칸은 무시 · 옛 SP(같은 로직 · 입력) = 같은 산근 + ( + 1, + NET, + ["s1", "s2", "s4"], + ), # 빈 입력 칸은 무시 · 사전조합 안 줄(같은 로직 · 입력) = 같은 산근 (2, NET, ["s3"]), (3, STONE, ["s4"]), ] @@ -130,7 +137,7 @@ def test_사본_없으면_빈_표_연결_없음(tmp_path, monkeypatch): got = sg.collect([("s1", tmp_path / "없음")], {}) assert got["sg"] == [] and got["sg_list"] == [] and got["sg_gaps"] == [] assert got["sg_notes"] == [sg.NO_SOURCE, sg.EMPTY] - plain = _snapshot(tmp_path / "p", [{"로직": STONE}]) # 승격 없는 사본도 빈 표 + plain = _snapshot(tmp_path / "p", [{"로직": STONE, "승격": True}]) # 일위대가화만 = 빈 표 assert sg.collect([("s1", plain)], {})["sg"] == [] async def fake_materials(_project_id): @@ -143,7 +150,7 @@ def test_사본_없으면_빈_표_연결_없음(tmp_path, monkeypatch): def test_단가_없으면_빈칸과_연결_없음(tmp_path): - a = _snapshot(tmp_path / "a", [{"로직": STONE, "승격": True}]) + a = _snapshot(tmp_path / "a", [{"로직": STONE}]) got = sg.collect([("s1", a)], {"labor": [], "machine": [], "compare": []}) (block,) = got["sg"] assert block["labor"] is None and block["sum"] is None diff --git a/resources/tester/test_145_2_b09_labor_calc.py b/resources/tester/test_145_2_b09_labor_calc.py index 78e877bbf..10c566831 100644 --- a/resources/tester/test_145_2_b09_labor_calc.py +++ b/resources/tester/test_145_2_b09_labor_calc.py @@ -1,6 +1,8 @@ """145-2 B09 일위대가표 · 일위대가목록표 — 엑셀 검산 대조 · 거울(`B09_Estimation_Calc_Labor.py` ↔ `.ts`) · 견본 사본 → 대가 블록(`B09_Estimation_Labor_Source`). +견본 사본 단정 = 155-3 갈래(사용자 뜻 · 145 정함 뒤집음) — 일위대가화(승격) 줄 = 대가(품셈) 블록 · +단가산출 줄 = 산근 줄(수량 그대로) · 사전조합 = 겹 대가 · 비고 「산근 n호표」. 엑셀 견본 = 기번3 공통 xlsx 「일위대가표」 같은 입력: 대가1 모르터바르기 109,259 · 대가2 파형강관부설 D800 16,001(15,002 · 584 · 415 · 공구손료 2 %) · @@ -22,6 +24,7 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from B09_Estimation import B09_Estimation_Prices as prices # noqa: E402 +from B09_Estimation import B09_Estimation_Sangeun as sangeun # noqa: E402 from B09_Estimation.B09_Estimation_Calc_Base import compute_base # noqa: E402 from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor, daega_price # noqa: E402 from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs # noqa: E402 @@ -257,8 +260,10 @@ def project(tmp_path_factory) -> Path: _snap(root, "pipe@100.0", "SS000901", LINES, _values(STONE)) _snap(root, "pipe@200.0", "SS000901", LINES, _values(STONE)) # 같은 원본 + 같은 입력값 _snap(root, "pipe@300.0", "SS000901", LINES, _values({**STONE, "높이": 5})) # 입력값 다름 - promoted = [ + promoted = [ # 일위대가화 줄 셋(인력 · 중기 · 재료 + % 줄) + 사전조합 {"로직": "GF000219", "승격": True, "수량": {"손": 1}}, + {"로직": "GF000119", "승격": True, "수량": {"손": 1}}, + {"로직": "GF000143", "승격": True, "수량": {"손": 1}}, {"참조": {"층": "system", "주인": None, "키": "SC-T"}, "수량": {"손": 4}}, ] _snap(root, "pipe@400.0", "SS000902", promoted, _values(STONE), SC) @@ -274,14 +279,18 @@ def test_견본_사본_대가_블록(project): owners["pipe@100.0"] == owners["pipe@200.0"] != owners["pipe@300.0"] ) # 같은 원본+입력 = 한 호표 blocks = {b["id"]: b for b in got["blocks"]} - assert [b["kind"] for b in got["blocks"]] == ["조합"] * 3 + ["사전조합"] + ["품셈"] * 4 + # 단가산출 줄은 대가 항목 아님 → 품셈 블록 = 일위대가화 줄 셋만 + assert [b["kind"] for b in got["blocks"]] == ["조합"] * 3 + ["사전조합"] + ["품셈"] * 3 a = blocks[owners["pipe@100.0"]] - assert [(ln["kind"], ln["qty"]) for ln in a["lines"]] == [ - ("daega", 2), - ("daega", 0.5), - ("daega", 1), + assert [(ln["kind"], ln["key"][:11], ln["qty"]) for ln in a["lines"]] == [ + ("sangeun", "SG:GF000219", 2), + ("sangeun", "SG:GF000119", 0.5), + ("sangeun", "SG:GF000143", 1), ] - stone, digger, crane = (blocks[ln["key"]] for ln in a["lines"]) + b = blocks[owners["pipe@400.0"]] + assert [ln["kind"] for ln in b["lines"]] == ["daega"] * 4 + stone, digger, crane = (blocks[ln["key"]] for ln in b["lines"][:3]) + assert stone["kind"] == "품셈" and stone["name"] == "돌쌓기 메쌓기" assert [(ln["kind"], ln["key"]) for ln in stone["lines"]] == [ ("labor", "LB000033"), ("labor", "LB000002"), @@ -290,13 +299,13 @@ def test_견본_사본_대가_블록(project): assert digger["lines"][0]["kind"] == "machine" and digger["lines"][0]["key"].startswith("EQ") pct = crane["lines"][-1] assert pct["kind"] == "pct" and pct["base"] == "part:material" and pct["qty"] == 20 - b = blocks[owners["pipe@400.0"]] - assert b["lines"][0]["kind"] == "sangeun" and b["lines"][0]["key"].startswith("SG:GF000219:") - sc = blocks[b["lines"][1]["key"]] - assert sc["kind"] == "사전조합" and b["lines"][1]["qty"] == 4 + sc = blocks[b["lines"][3]["key"]] + assert sc["kind"] == "사전조합" and b["lines"][3]["qty"] == 4 assert ( - sc["lines"][0]["key"] == stone["id"] and sc["lines"][0]["qty"] == 3 - ) # 겹 대가 = 같은 품셈 + sc["lines"][0]["kind"] == "sangeun" + and sc["lines"][0]["key"] == a["lines"][0]["key"] + and sc["lines"][0]["qty"] == 3 + ) # 사전조합 안 단가산출 줄 = 같은 산근(같은 로직 + 같은 입력값) def test_견본_사본_끝까지_셈(project): @@ -304,14 +313,18 @@ def test_견본_사본_끝까지_셈(project): inputs = prices.build_inputs(None, keys) inputs["daega"] = daega_inputs(project) base = compute_base(inputs, {}) - out = compute_labor(inputs["daega"], base) + sg_rows = {r["key"]: r for r in sangeun.build(project, base)["sg_list"]} + out = compute_labor(inputs["daega"], base, sg_rows) owner = inputs["daega"]["owners"]["pipe@100.0"] combo = _block(out, owner) assert combo["sum"] is not None and combo["no"] == 1 - # 조합 줄 = 부른 품셈 합계 × 수량(ROUNDDOWN 1) · 합계 = ROUNDDOWN 0 + # 조합 줄 = 산근 단가 × 수량(ROUNDDOWN 1) · 비고 「산근 n호표」 · 합계 = ROUNDDOWN 0 for ln in combo["lines"]: - child = _block(out, ln["key"]) - assert ln["prices"]["labor"] == child["labor"] + row = sg_rows[ln["key"]] + assert ln["prices"]["labor"] == row["labor"] and ln["ref_no"] == f"산근 {row['no']}호표" + # 일위대가화 블록 = 금액 다 참(기계 줄 빈칸 없음) + for ln in _block(out, inputs["daega"]["owners"]["pipe@400.0"])["lines"]: + assert _block(out, ln["key"])["sum"] is not None, ln["name"] price = daega_price(out, "pipe@100.0") assert price["no"] == "대가1" and price["sum"] == combo["sum"] assert daega_price(out, "pipe@500.0") is None # 사본 없음 = 없음 @@ -367,7 +380,7 @@ def test_집계표_이음_꼴(): def test_한_바퀴_내역_대가_산근_중기_집계표(tmp_path): """이음 끝 확인 — 설계내역서 구조물 줄(B08 열 owners) → 대가 블록마다 나눔 → 대가 줄 → - 산근(145-1 · 승격 줄) → 중기 → 집계표(145-5) 역전개 · 짝 셈 한 벌 = Router.compute_sheets.""" + 산근(155-3 · 단가산출 줄) → 중기 → 집계표(145-5) 역전개 · 짝 셈 한 벌 = Router.compute_sheets.""" from B09_Estimation.B09_Estimation_Calc_Base import rounddown from B09_Estimation.B09_Estimation_Router import compute_sheets from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines @@ -375,7 +388,7 @@ def test_한_바퀴_내역_대가_산근_중기_집계표(tmp_path): root = _project(tmp_path / "loop", [(100, "SS000901"), (400, "SS000902")]) _snap(root, "pipe@100.0", "SS000901", LINES, _values(STONE)) promoted = [ - {"로직": "GF000119", "승격": True, "수량": {"손": 1}}, # 노체포설 = 산근 · 중기 줄 + {"로직": "GF000119", "승격": True, "수량": {"손": 1}}, # 노체포설 = 일위대가화 · 중기 줄 {"참조": {"층": "system", "주인": None, "키": "SC-T"}, "수량": {"손": 4}}, ] _snap(root, "pipe@400.0", "SS000902", promoted, _values(STONE), SC) @@ -400,11 +413,14 @@ def test_한_바퀴_내역_대가_산근_중기_집계표(tmp_path): block = _block(sheets, row["ref"]["key"]) assert row["price_labor"] == block["labor"] and block["sum"] is not None assert row["labor"] == rounddown(row["qty"] * block["labor"], 0) - # 산근 이음 — 대가 줄 키 = 145-1 산근 키 · 단가가 들어옴 - combo = _block(sheets, owners["pipe@400.0"]) + # 산근 이음 — 단가산출 줄 키 = 145-1 산근 키 · 단가가 들어옴 + combo = _block(sheets, owners["pipe@100.0"]) sg_line = combo["lines"][0] sg_row = next(r for r in sheets["sg_list"] if r["key"] == sg_line["key"]) assert sg_line["kind"] == "sangeun" and sg_line["prices"]["labor"] == sg_row["labor"] + # 일위대가화 줄 = 대가 블록 · 기계 줄 = 중기 + digger = _block(sheets, _block(sheets, owners["pipe@400.0"])["lines"][0]["key"]) + assert digger["kind"] == "품셈" and digger["lines"][0]["kind"] == "machine" # 집계표 역전개 — 대가 · 사전조합 · 품셈 · 산근 끝까지 summary = compute_summary(sheets, hopyo_lines(sheets)) assert summary["unlinked"] == 0 diff --git a/resources/tester/test_155_b09_sources.py b/resources/tester/test_155_b09_sources.py new file mode 100644 index 000000000..9609cce6e --- /dev/null +++ b/resources/tester/test_155_b09_sources.py @@ -0,0 +1,109 @@ +"""155-3 · 155-4 B09 호표 갈래 — 사용자 뜻(10-9 둘째 · 145 정함 뒤집음 · M02 구분 짝 · 엑셀 꼴): +일위대가화(승격) 줄 = 일위대가 호표(대가 블록) · 단가산출 줄 = 산근 · +일위대가화 줄의 하위 로직 줄 = 산근(대가 블록 산근 줄 키 = 산근 목록 키) · +산근 줄 수량 = 수량 그대로(배 10 로직 금액 1/배 흠) · 비고 「산근 n호표」(파이썬 · TS 짝) · +산근 자재 자리표시 키(「MO:경유:…」) = 자재단가대비표 키. +""" + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +for path in (ROOT, ROOT / "resources" / "tester"): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + +import test_145_2_b09_labor_calc as t145 # noqa: E402 + +from B09_Estimation import B09_Estimation_Prices as prices # noqa: E402 +from B09_Estimation import B09_Estimation_Sangeun as sg # noqa: E402 +from B09_Estimation.B09_Estimation_Calc_Base import compute_base, rounddown # noqa: E402 +from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor # noqa: E402 +from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs # noqa: E402 +from B09_Estimation.B09_Estimation_Linked import linked_resource_keys, used_keys # noqa: E402 +from B09_Estimation.B09_Estimation_Router import compute_sheets # noqa: E402 +from M02_MasterTemplete import M02_Item_Prices as item_prices # noqa: E402 + +PILE = "GF000233" # 말뚝 다듬기 — 원/10개(배 10) · 인력 줄 둘 +PIPE = "GF000174" # 흄관 부설 — 하위 로직 줄(GF000155 · GF000160) = 하위 산근 +FUEL = "GF000011" # 재료 줄 「MO:경유:{지역}」 자리표시 + + +def _sheets_of(root): + inputs = prices.build_inputs(None, linked_resource_keys(root)) + inputs["daega"] = daega_inputs(root) + inputs["hopyo"] = {} + inputs["bill"] = {"items": []} + return inputs, compute_sheets(root, inputs, {}) + + +def _blk(sheets, key): + return next(b for b in sheets["daega_blocks"] if b["key"] == key) + + +def test_155_3_배_10_로직_산근_줄_수량_그대로_대가_줄만_나눔(tmp_path): + root = t145._project(tmp_path / "p", [(100, "SS000901"), (200, "SS000902")]) + t145._snap(root, "pipe@100.0", "SS000901", [{"로직": PILE, "수량": {"손": 20}}], {}) + t145._snap( + root, "pipe@200.0", "SS000902", [{"로직": PILE, "승격": True, "수량": {"손": 20}}], {} + ) + inputs, sheets = _sheets_of(root) + owners = sheets["daega_owners"] + (sg_line,) = _blk(sheets, owners["pipe@100.0"])["lines"] + (dg_line,) = _blk(sheets, owners["pipe@200.0"])["lines"] + assert (sg_line["kind"], sg_line["qty"]) == ("sangeun", 20) # 산근 단가 = 1 개 + assert (dg_line["kind"], dg_line["qty"]) == ("daega", 2) # 대가 블록 = 10 개 + row = next(r for r in sheets["sg_list"] if r["key"] == sg_line["key"]) + assert sg_line["ref_no"] == f"산근 {row['no']}호표" and dg_line["ref_no"].startswith("대가") + # 같은 일 · 같은 수량 = 금액 거의 같음(끝수 자리만 다름 · 예전 흠 = 1/10) + assert sg_line["amount"] is not None and dg_line["amount"] is not None + assert abs(sg_line["amount"] - dg_line["amount"]) <= 0.02 * dg_line["amount"] + + +def test_155_4_일위대가화_하위_로직_줄_산근_목록에(tmp_path): + root = t145._project(tmp_path / "p", [(100, "SS000901")]) + t145._snap( + root, "pipe@100.0", "SS000901", [{"로직": PIPE, "승격": True, "수량": {"손": 1}}], {} + ) + inputs, sheets = _sheets_of(root) + (call,) = _blk(sheets, sheets["daega_owners"]["pipe@100.0"])["lines"] + pipe = _blk(sheets, call["key"]) + assert pipe["kind"] == "품셈" and call["kind"] == "daega" + subs = [ln for ln in pipe["lines"] if ln["kind"] == "sangeun"] + listed = {r["key"]: r for r in sheets["sg_list"]} + assert {ln["key"][:11] for ln in subs} >= {"SG:GF000155", "SG:GF000160"} + for ln in subs: # 대가 블록 산근 줄 = 산근 목록 줄(같은 키) · 비고 · 단가 이음 + assert ln["key"] in listed and ln["ref_no"] == f"산근 {listed[ln['key']]['no']}호표" + assert ln["prices"]["labor"] == listed[ln["key"]]["labor"] + assert all(r["logic"] != PIPE for r in sheets["sg_list"]) # 일위대가화 줄 자체 = 산근 아님 + + +def test_155_4_산근_자재_자리표시_키_대비표로(tmp_path): + folder = tmp_path / "a" + folder.mkdir() + files = { + "단가.json": item_prices.to_json(item_prices.collect([FUEL])), + "일위대가.json": {"줄": {"종류": "일위대가", "담은로직": [{"로직": FUEL}]}}, + "입력값.json": {}, + } + for name, data in files.items(): + (folder / name).write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + sheets = compute_base(prices.build_inputs(None, used_keys(folder)), {}) + (block,) = sg.collect([("s1", folder)], sheets)["sg"] + fuel = [ln for ln in block["lines"] if ln["kind"] == "material"] + assert fuel and all(ln["ref"]["key"].startswith("MO0") for ln in fuel) + applied = {r["key"]: r["applied"] for r in sheets["compare"]} + for ln in fuel: + assert ln["material"] == rounddown(applied[ln["ref"]["key"]] * ln["qty"], 1) + + +def test_155_3_비고_산근_n호표_거울(): + sangeun = {k: {**v, "no": n} for n, (k, v) in enumerate(t145.SANGEUN.items(), 1)} + py = json.loads(json.dumps(compute_labor(t145.EXCEL, t145.SHEETS, sangeun), ensure_ascii=False)) + ts = t145._ts(t145.EXCEL, t145.SHEETS, sangeun) + assert t145._same(py, ts["out"]) + notes = [ + 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)