feat(m02): 사전조합 자재를 사전조합 값 · B08 참조 재료 · B09 겹 대가 · 사본 단가 묶음에 셈 (175-2)

- 사전조합 1 단위 값(Expand._combo) = 안 줄 + 자재 기준단가 × 손 · 빈칸 = 안 셈 · 단가 없음 = 까닭 · 안 줄에 자재 줄
- B08 _ref_mats 가 사전조합 자재(손 × 겹 손)를 품목 코드로 더함 · 사용자 자재는 연료 거름 안 함
- B09 겹 대가 블록에 자재 줄(조합 자재 줄과 한 꼴 _material)
- 사본 단가 묶음: 굳힌 사전조합 자재 · 고름 코드(frozen_materials) · 개인 자재 묶음도
- 화면 하위 행: 안 줄 자재 코드 표시
- 시험: test_175_precombo_mats 네 개 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
This commit is contained in:
2026-10-10 11:49:20 +09:00
co-authored by Claude Opus 5.5
parent 1157af125b
commit bc0160da2d
7 changed files with 152 additions and 14 deletions
+6 -1
View File
@@ -57,6 +57,7 @@ def _qty(value: Any) -> float | None:
def _ref_mats(whole: Any, read: Any, ref: dict, trail: tuple[str, ...] = ()) -> list[tuple]:
"""참조 문서 1 단위 재료 `[(재료 줄, 수량)]` — 층 셈 펼침(`M02_Labor_Ref.Expand`)과 같은 길:
승격 = 원 로직 재료 ÷ 배 · 사전조합 = 안 줄 재료 × 손 수량(빈칸 1) · 로직 줄은 알약 배로 나눔 ·
사전조합 자재(175-2) = 손 수량(빈칸 = 안 셈 · 품목 키 = 코드 · `자재` 표시 = 연료 거름 안 함) ·
순환 · 깊이 넘음 · 없는 문서 = 빈 것."""
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M02_MasterTemplete import M02_Labor_Doc as labor
@@ -94,6 +95,10 @@ def _ref_mats(whole: Any, read: Any, ref: dict, trail: tuple[str, ...] = ()) ->
continue
hand = _qty((one.get("수량") or {}).get("손"))
out += [(m, q * (1.0 if hand is None else hand)) for m, q in sub]
for mat in doc.get("자재") or []:
if hand := _qty((mat.get("수량") or {}).get("손")):
line = {k: mat.get(k) for k in ("이름", "단위")}
out.append(({**line, "품목": {"키": mat.get("코드")}, "자재": True}, hand))
return out
@@ -129,7 +134,7 @@ def _folder_units(root: str | Path, folder: str, meta: dict[str, dict]) -> dict[
def logic_mat(mat: dict, qty: float) -> None:
name, unit = str(mat.get("이름") or ""), str(mat.get("단위") or "")
if _fuel(name, unit):
if _fuel(name, unit) and not mat.get("자재"): # 사전조합 사용자 자재는 거르지 않음
return
goods = mat.get("품목")
code = str(goods.get("키") or "") if isinstance(goods, dict) else ""
+15 -7
View File
@@ -4,7 +4,8 @@
(`M02_Item_Prices.calc` = 142-1 층 셈 꼴)로 돌려 줄 수량을 받고, 블록 꼴로 펼침:
조합 구조물 조합 사본 하나 = 블록(엑셀 「조합」 꼴) · 줄 = 담은 로직 줄 · 자재 줄
사전조합 참조 SC = 겹 대가(굳힌 `참조사본`) · 줄 = 그 안 로직 · 참조 줄(수량 = 손)
사전조합 참조 SC = 겹 대가(굳힌 `참조사본`) · 줄 = 그 안 로직 · 참조 줄(수량 = 손) ·
자재 줄(175-2 · 수량 = 손 · 빈칸 = 수량 없음)
품셈 일위대가화(승격) 줄 · 옛 참조 SP = 로직마다 따로(노무 · 재료 · 중기 · 산근 · % 줄)
— 조합이 수량만큼 부름 · 기계 = 기계경비 로직 줄 · 다른 돈 로직 줄 = 하위 산근
산근 단가산출 줄(일위대가화 아닌 로직 줄) = 산근 호표 참조(키 = 145-1 `sangeun_key`)
@@ -206,7 +207,7 @@ class Builder:
"spec_raw": block.get("spec_raw"), "unit": block["unit"], "qty": each} # fmt: skip
def precombo(self, whole: Any, ref: dict, doc: dict, copies: dict, depth: int = 0) -> str:
"""사전조합(SC) = 겹 대가 — 줄 수량 = 손(기본 1) · 로직 줄 = 품셈 호표."""
"""사전조합(SC) = 겹 대가 — 줄 수량 = 손(기본 1) · 로직 줄 = 품셈 호표 · 자재 줄(175-2)."""
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
inner = [one for one in doc.get("담은로직") or [] if isinstance(one, dict)]
@@ -226,6 +227,10 @@ class Builder:
continue
qty = 1 if hand is None else hand
lines.append(self.line_of(whole, one, qty, times, name, copies, depth))
lines += [
_material(whole, mat, (mat.get("수량") or {}).get("손"))
for mat in doc.get("자재") or []
]
return self.put(
{
"kind": "사전조합",
@@ -262,11 +267,7 @@ class Builder:
if line.get("qty") is None and line["kind"] != "blank":
line = {**line, "why": "수량 없음 — 면적산출 알약 칸 · 손 수량을 넣음"}
lines.append(line)
for mat in result.get("자재") or []:
row = (whole.get(mat.get("코드")) if mat.get("코드") else None) or {}
lines.append({"kind": "material", "key": mat.get("코드"), "name": row.get("이름") or "",
"spec": row.get("규격") or "", "unit": row.get("단위") or "",
"qty": _f(mat.get("수량"))}) # fmt: skip
lines += [_material(whole, mat, mat.get("수량")) for mat in result.get("자재") or []]
manifest = read_json(folder / MANIFEST_NAME) or {}
origin = manifest.get("원본") or {}
return self.put(
@@ -282,6 +283,13 @@ class Builder:
)
def _material(whole: Any, mat: dict, qty: Any) -> dict[str, Any]:
"""자재 줄 — 조합 사용자 자재 · 사전조합 자재(175-2) 한 꼴 · 이름 · 규격 · 단위 = 사본 단가."""
row = (whole.get(mat.get("코드")) if mat.get("코드") else None) or {}
names = {k: row.get(v) or "" for k, v in (("name", "이름"), ("spec", "규격"), ("unit", "단위"))}
return {"kind": "material", "key": mat.get("코드"), **names, "qty": _f(qty)}
def _engine_bases(engine: list[dict], items: list[dict]) -> dict[str, float]:
"""엔진 호표 줄 → 덧줄 밑수 후보 — `part:비목` 합 · `lines:labor` 인력 줄 합 · `line:이름`."""
out: dict[str, float] = {f"part:{p}": 0.0 for p in PART.values()}
+3 -2
View File
@@ -162,10 +162,11 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]:
materials += [ # 고른 로직 자재(103-1) 품목 줄도 — 복사본 계산이 그 단가를 씀
str(key) for one in doc.get("담은로직") or [] for key in (one.get("고름") or {}).values()
]
materials += refs.frozen_materials((labor or {}).get("참조사본") or {}) # 175-2 사전조합 자재
wanted = logic_keys + [code for code in materials if code]
files[PRICE_FILE] = prices.to_json(prices.collect(logic_keys, materials)) if wanted else {}
if mine := own.bundle(
own.codes(doc), root
): # 개인 자재(148-3) — 원본 목록이 바뀌어도 사본 그대로
[c for c in materials if own.is_own(c)], root
): # 개인 자재(148-3 · 사전조합 것도 175-2) — 원본 목록이 바뀌어도 사본 그대로
files[PRICE_FILE][own.BUNDLE] = mine
return files
+35 -1
View File
@@ -7,7 +7,8 @@
precombo `{층}/precombo/SC-######.json` 사전조합
`{종류:"사전조합", 키, 이름, 단위, 본뜬, 담은로직:[{참조, 수량:{손}, 메모}
| {로직, 입력값, 메모, 수량:{손}, 고름?, 자재표시?}], 자재:[일위대가 자재 꼴]}` —
승격 · 사전조합 · 단가산출 로직(51-11) · 수량은 손(기본 1) · 자재 수량은 손 · 빈칸 = 안 셈(175) ·
승격 · 사전조합 · 단가산출 로직(51-11) · 수량은 손(기본 1) ·
자재 수량은 손 · 빈칸 = 안 셈(175) ·
값 = Σ 안 줄 1 단위 값 × 수량(로직 줄 = 기준 ÷ 배) + Σ 자재 기준단가 × 손
참조 = `{층, 주인, 키}`(마스터 주인 null · 회사 `C` · 개인 `C/U`) — 종류는 키 머리(SP · SC) ·
위 층만 가리킴(개인 → 그 개인 · 그 회사 · 마스터 / 회사 → 그 회사 · 마스터 / 마스터 → 마스터) ·
@@ -460,6 +461,29 @@ class Expand:
why = why or f"「{sub.get('이름')}」 {sub.get('까닭')}"
continue
rows.append({k: v * qty for k, v in sub["기준"].items()})
for mat in doc.get("자재") or []: # 사용자 자재(175-2) = 기준단가 × 손 · 빈칸 = 안 셈
qty = labor._number((mat.get("수량") or {}).get("손"))
if qty is None:
continue
got = labor.material_price(self.whole, mat["코드"], mat.get("단위"))
price = labor._number(got["기준단가"])
zero = dict.fromkeys(labor.COSTS, Decimal(0))
base = None if price is None else {**zero, "재료비": price, "계": price}
name = mat.get("이름") or mat["코드"]
inner.append(
{
"자재": mat["코드"],
"수량": qty,
"이름": name,
"단위": mat.get("단위"),
"기준": base,
"까닭": got["까닭"],
}
)
if base is None:
why = why or f"「{name}」 {got['까닭']}"
continue
rows.append({k: v * qty for k, v in base.items()})
return {
"이름": doc.get("이름"),
"단위": doc.get("단위"),
@@ -512,6 +536,16 @@ def with_pills(doc: dict[str, Any]) -> dict[str, Any]:
return {**doc, "알약": combo_store.combo_pills({"담은로직": lines})["물량"]}
def frozen_materials(copies: dict[str, Any]) -> list[str]:
"""굳힌 사전조합의 자재 · 고른 로직 자재 코드(175-2) — 프로젝트 단가 묶음에 넣음."""
out = []
for doc in copies.values():
out += [str(m.get("코드")) for m in doc.get("자재") or [] if isinstance(m, dict)]
lines = [one for one in doc.get("담은로직") or [] if isinstance(one, dict)]
out += [str(key) for one in lines for key in (one.get("고름") or {}).values()]
return out
def frozen_logics(copies: dict[str, Any]) -> list[str]:
"""굳힌 단가승격 · 사전조합이 담은 로직 키 — 프로젝트 단가 묶음에 넣음."""
return [
@@ -40,12 +40,13 @@ export interface CalcLine {
/** 로직 자재(103-1) — 맨 위 층 재료 줄 · 금액은 `기준` 안(여기서 안 더함) */
재료?: LogicMat[];
/** 참조 줄 — 가리킨 문서 이름 · 단가승격의 원 로직 키 · 사전조합 안 줄(한 층 · 50-3 ④ ·
* 참조 줄 = `참조` · 로직 줄(51-11) = `로직`) */
* 참조 줄 = `참조` · 로직 줄(51-11) = `로직` · 자재 줄(175-2) = `자재` 코드) */
이름?: string | null;
원로직?: string | null;
안?: {
참조?: LaborRef;
로직?: string;
자재?: string;
수량: number;
이름?: string | null;
단위?: string | null;
@@ -267,14 +267,14 @@ export interface LogicTableData {
}
/** 고른 줄 하위 행(51-2 · 같은 표 · 같은 칸) — 단가산출 · 단가승격 = 그 로직 재료 · 노무 · 경비 줄 ·
* 일위조합 = 세부 항목 한 층(51-13 · 참조 줄 · 로직 줄 · 안을 다시 펼치지 않음 · 금액 = 안 줄 기준 × 안 줄 수량) ·
* 일위조합 = 세부 항목 한 층(51-13 · 참조 줄 · 로직 줄 · 자재 줄 175-2 · 안을 다시 펼치지 않음 · 금액 = 안 줄 기준 × 안 줄 수량) ·
* 금액 칸 = 위 기준(위 줄 기준과 같은 바탕) / 아래 반영(× 위 줄 수량 ÷ 배) ·
* 하위 합 = 위 줄 금액 · 줄이 없으면 빈 한 줄(「…」 · 까닭 · 「—」) */
function subRows(line: LaborCalcLine | undefined, wait: boolean, tools: boolean): HTMLElement[] {
const times = line?.수량 === null || line?.수량 === undefined ? null : line.수량 / (line.배 || 1);
const items = line?.안
? line.안.map((x) => {
const key = x.참조?.키 ?? x.로직 ?? "";
const key = x.참조?.키 ?? x.로직 ?? x.자재 ?? "";
const label = x.이름 && x.이름 !== key ? `${key} ${x.이름}` : key; // 로직 줄 이름 없으면 키만
return {
name: `${label}${x.까닭 ? ` · ${x.까닭}` : ""}`,
@@ -2,13 +2,18 @@
175-1 화면 · 저장 — 서고 모드 자재목록 · 로직 자재 세 칸 막힘 풂 · 저장 `_combo_lines` 가
고름 · 자재표시 남김 · 문서 `자재`(일위대가 자재 검사 · 로직 자재와 같은 품목 400).
175-2 셈 — 담은 자재(손 · 빈칸 = 안 셈)가 사전조합 1 단위 값(`Expand._combo` · 안 줄) · B08 참조 줄
재료(`_ref_mats`) · B09 겹 대가 자재 줄 · 프로젝트 사본 단가 묶음(`frozen_materials`)에 들어감.
"""
import json
import os
from decimal import Decimal
import pytest
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 임시 층 fixture
@@ -19,6 +24,8 @@ NET = "GC000337" # 낙석방지망 — 재료 줄 3 · 4 · 5(철망 · 결속
NET_ITEM = "MT006697"
WIRE = "MT000194"
MAT = "MT000008" # 자재품목 · 물가정보 83000
STONE = "GF000219" # 원/㎡
SYS = {"층": "system", "주인": None}
def _save(folder, doc):
@@ -58,3 +65,85 @@ def test_로직_자재와_같은_품목_400_없는_자재_400(world): # noqa: F
with pytest.raises(store.StoreError) as e:
_save(world["system"], {"담은로직": [], "자재": [{"코드": "MT999999"}]})
assert e.value.status == 400
# ── 175-2 셈 ──────────────────────────────────────────────────────────
def _price(code):
whole = m01.loaded()[1]
return Decimal(str(labor.material_price(whole, code, None)["기준단가"]))
def _world_combo(world): # noqa: F811
"""STONE 1 + MAT 손 2 + WIRE 빈칸(안 셈) · NET 고름 4 = NET_ITEM — (키, 손셈 1 단위 값)."""
lines = [
{"로직": STONE, "수량": {"손": 1}},
{"로직": NET, "수량": {"손": 0}, "고름": {"4": NET_ITEM}},
]
mats = [{"코드": MAT, "수량": {"손": 2}}, {"코드": WIRE, "수량": None}]
with store.at(world["system"]):
key = refs.create("precombo", {"이름": "자재 조합", "담은로직": lines, "자재": mats})[
"이름"
]
stone = Decimal(str(labor.run_line(m01.loaded()[1], STONE, {})["기준"]["계"]))
return key, stone + _price(MAT) * 2
def test_사전조합_값에_자재_금액_빈칸은_안_셈_안_줄에_자재(world): # noqa: F811
key, want = _world_combo(world)
with store.at(world["system"]):
doc = labor.clean(
{"열": "rv", "담은로직": [{"참조": {**SYS, "키": key}, "수량": {"손": 3}}]}
)
one = labor.calc(doc, {})["줄"][0]
assert one["기준"]["계"] == pytest.approx(float(want))
assert one["반영"]["계"] == pytest.approx(float(want * 3))
mats = [x for x in one["안"] if x.get("자재")]
assert [(x["자재"], x["수량"]) for x in mats] == [(MAT, 2)] # WIRE 빈칸 = 안 셈
assert mats[0]["기준"]["재료비"] == pytest.approx(float(_price(MAT)))
# 목록 기준금액(51-4)도 같은 펼침
rows = [{"종류": "precombo", "이름": key}]
refs.summaries(world["system"], rows)
assert rows[0]["기준금액"] == pytest.approx(float(want))
def test_B08_참조_줄_재료에_사전조합_자재_손_곱(world): # noqa: F811
from B08_Quantity.B08_Quantity_Router_Material import _ref_mats
key, _ = _world_combo(world)
with store.at(world["system"]):
outer = refs.create(
"precombo",
{"이름": "겹", "담은로직": [{"참조": {**SYS, "키": key}, "수량": {"손": 3}}]},
)["이름"]
got = _ref_mats(m01.loaded()[1], refs.read_ref, {**SYS, "키": outer})
mats = [(m["품목"]["키"], q) for m, q in got if m.get("자재")]
assert mats == [(MAT, pytest.approx(6.0))] # 2 × 겹 손 3 · WIRE 빈칸 없음
def test_B09_겹_대가에_자재_줄(world): # noqa: F811
from B09_Estimation.B09_Estimation_Labor_Source import Builder
key, _ = _world_combo(world)
ref = {**SYS, "키": key}
build = Builder()
bid = build.precombo(m01.loaded()[1], ref, refs.read_ref(ref), {})
mats = [x for x in build.blocks[bid]["lines"] if x["kind"] == "material"]
assert [(x["key"], x["qty"]) for x in mats] == [(MAT, 2.0), (WIRE, None)]
assert mats[0]["name"] and mats[0]["unit"]
def test_사본_단가_묶음에_사전조합_자재_고름_사본_값_같음(world): # noqa: F811
from M02_MasterTemplete import M02_Item_Prices as prices
from M02_MasterTemplete import M02_Item_Source as source
key, want = _world_combo(world)
doc = {"열": "rv", "담은로직": [{"참조": {**SYS, "키": key}, "수량": {"손": 1}}], "자재": []}
with store.at(world["system"]):
rows = source.labor_doc_rows(labor.clean(doc))
mats = refs.frozen_materials(rows["참조사본"])
assert {MAT, WIRE, NET_ITEM} <= set(mats)
assert "refs.frozen_materials(" in open(source.__file__, encoding="utf-8").read()
bundle = prices.to_json(prices.collect(refs.frozen_logics(rows["참조사본"]), mats))
(world["system"] / "precombo" / f"{key}.json").unlink() # 원본이 사라져도 굳힌 사본
got = prices.calc(rows, bundle, {}, {}, None)["줄"][0]
assert got["기준"]["계"] == pytest.approx(float(want), rel=1e-9)