fix(841-1): 기계 덮음 단가산출 = B09 에 그대로 — SK 사본에 수식 문서 · 블록 합 = M02 SK 셈

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-11-15 16:17:05 +09:00
co-authored by Claude Opus 5.5
parent 193ce92806
commit 5f2f073902
6 changed files with 156 additions and 1 deletions
@@ -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]
@@ -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<string, Row>, 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<string, Row>, 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 이 아니라 모름(빈 줄)
@@ -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,
+17
View File
@@ -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)
}
+2
View File
@@ -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)
@@ -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