diff --git a/B09_Estimation/B09_Estimation_Calc_Labor.py b/B09_Estimation/B09_Estimation_Calc_Labor.py index 3b4ef0cd0..97e3c01cf 100644 --- a/B09_Estimation/B09_Estimation_Calc_Labor.py +++ b/B09_Estimation/B09_Estimation_Calc_Labor.py @@ -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"]: diff --git a/B09_Estimation/B09_Estimation_Calc_Labor.ts b/B09_Estimation/B09_Estimation_Calc_Labor.ts index e23923f07..54f5d4855 100644 --- a/B09_Estimation/B09_Estimation_Calc_Labor.ts +++ b/B09_Estimation/B09_Estimation_Calc_Labor.ts @@ -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, 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)); } } diff --git a/B09_Estimation/B09_Estimation_Labor_Source.py b/B09_Estimation/B09_Estimation_Labor_Source.py index 57da1420b..5dd10e815 100644 --- a/B09_Estimation/B09_Estimation_Labor_Source.py +++ b/B09_Estimation/B09_Estimation_Labor_Source.py @@ -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]: diff --git a/resources/tester/test_723_4_b09_sc_qty.py b/resources/tester/test_723_4_b09_sc_qty.py new file mode 100644 index 000000000..1ab872a2c --- /dev/null +++ b/resources/tester/test_723_4_b09_sc_qty.py @@ -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"])