feat(778-4): 율 줄을 단가산출(SK)에 담음 — 고정 비율 더한줄 · 기준 묶음 · 줄 목록 · 율 위 율

- SK 더한줄 율 줄 허용(일위대가 비율 줄과 같은 꼴 `고정 비율 {기준, 비목}` · 수량 = 율)
- 기준 = 비목(노무비 · 재료비 · 경비) · 묶음(인력 · 재료 · 장비 = 그 줄 종류만 · 기계 노무 섞인 블록도 인력 줄만) ·
  줄 목록(cid · add:#k · 앞 율 줄 = 율 위 율) · 검사 = 자기 · 뒤 율 줄 막음
- 밑 공용 함수 `M02_Labor_Fixed.rate_base` ↔ 짝 `Labor_Calc.ts` `rateBase`(일위대가 비율 줄 · Expand 같이 씀)
- SK 셈 `_rated` = 엔진 한 번 돈 줄로 밑 → 율 줄 값묶음 → 엔진 다시(율 줄 없는 문서 = 한 번 · 값 그대로)
- B09 pct 밑수 `bundle:` · `rows:`(파이썬 · TS 짝) · SK 블록 율 줄 = pct
- 굳힘 — 바탕 없는 SK 율 줄은 한 줄로 안 펼침(참조 그대로) · 겹침 때 기준 열쇠 새 차례
- 화면 — [율 줄 추가] 창(새 `M02_MasterTemplete_Sangeun_Rate.ts` · 부품 상자만) · 율 줄 = % 줄로 보임 ·
  더한줄 지움 · 되살림 때 기준 열쇠 옮김
- 시험 — 새 test_778_4_rate_line(25) · 거울 헬퍼에 rateBase 길 · 전후 대조 SK 16 · 일위대가 118 · B09 대가 입력 14 같음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-04 12:59:48 +09:00
co-authored by Claude Opus 5.5
parent 2f93dd8b11
commit d9a581659f
16 changed files with 825 additions and 53 deletions
+20 -4
View File
@@ -10,7 +10,8 @@
중기 줄 = 중기목록표(노무 · 재료 · 경비)
대가 줄 = 부른 블록 합계(노무 · 재료 · 경비) · 산근 줄 = 145-1 산근 단가(`sangeun` {키: 값})
비고 = 대가 줄 「대가n」 · 산근 줄 「산근 n호표」(산근 목록 번호)
% 줄 = 밑수(그 블록 노무 줄 금액 합) × % / 100
% 줄 = 밑수(그 블록 노무 줄 금액 합) × % / 100 · 율 더한줄(778-4) 밑수 = `bundle:` 묶음 줄 합 ·
`rows:` 블록 몇째 줄 합(`_base`)
고정 줄(705-1) = 줄 단가(`own`) · 이음(`link`) 있으면 그 비목만 단가 책 값(노임 = 노무 · 자재 =
재료(관급 · 별도 0원) · 기계 = 중기 세 비목) · 책에 없으면 줄 단가 그대로 · 빈칸 안 됨
끝수 = 엑셀 자리 그대로 — 줄 금액 = ROUNDDOWN(단가 × 수량, 1) ·
@@ -90,12 +91,27 @@ def _fixed(line: dict[str, Any], books: dict[str, Any]) -> dict[str, float | Non
return out
def _base(spec: str, lines: list[dict[str, Any]]) -> float | None:
def _base(spec: str, lines: list[dict[str, Any]], src: list) -> float | None:
"""% 줄 밑수 — `part:비목` 그 비목 줄 금액 합 · `lines:labor` 노무 줄 금액 합 ·
`line:이름` 그 줄."""
`line:이름` 그 줄 · `bundle:labor|material|machine` 그 종류 줄(+ 그 이음 고정 줄) 합 ·
`rows:0,2` 블록 그 차례 줄(`src` cid) 합(778-4 · 짝 `.ts` `pctBase`)."""
kind, _, name = spec.partition(":")
if kind == "part":
return total([ln["amounts"][name] for ln in lines])
if kind == "rows":
cids = {
src[int(i)].get("cid") for i in name.split(",") if i.isdigit() and int(i) < len(src)
}
return total([total(list(ln["amounts"].values())) for ln in lines if ln.get("cid") in cids])
if kind == "bundle":
return total(
[
total(list(ln["amounts"].values()))
for ln in lines
if ln["kind"] == name
or (ln["kind"] == "fixed" and (ln.get("link") or {}).get(name))
]
)
if kind == "line":
return next(
(total(list(ln["amounts"].values())) for ln in lines if ln.get("name") == name), None
@@ -145,7 +161,7 @@ def _block(
for line in block["lines"]:
if line["kind"] != "pct":
continue
base = _base(line.get("base") or "lines:labor", lines)
base = _base(line.get("base") or "lines:labor", lines, block["lines"])
mine = own.get(line.get("cid")) or {}
pct = num(mine["qty"]) if "qty" in mine else num(line.get("qty"))
extra = {"base": {"qty": num(line.get("qty"))}, "covered": ["qty"]} if "qty" in mine else {}
+19 -3
View File
@@ -88,12 +88,28 @@ function fixedPrices(line: Row, books: Row): Parts {
return out;
}
/** % 줄 밑수 — part:비목 그 비목 줄 금액 합 · lines:labor 노무 줄 금액 합 · line:이름 그 줄 */
function pctBase(spec: string, lines: Row[]): Num {
/** % 줄 밑수 — part:비목 그 비목 줄 금액 합 · lines:labor 노무 줄 금액 합 · line:이름 그 줄 ·
* bundle:labor|material|machine 그 종류 줄(+ 그 이음 고정 줄) 합 · rows:0,2 블록 그 차례 줄(src cid) 합
* (778-4 · 짝 `.py` `_base`) */
function pctBase(spec: string, lines: Row[], src: Row[]): Num {
const at = spec.indexOf(":");
const kind = spec.slice(0, at);
const name = spec.slice(at + 1);
const whole = (ln: Row): Num => total(PARTS.map((p) => ln.amounts[p]));
if (kind === "part") return total(lines.map((ln) => ln.amounts[name]));
if (kind === "rows") {
const cids = new Set(
name
.split(",")
.filter((i) => /^\d+$/.test(i) && Number(i) < src.length)
.map((i) => src[Number(i)].cid),
);
return total(lines.filter((ln) => cids.has(ln.cid)).map(whole));
}
if (kind === "bundle")
return total(
lines.filter((ln) => ln.kind === name || (ln.kind === "fixed" && ln.link?.[name])).map(whole),
);
if (kind === "line") {
const hit = lines.find((ln) => ln.name === name);
return hit ? total(PARTS.map((p) => hit.amounts[p])) : null;
@@ -133,7 +149,7 @@ function block(b: Row, books: Row, sums: Record<string, Row>, gaps: Gap[], own:
}
for (const line of b.lines) {
if (line.kind !== "pct") continue;
const base = pctBase(line.base || "lines:labor", lines);
const base = pctBase(line.base || "lines:labor", lines, b.lines);
const mine: Row = own[line.cid] || {};
const pct = "qty" in mine ? num(mine.qty) : num(line.qty);
const extra = "qty" in mine ? { base: { qty: num(line.qty) }, covered: ["qty"] } : {};
+22 -5
View File
@@ -54,6 +54,7 @@ mf = item_prices.mf
#: 엔진 비목 → 블록 비목
PART = {"노무비": "labor", "재료비": "material", "경비": "expense"}
GROUP = {"인력": "labor", "재료": "material", "장비": "machine"} # 율 기준 묶음(778-4)
#: % 덧줄 밑수 — `노무비 * …` · `줄.'이름' * …` · `(석공 * LB… + …) * …`(인력 줄)
BASE_PART = re.compile(r"^\s*(노무비|재료비|경비)\s*\*")
BASE_LINE = re.compile(r"^\s*줄\.'([^']+)'\s*\*")
@@ -195,6 +196,7 @@ class Builder:
except (sd.DocError, mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError):
return None
one = {"로직": key, "입력값": got["입력값"], "켜쌓기율": got["켜쌓기율"]}
where = {c: n for n, c in enumerate(got["정체"])} # 율 기준 줄 목록(778-4) → 블록 차례
block = self._poomsem(calc._Over(whole, key, row), {**one, "고름": got["고름"]})
if block is None:
return None
@@ -208,7 +210,7 @@ class Builder:
if isinstance(item.get("참조"), dict) and depth < refs.DEPTH:
line = {**self.line_of(whole, item, qty, 1, head["name"], copies, depth), **head}
elif isinstance(item.get("고정"), dict):
line = {**fixed_line(item["고정"], qty), **head}
line = {**fixed_line(item["고정"], qty, where), **head}
elif qty and ("단가" in item or item.get("값묶음")):
own = {PART[k]: _f(v) / qty for k, v in (x.get("비목") or {}).items() if k in PART}
line = {**head, "kind": "fixed", "qty": qty, "own": own, "link": {}}
@@ -508,13 +510,28 @@ def _Loose(whole: Any, read: Any) -> Any:
return Loose(whole, read)
def fixed_line(fx: dict, qty: Any) -> dict[str, Any]:
def fixed_line(fx: dict, qty: Any, where: dict | None = None) -> dict[str, Any]:
"""고정 줄(705-1) → 블록 줄 `fixed` — 수량 그대로(배 1) · 이음 없는 비목 = 줄 단가 ·
비율 줄(705-3) = `pct`(밑 = 블록 그 비목 합 · 수량 = 율)."""
비율 줄(705-3) = `pct`(밑 = 블록 그 비목 합 · 수량 = 율) · 율 기준(778-4) 묶음 = `bundle:` ·
줄 목록 = `rows:` 블록 차례(`where` {열쇠: 차례} · 단가산출 블록만 · 없으면 빈 줄)."""
if rate := fx.get("비율"):
name = {"name": fx.get("이름") or "", "spec": fx.get("규격") or "", "unit": "%"}
return {**name, "kind": "pct", "part": PART[rate["비목"]],
"base": f"part:{PART[rate['기준']]}", "qty": _f(qty)} # fmt: skip
basis = rate["기준"]
if isinstance(basis, list) and where is None:
return {
**name,
"kind": "blank",
"qty": _f(qty),
"why": "율 기준 줄 목록 — 대가로 못 셈",
}
base = (
"rows:" + ",".join(str(where[k]) for k in basis if k in where)
if isinstance(basis, list)
else f"part:{PART[basis]}"
if basis in PART
else f"bundle:{GROUP[basis]}"
)
return {**name, "kind": "pct", "part": PART[rate["비목"]], "base": base, "qty": _f(qty)}
link = fx.get("이음") or {}
return {
"kind": "fixed",