feat(723-4): B09 사본 일위대가(SC) 안 줄 수량 = 굳힌 손 → 구조물 사본 면적 알약 값 → 없음(빈 줄 · 이 공사에서 입력)

- Labor_Source.precombo = 빈 손 1 로 셈 걷음 · 안 줄 · 자재 수량 = 굳힌 옛 손 → 쓰는 구조물 사본 면적 알약 값
  (723-1 「참조 id#차례」 · 「참조 id#자재|코드」 · 짝 = M02_Labor_Expand._qty 차례) → 없음 = qty null · `ask`
- 알약 · 없음 쓴 SC 블록 덮개 열쇠 = `C:참조|S:사본` · `C:참조|L:서고`(쓰는 곳별 · 옛 손뿐 = `C:참조` 그대로)
- 셈 짝(Calc_Labor .py ↔ .ts) = `ask` 줄 수량 비면 비고 · 연결 없음 「수량 없음 — 이 공사에서 입력」 · 702 덮개 qty 입력 뒤 금액 · 글 없음
- 지킴 시험 test_723_4_b09_sc_qty(경고 · 입력 금액 · 알약 굳힘 · 옛 손 · 거울)
- 기존 공사 금액 전후 같음(검증_랩탑메인 · 검증_sub8 B09 셈 답 통째 같음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-01 21:55:12 +09:00
co-authored by Claude Opus 5.5
parent 6ff98c9e96
commit cf41e303d0
4 changed files with 179 additions and 28 deletions
+9 -4
View File
@@ -18,7 +18,7 @@
블록 비목 합 = ROUNDDOWN(Σ, 0) · 합계 = 세 비목 합.
빈칸 = 단가 · 수량을 낸 원천이 없으면 None · 「연결 없음」 목록(gaps).
빈 줄(166-6) = 블록 합 = 아는 줄 합 + `blank` n · `blank_names` — 내역 단가 · 부른 대가로는 안 넘김 ·
줄 0 개 블록 = 빈 줄 하나(`NO_LINES`).
줄 0 개 블록 = 빈 줄 하나(`NO_LINES`) · 줄 `ask`(723-4 SC 안 줄 수량 없음) = 수량이 비면 `why`.
고친 칸 덮개(702-2) = 사용자 값 `daega_cover` {블록 cover: {줄 cid: {qty · 노 · 재 · 경 단가}}} —
있는 칸만 셈 값 대신 · 줄 `base`(품셈 값 = 셈 수량 · 단가) · `covered`(고친 칸) ·
짝 잃은 덮개(그 블록 · 줄 없음) = `daega_orphans` [{cover, cid}] — 지우지 않고 알림만.
@@ -131,10 +131,15 @@ def _block(
for part, p in prices.items()
}
ref = {"ref": {"key": line["key"]}} if line.get("key") else {} # 집계표(145-5) 이음
lines.append({**line, **ref, "qty": qty, "prices": prices, "amounts": amounts, **extra})
ask = {"why": line["ask"]} if qty is None and line.get("ask") else {} # 723-4
lines.append(
{**line, **ref, **ask, "qty": qty, "prices": prices, "amounts": amounts, **extra}
)
if qty is None or None in prices.values():
why = line.get("why") or (
"수량 없음" if qty is None else WHY.get(line["kind"], "단가 없음")
why = (
line.get("why")
or ask.get("why")
or ("수량 없음" if qty is None else WHY.get(line["kind"], "단가 없음"))
)
gaps.append(gap(TAB, block["id"], f"{block['name']} · {line.get('name') or ''}", why))
for line in block["lines"]:
+5 -3
View File
@@ -15,7 +15,7 @@
* 관급 · 별도계상 자재 = 대가 안 0원(자재대 · 관급 단락에서 셈)
* 빈칸 = 원천이 없으면 null · 「연결 없음」 목록(gaps)
* 빈 줄(166-6) = 블록 합 = 아는 줄 합 + blank n · blank_names — 내역 단가 · 부른 대가로는 안 넘김 ·
* 줄 0 개 블록 = 빈 줄 하나(NO_LINES)
* 줄 0 개 블록 = 빈 줄 하나(NO_LINES) · 줄 ask(723-4 SC 안 줄 수량 없음) = 수량이 비면 why
* 고친 칸 덮개(702-2) = 사용자 값 daega_cover {블록 cover: {줄 cid: {qty · labor · material · expense}}} —
* 있는 칸만 셈 값 대신 · 줄 base(품셈 값) · covered(고친 칸) · 짝 잃은 덮개 = daega_orphans(알림만)
* 복사본(703-2) = 덮개 K:… 의 @copy {from · name · spec} — 원본 cover 블록을 본뜬 새 호표
@@ -123,9 +123,11 @@ function block(b: Row, books: Row, sums: Record<string, Row>, gaps: Gap[], own:
amounts[part] = p === null || qty === null ? null : rounddown(p * qty, 1);
}
const ref = line.key ? { ref: { key: line.key } } : {}; // 집계표(145-5) 이음
lines.push({ ...line, ...ref, qty, prices, amounts, ...extra });
const ask: Row = qty === null && line.ask ? { why: line.ask } : {}; // 723-4
lines.push({ ...line, ...ref, ...ask, qty, prices, amounts, ...extra });
if (qty === null || PARTS.some((part) => prices[part] === null)) {
const why = line.why || (qty === null ? "수량 없음" : (WHY[line.kind] ?? "단가 없음"));
const why =
line.why || ask.why || (qty === null ? "수량 없음" : (WHY[line.kind] ?? "단가 없음"));
gaps.push(gap(TAB, b.id, `${b.name} · ${line.name || ""}`, why));
}
}
+60 -21
View File
@@ -4,8 +4,11 @@
(`M02_Item_Prices.calc` = 142-1 층 셈 꼴)로 돌려 줄 수량을 받고, 블록 꼴로 펼침:
조합 구조물 조합 사본 하나 = 블록(엑셀 「조합」 꼴) · 줄 = 담은 로직 줄 · 자재 줄
사전조합 참조 SC = 겹 대가(굳힌 `참조사본`) · 줄 = 그 안 로직 · 참조 줄(수량 = 손) ·
자재 줄(175-2 · 수량 = 손 · 빈칸 = 수량 없음)
사전조합 참조 SC = 겹 대가(굳힌 `참조사본`) · 줄 = 그 안 로직 · 참조 줄 · 자재 줄(175-2) ·
안 줄 수량(723-4) = 굳힌 옛 손 → 쓰는 구조물 사본 면적 알약 값(723-1
「참조 id#차례」 · 「참조 id#자재|코드」) → 없음 = 빈 줄(`ask` · 이 공사에서 입력 =
702 덮개 qty) ·
알약 · 없음 쓴 블록 덮개 = `C:참조|S:사본` · `C:참조|L:서고`(옛 손뿐 = `C:참조`)
품셈 일위대가화(승격) 줄 · 옛 참조 SP = 로직마다 따로(노무 · 재료 · 중기 · 산근 · % 줄)
— 조합이 수량만큼 부름 · 기계 = 기계경비 로직 줄 · 다른 돈 로직 줄 = 하위 산근
산근 단가산출 줄(일위대가화 아닌 로직 줄) = 산근 호표 참조(키 = 145-1 `sangeun_key`)
@@ -56,6 +59,7 @@ BASE_PART = re.compile(r"^\s*(노무비|재료비|경비)\s*\*")
BASE_LINE = re.compile(r"^\s*줄\.'([^']+)'\s*\*")
BASE_LABOR = re.compile(r"^\s*\(.*\*\s*LB\d+")
AREA_FILE = "면적.json"
ASK = "수량 없음 — 이 공사에서 입력" # 723-4 SC 안 줄 수량 빈칸(펼침 품 · 수량 칸)
def _f(value: Any) -> float | None:
@@ -253,10 +257,18 @@ class Builder:
# ── 조합 · 사전조합 줄 ──────────────────────────────────────────────
def line_of(
self, whole: Any, one: dict, qty: Any, times: Any, name: str, copies: dict, depth: int = 0
self,
whole: Any,
one: dict,
qty: Any,
times: Any,
name: str,
copies: dict,
depth: int = 0,
use: tuple[dict, str] = ({}, ""),
) -> dict[str, Any]:
"""담은 로직 줄 하나 → 블록 줄 — 대가 줄 수량 = 수량 ÷ 배(부르는 호표 1 단위 몫) ·
산근 줄 수량 = 수량 그대로(산근 단가 = 결과단위 1 단위)."""
산근 줄 수량 = 수량 그대로(산근 단가 = 결과단위 1 단위) · use = 쓰는 곳(알약 값 · 자리)."""
q = _f(qty)
t = _f(times) or 1.0
each = None if q is None else q / t
@@ -290,7 +302,7 @@ class Builder:
return {"kind": "blank", "name": name, "qty": each, "why": "돈 아닌 로직"}
return {"kind": "daega", "key": bid, "name": self.blocks[bid]["name"],
"unit": doc.get("단위") or "", "qty": each} # fmt: skip
bid = self.precombo(whole, ref, doc, copies, depth + 1)
bid = self.precombo(whole, ref, doc, copies, depth + 1, use)
return {"kind": "daega", "key": bid, "name": doc.get("이름") or name,
"unit": doc.get("단위") or "", "qty": each} # fmt: skip
code = str(one.get("로직") or "")
@@ -312,10 +324,27 @@ class Builder:
return {"kind": "daega", "key": bid, "name": block["name"], "spec": block["spec"],
"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) · 로직 줄 = 품셈 호표 · 자재 줄(175-2)."""
def precombo(
self,
whole: Any,
ref: dict,
doc: dict,
copies: dict,
depth: int = 0,
use: tuple[dict, str] = ({}, ""),
) -> str:
"""사전조합(SC) = 겹 대가 — 로직 줄 = 품셈 호표 · 자재 줄(175-2) · 줄 수량(723-4) =
옛 손 → use 알약 값 → 없음(`ask`) · use = (쓰는 구조물 사본 면적 알약 값, 덮개 자리)."""
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
rid, (given, where) = refs.ref_id(ref), use
loose: list[bool] = [] # 줄마다 손 아닌 수량(알약 · 없음)
def qty_of(key: str, hand: Any) -> float | None: # 짝 = `M02_Labor_Expand._qty` 차례
got = _f(hand)
loose.append(got is None)
return _f(given.get(key)) if got is None else got
inner = [one for one in doc.get("담은로직") or [] if isinstance(one, dict)]
kept = doc.get("알약") # 굳힌 알약(142-2 · 지금 M01 안 읽음 · 719-2) · 없으면 셈
pills = {
@@ -325,20 +354,20 @@ class Builder:
for code in pill["로직"]
}
lines = []
for one in inner:
hand = _f((one.get("수량") or {}).get("손"))
for n, one in enumerate(inner):
qty = qty_of(f"{rid}#{n}", (one.get("수량") or {}).get("손"))
pill = pills.get(one.get("로직")) or {}
times = pill.get("배") if "로직" in one else 1
name = pill.get("이름") or str(one.get("로직") or "")
if depth >= refs.DEPTH and isinstance(one.get("참조"), dict):
lines.append({"kind": "blank", "name": name, "qty": hand, "why": "참조 깊이 넘음"})
lines.append({"kind": "blank", "name": name, "qty": qty, "why": "참조 깊이 넘음"})
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 []
]
lines.append(self.line_of(whole, one, qty, times, name, copies, depth, use))
for mat in doc.get("자재") or []:
key = f"{rid}#{labor_doc.material_key(mat.get('코드'))}"
lines.append(_material(whole, mat, qty_of(key, (mat.get("수량") or {}).get("손"))))
lines = [ln if ln["kind"] == "blank" or ln.get("qty") is not None else {**ln, "ask": ASK}
for ln in lines] # fmt: skip
return self.put(
{
"kind": "사전조합",
@@ -347,9 +376,10 @@ class Builder:
"spec_raw": str(ref.get("키") or ""),
"unit": doc.get("단위") or "",
"cite": "",
"origin": {"참조": refs.ref_id(ref)},
"origin": {"참조": rid},
"lines": lines,
}
},
f"C:{rid}|{where}" if where and any(loose) else None,
)
# ── 조합(구조물 사본) ───────────────────────────────────────────────
@@ -361,7 +391,9 @@ class Builder:
values = read_json(folder / VALUES_FILE) or {}
picks = read_json(folder / PICKS_FILE) or {}
whole = item_prices.load_prices(prices)
result = item_prices.calc(labor, prices, values, picks, read_json(folder / AREA_FILE))
area = read_json(folder / AREA_FILE)
result = item_prices.calc(labor, prices, values, picks, area)
use = (labor_doc.area_values(area), cover or f"S:{folder.name}") # SC 안 줄 알약(723-4)
doc = item_prices.with_values(labor.get("줄") or {}, values, picks)
copies = labor.get("참조사본") or {}
lines = []
@@ -371,7 +403,14 @@ class Builder:
(p for p in result.get("알약") or [] if p.get("키") == done.get("알약")), {}
)
line = self.line_of(
whole, one, done.get("수량"), done.get("배"), pill.get("이름") or name, copies
whole,
one,
done.get("수량"),
done.get("배"),
pill.get("이름") or name,
copies,
0,
use,
)
if line.get("qty") is None and line["kind"] != "blank":
line = {**line, "why": "수량 없음 — 면적산출 알약 칸 · 손 수량을 넣음"}
@@ -405,7 +444,7 @@ class Builder:
whole = item_prices.load_prices(read_json(folder / PRICE_FILE) or {})
if manifest["종류"] == "sangeun": # 내 자료집 단가산출(719-2)
return self.sangeun(whole, ref, doc, copies, 1)
return self.precombo(whole, ref, doc, copies, 1)
return self.precombo(whole, ref, doc, copies, 1, ({}, f"L:{folder.name}"))
def fixed_line(fx: dict, qty: Any) -> dict[str, Any]:
+105
View File
@@ -0,0 +1,105 @@
"""723-4 B09 공사 사본의 일위대가(SC) 안 줄 수량 = 프로젝트 값 연결.
- 723-1 = M02 일위대가(SC) 안 줄 · 자재 수량 저장 안 함(null = 수량 없음 · 1 아님)
- 구조물 조합에서 온 SC = 그 사본 면적 알약 값(「참조 id#차례」 · 「참조 id#자재|코드」)으로 굳힘
- 내역에서 바로 쓴 SC(서고 사본) = 빈 줄 `ask` → 이 공사에서 입력(702 덮개 qty) → 금액
- 옛 손(B09 굳힌 사본) = 그대로 · 덮개 열쇠 `C:참조` 그대로(기존 공사 금액 · 덮개 같음)
- 셈 짝(`B09_Estimation_Calc_Labor` .py ↔ .ts) 거울
"""
import importlib.util
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor # noqa: E402
from B09_Estimation.B09_Estimation_Labor_Source import ASK, Builder # noqa: E402
from M02_MasterTemplete import M02_Labor_Ref as refs # noqa: E402
_spec = importlib.util.spec_from_file_location(
"b145", Path(__file__).with_name("test_145_2_b09_labor_calc.py")
)
b145 = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(b145)
REF = {"층": "project", "주인": "t", "키": "SC-000001"}
RID = refs.ref_id(REF)
MAT = "MT00001"
SHEETS = {"labor": [], "material": [{"key": MAT, "price": 500}], "machine": []}
def _fix(name, labor):
return {"고정": {"이름": name, "단위": "식", "단가": {"노무비": labor}}}
def _doc(hand=None, mat=None):
"""SC 한 벌 — 고정 줄 둘(노무 1000 · 2000) + 자재 하나 · 손 = hand · mat(None = 수량 없음)."""
q = (lambda v: {"수량": {"손": v}}) if hand is not None else (lambda v: {"수량": None})
return {
"이름": "시험 SC",
"단위": "㎡",
"담은로직": [{**_fix("갑", 1000), **q(hand)}, {**_fix("을", 2000), **q(hand)}],
"자재": [{"코드": MAT, "수량": {"손": mat} if mat is not None else None}],
}
def _build(doc, use=({}, "")):
build = Builder()
whole = {MAT: {"이름": "철선", "규격": "#8", "단위": "kg"}} # 사본 단가 대신(_material 만 읽음)
bid = build.precombo(whole, REF, doc, {}, 1, use)
block = build.blocks[bid]
return {"blocks": [block], "owners": {"pipe@100.0": bid}}, block
def test_수량_없는_SC_서고_사본_경고():
daega, block = _build(_doc(), ({}, "L:lib"))
assert [ln["qty"] for ln in block["lines"]] == [None, None, None]
assert all(ln["ask"] == ASK for ln in block["lines"])
assert block["cover"] == f"C:{RID}|L:lib" # 다른 곳 SC 덮개와 안 섞임
out = compute_labor(daega, SHEETS)
got = out["daega_blocks"][0]
assert got["blank"] == 3 and got["sum"] == 0
assert [ln["why"] for ln in got["lines"]] == [ASK] * 3
assert sum(g["why"] == ASK for g in out["gaps"]) == 3
assert out["daega"][0]["blank"] == 3 # 내역 · 원가계산서 빈 줄(166-6 · 701-3)로 넘어감
def test_이_공사에서_입력_금액():
daega, block = _build(_doc(), ({}, "L:lib"))
cids = [ln["cid"] for ln in block["lines"]]
cover = {block["cover"]: {cids[0]: {"qty": 2}, cids[1]: {"qty": 0.5}, cids[2]: {"qty": 3}}}
got = compute_labor(daega, SHEETS, None, cover)["daega_blocks"][0]
assert [ln["amount"] for ln in got["lines"]] == [2000.0, 1000.0, 1500.0]
assert got["blank"] == 0 and got["sum"] == 4500.0
assert not any("why" in ln for ln in got["lines"]) # 입력 뒤 경고 글 없음
def test_구조물_조합_알약_값_굳힘():
given = {f"{RID}#0": 1.5, f"{RID}#1": 2, f"{RID}#자재|{MAT}": 4}
daega, block = _build(_doc(), (given, "S:pipe@100.0"))
assert [ln["qty"] for ln in block["lines"]] == [1.5, 2.0, 4.0]
assert not any("ask" in ln for ln in block["lines"])
assert block["cover"] == f"C:{RID}|S:pipe@100.0"
got = compute_labor(daega, SHEETS)["daega_blocks"][0]
assert got["sum"] == 1500.0 + 4000.0 + 2000.0 and got["blank"] == 0
def test_옛_손_그대로():
daega, block = _build(_doc(hand=3, mat=2), ({}, "S:pipe@100.0"))
assert [ln["qty"] for ln in block["lines"]] == [3.0, 3.0, 2.0]
assert block["cover"] == f"C:{RID}" # 옛 덮개 열쇠 그대로
got = compute_labor(daega, SHEETS)["daega_blocks"][0]
assert got["sum"] == 3000.0 + 6000.0 + 1000.0
def test_거울_파이썬_TS():
daega, block = _build(_doc(), ({}, "L:lib"))
cid = block["lines"][0]["cid"]
for cover in ({}, {block["cover"]: {cid: {"qty": 2}}}):
py = json.loads(json.dumps(compute_labor(daega, SHEETS, {}, cover), ensure_ascii=False))
ts = b145._ts(daega, SHEETS, {}, cover)
assert b145._same(py, ts["out"])