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:
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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] = {
|
||||
|
||||
@@ -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}」")
|
||||
|
||||
@@ -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];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"], # 되살림 = 뒤 줄 밂
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user