diff --git a/B09_Estimation/B09_Estimation_Linked.py b/B09_Estimation/B09_Estimation_Linked.py index e3495f1d1..caedd4c7b 100644 --- a/B09_Estimation/B09_Estimation_Linked.py +++ b/B09_Estimation/B09_Estimation_Linked.py @@ -12,6 +12,7 @@ from __future__ import annotations from pathlib import Path from typing import Any +from M01_MasterData import M01_MasterData_Store as m01 from M02_MasterTemplete import M02_Item_Prices as item_prices from M02_MasterTemplete.M02_Item_Snapshot import ( PICKS_FILE, @@ -50,7 +51,11 @@ def used_keys(folder: Path) -> list[str]: if not key: continue given = {name: (one or {}).get("값") for name, one in (values.get(key) or {}).items()} + given = {k: v for k, v in given.items() if v not in (None, "")} try: + # 빈 입력 = 자동값(142-1 · 145-1 산근 풀이와 같음) — 안 채우면 그 로직 자원을 못 모음 + auto = m01.mau.values(snapshot, snapshot.logic(key)) + given = {**{k: v for k, v in auto.items() if v is not None}, **m01._dec(given)} with mf.mp.choosing((picks or {}).get(key)): mf.run(snapshot, key, given) except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError): diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 7ac339c94..381fcb47b 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -9,7 +9,8 @@ 저장 자리 = 프로젝트 안 `B09_Estimation/v1/`(`B09_Estimation_Store`) · 단가 = `B09_Estimation_Prices` · -수요 = `B09_Estimation_Linked`(연결된 사본) · 셈 = `B09_Estimation_Calc_*`(화면 짝과 같은 식). +수요 = `B09_Estimation_Linked`(연결된 사본) · 셈 = `B09_Estimation_Calc_*`(화면 짝과 같은 식) · +산근 = `B09_Estimation_Sangeun`(서버 단독 · 145-1 · 화면은 받은 그대로). """ from __future__ import annotations @@ -24,6 +25,7 @@ from fastapi import APIRouter, Body from B08_Quantity.B08_Quantity_Router_Hydro import _project_root from B08_Quantity.B08_Quantity_Router_Material import material_sheets from B09_Estimation import B09_Estimation_Prices as prices +from B09_Estimation import B09_Estimation_Sangeun as sangeun from B09_Estimation import B09_Estimation_Store as store from B09_Estimation.B09_Estimation_Calc_Base import compute_base from B09_Estimation.B09_Estimation_Linked import linked_resource_keys @@ -53,11 +55,14 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]: book = await asyncio.to_thread(store.read_book, root) inputs = await asyncio.to_thread(prices.build_inputs, book, keys, b08["materials"]) user = await asyncio.to_thread(store.read_user, root) + sheets = compute_base(inputs, user) + sheets.update(await asyncio.to_thread(sangeun.build, root, sheets)) # 145-1 — 굳힌 단가로 풀이 + sheets["gaps"] = [*sheets["gaps"], *sheets["sg_gaps"]] return { "inputs": inputs, "user": user, "book": {"made": book.get("made")} if book else None, - "sheets": compute_base(inputs, user), + "sheets": sheets, "notes": notes, } diff --git a/B09_Estimation/B09_Estimation_Sangeun.py b/B09_Estimation/B09_Estimation_Sangeun.py new file mode 100644 index 000000000..3fcfd78d0 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Sangeun.py @@ -0,0 +1,377 @@ +"""B09 단가산출근거 · 단가산출근거목록표(PLAN 145-1) — 서버 단독(③ · 화면 짝 없음 · 저장 안 함). + +산근 호표 = 연결된 사본(`linked_snapshots` · 143-1)의 일위대가 로직 줄 중 + `승격: true` 인 줄(134-1 · 입력값 = 사본 `입력값.json` · 빈 입력 = 자동값) + + 옛 단가승격 참조 줄(134-2 ③ 로 남은 SP · 입력값 = 굳힌 `참조사본`) + 같은 로직 + 같은 입력값(+ 켜쌓기율 · 고른 자재) = 산근 하나(`sangeun_key`) · + 번호 = 첫 등장 차례 · + 로직 줄이 부르는 다른 돈 로직(기계경비 밖) = 하위 산근(그 산근도 목록에 · 산근 → 산근). +풀이 = 로직 호표 줄을 사본 단가 묶음(`단가.json`)으로 펼쳐 수량 · 자원 키만 얻고 + (M01 엔진 식 그대로) · + 단가 = B09 프로젝트 한 벌 굳힌 단가(144 · `compute_base` 표) — 노무 = 노무비목록표 · + 중기 = 중기목록표 노 · 재 · 경 · 재료 = 자재단가대비표 적용 단가 · + % 줄(덧줄) = 엔진 식에 B09 비목 합. +끝수 = 엑셀 자리 — 수량 = 결과단위 1 단위(÷ 배) · + 작업량 Q(중간 이름 Q · Q1 …) = ROUND(식, 2) 뒤 쓰임 · + 줄 금액 = ROUNDDOWN(단가 × 수량, 1) 비목마다 · 총계 = 비목마다 ROUNDDOWN(Σ, 0) · 합계 = 셋 합. +빈칸 규칙 = 단가 · 수량을 낸 원천이 없으면 그 칸 · 그 비목 총계 None(0 을 채우지 않음) + + 「연결 없음」. +조합 밖 토공 · 운반 · 사토 산근 · 준비공 = 원천 없음(만들지 않음 · 목록 안내 한 줄). +대가(145-2) · 내역서(145-3)가 읽는 길 = `sangeun_key` + `price_of`. +""" + +from __future__ import annotations + +import hashlib +import json +import re +from decimal import Decimal +from pathlib import Path +from typing import Any + +from B09_Estimation.B09_Estimation_Calc_Base import gap, num, round_half, rounddown, total +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.M02_Item_Snapshot import ( + PICKS_FILE, + VALUES_FILE, + linked_snapshots, + structures_dir, +) +from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, PRICE_FILE, read_json + +mf = item_prices.mf +PARTS = ("labor", "material", "expense") +COST_PART = {"노무비": "labor", "재료비": "material", "경비": "expense"} +DEPTH = 4 # 하위 산근 깊이 상한 +_Q = re.compile(r"Q\d*") # 작업량 중간값 이름(산림품셈 09장 토공 · 07장 집재 …) +NO_SOURCE = "조합 밖 토공 · 운반 · 사토 산근 · 준비공 = 원천 없음(연결 없음 · 만들지 않음)" +EMPTY = "연결된 구조물 조합 사본에 단가승격 줄 없음 — 연결 없음" + + +# ── 산근 정체 ───────────────────────────────────────────────────────── +def _clean_given(given: Any) -> dict[str, Any]: + return { + str(k): v for k, v in sorted((given or {}).items()) if v not in (None, "") and k != "줄" + } + + +def sangeun_key(code: str, given: Any = None, rate: Any = None, picks: Any = None) -> str: + """산근 이름표 — 같은 로직 + 같은 입력값(+ 켜쌓기율 · 고른 자재)이면 같은 글. + 145-2 대가 · 145-3 내역서가 승격 줄에서 이 글을 만들어 `price_of` 로 단가를 읽음.""" + body = { + "입력값": _clean_given(given), + "율": None if rate in (None, 100) else float(rate), + "고름": {str(k): v for k, v in sorted((picks or {}).items())}, + } + text = json.dumps(body, ensure_ascii=False, sort_keys=True, default=str) + return f"SG:{code}:{hashlib.sha1(text.encode('utf-8')).hexdigest()[:10]}" + + +def price_of(sheets: dict[str, Any], key: str) -> dict[str, Any] | None: + """산근 한 줄 단가 `{no, labor, material, expense, sum}`(1 단위 · 빈칸 = None) — 없으면 None.""" + for row in sheets.get("sg_list") or []: + if row["key"] == key: + return {k: row[k] for k in ("no", *PARTS, "sum")} + return None + + +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 참조 줄(차례대로).""" + from M02_MasterTemplete import M02_Labor_Ref as refs + + labor = read_json(folder / LABOR_FILE) + if not isinstance(labor, dict): + return [] + 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("로직"): + 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"])}) + return out + + +# ── 풀이 ────────────────────────────────────────────────────────────── +class _Prices: + """B09 표 단가 — 노무 · 중기(노 · 재 · 경) · 자재 적용 단가(키 → 값).""" + + def __init__(self, sheets: dict[str, Any]) -> None: + self.labor = {r["key"]: num(r.get("price")) for r in sheets.get("labor") or []} + self.machine = {r["key"]: r for r in sheets.get("machine") or []} + self.material = {r["key"]: num(r.get("applied")) for r in sheets.get("compare") or []} + + +def _unit(code: str) -> tuple[str, Decimal]: + """결과단위 1 단위 — (단위, 배)(「원/10㎡」 = ㎡ · 10).""" + pill = (combo_store.combo_pills({"담은로직": [{"로직": code}]})["물량"] or [{}])[0] + return str(pill.get("단위") or ""), Decimal(str(pill.get("배") or 1)) + + +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} + env = mf._inputs(row, {**auto, **m01._dec(given or {})}) + for step in row.get("중간", []): + try: + value = mf.evaluate(mf.parse(step["식"]), env, whole, 0) + except mf.EmptyInput as e: + env[step["이름"]] = mf.Blank(str(e)) + continue + if _Q.fullmatch(step["이름"]) and isinstance(value, (Decimal, int, float)): + value = Decimal(str(round_half(float(value), 2))) + env[step["이름"]] = value + return env + + +def _captured(whole: Any, node: Any, env: dict) -> list[str]: + """식을 돌리며 부른 기계 키(EQ) — 기계경비 로직 줄의 기계.""" + seen: list[str] = [] + base = whole.get + + def get(ref: str) -> dict[str, Any]: + row = base(ref) + key = str(row.get("키") or "") + if key.startswith("EQ") and key not in seen: + seen.append(key) + return row + + whole.get = get + try: + mf.evaluate(node, env, whole, 1) + except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError): + pass # 기계 단가를 못 셈 — 키만 있으면 됨(단가는 B09 중기목록표) + finally: + whole.get = base + return seen + + +def _money(price: float | None, qty: float | None) -> float | None: + """줄 금액 = ROUNDDOWN(단가 × 수량, 1)(엑셀 자리).""" + return None if price is None or qty is None else rounddown(price * qty, 1) + + +def block_totals(lines: list[dict[str, Any]]) -> dict[str, float | None]: + """총계 — 비목마다 ROUNDDOWN(Σ 줄 금액, 0) · 합계 = 셋 합 · 빈칸 하나면 그 비목 · 합계 빈칸.""" + out: dict[str, float | None] = {} + for part in PARTS: + s = total([ln[part] for ln in lines]) + out[part] = None if s is None else rounddown(s, 0) + return {**out, "sum": total([out[p] for p in PARTS])} + + +class Solver: + """한 번 셈 — 산근 블록을 첫 등장 차례로 모음(하위 산근 포함).""" + + def __init__(self, sheets: dict[str, Any]) -> None: + self.prices = _Prices(sheets) + self.blocks: dict[str, dict[str, Any]] = {} + + def add( + self, whole: Any, src: dict[str, Any], owner: str | None, depth: int = 0 + ) -> dict[str, Any]: + key = sangeun_key(src["code"], src["given"], src.get("rate"), src.get("picks")) + if key not in self.blocks: + self.blocks[key] = {"key": key, "no": len(self.blocks) + 1, "uses": []} # 자리 먼저 + self.blocks[key].update(self._solve(whole, src, depth)) + if owner and owner not in self.blocks[key]["uses"]: + self.blocks[key]["uses"].append(owner) + return self.blocks[key] + + def _solve(self, whole: Any, src: dict[str, Any], depth: int) -> dict[str, Any]: + code = src["code"] + try: + row = whole.logic(code) + except mf.FormulaError as e: + return self._stop(code, code, f"로직 없음 — {e}") + name = str(row.get("이름") or code) + if isinstance(row.get("흐름"), dict) or "결과" in row: + return self._stop(code, name, "돈 로직 아님(흐름 · 결과 로직) — 산근 풀이 꼴 없음") + unit, times = _unit(code) + rate = Decimal(str(src["rate"])) / 100 if src.get("rate") not in (None, 100) else None + lines: list[dict[str, Any]] = [] + try: + env = _env(whole, row, src["given"]) + with mf.mp.choosing(src.get("picks")): + for n, item in enumerate(row.get("호표", [])): + lines.append(self._line(whole, item, n, env, times, rate, depth)) + self._extras(whole, row, env, lines) + except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e: + 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) + return { + "logic": code, + "name": name, + "spec": ", ".join(f"{k}={v}" for k, v in src["given"].items()), + "unit": unit, + "source": row.get("출처") or "", + "lines": lines, + **sums, + "why": why, + } + + @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 + + def _blank(self, item: dict, why: str) -> dict[str, Any]: + part = COST_PART.get(item.get("비목"), "material") + return {"kind": "blank", "name": item.get("이름", ""), "spec": "", "qty": None, + "unit": item.get("단위", ""), "ref": None, "price": {}, + **{p: (None if p == part else 0.0) for p in PARTS}, "why": why} # fmt: skip + + def _line(self, whole, item, n, env, times, rate, depth) -> dict[str, Any]: + try: + raw = mf.evaluate(mf.parse(item["수량"]), env, whole, 0) + except mf.EmptyInput as e: + return self._blank(item, str(e)) + qty = Decimal(str(raw)) / times * (rate or 1) + q = float(qty) + base = {"name": item.get("이름", ""), "spec": "", "qty": q, "unit": item.get("단위", "")} + zero = dict.fromkeys(PARTS, 0.0) + kind = item.get("종류") + if kind == "로직": + return self._logic_line(whole, item, env, q, base, zero, depth) + if kind == "인력": + key = mf.fill(item["요소"], env) if isinstance(item["요소"], str) else "" + price = self.prices.labor.get(key) + why = None if price is not None else f"노무비목록표에 없음 「{key}」" + return {**base, **zero, "kind": "labor", "ref": {"tab": "labor", "key": key}, + "price": {"labor": price}, "labor": _money(price, q), "why": why} # fmt: skip + ref = ( + {k: mf.fill(v, env) if isinstance(v, str) else v for k, v in item["요소"].items()} + if isinstance(item["요소"], dict) + else mf.fill(item["요소"], env) + ) + _price, source, _why, extra = mf.mp.priced(whole, item, ref, env, n) + goods = (extra or {}).get("품목") or {} + key = str( + goods.get("키") or (source or "").split(".")[0] or (ref if isinstance(ref, str) else "") + ) + part = COST_PART.get(item.get("비목"), "material") + price = self.prices.material.get(key) + if q == 0: + price = price if price is not None else 0.0 # 안 쓰는 재료 줄 — 금액 0 + why = None if price is not None else f"자재단가대비표에 없음 「{key}」" + spec = goods.get("규격") or "" + ref = {"tab": "compare", "key": key} + return {**base, **zero, "spec": spec, "kind": "material", "ref": ref, + "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) + 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('이름')}」" + amounts = {p: _money(price[p], q) if m else None for p in PARTS} + 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: + 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) + 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"]}, + "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("덧줄", []): + 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 any(v is None for v in sums.values()): + lines.append({**line, part: None, "price": {}, "why": "밑수 빈칸"}) + continue + env.update({k: Decimal(str(v)) for k, v in sums.items()}) + env["줄"] = { + ln["name"]: Decimal(str(sum(ln[p] or 0 for p in PARTS))) + for ln in lines + if ln["name"] + } + try: + value = float(mf.evaluate(mf.parse(extra["식"]), env, whole, 0)) + except (mf.FormulaError, ArithmeticError, TypeError, ValueError) as e: + lines.append({**line, part: None, "price": {}, "why": str(e)}) + continue + lines.append({**line, "price": {part: value}, part: rounddown(value, 1), "why": None}) + + +# ── 모음 ────────────────────────────────────────────────────────────── +def collect(entries: list[tuple[str, Path]], sheets: dict[str, Any]) -> dict[str, Any]: + """`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: + solver.add(whole, src, owner) + blocks = sorted(solver.blocks.values(), key=lambda b: b["no"]) + listing = [ + {k: b[k] for k in ("no", "key", "logic", "name", "spec", "unit", "sum", *PARTS, "uses")} + for b in blocks + ] + gaps = [ + gap("sg", b["key"], f"산근 {b['no']} {b['name']}", b["why"] or "단가 빈칸") + for b in blocks + if b["sum"] is None + ] + return { + "sg": blocks, + "sg_list": listing, + "sg_gaps": gaps, + "sg_notes": [NO_SOURCE] + ([] if blocks else [EMPTY]), + } + + +def build(project_root: str | Path, sheets: dict[str, Any]) -> dict[str, Any]: + """연결된 사본 전부(구조물 id 차례)의 산근.""" + base = structures_dir(project_root) + entries = [ + (owner, base / linked["폴더"]) + for owner, linked in sorted(linked_snapshots(project_root).items()) + if "일위대가" in linked.get("부분", []) + ] + return collect(entries, sheets) diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index c90e24ec7..1aaf81a97 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -43,6 +43,7 @@ import { buildMachineBlocks, buildMaterial, } from "./B09_Estimation_UI_Tabs_Base"; +import { buildSg, buildSgList } from "./B09_Estimation_UI_Tabs_Sangeun"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -51,8 +52,6 @@ function L(key: keyof typeof ui_locales): string { const LATER: Record = { dg_list: "일위대가목록표 — 145-2(다른 창)가 들어오면 생김", dg: "일위대가표 — 145-2(다른 창)가 들어오면 생김", - sg_list: "단가산출근거목록표 — 145-1(다른 창)가 들어오면 생김", - sg: "단가산출근거 — 145-1(다른 창)가 들어오면 생김", }; const BUILDERS: Partial> = { @@ -64,6 +63,8 @@ const BUILDERS: Partial> = { labor: buildLabor, expense: buildExpense, compare: buildCompare, + sg_list: buildSgList, + sg: buildSg, }; async function request(path: string, init: RequestInit = {}): Promise { @@ -87,9 +88,15 @@ function note(textValue: string): HTMLElement { return p; } -/** 브라우저 셈 — 서버 짝과 같은 식(조작 중) */ +/** 산근(145-1) = 서버 단독 — 받은 그대로 두는 칸 */ +const SERVER_ONLY = ["sg", "sg_list", "sg_gaps", "sg_notes"] as const; + +/** 브라우저 셈 — 서버 짝과 같은 식(조작 중) · 산근은 서버 답 그대로(+ 「연결 없음」) */ export function computeSheets(payload: Row, user: Row): Row { - return computeBase(payload.inputs, user); + const base = computeBase(payload.inputs, user); + const server: Row = payload.sheets || {}; + const kept = Object.fromEntries(SERVER_ONLY.map((k) => [k, server[k] ?? []])); + return { ...base, ...kept, gaps: [...base.gaps, ...(server.sg_gaps || [])] }; } export function renderB09Estimation(root: HTMLElement): void { diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts new file mode 100644 index 000000000..57d6d8848 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts @@ -0,0 +1,141 @@ +/* ============================================================================= + * B09_Estimation_UI_Tabs_Sangeun.ts + * B09 단가산출근거 · 단가산출근거목록표 탭(PLAN 145-1) — 값 = 서버 `B09_Estimation_Sangeun` + * (서버 단독 · 이 탭엔 조작 없음 · [저장] · [단가 갱신] 때 서버가 다시 셈). + * + * 목록표 = 산근 호표 한 줄(합계 · 노무비 · 재료비 · 경비 · 1 단위) · 줄 누름 = 그 블록 + * 풀이표 = 블록 머리(제 n 호표) · 줄(노무 · 중기 노 · 재 · 경 · 자재 · 하위 산근 · % 줄) · 총계 + * 줄 누름 = 단가를 낸 탭 · 줄(노무비목록표 · 중기목록표 · 자재단가대비표 · 하위 산근) + * ========================================================================== */ + +import type { DataRow } from "@ui/ui_template_data_table"; +import type { Row } from "./B09_Estimation_Calc_Base"; +import { + money, + qty, + text, + type Link, + type TabBuild, + type TabId, + type TabTable, +} from "./B09_Estimation_UI_Tabs"; + +const PART_NAME: Record = { + labor: "노무비", + material: "재료비", + expense: "경 비", +}; +const PARTS = ["labor", "material", "expense"] as const; + +const notes = (sheets: Row): string => + ((sheets.sg_notes as string[]) || []).map((n) => ` · ${n}`).join(""); + +export const buildSgList: TabBuild = ({ sheets }): TabTable => { + const rows: DataRow[] = ((sheets.sg_list as Row[]) || []).map((r) => ({ + ...r, + note: `쓰는 구조물 ${(r.uses || []).length}`, + })); + return { + columns: [ + qty("no", "호표", 0), + text("name", "명칭", 220), + text("spec", "규격(입력값)", 200), + text("unit", "단위", 56), + money("sum", "합계"), + money("labor", "노무비"), + money("material", "재료비"), + money("expense", "경비"), + text("note", "비고", 110), + ], + rows, + frozen: 2, + hint: + "산근 = 연결된 구조물 조합 사본의 「단가승격」 로직 줄 · 같은 로직 + 같은 입력값 = 한 호표 · 합계 = 블록 총계 · 줄 누름 = 그 블록" + + notes(sheets), + link: (i) => (rows[i] ? { tab: "sg", key: String(rows[i].key) } : null), + }; +}; + +const LINE_TAB: Record = { + labor: "labor", + machine: "machine", + compare: "compare", + sg: "sg", +}; + +export const buildSg: TabBuild = ({ sheets }): TabTable | string => { + const rows: DataRow[] = []; + const links: Array = []; + const push = (row: DataRow, link: Link | null = null): void => { + 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}` }); + (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 at = (p: string): number | null => (part === null || part === p ? ln[p] : 0); + const price = part ? ln.price?.[part] : (Object.values(ln.price || {})[0] ?? null); + 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, + 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"), + }, + link, + ); + } + }); + push({ + key: `${b.key}|총계`, + name: " 총 계", + spec: b.why ?? "", + sum: b.sum, + labor: b.labor, + material: b.material, + expense: b.expense, + }); + } + 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), + money("sum", "합계", undefined, 1), + money("labor", "노무비", undefined, 1), + money("material", "재료비", undefined, 1), + money("expense", "경비", undefined, 1), + ], + rows, + frozen: 1, + hint: + "수량 = 로직 품(1 단위 · 작업량 Q = ROUND 2) · 단가 = 굳힌 B09 단가 · 줄 ROUNDDOWN 1 · 총계 비목마다 ROUNDDOWN 0 · 자재 적용 단가를 고치면 [저장] 뒤 반영 · 줄 누름 = 단가를 낸 탭" + + notes(sheets), + link: (i: number) => links[i] ?? null, + }; +}; diff --git a/resources/tester/test_144_b09_skeleton.py b/resources/tester/test_144_b09_skeleton.py index 23cfd7ec7..6e5e37503 100644 --- a/resources/tester/test_144_b09_skeleton.py +++ b/resources/tester/test_144_b09_skeleton.py @@ -130,5 +130,6 @@ def test_연결된_사본이_부른_자원만(tmp_path): (tmp_path / name).write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") assert used_keys(tmp_path) == ["EQ000004", "MO000002", "LB000048"] (tmp_path / "입력값.json").write_text("{}", encoding="utf-8") - assert used_keys(tmp_path) == [] # 입력 빈 로직은 못 돎 — 부른 줄 없음 + auto = used_keys(tmp_path) # 빈 입력 = 자동값(145-1 · 142-1) — 자동 기계 한 대 자원만 + assert auto and len(auto) <= 3 and auto[0].startswith("EQ") assert used_keys(tmp_path / "없음") == [] diff --git a/resources/tester/test_145_1_b09_sangeun.py b/resources/tester/test_145_1_b09_sangeun.py new file mode 100644 index 000000000..f951db5d1 --- /dev/null +++ b/resources/tester/test_145_1_b09_sangeun.py @@ -0,0 +1,179 @@ +"""145-1 B09 단가산출근거 · 목록표 — 승격 줄 → 산근 블록 · 엑셀 검산 · 같은 산근 하나 · 빈 표. + +- 산근 = 연결된 사본 일위대가 로직 줄 중 `승격: true` + 옛 SP 참조 줄(134-2 ③) · 승격 아닌 줄은 안 담음 +- 줄 금액 = ROUNDDOWN(B09 굳힌 단가 × 1 단위 수량, 1) · 총계 = 비목마다 ROUNDDOWN 0 — 엑셀 + 단가산출근거 제 6호표(뒷채움) 줄 값 · 총계 8,328(6,878 · 538 · 912)과 같음 +- 같은 로직 + 같은 입력값 = 산근 하나(쓰는 구조물이 늘어남) · 입력이 다르면 둘 · 번호 = 첫 등장 +- 사본 없으면 빈 표 + 「연결 없음」 · 조합 밖 토공 · 운반 산근 안내 한 줄 · 작업량 Q = ROUND 2 +""" + +import asyncio +import json +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +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_Router as router_mod # noqa: E402 +from B09_Estimation import B09_Estimation_Sangeun as sg # noqa: E402 +from B09_Estimation.B09_Estimation_Calc_Base import compute_base # noqa: E402 +from B09_Estimation.B09_Estimation_Linked import used_keys # noqa: E402 +from M02_MasterTemplete import M02_Item_Prices as item_prices # noqa: E402 + +STONE = "GF000219" # 돌쌓기 메쌓기(인력) — 석공 0.195 · 보통인부 0.156 / ㎡ +NET = "GC000337" # 낙석방지망 — 노무 · 크레인(중기) · 자재 셋 +NET_IN = {"구분": "기계식", "방지망": "포켓식", "지역": "전국평균", "크레인": "2104-0010"} +SP_REF = {"층": "system", "주인": None, "키": "SP-000001"} + + +def _snapshot(folder: Path, rows, values=None, copies=None) -> Path: + folder.mkdir(parents=True, exist_ok=True) + labor = { + "줄": {"종류": "일위대가", "담은로직": rows}, + **({"참조사본": copies} if copies else {}), + } + files = { + "단가.json": item_prices.to_json(item_prices.collect([STONE, NET])), + "일위대가.json": labor, + "입력값.json": {k: {n: {"값": v} for n, v in d.items()} for k, d in (values or {}).items()}, + } + for name, data in files.items(): + (folder / name).write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + return folder + + +def _sheets(*folders: Path) -> dict: + keys = [k for f in folders for k in used_keys(f)] + 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}) + sheets = _sheets(a) + got = sg.collect([("s1", a)], sheets) + (block,) = got["sg"] + assert (block["no"], block["logic"], block["unit"]) == (1, STONE, "㎡") + labor = {r["key"]: r["price"] for r in sheets["labor"]} + lines = block["lines"] + assert [(ln["kind"], ln["ref"]["key"], ln["qty"]) for ln in lines] == [ + ("labor", "LB000033", 0.195), + ("labor", "LB000002", 0.156), + ] + want = [sg.rounddown(labor[ln["ref"]["key"]] * ln["qty"], 1) for ln in lines] + assert [ln["labor"] for ln in lines] == want + assert block["labor"] == sg.rounddown(sum(want), 0) and block["sum"] == block["labor"] + assert block["material"] == 0 and block["expense"] == 0 and block["why"] is None + assert got["sg_list"][0]["key"] == block["key"] and got["sg_gaps"] == [] + assert sg.price_of(got, block["key"]) == { + "no": 1, "labor": block["labor"], "material": 0.0, "expense": 0.0, "sum": block["sum"], + } # fmt: skip + + +def test_중기는_중기목록표_노_재_경_자재는_적용_단가(tmp_path): + a = _snapshot(tmp_path / "a", [{"로직": NET, "승격": True}], {NET: NET_IN}) + sheets = _sheets(a) + (block,) = sg.collect([("s1", a)], sheets)["sg"] + kinds = [ln["kind"] for ln in block["lines"]] + assert kinds == ["labor", "labor", "machine", "material", "material", "material"] + crane = block["lines"][2] + row = next(r for r in sheets["machine"] if r["key"] == crane["ref"]["key"]) + for part in sg.PARTS: + assert crane[part] == sg.rounddown(row[part] * crane["qty"], 1) + net = block["lines"][3] + applied = next(r["applied"] for r in sheets["compare"] if r["key"] == net["ref"]["key"]) + assert net["material"] == sg.rounddown(applied * net["qty"], 1) + assert block["sum"] == block["labor"] + block["material"] + block["expense"] + + +def test_엑셀_6호표_뒷채움_검산(): + """단가산출근거 C285 = 8,328(6,878 · 538 · 912) — 보통인부 0.018 × 165,545 · 굴삭기(0.2m3) 0.07 × + 중기목록표 55,700 · 7,695 · 13,041(줄 2,979.8 · 3,899.0 · 538.6 · 912.8 · 계 8,330.2 → 비목마다 버림).""" + zero = dict.fromkeys(sg.PARTS, 0.0) + labor = {**zero, "labor": sg._money(165545, 0.018)} + machine = {p: sg._money(v, 0.07) for p, v in zip(sg.PARTS, (55700, 7695, 13041))} + assert labor["labor"] == 2979.8 + assert (machine["labor"], machine["material"], machine["expense"]) == (3899.0, 538.6, 912.8) + assert sg.block_totals([labor, machine]) == { + "labor": 6878, "material": 538, "expense": 912, "sum": 8328, + } # fmt: skip + 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, "": ""}}) + other = {**NET_IN, "방지망": "비포켓식"} + c = _snapshot(tmp_path / "c", [{"로직": NET, "승격": True}], {NET: other}) + sp = {"담은로직": [{"로직": NET, "입력값": NET_IN}], "배": 1} + d = _snapshot( + tmp_path / "d", + [{"로직": "", "참조": SP_REF}, {"로직": STONE, "승격": True}], + copies={"참조|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(같은 로직 · 입력) = 같은 산근 + (2, NET, ["s3"]), + (3, STONE, ["s4"]), + ] + assert blocks[0]["key"] == sg.sangeun_key(NET, {**NET_IN, "x": None}, 100, {}) + assert blocks[0]["key"] != sg.sangeun_key(NET, NET_IN, 90) # 켜쌓기율이 다르면 다른 산근 + + +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}]) # 승격 없는 사본도 빈 표 + assert sg.collect([("s1", plain)], {})["sg"] == [] + + async def fake_materials(_project_id): + return {} + + monkeypatch.setattr(router_mod, "material_sheets", fake_materials) + sheets = asyncio.run(router_mod.payload("p", tmp_path))["sheets"] + assert sheets["sg"] == [] and sheets["sg_list"] == [] + assert sg.EMPTY in sheets["sg_notes"] and sg.NO_SOURCE in sheets["sg_notes"] + + +def test_단가_없으면_빈칸과_연결_없음(tmp_path): + a = _snapshot(tmp_path / "a", [{"로직": STONE, "승격": True}]) + got = sg.collect([("s1", a)], {"labor": [], "machine": [], "compare": []}) + (block,) = got["sg"] + assert block["labor"] is None and block["sum"] is None + assert "노무비목록표에 없음" in block["why"] + assert [(g["tab"], g["key"]) for g in got["sg_gaps"]] == [("sg", block["key"])] + + +def test_작업량_Q는_소수_둘(): + import M01_MasterData.M01_MasterData_Store as m01 + + whole = m01.loaded()[1] + for code, row in sorted((whole.index.get("GF") or {}).items()): + if not any(sg._Q.fullmatch(s["이름"]) for s in row.get("중간", [])): + continue + try: + env = sg._env(whole, row, {}) + except Exception: # noqa: BLE001 — 입력이 필요한 로직은 건너뜀 + continue + q = next(env[s["이름"]] for s in row["중간"] if sg._Q.fullmatch(s["이름"])) + if hasattr(q, "as_tuple"): + assert q == round(q, 2), code + return + pytest.skip("자동값으로 도는 작업량 Q 로직 없음") + + +def test_화면_탭_둘_서버_산근_그대로(): + shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert "sg_list: buildSgList," in shell and "sg: buildSg," in shell + assert 'const SERVER_ONLY = ["sg", "sg_list", "sg_gaps", "sg_notes"] as const;' in shell + assert "gaps: [...base.gaps, ...(server.sg_gaps || [])]" in shell + 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 # 중기 = 노 · 재 · 경 세 줄