feat(778-4): 율 기준 줄 목록 열쇠@비목 — 그 줄 그 비목 몫(절 노무비 · 기계 노무 포함)

- 밑 공용 함수 rate_base ↔ rateBase · 검사 · 짝잃음 · B09 rows:n@labor(파이썬 · TS) · 굳힘 · 화면 열쇠 옮김이 비목 꼬리 그대로
- 여덟 파일 율 줄 152 중 기준 찾은 147 재현(인력 82 · 재료 35 · 절 소계 16 · 노무비 11 · 장비 2 · 자재+율 1)
- 시험 test_778_4_rate_line 29 · 전후 대조 148/148 같음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-04 13:15:13 +09:00
co-authored by Claude Opus 5.5
parent b8772a4172
commit fd9cce0b12
10 changed files with 114 additions and 28 deletions
+14 -6
View File
@@ -94,15 +94,23 @@ def _fixed(line: dict[str, Any], books: dict[str, Any]) -> dict[str, float | Non
def _base(spec: str, lines: list[dict[str, Any]], src: list) -> float | None:
"""% 줄 밑수 — `part:비목` 그 비목 줄 금액 합 · `lines:labor` 노무 줄 금액 합 ·
`line:이름` 그 줄 · `bundle:labor|material|machine` 그 종류 줄(+ 그 이음 고정 줄) 합 ·
`rows:0,2` 블록 그 차례 줄(`src` cid) 합(778-4 · 짝 `.ts` `pctBase`)."""
`rows:0,2@labor` 블록 그 차례 줄(`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 == "rows": # `3@labor` = 그 줄 그 비목 몫
out: list[float | None] = []
for one in dict.fromkeys(name.split(",")):
at, _, part = one.partition("@")
if not at.isdigit() or int(at) >= len(src):
continue
cid = src[int(at)].get("cid")
out += [
ln["amounts"].get(part) if part else total(list(ln["amounts"].values()))
for ln in lines
if ln.get("cid") == cid
]
return total(out)
if kind == "bundle":
return total(
[
+10 -7
View File
@@ -98,13 +98,16 @@ function pctBase(spec: string, lines: Row[], src: Row[]): Num {
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));
// `3@labor` = 그 줄 그 비목 몫
const out: Num[] = [];
for (const one of new Set(name.split(","))) {
const [at, part] = one.split("@");
if (!/^\d+$/.test(at) || Number(at) >= src.length) continue;
const cid = src[Number(at)].cid;
for (const ln of lines)
if (ln.cid === cid) out.push(part ? (ln.amounts[part] ?? null) : whole(ln));
}
return total(out);
}
if (kind === "bundle")
return total(
+11 -1
View File
@@ -525,7 +525,7 @@ def fixed_line(fx: dict, qty: Any, where: dict | None = None) -> dict[str, Any]:
"why": "율 기준 줄 목록 — 대가로 못 셈",
}
base = (
"rows:" + ",".join(str(where[k]) for k in basis if k in where)
"rows:" + ",".join(_row_of(k, where) for k in basis if _row_of(k, where))
if isinstance(basis, list)
else f"part:{PART[basis]}"
if basis in PART
@@ -546,6 +546,16 @@ def fixed_line(fx: dict, qty: Any, where: dict | None = None) -> dict[str, Any]:
}
def _row_of(key: str, where: dict) -> str:
"""율 기준 열쇠 → 블록 차례 글(`열쇠@비목` = `n@labor`) · 없는 열쇠 = ""."""
from M02_MasterTemplete.M02_Labor_Fixed import split_key
line, cost = split_key(key)
if line not in where:
return ""
return f"{where[line]}@{PART[cost]}" if cost else str(where[line])
def _material(whole: Any, mat: dict, qty: Any) -> dict[str, Any]:
"""자재 줄 — 조합 사용자 자재 · 사전조합 자재(175-2) 한 꼴 · 이름 · 규격 · 단위 = 사본 단가."""
row = (whole.get(mat.get("코드")) if mat.get("코드") else None) or {}
+17 -3
View File
@@ -116,16 +116,30 @@ def _all(money: dict | None) -> Decimal:
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))
out = Decimal(0)
for one in dict.fromkeys(basis):
key, cost = split_key(one)
for r in rows:
if r.get("키") == key:
out += (
Decimal(str((r["비목"] or {}).get(cost) or 0)) if cost else _all(r["비목"])
)
return out
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 split_key(one: str) -> tuple[str, str]:
"""목록 열쇠 → (줄 열쇠, 비목 | "") — `add:#3@노무비` = 그 줄 노무비 몫."""
key, at, cost = one.rpartition("@")
return (key, cost) if at and cost in COSTS else (one, "")
def rate_row(one: dict, key: Any, money: dict | None, rate: bool = False) -> dict[str, Any]:
"""`rate_base` 줄 하나 — 고정 줄 묶음 = 이음 · 그 밖 = 줄 `묶음`(SK 엔진 줄 · 자재 = 재료)."""
fx = one.get(FIXED)
+5 -1
View File
@@ -320,7 +320,11 @@ def _rekey(one: dict, move: Any) -> dict:
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]
keys = []
for x in rate["기준"]:
line, cost = fixed.split_key(x) # `열쇠@비목` = 비목 꼬리 그대로
if got := move(line):
keys.append(f"{got}@{cost}" if cost else got)
fx = {**one["고정"], "비율": {**rate, "기준": keys}}
return {**one, "고정": fx}
@@ -269,12 +269,25 @@ export function groupOf(fx: LaborFixed | undefined): RateGroup | null {
const all = (m: RateRow["비목"]): number => COSTS.reduce((s, k) => s + (m?.[k] ?? 0), 0);
/** 목록 열쇠 → [줄 열쇠, 비목 | null] — `add:#3@노무비` = 그 줄 노무비 몫(짝 `M02_Labor_Fixed.split_key`) */
export function splitKey(one: string): [string, CostKey | null] {
const at = one.lastIndexOf("@");
const cost = one.slice(at + 1);
return at >= 0 && (COSTS as readonly string[]).includes(cost)
? [one.slice(0, at), cost as CostKey]
: [one, null];
}
/** 율 줄 밑(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);
let sum = 0;
for (const one of new Set(basis)) {
const [key, cost] = splitKey(one);
for (const r of rows) if (r.키 === key) sum += cost ? (r.비목?.[cost] ?? 0) : all(r.비목);
}
return sum;
}
const plain = rows.filter((r) => !r.율);
if ((COSTS as readonly string[]).includes(basis))
+1 -1
View File
@@ -422,7 +422,7 @@ def _rated(row: dict, eng: list, ids: list, marks: dict, stack: Any = None) -> l
lost += [
{"cid": ids[i], "까닭": f"율 기준 줄 없음 「{k}」"}
for k in rate["기준"] or [""]
if k not in ids
if fixed.split_key(k)[0] not in ids
]
base = fixed.rate_base(rate["기준"], rows) / times
items[i] = {
+3
View File
@@ -359,9 +359,12 @@ def is_rate(one: Any) -> bool:
def _rate_keys(adds: list[dict]) -> None:
"""율 더한줄 기준 목록(778-4) — 열쇠 = 바탕 cid · `add:#k` · 자기 · 뒤 율 줄 못 가리킴(율 위 율 =
앞 율 줄만 · 순환 없음)."""
from M02_MasterTemplete.M02_Labor_Fixed import split_key
for at, one in enumerate(adds):
basis = ((one.get("고정") or {}).get("비율") or {}).get("기준")
for key in basis if isinstance(basis, list) else []:
key = split_key(key)[0] # `열쇠@비목` = 그 줄 그 비목 몫
hit = re.match(r"^add:#(\d+)$", key)
if not hit and not _CID.match(key):
raise DocError(f"더한줄 {at + 1}째 · 율 기준 줄 열쇠 꼴 아님 「{key}」")
+3 -2
View File
@@ -40,8 +40,9 @@ function moveKeys(doc: SkDoc, mode: { drop: number } | { push: number }): void {
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];
if ("drop" in mode)
return j === mode.drop ? [] : [j > mode.drop ? `add:#${j - 1}${hit[2]}` : key];
return [j >= mode.push ? `add:#${j + 1}${hit[2]}` : key];
});
}
}
+34 -4
View File
@@ -95,6 +95,15 @@ def test_줄_목록_절_소계와_율_위_율():
assert by["add:#5"]["금액"] == pytest.approx(float(part * 5 / 100), abs=0.1)
def test_줄_목록_비목_몫_절_노무비():
adds = [*LABOR, _rate(["add:#0", "add:#2@노무비"], 6, name="제잡비")]
by = {x["cid"]: x for x in _run(_doc(adds))["줄"]}
want = Decimal(str(by["add:#0"]["금액"])) + Decimal(str(by["add:#2"]["비목"]["노무비"]))
assert by["add:#3"]["금액"] == pytest.approx(float(want * 6 / 100), abs=0.1)
with pytest.raises(sd.DocError, match="자기"):
sd.clean(_doc([LABOR[0], _rate(["add:#1@노무비"])]), None, _whole())
def test_바탕_SK_율_줄과_바탕_덧줄():
whole = _whole()
doc = {**_doc([_rate("인력", 2)]), "바탕": {"로직": SUB}, "단위": "주"}
@@ -160,7 +169,21 @@ ROWS = [
@pytest.mark.parametrize(
"basis", ["노무비", "재료비", "경비", "인력", "재료", "장비", ["a", "c"], ["a", "r"], ["zz"]]
"basis",
[
"노무비",
"재료비",
"경비",
"인력",
"재료",
"장비",
["a", "c"],
["a", "r"],
["zz"],
["a", "b@노무비"],
["b@경비", "c@재료비", "c@재료비"],
["a@없는칸"],
], # fmt: skip
)
def test_밑_파이썬과_TS_같음(basis):
py = fixed.rate_base(basis, json.loads(json.dumps(ROWS)))
@@ -223,6 +246,8 @@ def test_B09_율_기준_밑수_꼴():
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"
rate["비율"]["기준"] = ["x", "y@노무비", "z"]
assert source.fixed_line(rate, 2, {"y": 4, "x": 1})["base"] == "rows:1,4@labor"
def test_B09_SK_블록_율_줄_pct():
@@ -251,6 +276,7 @@ BLOCK = {
{"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},
{"kind": "pct", "name": "절 노무", "part": "expense", "base": "rows:0,2@labor", "qty": 6},
],
} # fmt: skip
BOOKS = {
@@ -291,6 +317,10 @@ def test_B09_bundle_rows_파이썬과_TS_같음_손셈():
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)
labor_part = got["굴착기"]["amounts"]["labor"]
assert got["절 노무"]["amounts"]["expense"] == pytest.approx(
(86034.0 + labor_part) * 0.06, abs=0.1
)
assert py["gaps"] == []
@@ -322,7 +352,7 @@ 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"])])
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).고정.비율.기준;"
@@ -336,6 +366,6 @@ def test_화면_더한줄_지움_되살림_율_열쇠():
) # fmt: skip
assert got.returncode == 0, got.stderr
assert json.loads(got.stdout) == [
["add:#0", "labor:LB000038#0"], # 지운 줄 열쇠 버림 · 뒤 줄 당김
["add:#1", "labor:LB000038#0"], # 되살림 = 뒤 줄 밂
["add:#0@노무비", "labor:LB000038#0"], # 지운 줄 열쇠 버림 · 뒤 줄 당김(비목 꼬리 그대로)
["add:#1@노무비", "labor:LB000038#0"], # 되살림 = 뒤 줄 밂
]