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:
@@ -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"]:
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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"])
|
||||
Reference in New Issue
Block a user