From 5f2f0739026a06065f8a13e02e3f71048d83bd1a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 15 Nov 2026 16:17:05 +0900 Subject: [PATCH] =?UTF-8?q?fix(841-1):=20=EA=B8=B0=EA=B3=84=20=EB=8D=AE?= =?UTF-8?q?=EC=9D=8C=20=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=20=3D=20B09=20?= =?UTF-8?q?=EC=97=90=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=E2=80=94=20SK=20?= =?UTF-8?q?=EC=82=AC=EB=B3=B8=EC=97=90=20=EC=88=98=EC=8B=9D=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=C2=B7=20=EB=B8=94=EB=A1=9D=20=ED=95=A9=20=3D=20M02?= =?UTF-8?q?=20SK=20=EC=85=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M02_Item_Sangeun.freeze: 굳힌 SK 사본에 원본 「수식」 칸(기계덮음 · 수식 고침이 든 수식 문서)을 같이 담음(브레인 허락 · 두 줄) — 구조물 사본 · 서고 사본 같은 굳히기 - B09 단가산출 블록 합 = M02_Sangeun_Calc.unit(M02 가 일위대가 안 SK 를 셀 때 쓰는 셈) × 배 · 고친 칸 · 붙인 줄 · 재료 표시 바꿈 없을 때만(Supply.sk_money · Calc_Labor py · ts 짝) - 검증_sub5 실서버: 굴착기 가격 덮음 SK M02 4,560 = B09 4,560(전 3,548) · 옛 사본(수식 없음) = 덮기 전 값 그대로 - 사본 크기: SK 사본 일위대가.json 58 → 95 KB · 일위대가 사본 155 → 238 KB(원본 칸과 수식 겹쳐 담음) - 지킴 시험 test_841_1_b09_m02_values.py(B09 기계 시간당 다시 셈 없음 · 기계 대가 = 엔진 기준 · 덮음 SK 사본까지) Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Calc_Labor.py | 17 +++ B09_Estimation/B09_Estimation_Calc_Labor.ts | 14 +++ B09_Estimation/B09_Estimation_Labor_Source.py | 4 +- B09_Estimation/B09_Estimation_Supply.py | 17 +++ M02_MasterTemplete/M02_Item_Sangeun.py | 2 + resources/tester/test_841_1_b09_m02_values.py | 103 ++++++++++++++++++ 6 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 resources/tester/test_841_1_b09_m02_values.py diff --git a/B09_Estimation/B09_Estimation_Calc_Labor.py b/B09_Estimation/B09_Estimation_Calc_Labor.py index 8514ce5f2..7f18fabfc 100644 --- a/B09_Estimation/B09_Estimation_Calc_Labor.py +++ b/B09_Estimation/B09_Estimation_Calc_Labor.py @@ -119,6 +119,19 @@ def _apart(line: dict[str, Any], books: dict[str, Any]) -> bool: return (books["material"].get(line.get("key")) or {}).get("supply") in SUPPLY +def _as_m02(block: dict, lines: list[dict], books: dict, own: dict) -> bool: + """SK 블록 합을 M02 셈으로 — 고친 칸 · 붙인 줄 없음 · 재료 표시가 M02 자재표시 그대로일 때만.""" + return ( + bool(block.get("m02")) + and not own + and all( + _apart(ln, books) == (ln.get("mark") in SUPPLY) + for ln in lines + if ln["kind"] == "material" + ) + ) + + def _none() -> dict[str, float | None]: return dict.fromkeys(PARTS, None) @@ -237,6 +250,10 @@ def _block( known = [ln for ln in lines if ln["amount"] is not None and not ln.get("out")] parts = settle({part: sum(ln["amounts"][part] for ln in known) for part in PARTS}, block.get("rule")) # fmt: skip + if _as_m02( + block, lines, books, own + ): # 841-1 단가산출 블록 = M02 SK 셈 그대로(덮음 · 수식 고침) + parts = {p: rounddown(block["m02"][p], 0) for p in PARTS} blank_names = [ln.get("name") or "" for ln in lines if ln["amount"] is None] if not lines: # 줄 0 개 일위대가 = 금액 0 이 아니라 모름(빈 줄) blank_names = [NO_LINES] diff --git a/B09_Estimation/B09_Estimation_Calc_Labor.ts b/B09_Estimation/B09_Estimation_Calc_Labor.ts index 14f7b8615..5df398cf0 100644 --- a/B09_Estimation/B09_Estimation_Calc_Labor.ts +++ b/B09_Estimation/B09_Estimation_Calc_Labor.ts @@ -177,6 +177,18 @@ function cover(line: Row, own: Row, qty0: Num, prices0: Parts): [Num, Parts, Row return ["qty" in mine ? num(mine.qty) : qty0, prices, extra]; } +/** SK 블록 합을 M02 셈으로 — 고친 칸 · 붙인 줄 없음 · 재료 표시가 M02 자재표시 그대로일 때만 */ +function asM02(b: Row, lines: Row[], books: Row, own: Row): boolean { + const supplied = (v: unknown): boolean => (SUPPLY as readonly string[]).includes(v as string); + return ( + !!b.m02 && + !Object.keys(own).length && + lines + .filter((ln) => ln.kind === "material") + .every((ln) => supplied((books.material[ln.key] || {}).supply) === supplied(ln.mark)) + ); +} + function block(b: Row, books: Row, sums: Record, gaps: Gap[], own: Row): Row { const lines: Row[] = []; for (const line of b.lines) { @@ -226,6 +238,8 @@ function block(b: Row, books: Row, sums: Record, gaps: Gap[], own: const raw = { labor: 0, material: 0, expense: 0 }; for (const part of PARTS) raw[part] = known.reduce((acc, ln) => acc + ln.amounts[part], 0); const parts = settle(raw, b.rule); + // 841-1 단가산출 블록 = M02 SK 셈 그대로(덮음 · 수식 고침 · 짝 `_as_m02`) + if (asM02(b, lines, books, own)) for (const p of PARTS) parts[p] = rounddown(b.m02[p], 0); const blankNames = lines.length ? lines.filter((ln) => ln.amount === null).map((ln) => ln.name || "") : [NO_LINES]; // 줄 0 개 일위대가 = 금액 0 이 아니라 모름(빈 줄) diff --git a/B09_Estimation/B09_Estimation_Labor_Source.py b/B09_Estimation/B09_Estimation_Labor_Source.py index b2f095cfe..33cfbe118 100644 --- a/B09_Estimation/B09_Estimation_Labor_Source.py +++ b/B09_Estimation/B09_Estimation_Labor_Source.py @@ -39,7 +39,7 @@ from B09_Estimation import B09_Estimation_Sangeun as sg from B09_Estimation import B09_Estimation_Sangeun_Text as st from B09_Estimation.B09_Estimation_Bill_Items import LAYER_WORD from B09_Estimation.B09_Estimation_Copy_Prices import bundle -from B09_Estimation.B09_Estimation_Supply import supply_mark +from B09_Estimation.B09_Estimation_Supply import sk_money, supply_mark from M02_MasterTemplete import M02_Item_Prices as item_prices from M02_MasterTemplete import M02_Labor_Doc as labor_doc from M02_MasterTemplete import M02_Labor_Fixed as fixed @@ -228,6 +228,8 @@ class Builder: lines.append({**line, **keep, "sk": True, **({"poom": poom} if poom else {})}) block.pop("_items") name = doc.get("이름") or block["name"] + m02 = sk_money(doc, whole, _Loose(whole, read), rid) # 841-1 블록 합 = M02 SK 셈 + block = {**block, **({"m02": m02} if m02 else {})} origin = {**block["origin"], "단가산출": rid} cover = f"P:{_digest({'단가산출': rid})}" return self.put({**block, "name": name, diff --git a/B09_Estimation/B09_Estimation_Supply.py b/B09_Estimation/B09_Estimation_Supply.py index 42b60883f..0d6cf1fea 100644 --- a/B09_Estimation/B09_Estimation_Supply.py +++ b/B09_Estimation/B09_Estimation_Supply.py @@ -72,3 +72,20 @@ def price_lists(blocks: list[dict[str, Any]], sg_blocks: list[dict[str, Any]]) - elif kind == "machine" and None not in [price.get(p) for p in PARTS]: machine.setdefault(key, {**head, **{p: price[p] for p in PARTS}}) return list(labor.values()), list(machine.values()) + + +def sk_money(doc: dict, whole: Any, expand: Any, rid: str) -> dict[str, float] | None: + """단가산출 블록 합(841-1) = M02 가 일위대가 안 SK 를 셀 때 쓰는 1 단위 셈(`M02_Sangeun_Calc.unit` · + 수식 문서 · 기계 덮음 · 별도계상까지) × 배(블록 = 바탕 기준단위) — 못 셈 = None(줄 합 그대로).""" + from M02_MasterTemplete import M02_Sangeun_Calc as calc + + try: + got = calc.unit(doc, whole, expand, (rid,)).get("기준") + per = float(calc.ratio_of(doc, whole.logic(doc["바탕"]["로직"]), whole)) + except Exception: # noqa: BLE001 — M02 셈이 못 돌면 줄 합(옛 길) 그대로 + return None + if not got: + return None + return { + p: float(got[k]) * per for p, k in zip(PARTS, ("노무비", "재료비", "경비"), strict=True) + } diff --git a/M02_MasterTemplete/M02_Item_Sangeun.py b/M02_MasterTemplete/M02_Item_Sangeun.py index 9f4b9df23..5aec58da5 100644 --- a/M02_MasterTemplete/M02_Item_Sangeun.py +++ b/M02_MasterTemplete/M02_Item_Sangeun.py @@ -85,6 +85,8 @@ def freeze(copies: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]: } if got["켜쌓기율"] is not None: frozen["켜쌓기율"] = prices.to_json(got["켜쌓기율"]) + if isinstance(doc.get("수식"), dict): # 841-1 수식 문서(기계덮음) — 사본 셈 = M02 + frozen["수식"] = doc["수식"] out[rid] = frozen for rid in sorted(rid for rid, doc in copies.items() if _patched(doc)): out[rid] = _freeze_lines(rid, copies[rid], whole, read, taken) diff --git a/resources/tester/test_841_1_b09_m02_values.py b/resources/tester/test_841_1_b09_m02_values.py new file mode 100644 index 000000000..c225b41c9 --- /dev/null +++ b/resources/tester/test_841_1_b09_m02_values.py @@ -0,0 +1,103 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""841-1 B09 = M02 값 분류 · 합산(요구 865 · 866) — 서버 · 화면 없이. + +① B09 가 계수표(CC000336) · 기계 값(손료계수 · 조종원)으로 기계 시간당 · 운전사 노임을 + 다시 셈하지 않음 +② 기계 든 단가산출 대가 블록 = M02 엔진 기준(운전사 계수 따로 곱 0) +③ M02 에서 기계 덮음한 SK → 프로젝트 사본(구조물 · 서고 사본 같은 굳히기) → M02 엔진 반영합 · + B09 SK 블록 합(`Supply.sk_money`) = M02 SK 셈(덮은 값) · 옛 사본(수식 없음) = 덮기 전 값 +""" + +import copy as cp +import re +import tempfile +from pathlib import Path + +from B09_Estimation import B09_Estimation_Router as router +from B09_Estimation import B09_Estimation_Supply as supply +from B09_Estimation.B09_Estimation_Labor_Source import _Loose, daega_inputs +from M02_MasterTemplete import M02_Item_Prices as item_prices +from M02_MasterTemplete import M02_Item_Snapshot as snapshots +from M02_MasterTemplete import M02_Item_Source as source +from M02_MasterTemplete import M02_Labor_Doc as labor_doc +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from resources.tester.test_145_2_b09_labor_calc import _project, _snap +from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 + +B09 = Path(__file__).resolve().parents[2] / "B09_Estimation" +OWN_CALC = re.compile( + r"CC000336|driver_factor|driverHour|driver_hour|손료계수|조종원직종|machineBlock|machine_block\(" +) +MACHINE = "GF000119" # 노체포설 — 굴착기(무한궤도) 0.7㎥ 0201-0070 + + +def test_기계_시간당_다시_셈_없음(): + hits = [ + f"{p.name}:{n}" + for p in sorted([*B09.glob("*.py"), *B09.glob("*.ts")]) + for n, line in enumerate(p.read_text(encoding="utf-8").splitlines(), 1) + if OWN_CALC.search(line) + ] + assert hits == [] + + +def test_기계_대가_블록_M02_엔진_값(): + root = Path(tempfile.mkdtemp()) / "proj" + _project(root, [(100, "SS000901")]) + _snap(root, "pipe@100.0", "SS000901", [{"로직": MACHINE, "승격": True, "수량": {"손": 1}}], {}) + inputs = {"materials": [], "bill": {}, "hopyo": {}, "daega": daega_inputs(root)} + sheets = router.compute_sheets(root, inputs, {}) + block = next(b for b in sheets["daega"] if b["kind"] == "품셈") + folder = next(snapshots.structures_dir(root).rglob("단가.json")).parent + whole = item_prices.load_prices(source.read_json(folder / "단가.json")) + want = labor_doc.run_line(whole, MACHINE, {})["기준"] + assert [block[p] for p in ("labor", "material", "expense", "sum")] == [ + float(want[k]) for k in ("노무비", "재료비", "경비", "계") + ] + assert sheets["machine"] and sheets["machine_blocks"] == [] and sheets["expense"] == [] + + +def _covered_sk(me: Path) -> tuple[str, float, float]: + """임시 개인 층 SK(노체포설) + 기계 덮음(가격 × 2) → (키, 덮기 전 계, 덮은 뒤 계).""" + from M01_MasterData import M01_MasterData_Store as m01 + from M02_MasterTemplete import M02_Doc_Sk_Path as path + from M02_MasterTemplete import M02_Labor_SkRow as skrow + + with store.at(me): + sk = skrow.logic_doc({"로직": MACHINE, "입력값": {}}, {}, m01.loaded()[1]) + key = refs.create("sangeun", {**sk, "이름": "841 덮음"})["이름"] + got = path.read(key) + doc = cp.deepcopy(got["문서"]) + no = next( + v["연결"]["입력"]["기계"].strip("'") + for v in doc.get("변수") or [] + if ((v.get("연결") or {}).get("입력") or {}).get("기계") + ) + price = m01.loaded()[1].numbers["EQ"][no]["값"]["가격"] + doc["기계덮음"] = {no: {"값": {"가격": float(price) * 2}}} + after = path.save(key, {"문서": doc, "판": got["판"]}) + return key, float(got["셈"]["계"]), float(after["셈"]["계"]) + + +def test_기계_덮음_SK_사본까지(world): + me = world["personal"] + key, was, now = _covered_sk(me) + assert now > was + with store.at(me): + here = refs.here() + ref = {"층": here[0], "주인": here[1], "키": key} + rows = source.labor_doc_rows( + {"이름": "t", "단위": "m", "담은로직": [{"참조": ref, "수량": {"손": 1}}]} + ) + bundle = source.price_bundle(rows, me) + got = item_prices.calc(rows, bundle, {}, {}) # 구조물 · 서고 사본 = 같은 굳히기 · 같은 셈 + assert float(got["반영합"]["계"]) == now + rid = refs.ref_id(ref) + copies = rows["참조사본"] + whole = item_prices.load_prices(bundle) + expand = _Loose(whole, lambda r: copies.get(refs.ref_id(r))) + b09 = supply.sk_money(copies[rid], whole, expand, rid) + assert sum(b09.values()) == now + old = {k: v for k, v in copies[rid].items() if k != "수식"} # 옛 사본(수식 없음) = 덮기 전 + assert sum(supply.sk_money(old, whole, expand, rid).values()) == was