From fd9cce0b120143aceaa5181f1e1fff3b1721b2ee Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 4 Nov 2026 13:15:13 +0900 Subject: [PATCH] =?UTF-8?q?feat(778-4):=20=EC=9C=A8=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=20=EC=A4=84=20=EB=AA=A9=EB=A1=9D=20`=EC=97=B4=EC=87=A0@?= =?UTF-8?q?=EB=B9=84=EB=AA=A9`=20=E2=80=94=20=EA=B7=B8=20=EC=A4=84=20?= =?UTF-8?q?=EA=B7=B8=20=EB=B9=84=EB=AA=A9=20=EB=AA=AB(=EC=A0=88=20?= =?UTF-8?q?=EB=85=B8=EB=AC=B4=EB=B9=84=20=C2=B7=20=EA=B8=B0=EA=B3=84=20?= =?UTF-8?q?=EB=85=B8=EB=AC=B4=20=ED=8F=AC=ED=95=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 밑 공용 함수 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 --- B09_Estimation/B09_Estimation_Calc_Labor.py | 20 +++++++--- B09_Estimation/B09_Estimation_Calc_Labor.ts | 17 +++++---- B09_Estimation/B09_Estimation_Labor_Source.py | 12 +++++- M02_MasterTemplete/M02_Labor_Fixed.py | 20 ++++++++-- M02_MasterTemplete/M02_Library_Flat.py | 6 ++- .../M02_MasterTemplete_Labor_Calc.ts | 19 ++++++++-- M02_MasterTemplete/M02_Sangeun_Calc.py | 2 +- M02_MasterTemplete/M02_Sangeun_Doc.py | 3 ++ M02_MasterTemplete/logic/M02_Sangeun_Undo.ts | 5 ++- resources/tester/test_778_4_rate_line.py | 38 +++++++++++++++++-- 10 files changed, 114 insertions(+), 28 deletions(-) diff --git a/B09_Estimation/B09_Estimation_Calc_Labor.py b/B09_Estimation/B09_Estimation_Calc_Labor.py index 6051bfd4a..c8a3509b4 100644 --- a/B09_Estimation/B09_Estimation_Calc_Labor.py +++ b/B09_Estimation/B09_Estimation_Calc_Labor.py @@ -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( [ diff --git a/B09_Estimation/B09_Estimation_Calc_Labor.ts b/B09_Estimation/B09_Estimation_Calc_Labor.ts index 15b87330b..4a0582904 100644 --- a/B09_Estimation/B09_Estimation_Calc_Labor.ts +++ b/B09_Estimation/B09_Estimation_Calc_Labor.ts @@ -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( diff --git a/B09_Estimation/B09_Estimation_Labor_Source.py b/B09_Estimation/B09_Estimation_Labor_Source.py index c65a61549..52835a89a 100644 --- a/B09_Estimation/B09_Estimation_Labor_Source.py +++ b/B09_Estimation/B09_Estimation_Labor_Source.py @@ -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 {} diff --git a/M02_MasterTemplete/M02_Labor_Fixed.py b/M02_MasterTemplete/M02_Labor_Fixed.py index adb025277..dcb568609 100644 --- a/M02_MasterTemplete/M02_Labor_Fixed.py +++ b/M02_MasterTemplete/M02_Labor_Fixed.py @@ -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) diff --git a/M02_MasterTemplete/M02_Library_Flat.py b/M02_MasterTemplete/M02_Library_Flat.py index 2e5a26290..c1713b8fd 100644 --- a/M02_MasterTemplete/M02_Library_Flat.py +++ b/M02_MasterTemplete/M02_Library_Flat.py @@ -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} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts index 231858c71..8c658dcee 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts @@ -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)) diff --git a/M02_MasterTemplete/M02_Sangeun_Calc.py b/M02_MasterTemplete/M02_Sangeun_Calc.py index 311d7e5b5..bbe1a3757 100644 --- a/M02_MasterTemplete/M02_Sangeun_Calc.py +++ b/M02_MasterTemplete/M02_Sangeun_Calc.py @@ -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] = { diff --git a/M02_MasterTemplete/M02_Sangeun_Doc.py b/M02_MasterTemplete/M02_Sangeun_Doc.py index 94e7513ad..71473929e 100644 --- a/M02_MasterTemplete/M02_Sangeun_Doc.py +++ b/M02_MasterTemplete/M02_Sangeun_Doc.py @@ -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}」") diff --git a/M02_MasterTemplete/logic/M02_Sangeun_Undo.ts b/M02_MasterTemplete/logic/M02_Sangeun_Undo.ts index 73a7387bc..3bcdd838a 100644 --- a/M02_MasterTemplete/logic/M02_Sangeun_Undo.ts +++ b/M02_MasterTemplete/logic/M02_Sangeun_Undo.ts @@ -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]; }); } } diff --git a/resources/tester/test_778_4_rate_line.py b/resources/tester/test_778_4_rate_line.py index 41ea29b49..7eae698d2 100644 --- a/resources/tester/test_778_4_rate_line.py +++ b/resources/tester/test_778_4_rate_line.py @@ -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"], # 되살림 = 뒤 줄 밂 ]