feat(B09): 단가산출근거 · 목록표 — 승격 줄 · 옛 SP → 산근 블록 · B09 굳힌 단가 · 엑셀 끝수(145-1)

연결된 사본 일위대가 로직 줄 중 승격 + 옛 SP 참조 줄 · 같은 로직 + 같은 입력값 = 산근 하나 · 서버 단독 풀이 ·
대가 · 내역서가 읽는 길 sangeun_key · price_of · Linked.used_keys 빈 입력 = 자동값.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
This commit is contained in:
2026-10-09 15:19:31 +09:00
co-authored by Claude Opus 5.5
parent b3bd20d749
commit d9db1dfb1d
7 changed files with 722 additions and 7 deletions
+5
View File
@@ -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):
+7 -2
View File
@@ -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,
}
+377
View File
@@ -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)
+11 -4
View File
@@ -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<string, string> = {
dg_list: "일위대가목록표 — 145-2(다른 창)가 들어오면 생김",
dg: "일위대가표 — 145-2(다른 창)가 들어오면 생김",
sg_list: "단가산출근거목록표 — 145-1(다른 창)가 들어오면 생김",
sg: "단가산출근거 — 145-1(다른 창)가 들어오면 생김",
};
const BUILDERS: Partial<Record<TabId, TabBuild>> = {
@@ -64,6 +63,8 @@ const BUILDERS: Partial<Record<TabId, TabBuild>> = {
labor: buildLabor,
expense: buildExpense,
compare: buildCompare,
sg_list: buildSgList,
sg: buildSg,
};
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
@@ -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 {
@@ -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<string, string> = {
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<string, TabId> = {
labor: "labor",
machine: "machine",
compare: "compare",
sg: "sg",
};
export const buildSg: TabBuild = ({ sheets }): TabTable | string => {
const rows: DataRow[] = [];
const links: Array<Link | null> = [];
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,
};
};
+2 -1
View File
@@ -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 / "없음") == []
+179
View File
@@ -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 # 중기 = 노 · 재 · 경 세 줄