Merge remote-tracking branch 'origin/sub_desktop_1' into sub_laptop_2

This commit is contained in:
2026-10-09 19:30:25 +09:00
18 changed files with 1020 additions and 173 deletions
@@ -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 = [
{
@@ -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) => ({
+39 -36
View File
@@ -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()
+161 -49
View File
@@ -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`) ·
번호 = 첫 등장 차례 ·
로직 줄이 부르는 다른 돈 로직(기계경비 밖) = 하위 산근(그 산근도 목록에 · 산근 → 산근).
@@ -18,6 +20,9 @@
+ 「연결 없음」.
조합 밖 토공 · 운반 · 사토 산근 · 준비공 = 원천 없음(만들지 않음 · 목록 안내 한 줄).
대가(145-2) · 내역서(145-3)가 읽는 길 = `sangeun_key` + `price_of`.
텍스트 수식(155-5) = `B09_Estimation_Sangeun_Text`(같은 B09 단가) — `head`(머리 · 입력 · 중간) ·
줄 `text`(줄마다 · 중기 = 노 · 재 · 경 세 줄) · `foot`(계) · % 덧줄 = 규격 「노무비의 %」 ·
수량 = 율 · 단위 %(149-4 `pct_extras`).
"""
from __future__ import annotations
@@ -33,6 +38,7 @@ from B09_Estimation.B09_Estimation_Calc_Base import gap, num, round_half, roundd
from M01_MasterData import M01_MasterData_Store as m01
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M02_MasterTemplete import M02_Item_Prices as item_prices
from M02_MasterTemplete import M02_Labor_Doc as labor_doc
from M02_MasterTemplete.M02_Item_Snapshot import (
PICKS_FILE,
VALUES_FILE,
@@ -47,7 +53,7 @@ COST_PART = {"노무비": "labor", "재료비": "material", "경비": "expense"}
DEPTH = 4 # 하위 산근 깊이 상한
_Q = re.compile(r"Q\d*") # 작업량 중간값 이름(산림품셈 09장 토공 · 07장 집재 …)
NO_SOURCE = "조합 밖 토공 · 운반 · 사토 산근 · 준비공 = 원천 없음(연결 없음 · 만들지 않음)"
EMPTY = "연결된 구조물 조합 사본에 단가승격 줄 없음 — 연결 없음"
EMPTY = "연결된 구조물 조합 사본에 단가산출 줄 없음 — 연결 없음"
# ── 산근 정체 ─────────────────────────────────────────────────────────
@@ -81,42 +87,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 +157,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 +178,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] = []
@@ -228,6 +301,7 @@ class Solver:
return self._stop(code, name, str(e) or type(e).__name__)
sums = block_totals(lines)
why = next((ln["why"] for ln in lines if ln.get("why")), None)
head, foot = self._text(whole, src, times, lines)
return {
"logic": code,
"name": name,
@@ -237,12 +311,47 @@ class Solver:
"lines": lines,
**sums,
"why": why,
"head": head,
"foot": foot,
}
@staticmethod
def _stop(code: str, name: str, why: str) -> dict[str, Any]:
return {"logic": code, "name": name, "spec": "", "unit": "", "source": "", "lines": [],
**dict.fromkeys(PARTS), "sum": None, "why": why} # fmt: skip
**dict.fromkeys(PARTS), "sum": None, "why": why,
"head": [], "foot": ""} # fmt: skip
def price_of(self, key: str) -> Any:
"""글 짓기 단가(`Sangeun_Text` 꼴) — 노무 · 자재 = 수 · 중기 · 산근 = 비목 단가 묶음."""
if key in self.prices.labor:
return self.prices.labor[key]
if key in self.prices.material:
return self.prices.material[key]
if key in self.prices.machine:
return {p: num(self.prices.machine[key].get(p)) for p in PARTS}
block = self.blocks.get(key)
return {k: block.get(k) for k in (*PARTS, "sum", "no")} if block else None
def _text(self, whole, src, times, lines) -> tuple[list[str], str]:
"""텍스트 수식(155-5) → (머리 줄들, 계) · 줄마다 `text` 를 얹음(중기 = 세 줄 · 그 밖 한 줄).
줄 수가 안 맞으면 모두 머리에(값 칸과 어긋나게 얹지 않음)."""
from B09_Estimation.B09_Estimation_Sangeun_Text import render_sangeun_text
try:
text = render_sangeun_text(whole, src["code"], src["given"], self.price_of,
times=times, rate=src.get("rate"),
picks=src.get("picks")) # fmt: skip
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError):
return [], ""
counts = [3 if ln["kind"] == "machine" and not ln.get("why") else 1 for ln in lines]
start = len(text) - 1 - sum(counts)
if start < 1:
return text, ""
at = start
for ln, n in zip(lines, counts):
ln["text"] = text[at : at + n]
at += n
return text[:start], text[-1]
def _blank(self, item: dict, why: str) -> dict[str, Any]:
part = COST_PART.get(item.get("비목"), "material")
@@ -278,6 +387,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 +403,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,22 +413,28 @@ 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"]},
"price": price, **{p: _money(price[p], q) for p in PARTS}, "why": why} # fmt: skip
def _extras(self, whole, row, env, lines) -> None:
"""% 줄(덧줄) — 엔진 식 그대로 · 밑수 = 그때까지 B09 비목 합 · 줄 금액 = 이름별."""
for extra in row.get("덧줄", []):
"""% 줄(덧줄) — 엔진 식 그대로 · 밑수 = 그때까지 B09 비목 합 · 줄 금액 = 이름별 ·
「X * Y / 100」 덧줄 = 규격 「노무비의 %」 · 수량 = 율 · 단위 %(149-4 `pct_extras`)."""
heads = [{} for _ in row.get("호표", [])]
pcts = labor_doc.pct_extras(
whole, row, heads + [{} for _ in row.get("덧줄", [])], dict(env)
)
for extra, pct in zip(row.get("덧줄", []), pcts[len(heads) :]):
sums = {name: total([ln[p] for ln in lines]) for name, p in COST_PART.items()}
part = COST_PART.get(extra.get("비목"), "material")
line = {"kind": "extra", "name": extra["이름"], "spec": extra["식"], "qty": 1.0,
"unit": "식", "ref": None, **dict.fromkeys(PARTS, 0.0)} # fmt: skip
if "율" in pct:
line.update(spec=pct.get("규격") or extra["식"], qty=float(pct["율"]), unit="%")
if any(v is None for v in sums.values()):
lines.append({**line, part: None, "price": {}, "why": "밑수 빈칸"})
continue
@@ -342,11 +457,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 = [
@@ -0,0 +1,253 @@
"""B09 산근 한 항목 → 엑셀 단가산출근거 꼴 텍스트 수식 줄들(PLAN 155-5) — 서버 단독(③ · 순수 함수).
꼴 = 엑셀 단가산출근거 탭 산출내역 한 칸 글:
머리 「이름 / 단위」 · 출처 「… 적용」 · 입력 「이름 : 값 단위」 ·
중간 「Q = 이름식 = 값」(작업량 Q = ROUND 2 — `Sangeun._env`) ·
줄 「보통인부 : 172,068 * 0.022 = 3,785.4」(단가 * 수량식 값 꼴[ / 배][ * 율 %] = 금액) ·
중기 = 노무비 · 재료비 · 경비 세 줄 · 하위 산근 = 「산근 n호표 단가 * 수량」 ·
% 덧줄 = 「이름 (노무비의 %) : 밑수 * n % = 금액」(규격 = 149-4 `pct_extras`) ·
끝 = 「계 : 노무비 + 재료비 + 경비 = 합」.
식 풀이 = 엔진 `master_text` 낮은 부품(render · _names · fmt) · 단가 = 인자 `price_of(key)`(B09 굳힌
단가 · 엔진 단가 글은 안 씀) · 단가 없음 = 「단가 없음」 글.
끝수 = 엑셀 자리 — 줄 ROUNDDOWN 1 · 계 비목마다 ROUNDDOWN 0(`Sangeun._money` · `block_totals`).
`price_of(key)` — 인력 · 자재 키 → 수 | None · 기계(EQ) · 하위 산근(SG:) → {labor, material, expense
[, sum, no]} | None.
"""
from __future__ import annotations
from collections.abc import Callable
from decimal import Decimal
from typing import Any
from B09_Estimation import B09_Estimation_Sangeun as sg
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Labor_Doc as labor_doc
mf = sg.mf
mtx = m01.mtx
PART_NAME = {"labor": "노무비", "material": "재료비", "expense": "경비"}
_ERRORS = (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError)
NO_PRICE = "단가 없음"
PriceOf = Callable[[str], Any]
def _money(value: float | None) -> str:
return "빈칸" if value is None else f"{value:,.1f}"
def _price(value: Any) -> str:
return NO_PRICE if value is None else mtx.fmt_price(Decimal(str(value)))
def _qty_text(item: dict, env: dict, whole: Any, times: Decimal, rate: Decimal | None) -> str:
"""수량식 값 꼴 — 덧셈 묶음이면 괄호 · 결과단위 배 「/ 배」 · 켜쌓기율 「* 율 %」."""
text = mtx._times(mtx.render(mf.parse(item["수량"]), env, whole), item, env, whole)
if times != 1:
text += f" / {mtx.fmt(times)}"
if rate is not None:
text += f" * {mtx.fmt(rate * 100)} %"
return text
def _line(name: str, price: Any, qty_text: str, q: float) -> tuple[str, float | None]:
money = None if price is None else sg._money(float(price), q)
return f"{name} : {_price(price)} * {qty_text} = {_money(money)}", money
def _head(row: dict, env: dict, whole: Any, times: Decimal) -> list[str]:
unit = str(row.get("결과단위") or "").removeprefix("원/")
if times != 1:
unit = unit.removeprefix(mtx.fmt(times))
out = [f"{row.get('이름') or row.get('키')} / {unit}"]
if row.get("출처"):
out.append(f"{row['출처']} 적용")
for spec in row.get("입력", []):
value = env.get(spec["이름"])
if value is None:
continue
shown = mtx.fmt(value) if isinstance(value, Decimal) else str(value)
tail = f" {spec['단위']}" if spec.get("단위") else ""
out.append(f"{spec['이름']} : {shown}{tail}")
name_of = mf_names(row, whole)
for step in row.get("중간", []):
value = env.get(step["이름"])
if value is None or isinstance(value, mf.Blank):
out.append(f"{step['이름']} = 값 없음")
continue
shown = mtx.fmt(value)
try:
named = mtx._names(step["식"], env, whole, name_of)
except _ERRORS:
named = shown
out.append(
f"{step['이름']} = {shown}" if named == shown else f"{step['이름']} = {named} = {shown}"
)
return out
def mf_names(row: dict, whole: Any) -> dict:
"""별칭 이름표(표 찾기 · 로직 부르기 → 짧은 이름) — 못 지으면 빈 표(값 꼴로 물러남)."""
try:
return mtx.mc.names(mtx.mc.aliases(row, whole))
except _ERRORS:
return {}
def _material_key(whole: Any, item: dict, ref: Any, env: dict, n: int) -> tuple[str, str]:
"""자재 줄 (대비표 키, 규격) — 자리표시 글(「MO:경유:전국평균」)은 그 요소의 실제 키로."""
_p, source, _w, 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 "")
)
if ":" in key:
try:
key = str(whole.get(key).get("키") or key)
except _ERRORS:
pass
return key, str(goods.get("규격") or "")
def _logic_lines(whole, item, env, qty_text, q, price_of) -> list[tuple[str, dict]]:
"""로직 부르기 줄 — 기계경비 = 중기 노 · 재 · 경 세 줄 · 그 밖 = 하위 산근 한 줄."""
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
name = item.get("이름", "")
if target and any(s.get("이름") == "기계" for s in target.get("입력", [])):
keys = sg._captured(whole, node, env)
got = price_of(keys[0]) if keys else None
if not got:
return [(f"{name} : {NO_PRICE} * {qty_text}", dict.fromkeys(sg.PARTS))]
out = []
for part in sg.PARTS:
text, money = _line(f"{name} {PART_NAME[part]}", got.get(part), qty_text, q)
out.append((text, {part: money}))
return out
if not target:
return [(f"{name} : 하위 로직을 못 펼침", dict.fromkeys(sg.PARTS))]
given = {k: mf.evaluate(expr, env, whole, 1) for k, expr in node[2]}
got = price_of(sg.sangeun_key(called, sg._clean_given(given))) or {}
parts = {p: got.get(p) for p in sg.PARTS}
if any(v is None for v in parts.values()):
return [(f"{name} : {NO_PRICE} * {qty_text}", dict.fromkeys(sg.PARTS))]
amounts = {p: sg._money(float(v), q) for p, v in parts.items()}
head = f"산근 {got['no']}호표 " if got.get("no") else ""
unit = sum(float(v) for v in parts.values())
text = f"{name} : {head}{_price(unit)} * {qty_text} = {_money(sum(amounts.values()))}"
return [(text, amounts)]
def _pct_specs(whole, row, env) -> list[str]:
"""% 덧줄마다 규격(「노무비의 %」 · 149-4 `M02_Labor_Doc.pct_extras`) · % 아닌 덧줄 = 빈 글."""
head = [{} for _ in row.get("호표", [])]
tail = [{} for _ in row.get("덧줄", [])]
got = labor_doc.pct_extras(whole, row, head + tail, dict(env))[len(head) :]
return [str(one.get("규격") or "") for one in got]
def _extra_lines(whole, row, env, amounts: list[dict]) -> list[tuple[str, dict]]:
"""% 줄(덧줄) — 밑수 = 그때까지 B09 비목 합(`Sangeun._extras` 와 같음) · 금액 ROUNDDOWN 1."""
out: list[tuple[str, dict]] = []
specs = _pct_specs(whole, row, env)
for extra, spec in zip(row.get("덧줄", []), specs):
part = sg.COST_PART.get(extra.get("비목"), "material")
sums = {
name: sg.total([a.get(p, 0.0) for a in amounts]) for name, p in sg.COST_PART.items()
}
head = f"{extra['이름']}{f' ({spec})' if spec else ''} : "
if any(v is None for v in sums.values()):
out.append((f"{head}밑수 빈칸", {part: None}))
amounts.append({part: None})
continue
env.update({k: Decimal(str(v)) for k, v in sums.items()})
try:
node = mf.parse(extra["식"])
value = float(mf.evaluate(node, env, whole, 0))
shown = mtx.render(node, env, whole)
except _ERRORS as e:
out.append((f"{head}계산 못 함 — {e}", {part: None}))
amounts.append({part: None})
continue
money = sg.rounddown(value, 1)
out.append((f"{head}{shown} = {_money(money)}", {part: money}))
amounts.append({part: money})
return out
def render_sangeun_text(
whole: Any,
logic_id: str,
inputs: dict | None,
price_of: PriceOf,
*,
times: Any = 1,
rate: Any = None,
picks: dict | None = None,
) -> list[str]:
"""산근 한 항목(로직 + 입력값 + 단가) → 엑셀 꼴 글 줄들.
whole = 사본 단가 묶음(`item_prices.load_prices`) · inputs = 입력값(빈 칸 = 자동값) ·
price_of = B09 단가 조회 · times = 결과단위 배(「원/10㎡」 = 10) ·
rate = 켜쌓기율 %(100 · None = 없음) · picks = 고른 자재."""
try:
row = whole.logic(logic_id)
except mf.FormulaError as e:
return [f"{logic_id} : 로직 없음 — {e}"]
times = Decimal(str(times or 1))
scale = Decimal(str(rate)) / 100 if rate not in (None, 100) else None
try:
env = sg._env(whole, row, sg._clean_given(inputs))
except _ERRORS as e:
return [f"{row.get('이름') or logic_id} : 입력을 못 읽음 — {e}"]
out = _head(row, env, whole, times)
amounts: list[dict] = []
with mf.mp.choosing(picks):
for n, item in enumerate(row.get("호표", [])):
name = item.get("이름", "")
try:
raw = mf.evaluate(mf.parse(item["수량"]), env, whole, 0)
qty_text = _qty_text(item, env, whole, times, scale)
except mf.EmptyInput as e:
out.append(f"{name} : 수량 없음 — {e}")
amounts.append({sg.COST_PART.get(item.get("비목"), "material"): None})
continue
q = float(Decimal(str(raw)) / times * (scale or 1))
kind = item.get("종류")
if kind == "로직":
for text, money in _logic_lines(whole, item, env, qty_text, q, price_of):
out.append(text)
amounts.append(money)
continue
if kind == "인력":
key = mf.fill(item["요소"], env) if isinstance(item["요소"], str) else ""
text, money = _line(name, price_of(key), qty_text, q)
out.append(text)
amounts.append({"labor": money})
continue
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)
)
key, spec = _material_key(whole, item, ref, env, n)
price = price_of(key)
if q == 0 and price is None:
price = 0.0 # 안 쓰는 재료 줄 — 금액 0(`Sangeun._line` 과 같음)
label = f"{name}({spec})" if spec else name
text, money = _line(label, price, qty_text, q)
out.append(text)
amounts.append({sg.COST_PART.get(item.get("비목"), "material"): money})
for text, _money_part in _extra_lines(whole, row, env, amounts):
out.append(text)
sums = sg.block_totals([{**dict.fromkeys(sg.PARTS, 0.0), **a} for a in amounts])
parts = " + ".join(
f"{PART_NAME[p]} {'빈칸' if sums[p] is None else f'{sums[p]:,.0f}'}" for p in sg.PARTS
)
out.append(f"계 : {parts} = {'빈칸' if sums['sum'] is None else f'{sums["sum"]:,.0f}'}")
return out
@@ -199,6 +199,7 @@ export function renderB09Estimation(root: HTMLElement): void {
caption: TABS.find(([id]) => id === tab)?.[1],
editable: spec.editable,
onEdit: spec.onEdit,
rowClass: spec.rowClass,
onCellClick: (i, key) => {
if (spec.onCell) spec.onCell(i, key);
if (spec.link && !spec.editable?.(i, key)) goto(spec.link(i, key));
+2
View File
@@ -85,6 +85,8 @@ export interface TabTable {
onEdit?: (index: number, key: string, text: string) => void;
/** 칸 누름 → 그 자리 동작(예 관급 · 별도 표시 바꿈) */
onCell?: (index: number, key: string) => void;
/** 줄 꼴 class(공용 표 `rowClass` · 예 제목 줄 굵게) */
rowClass?: (index: number) => string | null | undefined;
}
export type TabBuild = (ctx: TabContext) => TabTable | string;
+10 -3
View File
@@ -4,6 +4,7 @@
*
* 목록표 = 호표 한 줄(합계 · 노무비 · 재료비 · 경비) · 줄 누름 = 그 블록으로
* 일위대가표 = 블록(머리 · [품셈 인용] · 줄 · 합계) · 줄 누름 = 단가를 낸 탭 · 줄(대가 줄 = 그 블록)
* 줄 꼴(155-2) = 머리 · 합계 굵게(`is-title`) · 인용 · 줄 = 들여쓰기 「ㄴ」(2단 · 겹 대가는 안 펼침)
* 연결된 구조물 조합 사본이 없으면 빈 표 「연결 없음」
* ========================================================================== */
@@ -21,6 +22,8 @@ import {
const EMPTY =
"연결 없음 — 연결된 구조물 조합 사본(일위대가)이 없음 · B05 · B06 에서 조합을 고르고 [저장]";
/** 하위 줄 앞 — 들여쓰기 + 「ㄴ」(공용 표가 앞 빈칸을 접지 않음) */
const CHILD = " ㄴ ";
const ROUNDS = "줄 금액 ROUNDDOWN(단가 × 수량, 1) · % 줄 = 밑수 × % / 100 · 합계 ROUNDDOWN 0";
/** 블록 줄 → 단가를 낸 탭 */
@@ -29,7 +32,7 @@ const LINE_TAB: Record<string, TabId> = {
material: "material",
machine: "machine",
daega: "dg",
sangeun: "sg_list",
sangeun: "sg",
};
export const buildDaegaList: TabBuild = ({ sheets }): TabTable | string => {
@@ -59,7 +62,9 @@ export const buildDaegaBlocks: TabBuild = ({ sheets }): TabTable | string => {
if (!blocks.length) return EMPTY;
const rows: DataRow[] = [];
const links: Array<Link | null> = [];
const titles = new Set<number>();
for (const b of blocks) {
titles.add(rows.length);
rows.push({
key: b.id,
name: `제 ${b.no} 호표 ${b.name}`,
@@ -69,13 +74,13 @@ export const buildDaegaBlocks: TabBuild = ({ sheets }): TabTable | string => {
});
links.push(null);
if (b.cite) {
rows.push({ key: `${b.id}|cite`, name: ` ${b.cite}`, note: "품셈 인용" });
rows.push({ key: `${b.id}|cite`, name: `${CHILD}${b.cite}`, note: "품셈 인용" });
links.push(null);
}
b.lines.forEach((ln: Row, n: number) => {
rows.push({
key: `${b.id}|${n}`,
name: ` ${ln.name || ""}`,
name: `${CHILD}${ln.name || ""}`,
spec: ln.spec || "",
qty: ln.qty,
unit: ln.unit || "",
@@ -92,6 +97,7 @@ export const buildDaegaBlocks: TabBuild = ({ sheets }): TabTable | string => {
const tab = LINE_TAB[ln.kind];
links.push(tab && ln.key ? { tab, key: String(ln.key) } : null);
});
titles.add(rows.length);
rows.push({
key: `${b.id}|합계`,
name: " 합 계",
@@ -122,5 +128,6 @@ export const buildDaegaBlocks: TabBuild = ({ sheets }): TabTable | string => {
frozen: 1,
hint: `단가 = B09 프로젝트 한 벌 굳힌 단가 · ${ROUNDS} · 줄 누름 = 단가를 낸 탭(대가 줄 = 그 블록)`,
link: (i: number) => links[i] ?? null,
rowClass: (i: number) => (titles.has(i) ? "is-title" : null),
};
};
@@ -4,8 +4,10 @@
* (서버 단독 · 이 탭엔 조작 없음 · [저장] · [단가 갱신] 때 서버가 다시 셈).
*
* 목록표 = 산근 호표 한 줄(합계 · 노무비 · 재료비 · 경비 · 1 단위) · 줄 누름 = 그 블록
* 풀이표 = 블록 머리(제 n 호표) · 줄(노무 · 중기 노 · 재 · 경 · 자재 · 하위 산근 · % 줄) · 총계
* 줄 누름 = 단가를 낸 탭 · 줄(노무비목록표 · 중기목록표 · 자재단가대비표 · 하위 산근)
* 풀이표(155-5 · 엑셀 단가산출근거 꼴) = 공종 · 산출내역(서버가 지은 텍스트 수식 한 칸) · 합계 ·
* 노무비 · 재료비 · 경비 — 머리(제 n 호표 · 출처 · 입력 · 중간 Q) → 줄(중기 = 노 · 재 · 경
* 세 줄) → 총계(계) · 줄 누름 = 단가를 낸 탭 · 줄(노무비목록표 · 중기목록표 · 자재단가대비표 ·
* 하위 산근)
* ========================================================================== */
import type { DataRow } from "@ui/ui_template_data_table";
@@ -50,7 +52,7 @@ export const buildSgList: TabBuild = ({ sheets }): TabTable => {
rows,
frozen: 2,
hint:
"산근 = 연결된 구조물 조합 사본의 「단가승격」 로직 줄 · 같은 로직 + 같은 입력값 = 한 호표 · 합계 = 블록 총계 · 줄 누름 = 그 블록" +
"산근 = 연결된 구조물 조합 사본의 단가산출 로직 줄 · 사전조합 안 줄 · 일위대가화 줄의 하위 로직(노무 · 자재 · 중기 줄은 각 목록표) · 같은 로직 + 같은 입력값 = 한 호표 · 합계 = 블록 총계 · 줄 누름 = 그 블록" +
notes(sheets),
link: (i) => (rows[i] ? { tab: "sg", key: String(rows[i].key) } : null),
};
@@ -63,79 +65,85 @@ const LINE_TAB: Record<string, TabId> = {
sg: "sg",
};
/** 공종 칸(엑셀 A 열) — 줄 꼴 이름 · 하위 산근 = 「산근 n호표」 */
const KIND_NAME: Record<string, string> = { labor: "노무", machine: "중기", material: "자재" };
export const buildSg: TabBuild = ({ sheets }): TabTable | string => {
const rows: DataRow[] = [];
const links: Array<Link | null> = [];
const push = (row: DataRow, link: Link | null = null): void => {
const titles = new Set<number>();
const push = (row: DataRow, link: Link | null = null, title = false): void => {
if (title) titles.add(rows.length);
rows.push(row);
links.push(link);
};
for (const b of (sheets.sg as Row[]) || []) {
push({
key: b.key,
name: `제 ${b.no} 호표 ${b.name}`,
spec: b.spec,
unit: b.unit,
sum: b.sum,
labor: b.labor,
material: b.material,
expense: b.expense,
});
if (b.source) push({ key: `${b.key}|출처`, name: ` ${b.source}` });
const blocks = (sheets.sg as Row[]) || [];
const noOf: Record<string, number> = Object.fromEntries(blocks.map((b) => [b.key, b.no]));
for (const b of blocks) {
const head: string[] = b.head?.length ? b.head : [`${b.name} / ${b.unit}`];
push({ key: b.key, kind: `제 ${b.no} 호표`, text: head[0], note: b.spec }, null, true);
head.slice(1).forEach((line, n) => push({ key: `${b.key}|머리${n}`, text: ` ${line}` }));
(b.lines as Row[]).forEach((ln, n) => {
const link = ln.ref?.key ? { tab: LINE_TAB[ln.ref.tab] ?? "compare", key: ln.ref.key } : null;
// 중기 = 노 · 재 · 경 세 줄(엑셀 꼴) · 그 밖 = 한 줄
const parts = ln.kind === "machine" ? PARTS.filter((p) => ln.price?.[p] !== 0) : [null];
for (const part of parts) {
const kind =
ln.kind === "sangeun" && noOf[ln.ref?.key]
? `산근 ${noOf[ln.ref.key]}호표`
: (KIND_NAME[ln.kind] ?? "");
// 중기 = 노 · 재 · 경 세 줄(엑셀 꼴 · 서버 글 줄과 같은 수) · 그 밖 = 한 줄
const parts = ln.kind === "machine" && !ln.why ? PARTS : [null];
const texts: string[] = ln.text || [];
parts.forEach((part, k) => {
const at = (p: string): number | null => (part === null || part === p ? ln[p] : 0);
const price = part ? ln.price?.[part] : (Object.values(ln.price || {})[0] ?? null);
const fallback = `${ln.name}${part ? ` ${PART_NAME[part]}` : ""} · ${ln.qty ?? ""} ${ln.unit ?? ""}`;
push(
{
key: `${b.key}|${n}${part ? `|${part}` : ""}`,
name: ` ${ln.name}${part ? ` ${PART_NAME[part]}` : ""}`,
spec: ln.why ? `${ln.spec ? `${ln.spec} · ` : ""}${ln.why}` : ln.spec,
qty: ln.qty,
unit: ln.unit,
price: price as number | null,
kind: k === 0 ? kind : "",
text: ` ${texts[k] ?? fallback}`,
sum: PARTS.some((p) => at(p) === null)
? null
: PARTS.reduce((s, p) => s + (at(p) as number), 0),
labor: at("labor"),
material: at("material"),
expense: at("expense"),
note: ln.why ?? "",
},
link,
);
}
});
push({
key: `${b.key}|총계`,
name: " 총 계",
spec: b.why ?? "",
sum: b.sum,
labor: b.labor,
material: b.material,
expense: b.expense,
});
});
push(
{
key: `${b.key}|총계`,
kind: "총 계",
text: b.foot || "",
sum: b.sum,
labor: b.labor,
material: b.material,
expense: b.expense,
note: b.why ?? "",
},
null,
true,
);
}
if (!rows.length) return ((sheets.sg_notes as string[]) || []).join(" · ") || "산근 없음";
return {
columns: [
text("name", "공종 · 산출내역", 260),
text("spec", "규격 · 까닭", 180),
qty("qty", "수량"),
text("unit", "단위", 56),
money("price", "단가", undefined, 1),
text("kind", "공종", 90),
text("text", "산출내역", 520),
money("sum", "합계", undefined, 1),
money("labor", "노무비", undefined, 1),
money("material", "재료비", undefined, 1),
money("expense", "경비", undefined, 1),
text("note", "비고 · 까닭", 180),
],
rows,
frozen: 1,
hint:
"수량 = 로직 품(1 단위 · 작업량 Q = ROUND 2) · 단가 = 굳힌 B09 단가 · 줄 ROUNDDOWN 1 · 총계 비목마다 ROUNDDOWN 0 · 자재 적용 단가를 고치면 [저장] 뒤 반영 · 줄 누름 = 단가를 낸 탭" +
"산출내역 = 값이 든 식(B09 굳힌 단가 * 수량 = 금액 · 작업량 Q = ROUND 2) · 줄 ROUNDDOWN 1 · 총계 비목마다 ROUNDDOWN 0 · 자재 적용 단가를 고치면 [저장] 뒤 반영 · 줄 누름 = 단가를 낸 탭" +
notes(sheets),
link: (i: number) => links[i] ?? null,
rowClass: (i: number) => (titles.has(i) ? "is-title" : null),
};
};
+20
View File
@@ -90,5 +90,25 @@ console.log(
}),
escaped: m.renderDataTableHtml({ columns: [{ key: "x", label: "<b>" }], rows: [{ x: "a&<" }] }),
noTotal: m.renderDataTableHtml({ columns: [tc], rows: [] }).includes("<tfoot>"),
rowClass: m.renderDataTableHtml({
columns: [{ key: "x", label: "X" }],
rows: [{ x: "제 1 호표" }, { x: " ㄴ 석공" }],
total: { x: 3 },
rowClass: (i) => (i === 0 ? "is-title" : null),
}),
scale: m.SCALE,
widths: m.renderDataTableHtml({
columns: [
{ key: "a", label: "A" },
{ key: "b", label: "B", kind: "number" },
{ key: "c", label: "C", width: 120 },
],
rows: [{ a: 1 }],
frozen: 2,
}),
css: fs.readFileSync(
path.join(__dirname, "..", "..", "ui_template", "ui_template_data_table.css"),
"utf8",
),
}),
);
+24 -16
View File
@@ -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
@@ -176,4 +183,5 @@ def test_화면_탭_둘_서버_산근_그대로():
assert "gaps: [...base.gaps, ...(server.sg_gaps || [])" in shell # 뒤에 대가(145-2) gaps 이음
tabs = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Sangeun.ts").read_text("utf-8")
assert 'link: (i) => (rows[i] ? { tab: "sg", key: String(rows[i].key) } : null),' in tabs
assert "PARTS.filter((p) => ln.price?.[p] !== 0)" in tabs # 중기 = 노 · 재 · 경 세 줄
# 155-5 — 중기 = 노 · 재 · 경 세 줄(서버 글 줄 수와 같게 · 값 0 줄도 둠)
assert 'const parts = ln.kind === "machine" && !ln.why ? PARTS : [null];' in tabs
+37 -21
View File
@@ -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
+133
View File
@@ -0,0 +1,133 @@
"""155-5 산근 텍스트 수식 — 엑셀 단가산출근거 꼴 글 줄(`B09_Estimation_Sangeun_Text`).
- 엑셀 6호표 뒷채움: 「보통인부 : 165,545 * 0.018 = 2,979.8」 · 중기 노 · 재 · 경 세 줄 ·
「계 : 노무비 6,878 + 재료비 538 + 경비 912 = 8,328」(줄 ROUNDDOWN 1 · 계 ROUNDDOWN 0)
- 엑셀 7호표 막자갈채집: 작업량 Q = ROUND 2 중간 줄 · 「55,700 * 1 / 21.59 * 2 = 5,159.7」
- % 덧줄 = 「공구손료 (노무비의 %) : 6,878.8 * 3 % = 206.3」(규격 = 149-4 `pct_extras`)
- 단가 없음 = 「단가 없음」 글 · 계 그 비목 빈칸 · 글 속 금액 = 산근 풀이(`Sangeun.Solver`) 줄 값
"""
import copy
import sys
from pathlib import Path
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_Sangeun as sg # noqa: E402
from B09_Estimation import B09_Estimation_Sangeun_Text as st # noqa: E402
from M02_MasterTemplete import M02_Item_Prices as item_prices # noqa: E402
BASE = "GF000218" # 기초다짐 및 뒷채움(기계) — 보통인부 + 굴착기 0.2㎥ 로직 줄
GIVEN = {"규격": 150, "지역": "전국평균", "보정작업": "해당 없음"}
EXCEL_EQ = {"labor": 55700, "material": 7695, "expense": 13041} # 중기목록표 굴삭기 0.2㎥
def _whole(edit=None):
"""사본 단가 묶음 — `edit(로직 줄)` 로 GF000218 을 엑셀 견본 꼴로 바꿈(수량 · 중간 · 덧줄)."""
files = copy.deepcopy(item_prices.to_json(item_prices.collect([BASE])))
if edit:
for data in files.values():
for row in data.get("줄") or []:
if isinstance(row, dict) and row.get("키") == BASE:
edit(row)
return item_prices.load_prices(files)
def _excel(qty_labor: str, qty_eq: str, mids=(), extras=()):
def edit(row):
labor, eq = row["호표"][0], row["호표"][1]
row["호표"] = [{**labor, "수량": qty_labor}, {**eq, "수량": qty_eq}]
row["중간"] = [{"이름": n, "식": f} for n, f in mids]
row["덧줄"] = list(extras)
return edit
def _prices(labor):
return lambda key: EXCEL_EQ if key.startswith("EQ") else labor.get(key)
def test_엑셀_6호표_뒷채움_같은_글():
whole = _whole(_excel("0.018", "0.07"))
got = st.render_sangeun_text(whole, BASE, GIVEN, _prices({"LB000002": 165545}))
assert got[:3] == ["기초다짐 및 뒷채움(기계) / ㎥", "산림품셈 13-3-2 적용", "규격 : 150 mm"]
assert got[-5:] == [
"보통인부 : 165,545 * 0.018 = 2,979.8",
"굴착기(무한궤도) 0.2㎥ 노무비 : 55,700 * 0.07 = 3,899.0",
"굴착기(무한궤도) 0.2㎥ 재료비 : 7,695 * 0.07 = 538.6",
"굴착기(무한궤도) 0.2㎥ 경비 : 13,041 * 0.07 = 912.8",
"계 : 노무비 6,878 + 재료비 538 + 경비 912 = 8,328",
]
def test_엑셀_7호표_작업량_Q_중간_줄():
whole = _whole(_excel("0", "1 / Q * 2", mids=[("Q", "21.594")]))
got = st.render_sangeun_text(whole, BASE, GIVEN, _prices({"LB000002": 165545}))
assert "Q = 21.594 = 21.59" in got # ROUND(식, 2) 뒤 쓰임
assert "굴착기(무한궤도) 0.2㎥ 노무비 : 55,700 * 1 / 21.59 * 2 = 5,159.7" in got
def test_퍼센트_덧줄_노무비의_퍼센트():
tool = {"이름": "공구손료", "식": "노무비 * 3 / 100", "비목": "경비"}
whole = _whole(_excel("0.018", "0.07", extras=[tool]))
got = st.render_sangeun_text(whole, BASE, GIVEN, _prices({"LB000002": 165545}))
assert got[-2:] == [
"공구손료 (노무비의 %) : 6,878.8 * 3 % = 206.3", # 밑수 = 인력 + 중기 노무비
"계 : 노무비 6,878 + 재료비 538 + 경비 1,119 = 8,535",
]
def test_배_켜쌓기율_꼬리_단가_없음():
whole = _whole(_excel("0.018", "0.07"))
got = st.render_sangeun_text(whole, BASE, GIVEN, _prices({}), times=10, rate=90)
assert "보통인부 : 단가 없음 * 0.018 / 10 * 90 % = 빈칸" in got
assert got[-1] == "계 : 노무비 빈칸 + 재료비 48 + 경비 82 = 빈칸"
def _solver_prices(whole, code, given):
"""산근 풀이와 같은 단가 — 키마다 같은 수(노무 · 자재 1,000 · 중기 엑셀 값)."""
sheets = {"labor": [], "machine": [], "compare": []}
asked = []
def price_of(key):
asked.append(key)
return EXCEL_EQ if key.startswith("EQ") else 1000.0
st.render_sangeun_text(whole, code, given, price_of)
for key in dict.fromkeys(asked):
if key.startswith("EQ"):
sheets["machine"].append({"key": key, **EXCEL_EQ})
elif key.startswith("LB"):
sheets["labor"].append({"key": key, "price": 1000.0})
else:
sheets["compare"].append({"key": key, "applied": 1000.0})
return sheets, price_of
def test_글_속_금액은_산근_풀이_값과_같음():
for code, given in ((BASE, GIVEN), ("GF000288", {})): # 중기 · 노무 · % 덧줄 · 배 1,000
whole = item_prices.load_prices(item_prices.to_json(item_prices.collect([code])))
sheets, price_of = _solver_prices(whole, code, given)
block = sg.Solver(sheets).add(whole, {"code": code, "given": given}, "s1")
times = sg._unit(code)[1] # 결과단위 배(「원/1,000㎡」) — 산근 풀이와 같은 1 단위
got = st.render_sangeun_text(whole, code, given, price_of, times=times)
parts = " + ".join(f"{st.PART_NAME[p]} {block[p]:,.0f}" for p in sg.PARTS)
assert got[-1] == f"계 : {parts} = {block['sum']:,.0f}", code
def test_자재_자리표시_키는_실제_키로():
"""「MO:휘발유:{지역}」 자리표시 = 대비표 키(MO…)로 물음 — 산근 풀이 흠(H4 · 155 데스크탑 보조가
고침)과 달리 단가가 나옴 · Q 중간 줄 · 「줄.'이름'」 % 덧줄."""
whole = item_prices.load_prices(item_prices.to_json(item_prices.collect(["GF000062"])))
asked = []
def price_of(key):
asked.append(key)
return 1000.0
got = st.render_sangeun_text(whole, "GF000062", {}, price_of)
assert not [k for k in asked if ":" in k] and any(k.startswith("MO") for k in asked)
assert "Q = 아키야윈치임업용윈치집재.수집량 = 8" in got and "일수 = 1 / Q = 0.125" in got
assert "휘발유(아키아윈치(휘발유)) : 1,000 * 6.5 * 0.125 = 812.5" in got
+133
View File
@@ -0,0 +1,133 @@
"""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)
def test_155_5_산근_글_줄마다_덧줄_퍼센트_표기(tmp_path):
lines = [{"로직": "GF000143", "수량": {"손": 1}}, {"로직": PILE, "수량": {"손": 20}}]
root = t145._project(tmp_path / "p", [(100, "SS000901")])
lines = [{"로직": "GF000143", "수량": {"손": 1}}, {"로직": PILE, "수량": {"손": 20}}]
t145._snap(root, "pipe@100.0", "SS000901", lines, {})
inputs, sheets = _sheets_of(root)
crane, pile = sheets["sg"]
assert crane["head"][:2] == ["케이블 크레인 운반대 설치 / 1기", "산림품셈 10-8-2 적용"]
extra = crane["lines"][-1]
assert (extra["kind"], extra["spec"], extra["qty"], extra["unit"]) == (
"extra", "재료비의 %", 20.0, "%",
) # fmt: skip
assert extra["text"][0].startswith("기타비용 (재료비의 %) : ")
for b in (crane, pile):
for ln in b["lines"]:
money = sum(ln[p] or 0 for p in sg.PARTS)
assert ln["text"][-1].endswith(f"= {money:,.1f}") or ln["kind"] == "machine"
assert b["foot"] == (
f"계 : 노무비 {b['labor']:,.0f} + 재료비 {b['material']:,.0f}"
f" + 경비 {b['expense']:,.0f} = {b['sum']:,.0f}"
)
assert " / 10 = " in pile["lines"][0]["text"][0] # 배 10 로직 꼬리
+104
View File
@@ -0,0 +1,104 @@
"""155 B09 탭 화면 꼴 — 일위대가표 줄 꼴(155-2) · 단가산출근거 텍스트 수식 칸(155-5).
`B09_Estimation_UI_Tabs.ts` + `B09_Estimation_UI_Tabs_Labor.ts` 를 Node(타입 지움)로 돌려
견본 블록 하나(머리 · 품셈 인용 · 줄 둘 · 합계)의 표 줄 글 · 줄 꼴을 판정.
"""
import json
import re
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
BLOCK = {
"id": "DG1",
"no": 1,
"name": "찰쌓기",
"spec": "",
"unit": "m2",
"kind": "품셈",
"cite": "건설표준품셈 7-1-2",
"lines": [
{"kind": "labor", "key": "L00005", "name": "석공", "qty": 0.072, "unit": "인",
"price": 258935, "amount": 18643.3, "ref_no": "노무3",
"prices": {"labor": 258935, "material": None, "expense": None},
"amounts": {"labor": 18643.3, "material": None, "expense": None}},
{"kind": "sangeun", "key": "SG1", "name": "뒷채움", "qty": 0.5, "unit": "m3",
"price": 8328, "amount": 4164, "ref_no": "산근 1호표",
"prices": {"labor": 6878, "material": 538, "expense": 912},
"amounts": {"labor": 3439, "material": 269, "expense": 456}},
],
"sum": 22807, "labor": 22082, "material": 269, "expense": 456,
} # fmt: skip
def _node(js_body, tab="UI_Tabs_Labor"):
src = "".join(
IMPORTS.sub("", (ROOT / "B09_Estimation" / f"B09_Estimation_{n}.ts").read_text("utf-8"))
for n in ("UI_Tabs", tab)
)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", src + js_body],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_155_2_일위대가표_제목_굵게_하위_ㄴ():
out = _node(
f"\nconst t = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(BLOCK)}] }} }});"
"\nconsole.log(JSON.stringify({ names: t.rows.map((r) => r.name),"
" cls: t.rows.map((_, i) => t.rowClass(i) ?? null) }));"
)
assert out["names"] == [
"제 1 호표 찰쌓기",
" ㄴ 건설표준품셈 7-1-2",
" ㄴ 석공",
" ㄴ 뒷채움",
" 합 계",
]
assert out["cls"] == ["is-title", None, None, None, "is-title"]
SG = {
"key": "SG:A", "no": 1, "name": "뒷채움", "unit": "m3", "spec": "규격=150",
"head": ["뒷채움 / m3", "산림품셈 13-3-2 적용", "규격 : 150 mm"],
"lines": [
{"kind": "labor", "name": "보통인부", "ref": {"tab": "labor", "key": "LB2"}, "why": None,
"labor": 2979.8, "material": 0.0, "expense": 0.0,
"text": ["보통인부 : 165,545 * 0.018 = 2,979.8"]},
{"kind": "machine", "name": "굴착기", "ref": {"tab": "machine", "key": "EQ1"}, "why": None,
"labor": 3899.0, "material": 538.6, "expense": 912.8,
"text": ["굴착기 노무비 : 55,700 * 0.07 = 3,899.0", "굴착기 재료비 : 7,695 * 0.07 = 538.6",
"굴착기 경비 : 13,041 * 0.07 = 912.8"]},
],
"labor": 6878, "material": 538, "expense": 912, "sum": 8328, "why": None,
"foot": "계 : 노무비 6,878 + 재료비 538 + 경비 912 = 8,328",
} # fmt: skip
def test_155_5_단가산출근거_엑셀_꼴_칸():
out = _node(
f"\nconst t = buildSg({{ sheets: {{ sg: [{json.dumps(SG, ensure_ascii=False)}] }} }});"
"\nconsole.log(JSON.stringify({ rows: t.rows, cols: t.columns.map((c) => c.key),"
" cls: t.rows.map((_, i) => t.rowClass(i) ?? null) }));",
"UI_Tabs_Sangeun",
)
assert out["cols"] == ["kind", "text", "sum", "labor", "material", "expense", "note"]
rows = out["rows"]
assert [(r.get("kind"), r["text"]) for r in rows] == [
("제 1 호표", "뒷채움 / m3"),
(None, " 산림품셈 13-3-2 적용"),
(None, " 규격 : 150 mm"),
("노무", " 보통인부 : 165,545 * 0.018 = 2,979.8"),
("중기", " 굴착기 노무비 : 55,700 * 0.07 = 3,899.0"),
("", " 굴착기 재료비 : 7,695 * 0.07 = 538.6"),
("", " 굴착기 경비 : 13,041 * 0.07 = 912.8"),
("총 계", "계 : 노무비 6,878 + 재료비 538 + 경비 912 = 8,328"),
]
assert [r.get("sum") for r in rows[3:7]] == [2979.8, 3899.0, 538.6, 912.8]
assert out["cls"] == ["is-title"] + [None] * 6 + ["is-title"]
+21
View File
@@ -101,3 +101,24 @@ def test_줄_누름_옵션일_때만_data_i(r):
assert '<tr class="is-alt is-clickable" data-i="1">' in pick
assert pick.count("data-i=") == 2
assert "data-i" not in r["opts"] and "is-clickable" not in r["foot"]
def test_155_1_글자_14px_열_폭_같은_비율(r):
# 155-1 — 표 글 12 → 14px · 열 폭(12px 글 기준 값)에 14/12 를 곱함 · 줄 높이 28px
assert abs(r["scale"] - 14 / 12) < 1e-9
w = r["widths"]
assert '<col style="width:112px"><col style="width:98px"><col style="width:140px">' in w
assert 'style="width:350px"' in w # 표 폭 = 112 + 98 + 140
assert 'class="is-frozen is-edge" style="left:112px"' in w # 둘째 고정 열 left 도 같이
assert "font-size: var(--text-body-sm);" in r["css"]
assert "--dt-row-h: 28px;" in r["css"]
def test_155_2_줄_꼴_곁값(r):
# 155-2 — rowClass 가 낸 class 를 본문 줄에만 · 빈 값이면 안 붙임 · 앞 빈칸을 접지 않는 서식
rc = r["rowClass"]
assert '<tr class="is-title"><td>제 1 호표</td></tr>' in rc
assert '<tr class="is-alt"><td> ㄴ 석공</td></tr>' in rc
assert rc.count("is-title") == 1 # 합계 줄엔 안 붙음
assert "white-space-collapse: preserve-spaces;" in r["css"]
assert "tr.is-title td" in r["css"]
+9 -2
View File
@@ -4,7 +4,7 @@
* ========================================================================== */
.ui-data-table {
--dt-row-h: 24px;
--dt-row-h: 28px;
--dt-zebra: var(--color-surface);
--dt-hover: color-mix(in srgb, var(--color-accent) 14%, var(--color-canvas));
--dt-head: color-mix(in srgb, var(--color-accent) 6%, var(--color-surface));
@@ -30,7 +30,7 @@
border-collapse: separate;
border-spacing: 0;
color: var(--color-text-body);
font-size: var(--text-caption);
font-size: var(--text-body-sm);
line-height: 1.3;
}
@@ -52,6 +52,7 @@
background: var(--color-canvas);
text-overflow: ellipsis;
white-space: nowrap;
white-space-collapse: preserve-spaces; /* 앞 빈칸(들여쓰기) · 겹 빈칸을 접지 않음 · 줄바꿈은 안 함 */
}
/* 숫자 — 오른쪽 맞춤 · 자리 폭 같게 */
@@ -134,6 +135,12 @@
font-weight: var(--font-weight-bold);
}
/* 제목 줄(PLAN 155-2 · rowClass) — 굵게 */
.ui-data-table__table tbody tr.is-title td {
color: var(--color-text);
font-weight: var(--font-weight-bold);
}
.ui-data-table__table tbody tr.is-clickable {
cursor: pointer;
}
+15 -5
View File
@@ -9,6 +9,7 @@
* 열 = { key: "cut_soil_area", label: "단면적", group: ["절토", "토사"], kind: "number", decimals: 2 }
* 고칠 칸(PLAN 121) = editable(줄, 열) 참인 칸만 글 상자 · 바꾸면 onEdit(줄, 열, 글) — 다시 그려도 커서 자리 지킴
* 칸 누름(PLAN 144) = onCellClick(줄, 열) · 줄로 이동 = t.focusRow(줄)(스크롤 + 강조)
* 줄 꼴(PLAN 155-2) = rowClass(줄) → 줄 class(예 is-title 굵게) · 안 주면 글 그대로
* ========================================================================== */
import "./ui_template_data_table.css";
@@ -26,7 +27,7 @@ export interface DataColumn {
decimals?: number;
/** 끝 0 지움(decimals 는 최대 자리) — 사용자가 넣는 값처럼 자리가 줄마다 다른 열 */
trim?: boolean;
/** 열 폭 px(기본 숫자 84 · 글 96) */
/** 열 폭 px(12px 글 기준 · 기본 숫자 84 · 글 96) — 그릴 때 글 비 SCALE 을 곱함 */
width?: number;
/** 맨 아래 머리 칸 풍선 글(기본 label) */
title?: string;
@@ -57,6 +58,8 @@ export interface DataTableOptions {
editable?: (index: number, key: string) => boolean;
/** 고친 칸 글(바꾼 뒤 · 커서가 옮겨 간 다음에 부름) */
onEdit?: (index: number, key: string, text: string) => void;
/** 줄 꼴 class(본문 줄만 · 예 `is-title` = 굵게) — 빈 값이면 안 붙임 */
rowClass?: (index: number) => string | null | undefined;
}
export interface DataTable {
@@ -70,8 +73,11 @@ const EMPTY = "-";
const ESC: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" };
const esc = (s: string): string => s.replace(/[&<>"]/g, (c) => ESC[c]);
/** 글 크기 비(14px ÷ 12px) — 페이지가 준 열 폭(12px 글 기준)을 같은 비율로 넓힘 */
export const SCALE = 14 / 12;
function colWidth(c: DataColumn): number {
return c.width ?? (c.kind === "number" ? 84 : 96);
return Math.round((c.width ?? (c.kind === "number" ? 84 : 96)) * SCALE);
}
/** 값 한 칸 → [보일 글, 빈 값인지] */
@@ -132,6 +138,7 @@ function rowHtml(
edit: number | null = null,
editable?: (index: number, key: string) => boolean,
focus = false,
extra: string | null | undefined = null,
): string {
let tr = "";
for (let j = 0; j < cols.length; j++) {
@@ -154,11 +161,13 @@ function rowHtml(
(fmts[j] ? ' data-num=""' : "");
tr += `<td${attrs}>${esc(txt)}</td>`;
}
const cls = [alt && "is-alt", index !== null && "is-clickable", focus && "is-focus"]
const cls = [alt && "is-alt", index !== null && "is-clickable", focus && "is-focus", extra]
.filter(Boolean)
.join(" ");
const data = index !== null ? ` data-i="${index}"` : "";
return cls || data ? `<tr${cls ? ` class="${cls}"` : ""}${data}>${tr}</tr>` : `<tr>${tr}</tr>`;
return cls || data
? `<tr${cls ? ` class="${esc(cls)}"` : ""}${data}>${tr}</tr>`
: `<tr>${tr}</tr>`;
}
/** 그릴 줄 구간 — 밖의 줄은 빈 줄 높이(rowH px)로만 채움 */
@@ -208,6 +217,7 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin
i,
o.editable,
o.focus === i,
o.rowClass?.(i),
);
if (win) body += pad((n - to) * win.rowH, cols.length);
const foot = o.total ? `<tfoot>${rowHtml(cols, fmts, left, o.total, false, blank)}</tfoot>` : "";
@@ -226,7 +236,7 @@ export function createDataTable(opts: DataTableOptions): DataTable {
root.className = "ui-data-table";
root.tabIndex = 0; // 키보드로 표 안 스크롤
const state = { ...opts };
let win: RowWindow = { from: -1, to: -1, rowH: 24 }; // --dt-row-h · 그린 뒤 재서 고침
let win: RowWindow = { from: -1, to: -1, rowH: 28 }; // --dt-row-h · 그린 뒤 재서 고침
// 보이는 줄 근처만 그림 — 줄 1,000개를 다 배치하면 수백 ms
const draw = (force: boolean) => {
const n = state.rows.length;