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",
+10 -4
View File
@@ -144,6 +144,7 @@ class Expand:
for code in pill["로직"]
}
inner, rows, why, rid = [], [], None, trail[-1] if trail else ""
keyed: list[dict] = [] # 율 밑 줄(778-4 · `fixed.rate_base`)
for at, one in enumerate(lines):
ref = one.get("참조")
if isinstance(ref, dict): # 줄 덮개(715-3) 있는 SK 참조 = 그 줄만 다시 셈
@@ -166,6 +167,8 @@ class Expand:
why = why or f"「{sub.get('이름')}」 {NO_QTY}"
continue
rows.append(labor.mcut.row({k: v * qty for k, v in sub["기준"].items()}))
if not refs.is_rate(head):
keyed.append(fixed.rate_row(head, labor.line_id(head), rows[-1]))
for mat in doc.get("자재") or []: # 사용자 자재(175-2) = 기준단가 × 수량(이은 값)
if labor.apart(mat): # 별도계상 = 안 셈 · 안 줄에도 없음(222-1)
continue
@@ -190,11 +193,14 @@ class Expand:
why = why or f"「{name}」 {inner[-1]['까닭']}"
continue
rows.append(labor.mcut.row({k: v * qty for k, v in base.items()}))
under = labor._sum(rows)
for one in inner: # 비율 고정 줄(705-3) — 밑 = 다른 줄 합
if (one.get("고정") or {}).get("비율"):
one["기준"], row = fixed.ratio_row(one["고정"]["비율"], one["수량"], under)
keyed.append({"키": None, "묶음": "재료", "비목": rows[-1]})
for one in inner: # 비율 고정 줄(705-3) — 밑 = `rate_base`(778-4)
if rate := (one.get("고정") or {}).get("비율"):
under = fixed.rate_base(rate["기준"], keyed)
one["기준"], row = fixed.ratio_row(rate, one["수량"], under)
rows.append(row)
if row:
keyed.append(fixed.rate_row(one, labor.line_id(one), row, True))
return {
"이름": doc.get("이름"),
"단위": doc.get("단위"),
+66 -16
View File
@@ -7,8 +7,11 @@
(노임 새 판 때 따라감) · 품(수량)은 그대로.
M02 셈(층 · 사본) = 노임 「값」 · 자재 기준단가로 이음 · 기계 = 줄 단가(기계 시간 단가 = B09 단가 책 몫).
출처 = `{파일, 호표, 줄, 판}`(글 · 수만) · 줄 가름 = 「고정|이름|규격|단위」(짝 `Labor_Calc.ts` `lineId`).
비율 줄(705-3 · 엑셀 % 줄) = `비율 {기준, 비목}` · 수량 = 율(%) — 반영[비목] = 다른 줄 반영합[기준] × 율 ÷ 100
(`ratios` · 짝 `Labor_Calc.ts` `ratioLines` · B09 = `pct` 줄).
비율 줄(705-3 · 엑셀 % 줄) = `비율 {기준, 비목}` · 수량 = 율(%) — 반영[비목] = 밑 × 율 ÷ 100
(`ratios` · 짝 `Labor_Calc.ts` `ratioLines` · B09 = `pct` 줄) · 단가산출(SK) 율 더한줄(778-4)도 이 꼴.
기준(778-4 · 밑 = `rate_base` · 짝 `Labor_Calc.ts` `rateBase`) = 비목(노무비 · 재료비 · 경비 = 율 줄 뺀
그 비목 합) · 묶음(인력 · 재료 · 장비 = 그 묶음 줄 노 · 재 · 경 합 · 고정 줄 묶음 = 이음) ·
줄 목록 [열쇠](그 줄 합 · 앞 율 줄 넣으면 율 위 율 · SC 열쇠 = 줄 가름 · SK = cid).
B09 대가 갈래 `fixed` = `B09_Estimation_Labor_Source.fixed_line` · 셈 `B09_Estimation_Calc_Labor`.
"""
@@ -27,6 +30,8 @@ COSTS = mf.COST_ITEMS # 노무비 · 재료비 · 경비
FIXED = "고정"
LINKS = {"노임": "LB", "자재": "MT", "기계": "EQ"}
SOURCE_KEYS = ("파일", "호표", "줄", "판")
GROUPS = {"노임": "인력", "자재": "재료", "기계": "장비"} # 율 기준 묶음(778-4) ← 고정 줄 이음
BASES = (*COSTS, *GROUPS.values())
def _bad(text: str) -> Exception:
@@ -82,9 +87,9 @@ def clean(fx: Any, where: str, whole: Any) -> dict[str, Any]:
link[k] = code or None
rate = fx.get("비율")
if rate is not None and not (
isinstance(rate, dict) and rate.get("기준") in COSTS and rate.get("비목") in COSTS
isinstance(rate, dict) and _basis(rate.get("기준")) and rate.get("비목") in COSTS
):
raise _bad(f"{where} · 고정 줄 비율은 {{기준, 비목}} — 노무비 · 재료비 · 경비")
raise _bad(f"{where} · 고정 줄 비율은 {{기준, 비목}} — 기준 = 비목 · 묶음 · 줄 목록")
src = fx.get("출처") if isinstance(fx.get("출처"), dict) else {}
kept = {k: v for k in SOURCE_KEYS if isinstance(v := src.get(k), (str, int, float))}
out = {"이름": name, "규격": str(fx.get("규격") or ""), "단위": str(fx.get("단위") or "")}
@@ -92,6 +97,42 @@ def clean(fx: Any, where: str, whole: Any) -> dict[str, Any]:
return {**out, "비율": {k: rate[k] for k in ("기준", "비목")}} if rate else out
def _basis(raw: Any) -> bool:
"""율 기준 — 비목 · 묶음 이름 · 줄 열쇠 목록(글 · 빈 목록 = 밑 0 · 기준 줄을 지운 뒤)."""
if isinstance(raw, list):
return all(isinstance(x, str) and x for x in raw)
return raw in BASES
def group_of(fx: dict) -> str | None:
"""고정 줄 묶음 — 첫 이음(노임 · 자재 · 기계) · 이음 없음 = None."""
link = fx.get("이음") or {}
return next((g for k, g in GROUPS.items() if link.get(k)), None)
def _all(money: dict | None) -> Decimal:
return sum((Decimal(str((money or {}).get(k) or 0)) for k in COSTS), Decimal(0))
def rate_base(basis: Any, rows: list[dict]) -> Decimal:
"""율 줄 밑(778-4 · 짝 `Labor_Calc.ts` `rateBase`) — `rows` [{키, 묶음, 비목 {노 · 재 · 경}, 율?}] ·
비목 = 율 줄 뺀 그 비목 합 · 묶음 = 율 줄 뺀 그 묶음 줄 셋 합 · 목록 = 그 열쇠 줄 셋 합."""
if isinstance(basis, list):
keep = set(basis)
return sum((_all(r["비목"]) for r in rows if r.get("키") in keep), Decimal(0))
rows = [r for r in rows if not r.get("율")]
if basis in COSTS:
return sum((Decimal(str((r["비목"] or {}).get(basis) or 0)) for r in rows), Decimal(0))
return sum((_all(r["비목"]) for r in rows if r.get("묶음") == basis), Decimal(0))
def rate_row(one: dict, key: Any, money: dict | None, rate: bool = False) -> dict[str, Any]:
"""`rate_base` 줄 하나 — 고정 줄 묶음 = 이음 · 그 밖 = 줄 `묶음`(SK 엔진 줄 · 자재 = 재료)."""
fx = one.get(FIXED)
group = group_of(fx) if isinstance(fx, dict) else one.get("묶음")
return {"키": key, "묶음": group, "비목": money, "율": rate}
def _has(whole: Any, code: str) -> bool:
try:
return bool(whole.get(code))
@@ -136,26 +177,35 @@ def line(whole: Any, fx: dict) -> dict[str, Any]:
return {**head, "기준": money, "이은": used, "까닭": None}
def ratio_row(rate: dict, qty: Decimal | None, under: dict) -> tuple[dict, dict | None]:
"""비율 줄 (기준, 반영) — 기준[비목] = 밑[기준] · 반영 = 기준 × 율 ÷ 100 ·
def ratio_row(rate: dict, qty: Decimal | None, under: Decimal) -> tuple[dict, dict | None]:
"""비율 줄 (기준, 반영) — 기준[비목] = 밑(`rate_base`) · 반영 = 기준 × 율 ÷ 100 ·
0.1 원 버림(751-3) · 수량 없음 = None."""
base = dict.fromkeys((*COSTS, "계"), Decimal(0))
base[rate["비목"]] = base["계"] = under[rate["기준"]]
base[rate["비목"]] = base["계"] = under
return base, None if qty is None else mcut.row({k: base[k] * qty / 100 for k in COSTS})
def ratios(lines: list[dict], reflects: list[dict | None], bases: list[dict | None]) -> None:
"""비율 줄(705-3) — `settle` 끝에 · 밑 = 비율 줄 뺀 반영합(줄 · 자재) · 배 100 · 기준합엔 안 셈 ·
`lines` · `reflects` · `bases` 앞 줄 = 같은 차례(짝 `Labor_Calc.ts` `ratioLines`)."""
at = {i for i, x in enumerate(lines) if (x.get(FIXED) or {}).get("비율")}
under = {
k: sum((r[k] for i, r in enumerate(reflects) if r and i not in at), Decimal(0))
for k in COSTS
}
for i in sorted(at):
base, done = ratio_row(lines[i][FIXED]["비율"], lines[i].get("수량"), under)
"""비율 줄(705-3) — `settle` 끝에 · 밑 = `rate_base`(율 줄 뺀 반영(줄 · 자재) · 앞 율 줄은 목록만) ·
배 100 · 기준합엔 안 셈 · `lines` · `reflects` · `bases` 앞 줄 = 같은 차례(짝 `Labor_Calc.ts`
`ratioLines`) · 자재 줄 = 묶음 재료 · 열쇠 없음."""
from M02_MasterTemplete import M02_Labor_Doc as labor
at = [i for i, x in enumerate(lines) if (x.get(FIXED) or {}).get("비율")]
rows = [
rate_row(lines[i], labor.line_id(lines[i]), r)
if i < len(lines)
else {"키": None, "묶음": "재료", "비목": r}
for i, r in enumerate(reflects)
if r and i not in at
]
for i in at:
rate = lines[i][FIXED]["비율"]
base, done = ratio_row(rate, lines[i].get("수량"), rate_base(rate["기준"], rows))
lines[i], reflects[i], bases[i] = (
{**lines[i], "기준": base, "배": 100, "반영": done},
done,
None,
)
if done:
rows.append(rate_row(lines[i], labor.line_id(lines[i]), done, True))
+31 -4
View File
@@ -14,7 +14,9 @@
`{층: 사본, 주인: 몸 지문, 키: 본 SC, 몸}` 에 담음(`M02_Labor_Ref.copy_ref` · 원본
연결 없음 · 하위 호표 끝수 단계 · B09 하위 대가 블록 그대로 · 같은 기초 겹침도
블록 안)
바탕 SK 참조 · 패치 있는 SK 참조 줄 = 위 덮개를 아래 덮개에 겹쳐 한 벌(`compose`)
바탕 SK 참조 · 패치 있는 SK 참조 줄 = 위 덮개를 아래 덮개에 겹쳐 한 벌(`compose` · 율 기준 줄 열쇠
`add:#k` 도 새 차례로 · 778-4)
율 줄(778-4) 있는 바탕 없는 SK = 한 줄로 못 펼침(율 · 기준 묶음이 넣는 쪽 줄로 번짐) → 참조 그대로
못 펼친 줄 = 저장 · 읽기 · 옮기기에선 옛 참조 그대로(값 그대로) · 넣기 길(`strict`)에선 400 까닭.
초기화(`reset`) = 덮개 · 뺀줄 · 더한줄 단가 걷음(안쪽 넣은 몸 · 사본 블록까지) · 이음 기계 고정 줄
단가 = M01 기계 한 시간 값 다시 받음(736-3) · 항목 · 수량 · 입력값 · 고름 · 자재표시 · 켜쌓기율 ·
@@ -142,6 +144,8 @@ def sk_adds(y: dict, q: Decimal, whole: Any, tag: dict) -> list[dict]:
adds = filled_adds(y, whole)
code = (y.get("바탕") or {}).get("로직")
if not code and any(sd.is_rate(one) for one in adds):
raise sd.DocError("율 줄 있는 바탕 없는 단가산출은 펼쳐 넣지 못함 — 참조로 둠")
if not code:
return [_scaled(one, q, tag) for one in adds]
row = whole.logic(code)
@@ -291,13 +295,36 @@ def compose(under: dict, over: dict, whole: Any) -> dict:
into[t[1]] = value
rate = over.get("켜쌓기율", under.get("켜쌓기율"))
out = {**over, "바탕": base, "덮개": covers, "뺀줄": drop, "고름": picks, "자재표시": marks}
out["더한줄"] = [a for k, a in enumerate(adds) if k not in gone] + list(
over.get("더한줄") or []
)
kept = [k for k in range(len(adds)) if k not in gone]
new = {f"add:#{k}": f"add:#{n}" for n, k in enumerate(kept)} # 율 기준 열쇠(778-4)
mine = [
_rekey(a, lambda x: new.get(x) if ADD.match(x) else x)
for k, a in enumerate(adds)
if k not in gone
]
def up(cid: str) -> str | None: # 위 문서 열쇠 → 한 벌 열쇠
hit = ADD.match(cid)
if hit:
return f"add:#{len(kept) + int(hit[1])}"
t = to(cid)
return cid if t is None else t[1] if t[0] == "base" else new.get(f"add:#{t[1]}")
out["더한줄"] = mine + [_rekey(dict(a), up) for a in over.get("더한줄") or []]
out["입력값"] = {**(under.get("입력값") or {}), **(over.get("입력값") or {})}
return out | ({"켜쌓기율": rate} if rate is not None else {})
def _rekey(one: dict, move: Any) -> dict:
"""율 더한줄 기준 줄 목록 열쇠를 `move` 로(못 옮긴 열쇠 = 버림 · 778-4)."""
rate = ((one.get("고정") or {}).get("비율") or {}) if isinstance(one, dict) else {}
if not isinstance(rate.get("기준"), list):
return one
keys = [k for k in (move(x) for x in rate["기준"]) if k]
fx = {**one["고정"], "비율": {**rate, "기준": keys}}
return {**one, "고정": fx}
# ── SC ────────────────────────────────────────────────────────────────
def sk_line(y: dict, one: dict, whole: Any, tag: dict) -> dict:
"""펼친 SK → 일위대가 로직 줄 하나(+ 패치) — 옛 참조 줄 `one` 의 별칭 · 메모 · 수량 · 패치
@@ -8,7 +8,9 @@
* 별도계상 자재(222-1 · 빈 칸 = 기본 별도계상) = 반영은 보이되 합에 안 더함(짝 `apart`).
* 참조 줄(단가승격 · 사전조합 · 50-3)은 `lineId` = 「참조|층|주인|키」(짝 `M02_Labor_Ref.ref_id`) ·
* 고정 줄(705-1)은 「고정|이름|규격|단위」(짝 `M02_Labor_Fixed.fixed_id`) · 기준 = 서버가 낸 줄 단가.
* 규격 열(704-4) = 문서 `규격` · 그 열 수량이 있는 줄 = `{손}` 자리(`atSpec` · 짝 `M02_Labor_Spec.at`). */
* 규격 열(704-4) = 문서 `규격` · 그 열 수량이 있는 줄 = `{손}` 자리(`atSpec` · 짝 `M02_Labor_Spec.at`).
* 율 줄 밑(778-4) `rateBase` = 짝 `M02_Labor_Fixed.rate_base` — 일위대가 비율 줄 · 단가산출(SK) 율 더한줄
* (서버 `M02_Sangeun_Calc._rated`) 한 규칙 · 거울 시험 `resources/tester/test_778_4_rate_line.py`. */
import type { CalcLine as LogicLine } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import type { LogicMat } from "./M02_Labor_LogicMats_Model";
@@ -36,10 +38,25 @@ export interface LaborFixed {
단가: Record<"노무비" | "재료비" | "경비", number>;
이음?: Record<"노임" | "자재" | "기계", string | null>;
출처?: Record<string, string | number>;
/** 비율 줄(705-3 · 엑셀 % 줄) — 수량 = 율 · 반영[비목] = 다른 줄 반영합[기준] × 율 ÷ 100 */
비율?: { 기준: CostKey; 비목: CostKey };
/** 비율 줄(705-3 · 엑셀 % 줄) — 수량 = 율 · 반영[비목] = 밑(`rateBase`) × 율 ÷ 100 */
비율?: { 기준: RateBasis; 비목: CostKey };
}
type CostKey = "노무비" | "재료비" | "경비";
/** 율 기준(778-4) — 비목 · 묶음(인력 · 재료 · 장비) · 줄 열쇠 목록 */
export type RateBasis = CostKey | RateGroup | string[];
export type RateGroup = "인력" | "재료" | "장비";
/** 율 밑 줄 — 열쇠(SC 줄 가름 · SK cid) · 묶음 · 비목 · 율 줄인가 */
export interface RateRow {
키: string | null;
묶음: string | null;
비목: Partial<Record<CostKey, number | null>> | null;
율?: boolean;
}
const GROUPS: Record<"노임" | "자재" | "기계", RateGroup> = {
노임: "인력",
자재: "재료",
기계: "장비",
};
export interface CalcLine {
/** 참조 줄은 서버 답에 없음(빈 글로 다룸) */
@@ -234,27 +251,69 @@ export function settle(
if (cost !== null && !aside.has(one.코드)) add(done, { ...zero(), 재료비: cost, 계: cost });
return { ...one, 수량: qty, 수량출처: source, 반영: cost };
});
const lines = ratioLines(줄, done, base);
const lines = ratioLines(
줄,
자재.filter((m) => !aside.has(m.코드)),
done,
base,
);
return { 줄: lines, 자재, 기준합: base, 반영합: cutSums(done) };
}
/** 비율 고정 줄(705-3 · 짝 `M02_Labor_Fixed.ratios`) — 밑 = 비율 줄 뺀 반영합 · 기준[비목] = 밑[기준] ·
* 배 100 · 반영 = 기준 × 율 ÷ 100 · `done` 에 더함 · 기준합(`base`)엔 안 셈 */
function ratioLines(lines: CalcLine[], done: Money, base: Money): CalcLine[] {
/** 고정 줄 묶음 — 첫 이음(노임 · 자재 · 기계 · 짝 `M02_Labor_Fixed.group_of`) */
export function groupOf(fx: LaborFixed | undefined): RateGroup | null {
const link = fx?.이음;
const hit = (Object.keys(GROUPS) as (keyof typeof GROUPS)[]).find((k) => link?.[k]);
return hit ? GROUPS[hit] : null;
}
const all = (m: RateRow["비목"]): number => COSTS.reduce((s, k) => s + (m?.[k] ?? 0), 0);
/** 율 줄 밑(778-4 · 짝 `M02_Labor_Fixed.rate_base`) — 비목 = 율 줄 뺀 그 비목 합 ·
* 묶음 = 율 줄 뺀 그 묶음 줄 셋 합 · 목록 = 그 열쇠 줄 셋 합(앞 율 줄 됨) */
export function rateBase(basis: RateBasis, rows: RateRow[]): number {
if (Array.isArray(basis)) {
const keep = new Set(basis);
return rows.reduce((s, r) => s + (r.키 !== null && keep.has(r.키) ? all(r.비목) : 0), 0);
}
const plain = rows.filter((r) => !r.율);
if ((COSTS as readonly string[]).includes(basis))
return plain.reduce((s, r) => s + (r.비목?.[basis as CostKey] ?? 0), 0);
return plain.reduce((s, r) => s + (r.묶음 === basis ? all(r.비목) : 0), 0);
}
/** 비율 고정 줄(705-3 · 짝 `M02_Labor_Fixed.ratios`) — 밑 = `rateBase`(율 줄 뺀 반영 줄 · 자재 = 묶음 재료) ·
* 기준[비목] = 밑 · 배 100 · 반영 = 기준 × 율 ÷ 100 · `done` 에 더함 · 기준합(`base`)엔 안 셈 */
function ratioLines(
lines: CalcLine[],
mats: { 반영?: number | null }[],
done: Money,
base: Money,
): CalcLine[] {
for (const l of lines.filter((x) => x.고정?.비율)) {
for (const k of MONEY_KEYS) {
done[k] -= l.반영?.[k] ?? 0;
base[k] -= l.기준?.[k] ?? 0;
}
}
const under = { ...done };
const rows: RateRow[] = [
...lines
.filter((l) => !l.고정?.비율 && l.반영)
.map((l) => ({ 키: lineId(l) ?? null, 묶음: groupOf(l.고정), 비목: l.반영 ?? null })),
...mats
.filter((m) => m.반영 != null)
.map((m) => ({ 키: null, 묶음: "재료", 비목: { 재료비: m.반영 } })),
];
return lines.map((line) => {
const rate = line.고정?.비율;
if (!rate) return line;
const own = zero();
own[rate.비목] = own.계 = under[rate.기준];
own[rate.비목] = own.계 = rateBase(rate.기준, rows);
const got = reflect(own, line.수량 ?? null, 100);
if (got) for (const k of MONEY_KEYS) done[k] += got[k];
if (got) {
for (const k of MONEY_KEYS) done[k] += got[k];
rows.push({ 키: lineId(line) ?? null, 묶음: groupOf(line.고정), 비목: got, 율: true });
}
return { ...line, 기준: own, 배: 100, 반영: got };
});
}
@@ -38,6 +38,7 @@ import type { Layer } from "./M02_MasterTemplete_Api_Fetch";
import { baseHeads } from "./M02_MasterTemplete_Sangeun_BaseHead";
import { calcSangeun, removedStrip } from "./M02_MasterTemplete_Sangeun_Cover";
import { openSangeunLinePicker } from "./M02_MasterTemplete_Sangeun_Pick";
import { openRateLine } from "./M02_MasterTemplete_Sangeun_Rate";
import { createSangeunSide, tabOf, type SourceTab } from "./M02_MasterTemplete_Sangeun_Side";
import { baseUnitOf, buildUnitSwap, type SwapHow } from "./M02_MasterTemplete_Sangeun_Unit";
import {
@@ -121,6 +122,18 @@ export function mountSangeunDetail(
}),
});
addButton.hidden = !edit;
// 778-4 율 줄 — 기준 줄 후보 = 지금 셈 답 줄(세부 줄 뺌) · 새 줄은 맨 끝(앞 줄만 가리킴)
const rateButton = createButton({
label: L("M02_SkRateAdd"),
variant: "ghost",
size: 28,
onClick: () =>
openRateLine(
(ans?.줄 ?? []).map((l) => ({ cid: l.cid, 이름: l.이름 })),
(line) => ((doc.더한줄 = [...(doc.더한줄 ?? []), withFirst(line)]), touch()),
),
});
rateButton.hidden = !edit;
/** 둘째 기초(728-2) — 수량 처음값 = 바탕 기준단위 1 개 = 그 기초 기준단위 몇 개(같은 성격 · 다르면 1) */
async function addBase(line: { 로직: string; 수량: number }): Promise<void> {
let qty = 1;
@@ -153,7 +166,7 @@ export function mountSangeunDetail(
children: [
head,
notes,
el("div", { className: "m02-sk__head", children: [views.root, addButton] }),
el("div", { className: "m02-sk__head", children: [views.root, addButton, rateButton] }),
editor,
],
});
@@ -0,0 +1,103 @@
/* =============================================================================
* M02_MasterTemplete_Sangeun_Rate.ts
* M02 단가산출(SK) 율 줄 넣기 창(PLAN 778-4 · 요구 623) — [율 줄 추가] → 이름 · 율 % · 기준 · 금액이 드는 칸.
* 기준 = 묶음(인력 · 재료 · 장비 줄 합) · 비목 전체(노무비 · 재료비 · 경비) · 줄 고르기(이 SK 줄 cid 목록 ·
* 앞 율 줄 고르면 율 위 율) → 더한줄 `{고정: {이름, 단위 %, 단가 0, 이음 없음, 비율 {기준, 비목}}, 수량 = 율}`
* (꼴 = 서버 `M02_Sangeun_Doc` · 셈 = 서버 `M02_Sangeun_Calc._rated`) · 부품 상자만.
* ========================================================================== */
import { createButton } from "@ui/ui_template_button";
import { createCheckbox } from "@ui/ui_template_checkbox";
import { el } from "@ui/ui_template_elements";
import { createFormGrid } from "@ui/ui_template_general_layout";
import { createInputField, createSelectField } from "@ui/ui_template_input";
import { t as L } from "@ui/ui_template_locale";
import { openModal } from "@ui/ui_template_modal";
import type { SkAdded } from "./logic/M02_Sangeun_Cover_Model";
const LINES = "줄";
/** 율 줄 넣기 — `rows` = 지금 셈 답 줄(기준 줄 고르기 후보) · `done` = 새 더한줄 한 줄 */
export function openRateLine(
rows: { cid: string; 이름: string }[],
done: (line: SkAdded) => void,
): void {
const name = createInputField({ label: L("M02_SkRateName"), value: L("M02_SkRateDefault") });
const rate = createInputField({
label: L("M02_SkRateRate"),
type: "number",
value: "2",
unit: "%",
min: 0,
});
const picks = rows.map((r) => ({
cid: r.cid,
box: createCheckbox(r.이름 || r.cid, { chip: true, size: 28 }),
}));
const list = el("div", {
className: "m02-sk__tags",
attrs: { "data-sk-rate-lines": "" },
children: picks.length
? picks.map((p) => p.box.root)
: [el("span", { text: L("M02_SkRateNoLines") })],
});
list.hidden = true;
const basis = createSelectField({
label: L("M02_SkRateBasis"),
value: "인력",
options: [
{ value: "인력", text: L("M02_SkRateGroupLabor") },
{ value: "재료", text: L("M02_SkRateGroupMat") },
{ value: "장비", text: L("M02_SkRateGroupMach") },
{ value: "노무비", text: L("M02_SkRateCostLabor") },
{ value: "재료비", text: L("M02_SkRateCostMat") },
{ value: "경비", text: L("M02_SkRateCostExp") },
{ value: LINES, text: L("M02_SkRateBasisLines") },
],
onChange: (v) => (list.hidden = v !== LINES),
});
const part = createSelectField({
label: L("M02_SkRatePart"),
value: "경비",
options: [
{ value: "경비", text: L("M02_SkRatePartExp") },
{ value: "재료비", text: L("M02_SkRatePartMat") },
{ value: "노무비", text: L("M02_SkRatePartLabor") },
],
});
openModal({
title: L("M02_SkRateTitle"),
closeLabel: L("M02_SkRateClose"),
width: "40rem",
mount: (body, close, actions) => {
const ok = createButton({
label: L("M02_SkRateOk"),
variant: "filled",
onClick: () => {
const label = name.input.value.trim();
const q = Number(rate.input.value);
const keys = picks.filter((p) => p.box.input.checked).map((p) => p.cid);
const lines = basis.select.value === LINES;
name.setError(label ? undefined : L("M02_SkRateNeedName"));
rate.setError(rate.input.value.trim() && q >= 0 ? undefined : L("M02_SkRateBadRate"));
if (lines && !keys.length) return void rate.setError(L("M02_SkRateNeedLine"));
if (!label || !rate.input.value.trim() || !(q >= 0)) return;
const fx = {
이름: label,
단위: "%",
단가: { 노무비: 0, 재료비: 0, 경비: 0 },
이음: { 노임: null, 자재: null, 기계: null },
비율: { 기준: lines ? keys : basis.select.value, 비목: part.select.value },
};
done({ 고정: fx, 이름: label, 단위: "%", 수량: q });
close();
},
});
ok.dataset.skRateOk = "";
actions.append(ok);
const grid = createFormGrid([name.root, rate.root, basis.root, part.root]);
grid.dataset.skRate = "";
body.append(grid, list);
},
});
}
+63
View File
@@ -19,6 +19,9 @@ SK 단위 환산(728-2) = 배 = 바탕 기준단위(「100본」) → SK 단위(
일위대가 덮개 줄(`line` · `M02_Labor_Patch.run`)은 일위대가 기본(222-1 · 162-1) 그대로.
보기 넷 한 답(750-1) = 화면 셈 길 답에 `글`(읽는 식) · `중간`(`M02_Sangeun_Views`) · 일위대가 줄
모달 = 문서 `줄모드` true → `line`(일위대가 그 줄 셈과 같은 값).
율 더한줄(778-4 · `_rated`) = 엔진 한 번 돈 줄로 밑(`M02_Labor_Fixed.rate_base` · 짝 화면
`M02_MasterTemplete_Labor_Calc.ts` `rateBase` · 거울 시험 `test_778_4_rate_line.py`) → 율 줄 `값묶음` =
{비목: 밑 ÷ 100} → 엔진 다시(율 줄 없는 문서 = 한 번만 · 값 그대로) · 줄 덧칸 `율` · `규격`.
"""
from __future__ import annotations
@@ -350,6 +353,10 @@ def run(
names = {str(i.get("이름")) for i in row.get("입력") or []}
values = {k: v for k, v in {**got["입력값"], **(given or {})}.items() if k in names}
out = labor.run_line(over, key, values, got["켜쌓기율"], got["고름"] or None)
rates = _rated(row, out.get("줄") or [], got["정체"], got["자재표시"], got["켜쌓기율"])
if rates is not None: # 율 더한줄(778-4) — 밑을 넣고 다시
head["짝잃음"] = head["짝잃음"] + rates
out = labor.run_line(over, key, values, got["켜쌓기율"], got["고름"] or None)
eng = out.get("줄") or []
seen = (
views.text_of(over, key, values, got["고름"], got["정체"], eng) if detail and seen else {}
@@ -365,6 +372,10 @@ def run(
one = got["채운더한줄"][int(hit[1])] if hit else {}
if detail and one.get("종류") == "로직" and at not in gaps:
mark |= _detail(one, int(hit[1]), whole)
if sd.is_rate(one): # 율 줄 = 율 · 「기준의 %」(화면 % 줄 · `per_unit` 수량 그대로)
rate = one["고정"]["비율"]
group = {"노무비": "인력", "재료비": "재료", "경비": "경비"}[rate["비목"]]
mark |= {"율": Decimal(str(one["수량"])), "규격": _rate_text(rate), "묶음": group}
lines.append(
{**line, "cid": cid, **mark, **({"원값": mine["원값"]} if "원값" in mine else {})}
)
@@ -380,6 +391,58 @@ def run(
return {**out, **head, **seen, "1단위": unit, "빠진줄": gone, "꼬리표": tag}
def _group(item: dict, line: dict) -> str | None:
"""율 밑 묶음(778-4) — 인력 · 재료 · 장비(기계경비 로직 · 기계 이음 고정 · 기계 줄) · 그 밖 None."""
if isinstance(item.get("고정"), dict):
return fixed.group_of(item["고정"])
if line.get("장비"):
return "장비"
return {"인력": "인력", "재료": "재료", "기계": "장비"}.get(str(item.get("종류")))
def _rated(row: dict, eng: list, ids: list, marks: dict, stack: Any = None) -> list[dict] | None:
"""율 더한줄(778-4) `값묶음` = {비목: 밑 ÷ 100} — 한 번 돈 엔진 줄(`eng` · 정체 `ids`)로
`fixed.rate_base`(별도계상 재료 뺌 · 앞 율 줄 차례로 · 켜쌓기율 몫 뗌) · 제자리 · 율 줄 없음 = None ·
돌려줌 = 짝잃음(없는 기준 줄)."""
items = row["호표"]
at = [i for i, x in enumerate(items) if (x.get("고정") or {}).get("비율")]
if not at:
return None
rows = [
{"키": cid, "묶음": _group(items[i] if i < len(items) else {}, x), "비목": x.get("비목")}
for i, (cid, x) in enumerate(zip(ids, eng))
if i not in at
and not (
i < len(items) and items[i].get("종류") == "재료" and labor.apart(marks.get(str(i)))
)
]
times = Decimal(str(stack)) / 100 if stack not in (None, 100) else Decimal(1)
lost = []
for i in at:
rate, qty = items[i]["고정"]["비율"], Decimal(str(items[i]["수량"]))
if isinstance(rate["기준"], list):
lost += [
{"cid": ids[i], "까닭": f"율 기준 줄 없음 「{k}」"}
for k in rate["기준"] or [""]
if k not in ids
]
base = fixed.rate_base(rate["기준"], rows) / times
items[i] = {
**items[i],
"값묶음": {**dict.fromkeys(COSTS, Decimal(0)), rate["비목"]: base / 100},
}
rows.append(
{"키": ids[i], "묶음": None, "비목": {rate["비목"]: base * qty / 100}, "율": True}
)
return lost
def _rate_text(rate: dict) -> str:
"""율 줄 규격 — 「인력의 %」 · 목록 = 「줄 n 개의 %」."""
basis = rate["기준"]
return f"줄 {len(basis)} 개의 %" if isinstance(basis, list) else f"{basis}의 %"
def unit(doc: dict, whole: Any, expand: Any, trail: tuple) -> dict[str, Any]:
"""펼침(`Expand`) 한 칸 — `{이름, 단위, 기준(1 단위), 까닭, 줄, 짝잃음}` · 못 셈 = 까닭."""
head = {"이름": doc.get("이름"), "단위": doc.get("단위")}
+24 -2
View File
@@ -11,6 +11,8 @@
| {종류: 로직, 로직, 입력값, 수량, 덮개?, 뺀줄?, 바탕판?,
고름? · 자재표시? · 켜쌓기율? · 더한줄?(넣은 SK 몸 · 740-1) · 본뜬?(출처)}
| {참조 | 고정, 이름?, 단위?, 수량}],
(율 줄 778-4 = 고정 `비율 {기준, 비목}` · 수량 = 율 % · 단위 % · 기준 = 비목 · 묶음 인력 · 재료 · 장비 ·
줄 열쇠 목록 [cid | add:#k] — 더한줄 율 줄은 앞 줄만 · 셈 `M02_Sangeun_Calc._rated`)
뺀줄 [cid], 바탕판, 환산?}
SK 단위 환산(728-2) = `환산` {계수: 양수}(성격 다름 · 바탕 앞 수 뗀 1 단위 = SK 몇 단위) |
{단위중량: {종별, 형상}}(부피 ↔ 무게 · 표 CC000337) — 셈 = `M02_Sangeun_Calc.ratio_of`.
@@ -168,8 +170,8 @@ def _added(raw: Any, where: str, whole: Any) -> dict:
head = {"고정": fixed.clean(raw["고정"], where, whole) if whole else raw["고정"]}
except Exception as e: # noqa: BLE001 — 고정 줄 검사 = StoreError(400)
raise DocError(str(getattr(e, "detail", e))) from e
if head["고정"].get("비율"):
raise DocError(f"{where} · 비율 고정 줄은 단가산출에 못 담음 — 덧줄 율로")
if head["고정"].get("비율"): # 율 줄(778-4)
unit = unit or "%"
elif raw.get("종류") == "로직":
code = str(raw.get("로직") or "")
row = _logic(whole, code, where)
@@ -339,6 +341,7 @@ def clean(doc: Any, base_row: dict | None = None, whole: Any = None) -> dict[str
out["더한줄"] = [
_added(one, f"더한줄 {at}째", whole) for at, one in enumerate(doc.get("더한줄") or [], 1)
]
_rate_keys(out["더한줄"])
drop = [_cid(x, "뺀줄") for x in doc.get("뺀줄") or []]
if len(set(drop)) != len(drop):
raise DocError("뺀줄에 같은 줄 두 번")
@@ -348,6 +351,25 @@ def clean(doc: Any, base_row: dict | None = None, whole: Any = None) -> dict[str
return out
def is_rate(one: Any) -> bool:
"""율 더한줄(778-4) — 고정 `비율`."""
return isinstance(one, dict) and bool((one.get("고정") or {}).get("비율"))
def _rate_keys(adds: list[dict]) -> None:
"""율 더한줄 기준 목록(778-4) — 열쇠 = 바탕 cid · `add:#k` · 자기 · 뒤 율 줄 못 가리킴(율 위 율 =
앞 율 줄만 · 순환 없음)."""
for at, one in enumerate(adds):
basis = ((one.get("고정") or {}).get("비율") or {}).get("기준")
for key in basis if isinstance(basis, list) else []:
hit = re.match(r"^add:#(\d+)$", key)
if not hit and not _CID.match(key):
raise DocError(f"더한줄 {at + 1}째 · 율 기준 줄 열쇠 꼴 아님 「{key}」")
k = int(hit[1]) if hit else -1
if k == at or (k > at and k < len(adds) and is_rate(adds[k])):
raise DocError(f"더한줄 {at + 1}째 · 율 기준에 자기 · 뒤 율 줄 「{key}」")
def _swap_how(raw: Any) -> dict[str, Any]:
"""단위 환산 칸(728-2) — 계수(양수) · 단위중량 {종별, 형상} 중 있는 것만."""
if not isinstance(raw, dict):
@@ -257,7 +257,7 @@ export function rowsOf(ans: SkAnswer, bare: SkAnswer | null, doc: SkDoc): CoverR
const one = added === null ? undefined : doc.더한줄?.[added];
const owner = at ? doc.더한줄?.[at.k] : undefined;
const cover = at ? owner?.덮개?.[at.cid] : added === null ? doc.덮개?.[cid] : undefined;
const pct = (at?.cid ?? cid).startsWith("pct:");
const pct = (at?.cid ?? cid).startsWith("pct:") || extra.율 !== undefined; // 덧줄 · 율 줄(778-4)
const keys = Object.keys(parts);
const shown = added === null && !at ? (was.get(cid) ?? null) : null;
const base = at
+13 -1
View File
@@ -2,7 +2,8 @@
* M02_MasterTemplete/logic/M02_Sangeun_Undo.ts
* 단가산출(SK) 되돌리기 기억(PLAN 744-1 서버 · 748-0 화면) — 더한 기초 · 더한 항목 빼기 → 문서 `뺀더한줄`
* `[{차례, 줄}]`(되살림 = 그 차례에 다시 끼움) · 더한 항목 수량 → `처음수량`(넣을 때 값) 되돌림.
* 더한줄 지움 · 끼움 때 뒤 더한줄 열쇠 `add:#j[/…]`(덮개 · 고름 · 자재표시)를 한 칸씩 당김 · 밂.
* 더한줄 지움 · 끼움 때 뒤 더한줄 열쇠 `add:#j[/…]`(덮개 · 고름 · 자재표시 · 율 줄 기준 목록 778-4)를
* 한 칸씩 당김 · 밂(지운 줄을 가리킨 기준 열쇠는 버림 — 빈 목록 = 서버 짝잃음 알림).
* 금액은 늘 서버가 셈 — 여기는 문서 칸 옮김만 · 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장).
* ========================================================================== */
@@ -32,6 +33,17 @@ function moveKeys(doc: SkDoc, mode: { drop: number } | { push: number }): void {
}
(doc as Record<string, unknown>)[name] = out;
}
for (const one of doc.더한줄 ?? []) {
const rate = (one.고정 as { 비율?: { 기준?: unknown } } | undefined)?.비율;
if (!rate || !Array.isArray(rate.기준)) continue;
rate.기준 = (rate.기준 as string[]).flatMap((key) => {
const hit = KEY_RE.exec(key);
if (!hit) return [key];
const j = Number(hit[1]);
if ("drop" in mode) return j === mode.drop ? [] : [j > mode.drop ? `add:#${j - 1}` : key];
return [j >= mode.push ? `add:#${j + 1}` : key];
});
}
}
/** 더한줄 k 를 빼고 기억에 둠 — 이름은 되살림 띠 글(서버가 저장 때 다시 적음) */
+5 -2
View File
@@ -1,6 +1,6 @@
/* M02 일위대가 수량 · 반영 · 합(`M02_MasterTemplete_Labor_Calc.ts` `settle`) 거울 헬퍼 —
* 그 자리에서 트랜스파일해 Node 로 돌림(상대 경로 .ts 가져옴도 · B09 `rounddown` 751-3) ·
* stdin = {답, 문서, 값, 열?} · stdout = settle 결과 JSON. */
* stdin = {답, 문서, 값, 열?} · stdout = settle 결과 JSON · {밑: {기준, 줄}} = `rateBase`(778-4). */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
@@ -25,4 +25,7 @@ function load(at) {
const box = { exports: load(file) };
const input = JSON.parse(fs.readFileSync(0, "utf8"));
process.stdout.write(JSON.stringify(box.exports.settle(input.답, input.문서, input.값, input.열)));
const out = input.밑
? box.exports.rateBase(input.밑.기준, input.밑.줄)
: box.exports.settle(input.답, input.문서, input.값, input.열);
process.stdout.write(JSON.stringify(out));
+341
View File
@@ -0,0 +1,341 @@
"""778-4 단가산출(SK) 율 줄 — 고정 비율 더한줄 · 기준 = 비목 · 묶음 · 줄 목록 · 율 위 율
(요구 623 · 분석 621-D 2-4 · 5-2) · M01 읽기만 · 쓰는 일 없음.
- 서버 셈(`M02_Sangeun_Calc._rated`) = 손셈 · 묶음 인력 = 인력 줄만(기계 노무 섞인 블록 = 노무비 합과 다름)
- 밑 공용 함수 `M02_Labor_Fixed.rate_base` ↔ `Labor_Calc.ts` `rateBase` 거울 · 일위대가 비율 줄 settle 거울(1e-9)
- 검사 — 자기 · 뒤 율 줄 · 꼴 틀린 열쇠 · 모르는 기준 막음 · 율 줄 없는 문서 = 엔진 한 번(값 그대로)
- B09 — 율 기준 → pct 밑수(`part:` · `bundle:` · `rows:`) · 파이썬 = TS · SK 블록 밑수 꼴
- 굳힘 — 바탕 없는 SK 율 줄 = 한 줄로 안 펼침 · 겹침(`compose`) 열쇠 새 차례
"""
from __future__ import annotations
import json
import os
import re
import subprocess
from decimal import Decimal
from pathlib import Path
import pytest
from B09_Estimation import B09_Estimation_Labor_Source as source
from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_Labor_Fixed as fixed
from M02_MasterTemplete import M02_Library_Flat as flat
from M02_MasterTemplete import M02_Sangeun_Calc as calc
from M02_MasterTemplete import M02_Sangeun_Doc as sd
ROOT = Path(__file__).resolve().parents[2]
HERE = os.path.dirname(os.path.abspath(__file__))
IMPORTS = re.compile(r"^import[^;]*;\r?\n", re.MULTILINE)
SUB = "GC000999" # 원/주 · 조경공 · 보통인부 + 고소작업차 로직 줄 + 덧줄 공구손료 3 %
MACHINE = "GC000268" # 기계경비(손료 보정 없는 기종) — 노무비 몫 있음
def _whole():
return m01.loaded()[1]
def _rate(basis, q=2, part="경비", name="공구손료"):
fx = {"이름": name, "단가": {}, "이음": {}, "비율": {"기준": basis, "비목": part}}
return {"고정": fx, "수량": q}
def _doc(adds, **more):
return {
"키": "SK-000001",
"이름": "율 시험",
"단위": "㎥",
"바탕": None,
"더한줄": adds,
**more,
}
def _run(doc):
whole = _whole()
return labor._json(calc.run(sd.clean(doc, None, whole), whole))
LABOR = [
{"종류": "인력", "요소": "LB000002", "수량": 0.5}, # 보통인부
{"종류": "인력", "요소": "LB000003", "수량": 0.2}, # 특별인부
{"종류": "로직", "로직": MACHINE, "입력값": {}, "수량": 0.29},
]
# ── 서버 셈 ──────────────────────────────────────────────────────────────────
def test_묶음_인력은_인력_줄만_노무비와_다름():
got = _run(_doc([*LABOR, _rate("인력"), _rate("노무비", 3, name="노무 율")]))
by = {x["cid"]: x for x in got["줄"]}
people = sum(Decimal(str(by[f"add:#{k}"]["금액"])) for k in (0, 1))
mach = Decimal(str(by["add:#2"]["비목"]["노무비"]))
assert mach > 0 # 기계 노무 섞인 블록
tool, cost = by["add:#3"], by["add:#4"]
assert tool["금액"] == pytest.approx(float(people * 2 / 100), abs=0.1)
assert cost["금액"] == pytest.approx(float((people + mach) * 3 / 100), abs=0.1)
assert tool["율"] == 2 and tool["규격"] == "인력의 %" and tool["묶음"] == "경비"
assert tool["비목"]["경비"] == pytest.approx(tool["금액"])
assert got["짝잃음"] == []
def test_줄_목록_절_소계와_율_위_율():
adds = [*LABOR, _rate("인력"), _rate(["add:#0", "add:#3"], 10, "재료비", "율 위 율")]
adds.append(_rate(["add:#0", "add:#1"], 5, name="절 소계"))
got = _run(_doc(adds))
by = {x["cid"]: x for x in got["줄"]}
first = Decimal(str(by["add:#0"]["금액"]))
tool = (first + Decimal(str(by["add:#1"]["금액"]))) * 2 / 100
assert by["add:#4"]["금액"] == pytest.approx(float((first + tool) * 10 / 100), abs=0.1)
assert by["add:#4"]["비목"]["재료비"] == pytest.approx(by["add:#4"]["금액"])
part = first + Decimal(str(by["add:#1"]["금액"]))
assert by["add:#5"]["금액"] == pytest.approx(float(part * 5 / 100), abs=0.1)
def test_바탕_SK_율_줄과_바탕_덧줄():
whole = _whole()
doc = {**_doc([_rate("인력", 2)]), "바탕": {"로직": SUB}, "단위": "주"}
got = labor._json(calc.run(sd.clean(doc, None, whole), whole))
bare = labor.run_line(whole, SUB, {})
people = sum(Decimal(str(bare["줄"][k]["금액"])) for k in (0, 1))
rate = next(x for x in got["줄"] if x["cid"] == "add:#0")
assert rate["금액"] == pytest.approx(float(people * 2 / 100), abs=0.1)
assert got["기준"]["경비"] == pytest.approx(
float(Decimal(str(bare["기준"]["경비"])) + Decimal(str(rate["금액"]))), abs=1
)
def test_없는_기준_줄_짝잃음():
got = _run(_doc([LABOR[0], _rate(["add:#0", "labor:LB000099#0"])]))
assert got["짝잃음"] == [{"cid": "add:#1", "까닭": "율 기준 줄 없음 「labor:LB000099#0」"}]
got = _run(_doc([LABOR[0], _rate([])])) # 기준 줄을 지운 뒤 = 밑 0 · 알림
assert got["짝잃음"] == [{"cid": "add:#1", "까닭": "율 기준 줄 없음 「」"}]
assert got["줄"][1]["금액"] == 0
def test_율_줄_없으면_엔진_한_번(monkeypatch):
calls = []
real = labor.run_line
monkeypatch.setattr(labor, "run_line", lambda *a, **k: calls.append(1) or real(*a, **k))
_run(_doc(LABOR))
assert len(calls) == 1
_run(_doc([*LABOR, _rate("인력")]))
assert len(calls) == 3
@pytest.mark.parametrize(
"adds, why",
[
([LABOR[0], _rate(["add:#1"])], "자기"),
([LABOR[0], _rate(["add:#2"]), _rate("인력")], "뒤 율"),
([LABOR[0], _rate(["보통인부"])], "꼴 아님"),
([LABOR[0], _rate("합계")], "비율"),
],
)
def test_검사_막음(adds, why):
with pytest.raises(sd.DocError, match=why):
sd.clean(_doc(adds), None, _whole())
# ── 밑 공용 함수 거울 ───────────────────────────────────────────────────────
def _node(payload: dict):
got = subprocess.run(
["node", os.path.join(HERE, "helper_m02_labor_calc.cjs")],
input=json.dumps(payload, ensure_ascii=False, default=float),
capture_output=True, text=True, encoding="utf-8", check=True,
) # fmt: skip
return json.loads(got.stdout)
ROWS = [
{"키": "a", "묶음": "인력", "비목": {"노무비": 1000.5, "재료비": 0, "경비": 0}},
{"키": "b", "묶음": "장비", "비목": {"노무비": 300.25, "재료비": 12.5, "경비": 700}},
{"키": "c", "묶음": "재료", "비목": {"노무비": 0, "재료비": 4000, "경비": None}},
{"키": "d", "묶음": None, "비목": {"노무비": 10, "재료비": 20, "경비": 30}},
{"키": "r", "묶음": None, "비목": {"노무비": 0, "재료비": 0, "경비": 55.5}, "율": True},
]
@pytest.mark.parametrize(
"basis", ["노무비", "재료비", "경비", "인력", "재료", "장비", ["a", "c"], ["a", "r"], ["zz"]]
)
def test_밑_파이썬과_TS_같음(basis):
py = fixed.rate_base(basis, json.loads(json.dumps(ROWS)))
assert _node({"밑": {"기준": basis, "줄": ROWS}}) == pytest.approx(float(py), rel=1e-9)
def _fx(name, price, link=None, rate=None):
fx = {"이름": name, "단위": "식", "단가": price, "이음": link or {}}
return fx | ({"비율": rate} if rate else {})
def test_일위대가_비율_줄_묶음_목록_settle_거울():
plain = _fx("손 노임", {"노무비": 50000})
tool = _fx("공구손료", {}, rate={"기준": "인력", "비목": "경비"})
doc = labor.clean(
{
"담은로직": [
{"고정": _fx("보통인부", {"노무비": 1}, {"노임": "LB000002"}), "수량": {"손": 0.5}},
{"고정": plain, "수량": {"손": 0.3}},
{"고정": tool, "수량": {"손": 2}},
{"고정": _fx("목록 율", {}, rate={"기준": [fixed.fixed_id(tool)], "비목": "재료비"}),
"수량": {"손": 10}},
{"고정": _fx("노무 율", {}, rate={"기준": "노무비", "비목": "경비"}), "수량": {"손": 3}},
],
"자재": [],
}
) # fmt: skip
answer = labor.calc(doc, {})
py = labor.settle(answer, doc, {})
ts = _node({"답": answer, "문서": doc, "값": {}})
_same(json.loads(json.dumps(labor._json(py), default=float)), ts)
lines = py["줄"]
people = Decimal(str(lines[0]["반영"]["노무비"]))
assert lines[2]["기준"]["경비"] == people # 인력 = 노임 이음 줄만(손 노임 뺌)
assert lines[3]["기준"]["재료비"] == lines[2]["반영"]["계"] # 율 위 율
assert lines[4]["기준"]["경비"] == people + Decimal(str(lines[1]["반영"]["노무비"]))
def _same(a, b) -> None:
if isinstance(a, dict):
assert set(a) == set(b), set(a) ^ set(b)
for k in a:
_same(a[k], b[k])
elif isinstance(a, list):
assert len(a) == len(b)
for x, y in zip(a, b):
_same(x, y)
elif isinstance(a, (int, float)) and not isinstance(a, bool):
assert b == pytest.approx(a, rel=1e-9, abs=1e-9)
else:
assert a == b
# ── B09 ─────────────────────────────────────────────────────────────────────
def test_B09_율_기준_밑수_꼴():
rate = {"이름": "공구손료", "비율": {"기준": "인력", "비목": "경비"}}
assert source.fixed_line(rate, 2)["base"] == "bundle:labor"
rate["비율"]["기준"] = "노무비"
assert source.fixed_line(rate, 2)["base"] == "part:labor"
rate["비율"]["기준"] = ["x", "y"]
assert source.fixed_line(rate, 2)["kind"] == "blank" # 일위대가 블록 = 열쇠 차례 모름
assert source.fixed_line(rate, 2, {"y": 4, "x": 1})["base"] == "rows:1,4"
def test_B09_SK_블록_율_줄_pct():
whole = _whole()
adds = [{"종류": "인력", "요소": "LB000003", "수량": 0.1}, _rate("인력")]
adds.append(_rate(["labor:LB000002#0", "add:#1"], 5, "재료비", "목록"))
doc = sd.clean({**_doc(adds), "바탕": {"로직": SUB}, "단위": "주"}, None, whole)
build = source.Builder()
bid = build.sangeun(whole, {"층": "project", "키": "SK-000001"}, {**doc, "굳힌줄": {}}, {})
lines = build.blocks[bid]["lines"]
pct = {ln["name"]: ln for ln in lines if ln["kind"] == "pct"}
assert pct["공구손료"]["base"] == "bundle:labor" and pct["공구손료"]["qty"] == 2
assert pct["목록"]["base"] == "rows:1,4" and pct["목록"]["part"] == "material"
assert [ln["kind"] for ln in lines[:5]] == ["labor", "labor", "machine", "labor", "pct"]
BLOCK = {
"id": "DGR", "cover": "S:r", "kind": "조합", "name": "율 블록", "spec": "", "unit": "㎥",
"cite": "", "origin": {},
"lines": [
{"kind": "labor", "key": "L1", "name": "보통인부", "qty": 0.5},
{"kind": "fixed", "name": "특별인부", "qty": 0.2, "own": {"labor": 100000.0},
"link": {"labor": "L9", "material": None, "machine": None}},
{"kind": "machine", "key": "E1", "name": "굴착기", "qty": 0.29},
{"kind": "material", "key": "M1", "name": "시멘트", "qty": 3},
{"kind": "pct", "name": "공구손료", "part": "expense", "base": "bundle:labor", "qty": 2},
{"kind": "pct", "name": "율 위 율", "part": "material", "base": "rows:3,4", "qty": 10},
{"kind": "pct", "name": "장비 율", "part": "expense", "base": "bundle:machine", "qty": 1},
],
} # fmt: skip
BOOKS = {
"labor": [{"key": "L1", "price": 172068}],
"material": [{"key": "M1", "price": 5000}],
"machine": [{"key": "E1", "labor": 30000, "material": 5000, "expense": 70000}],
}
def _ts_labor(daega, sheets):
base = IMPORTS.sub("", (ROOT / "B09_Estimation/B09_Estimation_Calc_Base.ts").read_text("utf-8"))
code = IMPORTS.sub(
"", (ROOT / "B09_Estimation/B09_Estimation_Calc_Labor.ts").read_text("utf-8")
)
body = (
f"\nconst d = {json.dumps(daega, ensure_ascii=False)};"
f"\nconst s = {json.dumps(sheets, ensure_ascii=False)};"
"\nconsole.log(JSON.stringify(computeLabor(d, s, {}, {})));"
)
got = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", base + code + body],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert got.returncode == 0, got.stderr
return json.loads(got.stdout)
def test_B09_bundle_rows_파이썬과_TS_같음_손셈():
lines = [{**ln, "cid": f"{ln['kind']}:{ln['name']}#0"} for ln in BLOCK["lines"]]
daega = {"blocks": [{**BLOCK, "lines": lines}], "owners": {}}
py = json.loads(json.dumps(compute_labor(daega, BOOKS, {}), ensure_ascii=False))
_same(py, _ts_labor(daega, BOOKS))
got = {ln["name"]: ln for ln in py["daega_blocks"][0]["lines"]}
people = 86034.0 + 20000.0 # 보통인부 + 노임 이음 고정 줄
assert got["공구손료"]["amounts"]["expense"] == pytest.approx(people * 0.02, abs=0.1)
tool = got["공구손료"]["amounts"]["expense"]
assert got["율 위 율"]["amounts"]["material"] == pytest.approx((15000 + tool) * 0.1, abs=0.1)
mach = got["굴착기"]["amount"]
assert got["장비 율"]["amounts"]["expense"] == pytest.approx(mach * 0.01, abs=0.1)
assert py["gaps"] == []
# ── 굳힘 ────────────────────────────────────────────────────────────────────
def test_바탕_없는_SK_율_줄은_한_줄로_안_펼침():
whole = _whole()
y = sd.clean(_doc([LABOR[0], _rate("인력")]), None, whole)
with pytest.raises(sd.DocError, match="율 줄"):
flat.sk_adds(y, Decimal(2), whole, {})
plain = sd.clean(_doc([LABOR[0]]), None, whole)
assert flat.sk_adds(plain, Decimal(2), whole, {})[0]["수량"] == 1 # 율 없으면 그대로
def test_겹침_율_열쇠_새_차례():
whole = _whole()
under = sd.clean(
{**_doc([LABOR[0], _rate(["add:#0"])]), "바탕": {"로직": SUB}, "단위": "주"}, None, whole
)
seen = sd.patched(whole.logic(SUB), under)
view = dict(zip(seen["정체"], sd.line_ids(seen["줄"]))) # 아래 차례 → 위 문서 cid
over = {"더한줄": [LABOR[1], _rate(["add:#0", view["add:#1"], view["add:#0"]], 3)]}
out = flat.compose(under, over, whole)
keys = [((a.get("고정") or {}).get("비율") or {}).get("기준") for a in out["더한줄"]]
assert keys == [None, ["add:#0"], None, ["add:#2", "add:#1", "add:#0"]]
# ── 화면 — 더한줄 지움 · 되살림 때 율 기준 열쇠 옮김(`logic/M02_Sangeun_Undo.ts`) ──────────
def test_화면_더한줄_지움_되살림_율_열쇠():
code = IMPORTS.sub(
"", (ROOT / "M02_MasterTemplete/logic/M02_Sangeun_Undo.ts").read_text("utf-8")
)
doc = _doc([LABOR[0], LABOR[1], _rate(["add:#1", "add:#0", "labor:LB000038#0"])])
body = (
f"\nconst doc = {json.dumps(doc, ensure_ascii=False)};"
"\nconst key = () => doc.더한줄.at(-1).고정.비율.기준;"
"\nforgetAdd(doc, 0, ''); const a = key(); reviveAdd(doc, 0);"
"\nconsole.log(JSON.stringify([a, key()]));"
)
got = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", code + body],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert got.returncode == 0, got.stderr
assert json.loads(got.stdout) == [
["add:#0", "labor:LB000038#0"], # 지운 줄 열쇠 버림 · 뒤 줄 당김
["add:#1", "labor:LB000038#0"], # 되살림 = 뒤 줄 밂
]
+24
View File
@@ -347,4 +347,28 @@ export const ui_locales_m2 = {
M02_CmpColLook: ["꼴", "Result"],
M02_CmpLost: ["못옮김 {n}", "{n} unmoved"],
M02_ElemTitle: ["M01 요소 후보", "M01 element candidates"],
M02_SkRateAdd: ["율 줄 추가", "Add % line"],
M02_SkRateTitle: ["율 줄 — 기준 × 율 %", "% line — base × rate %"],
M02_SkRateName: ["이름", "Name"],
M02_SkRateDefault: ["공구손료", "Tool cost"],
M02_SkRateRate: ["율", "Rate"],
M02_SkRateBasis: ["기준", "Base"],
M02_SkRatePart: ["금액이 드는 칸", "Cost item"],
M02_SkRateLines: ["기준 줄", "Base lines"],
M02_SkRateBasisLines: ["줄 고르기", "Pick lines"],
M02_SkRateGroupLabor: ["인력 줄 합", "Labor lines"],
M02_SkRateGroupMat: ["재료 줄 합", "Material lines"],
M02_SkRateGroupMach: ["장비 줄 합", "Equipment lines"],
M02_SkRateCostLabor: ["노무비 전체", "All labor cost"],
M02_SkRateCostMat: ["재료비 전체", "All material cost"],
M02_SkRateCostExp: ["경비 전체", "All expense"],
M02_SkRatePartLabor: ["노무비", "Labor"],
M02_SkRatePartMat: ["재료비", "Material"],
M02_SkRatePartExp: ["경비", "Expense"],
M02_SkRateOk: ["넣기", "Add"],
M02_SkRateClose: ["닫기", "Close"],
M02_SkRateNoLines: ["고를 줄 없음 — 먼저 항목 추가", "No lines yet — add items first"],
M02_SkRateNeedLine: ["줄을 하나 이상 고름", "Pick at least one line"],
M02_SkRateBadRate: ["율은 0 이상 수", "Rate must be a number ≥ 0"],
M02_SkRateNeedName: ["이름이 빔", "Name is empty"],
} as const;