diff --git a/B09_Estimation/B09_Estimation_Bill_Library.py b/B09_Estimation/B09_Estimation_Bill_Library.py index 4b31102a2..93289d51e 100644 --- a/B09_Estimation/B09_Estimation_Bill_Library.py +++ b/B09_Estimation/B09_Estimation_Bill_Library.py @@ -6,6 +6,9 @@ 도면 · 면적 · 일위대가 · 단가 · 입력값 · 고른자재 첫 값) precombo 사전조합 = `일위대가.json` {줄: {담은로직: [{참조, 수량: {손: 1}}]}, 참조사본} · `단가.json`(`M02_Item_Source.price_bundle` — 사본 단가 묶음 한 벌) + sangeun 단가산출(SK · 내 자료집 · 719-2) = precombo 와 한 꼴(참조사본 = 풀어 굳힌 SK) +[업데이트](719-2) = 원본판이 다른 사본만 같은 폴더에 다시 굳힘(`changed` · `refresh_copy` · +값 그대로 · 원본판 없는 옛 사본 = 그대로). 사본 = 원본이 바뀌거나 지워져도 그대로(굳힘) · 대가 블록 = `Labor_Source.daega_inputs`(`libs` {폴더: 블록 id}) · 단가 수요 = `Linked.linked_resource_keys` · 줄 연결 = {tab: dg, key: 블록 id, lib: 폴더} · @@ -58,11 +61,11 @@ from M02_MasterTemplete.M02_Item_Source import ( ) LIBRARY = "library" -KINDS = ("combo", "precombo") +KINDS = ("combo", "precombo", "sangeun") #: 폴더 이름 — 종류 · 층(project = 불러온 공사 일위대가 · 707-7) · 주인 · #: 키(영문 · 숫자 · `-` `_`) · 값 해시(263-2 · 없을 수 있음) NAME = re.compile( - r"^(combo|precombo)@(system|company|personal|project)@[0-9-]+@[A-Za-z0-9_-]{1,40}(~[0-9a-f]{8})?$" + r"^(combo|precombo|sangeun)@(system|company|personal|project)@[0-9-]+@[A-Za-z0-9_-]{1,40}(~[0-9a-f]{8})?$" ) @@ -87,11 +90,11 @@ def folder_name( return name -def _precombo_files(layer: str, owner: str | None, key: str) -> dict[str, Any]: +def _precombo_files(layer: str, owner: str | None, key: str, kind: str = "precombo") -> dict: ref = {"층": layer, "주인": owner, "키": key} doc = refs.read_ref(ref) - if doc is None or doc.get("종류") != refs.TITLE["precombo"]: - raise LookupError(f"사전조합 {key} 이 {layer} 층에 없음") + if doc is None or doc.get("종류") != refs.TITLE[kind]: + raise LookupError(f"{refs.TITLE[kind]} {key} 이 {layer} 층에 없음") line = {"참조": ref, "수량": {"손": 1}} labor = labor_doc_rows({"이름": doc.get("이름"), "단위": doc.get("단위"), "담은로직": [line]}) return { @@ -116,10 +119,43 @@ def _build(kind: str, layer: str, owner: str | None, key: str) -> tuple[dict, di item = {"layer": layer, "owner": owner, "key": key} item["name"] = item_name(item) return build_snapshot(item), origin_of(item) - files = _precombo_files(layer, owner, key) + files = _precombo_files(layer, owner, key, kind) return files, files.pop("_원본") +def _source_of(manifest: dict[str, Any]) -> tuple[str, str, str | None, str]: + """manifest → (종류, 층, 주인, 키).""" + kind, origin = str(manifest.get("종류")), manifest.get("원본") or {} + if kind == "combo": + return kind, origin.get("layer"), origin.get("owner"), origin.get("key") + ref = origin.get("참조") or {} + return kind, ref.get("층"), ref.get("주인"), ref.get("키") + + +def changed(project_root: str | Path) -> list[tuple[Path, dict[str, Any], dict, dict]]: + """원본판이 지금 원본과 다른 사본 `(폴더, manifest, 새 파일들, 새 원본 표지)` — 원본판 없는 옛 + 사본 · 원본 없음 = 뺌(그대로).""" + out = [] + for folder, manifest in copies(project_root): + if not manifest.get("원본판"): + continue + try: + files, origin = _build(*_source_of(manifest)) + except (LookupError, ValueError, KeyError): + continue + if source_version(files) != manifest["원본판"]: + out.append((folder, manifest, files, origin)) + return out + + +def refresh_copy(project_root: str | Path, one: tuple) -> None: + """`changed` 한 줄 → 같은 폴더에 다시 굳힘(값 · 가져온이 그대로 · 원본판 새로).""" + folder, manifest, files, origin = one + keep = {k: v for k, v in manifest.items() if k not in ("값", "원값")} + fresh = {**keep, "원본": origin, "원본판": source_version(files), "가져온날": now_text()} + _write(project_root, folder.name, files, fresh, manifest.get("값") or {}) + + # ── 운반 값 사본(263-2) ──────────────────────────────────────────────── def _logic(code: str) -> dict[str, Any]: try: diff --git a/B09_Estimation/B09_Estimation_Daega_Personal.py b/B09_Estimation/B09_Estimation_Daega_Personal.py index 7aa7d7c9b..d35d2a962 100644 --- a/B09_Estimation/B09_Estimation_Daega_Personal.py +++ b/B09_Estimation/B09_Estimation_Daega_Personal.py @@ -9,6 +9,11 @@ 복사본 `K:`(703-2) = 원본까지 따라감(이름 · 덮개 = 복사본 것) 같은 이름 = 409 `{같은이름: 키, 이름}` → 부르는 쪽이 덮어쓰기(`overwrite` = 그 키) · 새 이름 · 쓰기 = 지금 층(`store.at` · 층 권한 · 폴더는 부르는 쪽). +품셈 블록(`P:` · 굳힌 SK 포함 · 719-2) = 개인 층 단가산출(SK · `sangeun`) — 바탕 = 그 M01 로직 · +원본 SK 문서(바탕 · 덮개 · 더한줄 · 뺀줄) · 넣을 값 · 고른 자재(cid) · B09 품 · 단가 덮개 = +SK 덮개(같은 cid · 굳힌 SK = `정체` 로 원본 cid · 더한줄 줄 = 그 더한줄 수량 · 단가) · +못 옮긴 칸 = 안담음 수 · +내 자료집 SK 불러오기 = B09 서고 사본 `sangeun`(`Bill_Library`). 불러온 공사 일위대가(707-7 · 참조 층 `project`) = 하위 호표 굳힌 문서를 문서 `_하위` 에 실어 보내고 쓸 때 지금 층에 같이 씀(새 키 · `본뜬.복사원본` = 그 참조 · 같은 하위는 한 번만 · 이미 있으면 그 키). @@ -16,11 +21,13 @@ from __future__ import annotations +import copy from pathlib import Path from typing import Any from B09_Estimation import B09_Estimation_Bill_Library as library -from B09_Estimation.B09_Estimation_Calc_Labor import COPY, with_copies +from B09_Estimation import B09_Estimation_Sangeun as sg +from B09_Estimation.B09_Estimation_Calc_Labor import COPY, COVER_KEYS, with_copies from B09_Estimation.B09_Estimation_Labor_Source import AREA_FILE, daega_inputs from M02_MasterTemplete import M02_Hopyo_Build as hopyo_build from M02_MasterTemplete import M02_Item_Prices as item_prices @@ -35,6 +42,8 @@ from M02_MasterTemplete.M02_Item_Snapshot import ( from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, PRICE_FILE, read_json KIND = "precombo" +SK_KIND = "sangeun" +SK_KEEP = ("바탕", "입력값", "열린입력", "켜쌓기율", "고름", "자재표시", "덮개", "더한줄", "뺀줄") SUBS = "_하위" PRICE_KEYS = ("labor", "material", "expense") @@ -113,8 +122,10 @@ def build(root: Path, cover_key: str, cover: dict[str, Any], name: str | None = base = cover_key while base.startswith("K:"): # 복사본 → 원본 cover base = str(((cover.get(base) or {}).get(COPY) or {}).get("from") or "") - lines, mats = source(root, blocks.get(base, block), base) mine = cover.get(cover_key) or {} + if block["kind"] == "품셈": + return _sangeun(root, blocks.get(base, block), mine, name) + lines, mats = source(root, blocks.get(base, block), base) kept = {"수량": 0, "넣을값": sum(bool(ln.get("입력값")) for ln in lines)} kept["고른자재"] = sum(bool(ln.get("고름")) for ln in lines) skip = {"품": 0, "단가": 0} @@ -155,6 +166,70 @@ def build(root: Path, cover_key: str, cover: dict[str, Any], name: str | None = return {"문서": doc, "담음": kept, "안담음": skip} +def _sangeun(root: Path, block: dict, mine: dict, name: str | None) -> dict[str, Any]: + """품셈 블록 → 개인 층 SK 문서 `{문서, 담음, 안담음}` — 블록 줄 차례 = 엔진 줄 차례 = + cid 차례.""" + from M01_MasterData import M01_MasterData_Store as m01 + from M02_MasterTemplete import M02_Sangeun_Doc as sd + + origin = block.get("origin") or {} + rid = origin.get("단가산출") + if rid: # 굳힌 SK = 원본 문서 위에 · cid = 정체(굳힌 줄 차례 → 원본 cid) + frozen = _frozen(root, rid) + if frozen is None or not isinstance(frozen.get("원본"), dict): + raise LookupError(f"단가산출 원천을 못 찾음 「{rid}」") + doc = {k: copy.deepcopy(frozen["원본"][k]) for k in SK_KEEP if k in frozen["원본"]} + ids = list(frozen.get("정체") or []) + unit = str(frozen["원본"].get("단위") or "") + else: # M01 로직 한 줄 = 바탕 · 고름 차례 → cid + try: + row = m01.loaded()[1].logic(str(origin.get("로직"))) + except Exception as e: # noqa: BLE001 — 엔진 FormulaError + raise LookupError(f"로직을 못 찾음 「{origin.get('로직')}」") from e + ids, unit = sd.line_ids(row), sg._unit(row)[0] + chosen = (origin.get("고름") or {}).items() + picks = {ids[int(n)]: mt for n, mt in chosen if int(n) < len(ids)} + doc = {"바탕": {"로직": origin.get("로직")}, "입력값": dict(origin.get("입력값") or {})} + doc |= {"고름": picks} if picks else {} + if origin.get("켜쌓기율") is not None: + doc["켜쌓기율"] = origin["켜쌓기율"] + covers, added = doc.setdefault("덮개", {}), doc.setdefault("더한줄", []) + kept, skip = {"품": 0, "단가": 0}, {"품": 0, "단가": 0} + for n, line in enumerate(block["lines"]): + cell = {k: v for k, v in (mine.get(line.get("cid")) or {}).items() if k in COVER_KEYS} + cid = ids[n] if n < len(ids) else "" + if not cell: + continue + if cid.startswith("add:#"): # 더한줄 = 그 줄 수량 · 단가(한 비목 줄만) + one = added[int(cid[5:])] + if "qty" in cell: + one["수량"], kept["품"] = cell["qty"], kept["품"] + 1 + prices = [cell[k] for k in PRICE_KEYS if k in cell] + ok = len(prices) == 1 and "참조" not in one and "고정" not in one + if ok: + one["단가"], kept["단가"] = prices[0], kept["단가"] + 1 + skip["단가"] += len(prices) - (1 if ok else 0) + continue + if not cid or ("qty" in cell and doc.get("열린입력")): # 열어 둔 SK = 품 배율만 + skip["품"] += "qty" in cell + cell.pop("qty", None) + if not cell: + continue + if not cid: + skip["단가"] += len(cell) + continue + was = {k: v for k, v in (covers.get(cid) or {}).items() if k != "원값"} + if "qty" in cell: + was.pop("배율", None) + covers[cid] = {**was, **cell} + kept["품"] += "qty" in cell + kept["단가"] += sum(k in cell for k in PRICE_KEYS) + if not covers: + doc.pop("덮개") + head = {"종류": sd.TITLE, "이름": (name or block.get("name") or "").strip(), "단위": unit} + return {"문서": {**head, **doc}, "담음": kept, "안담음": skip} + + def _own(lines: Any) -> list[dict[str, Any]]: """불러온 공사 하위 호표 참조(층 `project` — 층에 없음 · 굳힌 문서뿐).""" return [ @@ -211,9 +286,10 @@ def write(doc: dict[str, Any], overwrite: str | None = None) -> dict[str, Any]: """지금 층(`store.at` 안)에 씀 — 같은 이름이면 409 · `overwrite` = 같은 이름 그 키를 덮음.""" subs = doc.get(SUBS) or {} doc = {k: v for k, v in doc.items() if k != SUBS} + kind = SK_KIND if doc.get("종류") == refs.TITLE[SK_KIND] else KIND same = [ path.stem - for path in sorted((store.root() / KIND).glob("SC-*.json")) + for path in sorted((store.root() / kind).glob(f"{refs.PREFIX[kind]}-*.json")) if (read_json(path) or {}).get("이름") == doc["이름"] ] if overwrite and overwrite not in same: @@ -223,6 +299,6 @@ def write(doc: dict[str, Any], overwrite: str | None = None) -> dict[str, Any]: if subs: doc = {**doc, "담은로직": _carry(doc["담은로직"], subs, {})} if overwrite: - old = store.read(KIND, overwrite) - return store.write(KIND, overwrite, old["판"], {**doc, "키": overwrite}) - return refs.create(KIND, doc) + old = store.read(kind, overwrite) + return store.write(kind, overwrite, old["판"], {**doc, "키": overwrite}) + return refs.create(kind, doc) diff --git a/B09_Estimation/B09_Estimation_Labor_Source.py b/B09_Estimation/B09_Estimation_Labor_Source.py index 7520f426d..57da1420b 100644 --- a/B09_Estimation/B09_Estimation_Labor_Source.py +++ b/B09_Estimation/B09_Estimation_Labor_Source.py @@ -115,6 +115,14 @@ class Builder: # ── 품셈(로직 하나) ───────────────────────────────────────────────── def poomsem(self, whole: Any, one: dict[str, Any], title: str = "") -> str | None: + got = self._poomsem(whole, one, title) + if got is None: + return None + got.pop("_engine"), got.pop("_items") + return self.put(got) + + def _poomsem(self, whole: Any, one: dict[str, Any], title: str = "") -> dict | None: + """품셈 블록 꼴(넣기 전) — 엔진 줄 · 줄마다 `_n`(엔진 차례 · 단가산출 블록이 씀).""" code = str(one.get("로직") or "") given, rate, picks = one.get("입력값") or {}, one.get("켜쌓기율"), one.get("고름") try: @@ -142,18 +150,73 @@ class Builder: f"{k} {st._word(whole, k, v) if isinstance(v, str) else v}" for k, v in sorted(given.items()) ) - return self.put( - { - "kind": "품셈", - "name": title or logic.get("이름") or code, - "spec": shown, - "spec_raw": spec, - "unit": unit, - "cite": logic.get("출처") or "", - "origin": {"로직": code, "입력값": given, "켜쌓기율": rate, "고름": picks}, - "lines": lines, - } - ) + return { + "kind": "품셈", + "name": title or logic.get("이름") or code, + "spec": shown, + "spec_raw": spec, + "unit": unit, + "cite": logic.get("출처") or "", + "origin": {"로직": code, "입력값": given, "켜쌓기율": rate, "고름": picks}, + "lines": lines, + "_engine": engine, + "_items": items, + } + + # ── 단가산출(SK · 719-2) ──────────────────────────────────────────── + def sangeun(self, whole: Any, ref: dict, doc: dict, copies: dict, depth: int = 0) -> str | None: + """굳힌 SK(사본 `단가.json` 로직 줄 · 719-1) = 품셈 블록 꼴 대가 — 셈 = 굳힌 줄 그대로 · + 단가산출 속 일위대가 · SK 줄(참조) = 그 대가 블록을 부르는 줄(펼침 · B09 차례 그대로) · + 고정 줄 = `fixed` · 단가 고친 줄(덮개 · 더한줄 단가 · 값묶음) = `fixed`(줄 단가 = M02 셈) · + cover = `P:` + SK 이름표(SK 를 고쳐 [업데이트] 해도 B09 덮개 그대로) · + 줄 `sk` · `poom`(M02 에서 고친 줄의 품셈 값 = 원본 덮개 `원값`).""" + from M02_MasterTemplete import M02_Item_Sangeun as item_sk + from M02_MasterTemplete import M02_Sangeun_Calc as calc + from M02_MasterTemplete import M02_Sangeun_Doc as sd + from M02_MasterTemplete.M02_Labor_Expand import Expand + + rid = refs.ref_id(ref) + key = str((doc.get("바탕") or {}).get("로직") or "") + if item_sk.ROW not in doc or not key: + return None # 못 푼 SK(없는 바탕 · 순환) — 부르는 줄이 까닭 + + def read(r: dict) -> dict | None: + return copies.get(refs.ref_id(r)) + + try: + got = calc.resolve(doc, whole, read, (rid,)) + row = {**got["줄"], "키": key, "호표": [dict(x) for x in got["줄"]["호표"]]} + if calc._bundles(row, whole, Expand(whole, read), (rid,)): + return None + except (sd.DocError, mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError): + return None + one = {"로직": key, "입력값": got["입력값"], "켜쌓기율": got["켜쌓기율"]} + block = self._poomsem(calc._Over(whole, key, row), {**one, "고름": got["고름"]}) + if block is None: + return None + ids = list(doc.get("정체") or []) + was = {c: v.get("원값") for c, v in ((doc.get("원본") or {}).get("덮개") or {}).items()} + lines = [] + for n, (line, x) in enumerate(zip(block["lines"], block.pop("_engine"))): + item = block["_items"][n] if n < len(block["_items"]) else {} + qty = _f(x.get("수량")) + head = {"name": line.get("name") or "", "spec": "", "unit": line.get("unit") or ""} + if isinstance(item.get("참조"), dict) and depth < refs.DEPTH: + line = {**self.line_of(whole, item, qty, 1, head["name"], copies, depth), **head} + elif isinstance(item.get("고정"), dict): + line = {**fixed_line(item["고정"], qty), **head} + elif qty and ("단가" in item or item.get("값묶음")): + own = {PART[k]: _f(v) / qty for k, v in (x.get("비목") or {}).items() if k in PART} + line = {**head, "kind": "fixed", "qty": qty, "own": own, "link": {}} + poom = was.get(ids[n]) if n < len(ids) else None + lines.append({**line, "sk": True, **({"poom": poom} if poom else {})}) + block.pop("_items") + name = doc.get("이름") or block["name"] + origin = {**block["origin"], "단가산출": rid} + cover = f"P:{_digest({'단가산출': rid})}" + return self.put({**block, "name": name, + "cite": f"단가산출 {ref.get('키')}", "origin": origin, + "lines": lines}, cover) # fmt: skip def _engine_line( self, item: dict | None, x: dict, logic: dict, extra: int, bases: dict, call: dict | None @@ -209,6 +272,17 @@ class Builder: "qty": each, "why": f"없는 참조 「{ref.get('키')}」", } + if refs.kind_of(ref.get("키")) == "sangeun": # 단가산출(719-2) = 굳힌 줄 대가 + bid = self.sangeun(whole, ref, doc, copies, depth + 1) + if bid is None: + why = "단가산출을 못 셈(바탕 없음 · 순환)" + return {"kind": "blank", "name": name, "qty": each, "why": why} + from M02_MasterTemplete.M02_Sangeun_Calc import times_of + + block = self.blocks[bid] # 블록 = 굳힌 줄 결과단위(배) 몫 · M02 참조 줄 = 1 단위 + per = float(times_of(whole.logic(doc["바탕"]["로직"]))) + return {"kind": "daega", "key": bid, "name": block["name"], "unit": block["unit"], + "qty": None if each is None else each / per} # fmt: skip if refs.kind_of(ref.get("키")) == "promote": # 옛 단가승격 참조 = 일위대가화 = 대가 first = (doc.get("담은로직") or [{}])[0] bid = self.poomsem(whole, first, doc.get("이름") or name) @@ -225,7 +299,7 @@ class Builder: return {"kind": "blank", "name": name or code, "qty": q, "why": "돈 아닌 로직"} key = sangeun_ref(code, one.get("입력값"), one.get("켜쌓기율"), one.get("고름")) return {"kind": "sangeun", "key": key, "name": name or code, - "unit": sg.unit_of(code), "qty": q} # fmt: skip + "unit": sg.unit_of(whole, code), "qty": q} # fmt: skip bid = self.poomsem(whole, one, str(one.get("별칭") or "") or name) if bid is None: return { @@ -243,9 +317,11 @@ class Builder: from M01_MasterData import M01_MasterData_Store_Combo as combo_store inner = [one for one in doc.get("담은로직") or [] if isinstance(one, dict)] + kept = doc.get("알약") # 굳힌 알약(142-2 · 지금 M01 안 읽음 · 719-2) · 없으면 셈 pills = { code: pill - for pill in combo_store.combo_pills({"담은로직": inner})["물량"] + for pill in (kept if isinstance(kept, list) else None) + or combo_store.combo_pills({"담은로직": inner})["물량"] for code in pill["로직"] } lines = [] @@ -318,7 +394,7 @@ class Builder: def library(self, folder: Path, manifest: dict[str, Any]) -> str | None: """설계내역서 서고 사본(204-2) — 구조물조합 = 조합 블록 · 사전조합 = 겹 대가 블록 그대로.""" - if manifest.get("종류") != "precombo": + if manifest.get("종류") not in ("precombo", "sangeun"): return self.combo(folder, f"L:{folder.name}") labor = read_json(folder / LABOR_FILE) ref = (manifest.get("원본") or {}).get("참조") @@ -327,6 +403,8 @@ class Builder: if not isinstance(doc, dict): return None 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) diff --git a/B09_Estimation/B09_Estimation_Linked.py b/B09_Estimation/B09_Estimation_Linked.py index 2ea2e6a7f..8f33276ac 100644 --- a/B09_Estimation/B09_Estimation_Linked.py +++ b/B09_Estimation/B09_Estimation_Linked.py @@ -5,6 +5,7 @@ 실제로 부른 자원 줄(인력 LB · 기계 EQ · 자재 MT · 유가 MO)만 모음 — 단가 묶음 통째 (고르기 후보 전부)는 안 씀 · 값은 쓰지 않음(단가는 원가 한 벌 `price_book.json` 이 맡음) · 사본 일위대가 자재 · 굳힌 사전조합 자재 코드 · 고정 줄 이음 키도(183-1 · 705-1 · 로직을 안 돌아 `get` 에 안 걸림). +굳힌 SK(719-2) = 굳힌 키 로직 줄로 돌림(사본 `단가.json` · M01 안 읽음). 고르기가 훑은 후보 줄도 걸림 → 원가 길이 두 번 셈해 쓰는 줄만 남김(`Summary.used_materials`). 구조물 조합 사본 없으면 그 구조물은 없음 · 일위대가 없는 사본은 자원 없음. """ @@ -54,7 +55,9 @@ def used_keys(folder: Path) -> list[str]: # 굳힌 참조사본 안 로직까지 doc = item_prices.with_values(labor.get("줄") or {}, values, picks) inner = [d for d in (labor.get("참조사본") or {}).values() if isinstance(d, dict)] - for one in [*(doc.get("담은로직") or []), *(x for d in inner for x in d.get("담은로직") or [])]: + sks = [_sk_line(d) for d in inner if d.get("종류") == "단가산출" and "굳힌줄" in d] + lines = [*(doc.get("담은로직") or []), *(x for d in inner for x in d.get("담은로직") or [])] + for one in [*lines, *sks]: # 못 돈 로직 — 부른 데까지만(까닭은 안 봄) if isinstance(one, dict) and one.get("로직"): labor_doc.run_line( @@ -63,12 +66,30 @@ def used_keys(folder: Path) -> list[str]: mats = [*(doc.get("자재") or []), *(m for d in inner for m in d.get("자재") or [])] codes = [str(mat.get("코드") or "") if isinstance(mat, dict) else "" for mat in mats] codes += [k for d in [doc, *inner] for k in fixed.link_keys(d)] # 705-1 고정 줄 이음 + codes += [ # 굳힌 SK 고정 더한줄 이음(719-2) + k + for d in inner + for x in (d.get("굳힌줄") or {}).get("호표") or [] + if isinstance(x.get("고정"), dict) + for k in fixed.link_keys({"담은로직": [{"고정": x["고정"]}]}) + ] for code in codes: if code[:2] in RESOURCE_TABLES and code not in seen: seen.append(code) return seen +def _sk_line(doc: dict[str, Any]) -> dict[str, Any]: + """굳힌 SK(719-1) → 로직 줄 꼴 — 굳힌 키(사본 `단가.json` 로직) · 고름 cid → 차례.""" + from M02_MasterTemplete.M02_Sangeun_Doc import line_ids + + chosen = doc.get("고름") or {} + ids = line_ids(doc["굳힌줄"]) + picks = {str(n): chosen[cid] for n, cid in enumerate(ids) if cid in chosen} + one = {"로직": (doc.get("바탕") or {}).get("로직"), "입력값": doc.get("입력값")} + return {**one, "켜쌓기율": doc.get("켜쌓기율"), "고름": picks or None} + + def linked_resource_keys(project_root: str | Path, keep: set[str] | None = None) -> list[str]: """연결된 사본 전부가 부른 자원 키 — 구조물 id 차례 → 설계내역서 서고 사본(204-2) · 겹침 없음 · `keep` = 화면 캐시 줄이 잇는 서고 사본만(296 · 지운 줄의 사본 뺌).""" diff --git a/B09_Estimation/B09_Estimation_Refresh.py b/B09_Estimation/B09_Estimation_Refresh.py index c29a75999..af191bd16 100644 --- a/B09_Estimation/B09_Estimation_Refresh.py +++ b/B09_Estimation/B09_Estimation_Refresh.py @@ -5,6 +5,9 @@ 재적용 · 새 판 뒤에도 남음) · 노임 판(굳힌 판 → 지금 M01 판 · 옛 책 = 굳힌 날) 적용(`reapply_changed` → 부르는 쪽이 수요 다시 → `prices.refresh_book`) — 사본 먼저(수요가 바뀜) · 덮개(사용자 값)는 안 건드림 · 사본 하나가 막혀도 나머지는 함(막힌 것 = 알림). +M02 단가산출(SK · 719-2) — 구조물 · 서고 사본의 굳힌 SK 를 원본 층에서 다시 풀어 굳힘(같은 굳힌 키 · + 같은 cover → B09 덮개 · 입력값 · 고른 자재 그대로) · 서고 사본도 원본판이 다르면 다시 굳힘 · + 미리 보기 `단가산출` = 바뀌는 SK 줄 [{사본, 이름, 키, 판(새 · 바뀜 · 빠짐)}]. """ from __future__ import annotations @@ -12,10 +15,13 @@ from __future__ import annotations from pathlib import Path from typing import Any +from B09_Estimation import B09_Estimation_Bill_Library as library from B09_Estimation import B09_Estimation_Prices as prices from B09_Estimation.B09_Estimation_Calc_Labor import COPY from M02_MasterTemplete import M02_Item_Copy as item_copy -from M02_MasterTemplete.M02_Item_Snapshot import linked_snapshots +from M02_MasterTemplete import M02_Item_Sangeun as item_sk +from M02_MasterTemplete.M02_Item_Snapshot import MANIFEST_NAME, linked_snapshots, structures_dir +from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, build_source, read_json def changed_copies(root: str | Path) -> list[dict[str, Any]]: @@ -28,6 +34,41 @@ def changed_copies(root: str | Path) -> list[dict[str, Any]]: ] +def _sks(labor: Any) -> dict[str, dict]: + copies = (labor or {}).get("참조사본") or {} + return {rid: doc for rid, doc in copies.items() if item_sk.is_sangeun(doc)} + + +def sk_lines(old: Any, new: Any, where: str) -> list[dict[str, str]]: + """사본 하나 전후 일위대가.json → 바뀌는 SK 줄(굳힌 줄 · 원본 SK 문서가 다름).""" + was, now = _sks(old), _sks(new) + out = [] + for rid in sorted(set(was) | set(now)): + a, b = was.get(rid) or {}, now.get(rid) or {} + if all(a.get(k) == b.get(k) for k in (item_sk.ROW, "원본")): + continue + mark = "새" if not a else "빠짐" if not b else "바뀜" + out.append({"사본": where, "이름": (b or a).get("이름") or "", "키": rid.rsplit("|")[-1], + "판": mark}) # fmt: skip + return out + + +def sk_changes(root: str | Path, copies: list[dict], libs: list[tuple]) -> list[dict[str, str]]: + """다시 가져올 구조물 사본(`changed_copies`) · 서고 사본(`library.changed`)의 바뀌는 SK 줄.""" + out = [] + for one in copies: + folder = structures_dir(root) / one["폴더"] + try: + fresh = build_source((read_json(folder / MANIFEST_NAME) or {})["원본"]) + except (LookupError, ValueError, KeyError): + continue + out += sk_lines(read_json(folder / LABOR_FILE), fresh.get(LABOR_FILE), one["이름"]) + for folder, manifest, files, origin in libs: + name = str(origin.get("name") or folder.name) + out += sk_lines(read_json(folder / LABOR_FILE), files.get(LABOR_FILE), name) + return out + + def cover_cells(cover: dict[str, Any] | None) -> int: """고친 칸 수 — 덮개 칸(품 · 수량 · 단가) · 복사본 표지 뺌.""" return sum( @@ -43,6 +84,8 @@ def preview( root: str | Path, book: dict[str, Any] | None, keys: list[str], cover: dict | None ) -> dict[str, Any]: diff = prices.book_diff(book, prices.refresh_book(keys, book)) + copies, libs = changed_copies(root), library.changed(root) + lib_names = [{"폴더": f.name, "이름": str(o.get("name") or f.name)} for f, _m, _f, o in libs] before = (book or {}).get("labor_edition") made = str((book or {}).get("made") or "")[:10] return { @@ -51,7 +94,8 @@ def preview( "바뀐줄": diff["changed"], "계수": diff["factor"], "환율": diff["fx"], - "사본": changed_copies(root), + "사본": copies + lib_names, + "단가산출": sk_changes(root, copies, libs), "고친칸": cover_cells(cover), "노임판": { "전": edition_text(before) if before else (f"굳힌 날 {made}" if made else "안 굳힘"), @@ -61,8 +105,16 @@ def preview( def reapply_changed(root: str | Path, who: dict[str, Any] | None = None) -> dict[str, list]: - """원본이 바뀐 연결 사본 재적용 — `{다시: [이름], 막힘: [이름 — 까닭]}`.""" + """원본이 바뀐 연결 사본 재적용 · 서고 사본 다시 굳힘 — + `{다시: [이름], 막힘: [이름 — 까닭]}`.""" done, stuck = [], [] + for one in library.changed(root): + name = str(one[3].get("name") or one[0].name) + try: + library.refresh_copy(root, one) + done.append(name) + except (LookupError, ValueError, KeyError, OSError) as error: + stuck.append(f"{name} — {error}") for one in changed_copies(root): try: item_copy.reapply(root, one["폴더"], who) diff --git a/B09_Estimation/B09_Estimation_Sangeun.py b/B09_Estimation/B09_Estimation_Sangeun.py index 02549d389..95f159784 100644 --- a/B09_Estimation/B09_Estimation_Sangeun.py +++ b/B09_Estimation/B09_Estimation_Sangeun.py @@ -129,6 +129,17 @@ def rows_of(folder: Path, whole: Any = None) -> list[dict[str, Any]]: doc = copies.get(refs.ref_id(ref)) if not isinstance(doc, dict) or depth >= refs.DEPTH: continue + if refs.kind_of(ref.get("키")) == "sangeun": # 굳힌 SK(719-2) = 대가 · 하위 = 산근 + base = str((doc.get("바탕") or {}).get("로직") or "") + if base and "굳힌줄" in doc: + logic_line(base, doc.get("입력값"), doc.get("켜쌓기율"), None, True) + inner_refs = [ + {"참조": x["참조"]} + for x in doc["굳힌줄"].get("호표") or [] + if isinstance(x.get("참조"), dict) + ] + walk(inner_refs, True, depth + 1) # 단가산출 속 일위대가 · SK + continue if refs.kind_of(ref.get("키")) == "promote": # 옛 SP = 일위대가화 first = (doc.get("담은로직") or [None])[0] if isinstance(first, dict) and first.get("로직"): @@ -194,15 +205,23 @@ class _Prices: ) -def _unit(code: str) -> tuple[str, Decimal]: - """결과단위 1 단위 — (단위, 배)(「원/10㎡」 = ㎡ · 10).""" - pill = (combo_store.combo_pills({"담은로직": [{"로직": code}]})["물량"] or [{}])[0] - return str(pill.get("단위") or ""), Decimal(str(pill.get("배") or 1)) +def _unit(row: dict) -> tuple[str, Decimal]: + """결과단위 1 단위 — (단위, 배)(「원/10㎡」 = ㎡ · 10) · 알약 규칙(`combo_pills`) 그대로 · + 줄 = 사본 단가 묶음 것(M01 다시 안 읽음 · 719-2).""" + per = combo_store._PER.findall(str(row.get("결과단위") or "")) + if not per: + return "", Decimal(1) + times = per[0][0].replace(",", "") + unit = " · ".join(dict.fromkeys(u for _, u in per)) + return unit, Decimal(times) if len(per) == 1 and times else Decimal(1) -def unit_of(code: str) -> str: - """산근 1 단위 이름(「원/10㎡」 = ㎡).""" - return _unit(code)[0] +def unit_of(whole: Any, code: str) -> str: + """산근 1 단위 이름(「원/10㎡」 = ㎡) — `whole` = 사본 단가 묶음.""" + try: + return _unit(whole.logic(code))[0] + except mf.FormulaError: + return "" def _env(whole: Any, row: dict, given: dict) -> dict: @@ -343,7 +362,7 @@ class Solver: name = str(row.get("이름") or code) if isinstance(row.get("흐름"), dict) or "결과" in row: return self._stop(code, name, "돈 로직 아님(흐름 · 결과 로직) — 산근 풀이 꼴 없음") - unit, times = _unit(code) + unit, times = _unit(row) rate = Decimal(str(src["rate"])) / 100 if src.get("rate") not in (None, 100) else None lines: list[dict[str, Any]] = [] try: diff --git a/B09_Estimation/B09_Estimation_UI_Check.ts b/B09_Estimation/B09_Estimation_UI_Check.ts index 430a73b10..ed0d17dda 100644 --- a/B09_Estimation/B09_Estimation_UI_Check.ts +++ b/B09_Estimation/B09_Estimation_UI_Check.ts @@ -73,11 +73,15 @@ export function refreshTip(payload: Row | null): string { /** [업데이트] 미리 보기 글 줄(703-3) */ export function previewLines(got: Row): string[] { const copies: Row[] = got.사본 || []; + const sks: Row[] = got.단가산출 || []; const edition = got.노임판 || {}; return [ `바뀌는 단가 줄 ${got.단가줄 ?? 0}(새 줄 ${got.새줄 ?? 0} · 값 바뀜 ${got.바뀐줄 ?? 0})` + `${got.계수 ? " · 운전사 계수 바뀜" : ""}${got.환율 ? " · 환율 바뀜" : ""}`, - `다시 가져올 구조물 사본 ${copies.length}${copies.length ? ` — ${copies.map((c) => c.이름).join(" · ")}` : ""}`, + `다시 가져올 사본 ${copies.length}${copies.length ? ` — ${copies.map((c) => c.이름).join(" · ")}` : ""}`, + ...(sks.length + ? [`M02 단가산출 바뀜 ${sks.length} — ${sks.map((s) => `${s.이름}(${s.판})`).join(" · ")}`] + : []), `고친 칸 ${got.고친칸 ?? 0} 지킴(일위대가 덮개 · 새 판 · 재적용 뒤에도 그대로)`, `노임 판 ${edition.전 || "—"} → ${edition.후 || "—"}`, ]; diff --git a/B09_Estimation/B09_Estimation_UI_Store.ts b/B09_Estimation/B09_Estimation_UI_Store.ts index 81c62c5c6..c12b07ef6 100644 --- a/B09_Estimation/B09_Estimation_UI_Store.ts +++ b/B09_Estimation/B09_Estimation_UI_Store.ts @@ -80,11 +80,12 @@ function hereItems(ctx: TabContext): Item[] { return [...of("dg", ctx.sheets.daega), ...of("sg", ctx.sheets.sg_list)]; } -/** 내 것 · 회사 — 구조물 조합(그 층) + 사전조합(그 층 일위대가) */ +/** 내 것 · 회사 — 구조물 조합(그 층) + 사전조합(그 층 일위대가) + 단가산출(SK · 719-2) */ async function layerItems(layer: "personal" | "company"): Promise { const owner = ownOwner(layer, await fetchSessionUser().catch(() => null)); const combos = (await loadRows().catch(() => [])).filter((r) => r.layer === layer); const pres = owner ? await fetchLibrary({ 층: layer, 주인: owner }, "precombo") : []; + const sks = owner ? await fetchLibrary({ 층: layer, 주인: owner }, "sangeun") : []; return [ ...combos.map((r) => { const name = itemName(r); @@ -99,15 +100,19 @@ async function layerItems(layer: "personal" | "company"): Promise { void importLib({ apply, onPayload: onPayload(ctx) }, pick), }; }), - ...pres.map((r) => { + ...[ + ...pres.map((r) => ["precombo", r] as const), + ...sks.map((r) => ["sangeun", r] as const), + ].map(([kind, r]) => { const name = r.제목 || r.이름; - const pick = { kind: "precombo" as const, layer, owner, key: r.이름 }; + const tag = kind === "precombo" ? "일위대가" : "단가산출"; + const pick = { kind, layer, owner, key: r.이름 }; return { - tag: "일위대가", + tag, name, sub: [r.구분, r.상세구분].filter(Boolean).join(" "), unit: String(r.단위 || ""), - hit: has(`일위대가 ${name} ${r.구분 ?? ""} ${r.상세구분 ?? ""}`), + hit: has(`${tag} ${name} ${r.구분 ?? ""} ${r.상세구분 ?? ""}`), go: (ctx: TabContext, apply: ApplyRef) => void importLib({ apply, onPayload: onPayload(ctx) }, pick), }; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts index 026839535..15dfbce47 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts @@ -8,6 +8,7 @@ * {블록 cover: {줄 cid: {qty · labor · material · expense}}} · 캐시에 바로 · 셈 짝이 바로 다시 셈 · * [저장] 때 서버가 덮개 입력값으로 정본을 다시 셈 · 노임 새 판 · [재적용] 뒤에도 남음(702-4) * 고친 칸 = 칸 색(`is-edited`) · 「품셈 값」 칸 = 셈 값 · 우클릭 [품셈 값으로] · 빈칸 입력 = 되돌림 + * M02 단가산출 블록 줄(`sk` · 719-2) = 되돌림 [단가산출 값으로](셈 값 = 단가산출 값) · 곁 글 = 품셈 값(`poom`) * 일위대가 연결(dg) 없는 줄 = 펼치지 않음 · 고른 줄 = 이 파일이 기억(다시 그려도 그대로) * 우클릭 덧메뉴(703-2) = 복사해 새 일위대가 · 내 자료집에 저장 · 어디 쓰였나(`…_Bill_Fold_Menu`) * 엑셀 붙여넣기(705-2) = 표를 누르고 Ctrl+V → 서버가 붙인 글을 읽어 지금 호표 끝 고정 줄(덮개 `@add` · 캐시 · @@ -59,11 +60,20 @@ let open: string | null = null; const plain = (v: unknown): string => typeof v === "number" ? v.toLocaleString("ko-KR", { maximumFractionDigits: 4 }) : "빈칸"; -/** 품셈 값 칸 글 — 안 고침 = 「같음」 · 고친 칸 = 셈 값 */ +/** 되돌림 값 이름 — 단가산출 블록 줄 = 단가산출 값 · 그 밖 = 품셈 값 */ +const backName = (line: Row | undefined): string => (line?.sk ? "단가산출 값" : "품셈 값"); + +/** 단가산출에서 고친 줄의 품셈 값 곁 글 */ +const poomText = (line: Row): string => + line.poom ? `품셈 품 ${plain(line.poom.qty)} · 단가 ${plain(line.poom.단가)}` : ""; + +/** 품셈 값 칸 글 — 안 고침 = 「같음」 · 고친 칸 = 셈 값 · 단가산출 줄 = 품셈 값 곁 */ function baseText(line: Row): string { const covered: string[] = line.covered || []; - if (!covered.length) return "같음"; - return covered.map((k) => `${LABEL[k]} ${plain(line.base?.[k])}`).join(" · "); + const poom = poomText(line); + if (!covered.length) return poom ? `단가산출 고침 — ${poom}` : "같음"; + const base = covered.map((k) => `${LABEL[k]} ${plain(line.base?.[k])}`).join(" · "); + return line.sk ? `단가산출 ${base}${poom ? ` (${poom})` : ""}` : base; } function linesOf(block: Row, blocks: Map, level: number, trail: string[]): FoldLine[] { @@ -184,7 +194,9 @@ function foldCard( const at = lines[Number(cell.parentElement?.dataset.i)]; const field = FIELD[key]; if (!at || !field || !covered(at, field)) return null; - return `고친 칸 — 품셈 값 ${plain(at.line.base?.[field])} · 우클릭 [품셈 값으로]`; + const poom = poomText(at.line); + const back = backName(at.line); + return `고친 칸 — ${back} ${plain(at.line.base?.[field])}${poom ? ` · ${poom}` : ""} · 우클릭 [${back}으로]`; }, "tr[data-i] td", ); @@ -192,7 +204,7 @@ function foldCard( const at = lines[i]; const undo: MapContextMenuItem[] = at && (at.line.covered || []).length - ? [["품셈 값으로", () => clearCovers(ctx, [at])], "-"] + ? [[`${backName(at.line)}으로`, () => clearCovers(ctx, [at])], "-"] : []; return [...undo, ...foldMenu(ctx, raw, row, block, at?.line, go)]; }); @@ -200,7 +212,7 @@ function foldCard( ...(edited.length ? [ createButton({ - label: `고친 칸 ${edited.length}줄 모두 품셈 값으로`, + label: `고친 칸 ${edited.length}줄 모두 ${backName((block.lines || []).find((l: Row) => l.sk))}으로`, variant: "ghost", size: "sm", onClick: () => clearCovers(ctx, edited), diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts index c633edc0c..34c593700 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts @@ -87,11 +87,15 @@ async function postPersonal(body: Row): Promise<{ ok: boolean; status: number; g return { ok: response.ok, status: response.status, got }; } -/** 내 자료집에 저장 — 개인 층 사전조합(덮개 · 넣을 값 · 고른 자재) · 같은 이름 물음 */ +/** 내 자료집에 저장 — 개인 층 사전조합(덮개 · 넣을 값 · 고른 자재) · 품셈 블록 = 단가산출(SK · + * 품 · 단가 고친 칸 = SK 덮개 · 719-2) · 같은 이름 물음 */ async function savePersonal(ctx: TabContext, block: Row): Promise { + const sk = block.kind === "품셈"; let name = await askText({ title: "내 자료집에 저장", - message: "내 자료집 일위대가로 — 옵션 값 · 고른 자재 · 줄 수량 고친 칸까지", + message: sk + ? "내 자료집 단가산출로 — 옵션 값 · 고른 자재 · 품 · 단가 고친 칸까지" + : "내 자료집 일위대가로 — 옵션 값 · 고른 자재 · 줄 수량 고친 칸까지", value: block.name || "", okLabel: "저장", }); @@ -104,9 +108,9 @@ async function savePersonal(ctx: TabContext, block: Row): Promise { const skip: Row = got.안담음 || {}; const left = (skip.품 || 0) + (skip.단가 || 0); const tail = left - ? ` · 안 담은 고친 칸 ${left}(품 ${skip.품} · 단가 ${skip.단가}) — 품은 고정 줄(PLAN 705) 뒤 담김` + ? ` · 안 담은 고친 칸 ${left}(품 ${skip.품} · 단가 ${skip.단가})${sk ? "" : " — 품은 고정 줄(PLAN 705) 뒤 담김"}` : ""; - showToast(`내 자료집에 저장 「${got.이름}」${tail}`, "success", 6000); + showToast(`내 자료집${sk ? " 단가산출" : ""}에 저장 「${got.이름}」${tail}`, "success", 6000); return; } if (status !== 409 || !same) { diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts index 1e358ccf5..6054a8527 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts @@ -66,7 +66,7 @@ export function askInputs(apply: ApplyRef, code: string, name: string, old?: Row /** 단가연결 목록에서 고른 것(263-1 · 「운반 단가」 모달이 받아 보임) · * lib = 있는 사본 폴더 · haul = 운반 값(263-2 · 손 값 {줄 id: {입력: 값}} · 덮은 칸 · 그 행 거리) */ export type LibPick = { - kind: "combo" | "precombo"; + kind: "combo" | "precombo" | "sangeun"; layer: string; owner: string | null; key: string; @@ -85,7 +85,7 @@ export interface LibLine { /** 사본 폴더 이름 → 서고 항목(`종류@층@주인@키~해시` · 짝 `Bill_Library.folder_name`) */ export function parseLib(lib: string): LibPick | null { const [kind, layer, owner, key] = lib.split("~")[0].split("@"); - if ((kind !== "combo" && kind !== "precombo") || !key) return null; + if ((kind !== "combo" && kind !== "precombo" && kind !== "sangeun") || !key) return null; return { kind, layer, owner: owner === "-" ? null : owner.replace("-", "/"), key, lib }; } diff --git a/M02_MasterTemplete/M02_Item_Sangeun.py b/M02_MasterTemplete/M02_Item_Sangeun.py index 0b5962009..487125d62 100644 --- a/M02_MasterTemplete/M02_Item_Sangeun.py +++ b/M02_MasterTemplete/M02_Item_Sangeun.py @@ -10,7 +10,8 @@ 고른자재가 짝을 안 잃음). 원본 층 문서는 읽기만. 사본 SK 문서 = {종류 · 키 · 이름 · 단위 · 구분 · 상세구분, 바탕 {로직: 굳힌 키}, 입력값(사슬 합), 열린입력, 켜쌓기율, 고름 · 자재표시(굳힌 줄 cid), 덮개 {}, 더한줄 [], 뺀줄 [], -굳힌줄(= 단가.json 줄), 원본(원래 SK 문서 · 「M02 에서 고침」 근거), 바탕판, 짝잃음}. +굳힌줄(= 단가.json 줄), 원본(원래 SK 문서 · 「M02 에서 고침」 근거), 정체(굳힌 줄 차례 → 원본 +cid · 더한줄 = `add:#k`), 바탕판, 짝잃음}. 단가산출 속 일위대가(SC) · SK 더한줄 = 참조 줄 그대로 → 사본 셈이 굳힌 참조사본으로 펼침. """ @@ -78,6 +79,7 @@ def freeze(copies: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]: "뺀줄": [], ROW: row, "원본": doc, + "정체": list(got["정체"]), # 굳힌 줄 차례 → 원본 cid(B09 덮개 · 내 자료집 · 719-2) "바탕판": got["바탕판"], "짝잃음": got["짝잃음"], } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts index e18842fb5..6907c5790 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -342,7 +342,7 @@ export interface LibraryRow { /** 서고 목록 — 그 층(주인) 사전조합 `precombo` 문서 */ export const fetchLibrary = async ( ref: Omit, - kind: "precombo", + kind: "precombo" | "sangeun", ): Promise => { const q = ref.주인 && ref.층 !== "system" ? `?owner=${enc(ref.주인)}` : ""; const got = await call<{ 양식: (LibraryRow & { 종류: string })[] }>( diff --git a/resources/tester/test_155_5_sangeun_text.py b/resources/tester/test_155_5_sangeun_text.py index 6b780ec3e..e24c8438f 100644 --- a/resources/tester/test_155_5_sangeun_text.py +++ b/resources/tester/test_155_5_sangeun_text.py @@ -111,7 +111,8 @@ def test_글_속_금액은_산근_풀이_값과_같음(): whole = item_prices.load_prices(item_prices.to_json(item_prices.collect([code]))) sheets, price_of = _solver_prices(whole, code, given) block = sg.Solver(sheets).add(whole, {"code": code, "given": given}, "s1") - times = sg._unit(code)[1] # 결과단위 배(「원/1,000㎡」) — 산근 풀이와 같은 1 단위 + # 결과단위 배(「원/1,000㎡」) — 산근 풀이와 같은 1 단위 + times = sg._unit(whole.logic(code))[1] got = st.render_sangeun_text(whole, code, given, price_of, times=times) parts = " + ".join(f"{st.PART_NAME[p]} {block[p]:,.0f}" for p in sg.PARTS) assert got[-1] == f"계 : {parts} = {block['sum']:,.0f}", code diff --git a/resources/tester/test_719_2_sk_project.py b/resources/tester/test_719_2_sk_project.py new file mode 100644 index 000000000..252707852 --- /dev/null +++ b/resources/tester/test_719_2_sk_project.py @@ -0,0 +1,267 @@ +"""719-2 공사 쪽 M02 단가산출(SK) — B09 대가 블록 · 산근 · [업데이트] · 내 자료집 SK(임시 storage · +실제 M01 읽기만). + +- 굳힌 SK = 품셈 꼴 대가 블록(`Labor_Source.sangeun`) · 금액 = M02 SK 셈 · 단가산출 속 일위대가 줄 = + 사전조합 블록을 부르는 줄 · 로직 더한줄의 하위 로직 = 산근 목록(`Sangeun.rows_of`) +- [업데이트] = SK 고친 뒤 다시 굳힘 — 굳힌 키 · cover · cid · 입력값 · 고른자재 · B09 덮개 그대로 · + 덮개 밑 값(base) = 새 단가산출 값 · 서고 사본(`sangeun`)도 원본판 다르면 다시 굳힘 · + 미리 보기 SK 줄 +- 내 자료집 = SK 블록 · 품셈 블록 → 개인 층 SK(B09 덮개 = SK 덮개) → 서고로 다시 불러와 같은 금액 +""" + +# ruff: noqa: F811 — 임시 층 fixture `world` 를 들여 씀 +import json + +import pytest + +from B09_Estimation import B09_Estimation_Bill_Library as library +from B09_Estimation import B09_Estimation_Daega_Personal as personal +from B09_Estimation import B09_Estimation_Prices as prices +from B09_Estimation import B09_Estimation_Refresh as refresh +from B09_Estimation import B09_Estimation_Sangeun as sangeun +from B09_Estimation.B09_Estimation_Calc_Base import compute_base +from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor +from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs +from B09_Estimation.B09_Estimation_Linked import linked_resource_keys +from M02_MasterTemplete import M02_Item_Copy as item_copy +from M02_MasterTemplete import M02_Item_Snapshot as snapshots +from M02_MasterTemplete import M02_Item_Source as source +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 STONE, _item, _project, _snap +from resources.tester.test_715_sangeun_store import PCT, _calc, _sk +from resources.tester.test_m02_labor_ref import MINE, world # noqa: F401 — 같은 임시 층 + +TWO_MATS = "GC000565" # 재료 줄 둘(고른자재 열쇠) +COVER = {"labor:LB000036#0": {"qty": 0.5}} +PRICE = {"labor:LB000002#0": {"labor": 200000}} +OWNER = "7/42" + + +def _json(value): + return json.loads(json.dumps(value, ensure_ascii=False, default=str)) + + +def _sc(world, lines): + with store.at(world["personal"]): + doc = {"이름": "속 일위대가", "단위": "㎡", "담은로직": lines} + return refs.create("precombo", doc)["이름"] + + +def _mine(world, full=False): + """SK = 품 덮개 · 인력 더한줄 · 로직 더한줄(하위 산근) · full = 단가 덮개 + SC 더한줄.""" + added = [ + {"종류": "인력", "요소": "LB000002", "수량": 0.1}, + {"종류": "로직", "로직": PCT, "입력값": {}, "수량": 0.5}, + ] + cover = COVER + if full: + sc = _sc(world, [{"로직": PCT, "수량": {"손": 1}}]) + added.append({"참조": {**MINE, "키": sc}, "수량": 2}) # 단가산출 속 일위대가 + cover = {**COVER, **PRICE} + return _sk(world["personal"], 바탕={"로직": PCT}, 덮개=cover, 더한줄=added) + + +def _engine_ready(world): + """엔진 입력 덧칸(715-1b `단가` · `값묶음`)이 붙었나 — 아니면 M02 도 못 셈.""" + key = _sk( + world["personal"], 이름="엔진", 바탕={"로직": PCT}, 덮개={"labor:LB000002#0": {"labor": 1}} + ) + got = _calc(world["personal"], key) + return not got.get("까닭") and got["줄"][1]["단가"] == 1 + + +def _b09(root, cover=None): + keys = linked_resource_keys(root) + inputs = prices.build_inputs(None, keys) + inputs["daega"] = daega_inputs(root) + base = compute_base(inputs, {}) + sg = sangeun.build(root, base) + rows = {r["key"]: r for r in sg["sg_list"]} + out = compute_labor(inputs["daega"], base, rows, cover) + return inputs["daega"], {b["id"]: b for b in out["daega_blocks"]}, rows + + +def _lib(world, tmp_path, key, kind="sangeun"): + root = _project(tmp_path / "proj", []) + made = library.import_copy(root, kind, "personal", OWNER, key) + return root, made["폴더"] + + +def _set_cover(world, key, cover): + with store.at(world["personal"]): + now = store.read("sangeun", key) + store.write("sangeun", key, now["판"], {**now["문서"], "덮개": cover}) + + +def _same_money(block, live): + assert block["sum"] == pytest.approx(float(live["1단위"]["계"]), abs=5) # 끝수(내림)만 다름 + for part, name in (("labor", "노무비"), ("material", "재료비"), ("expense", "경비")): + assert block[part] == pytest.approx(float(live["1단위"][name]), abs=3) + + +def test_SK_대가_블록_금액_M02_같음_하위_산근(world, tmp_path): + key = _mine(world) + live = _calc(world["personal"], key) + assert not live.get("까닭") and live["1단위"]["계"] > 0 + root, folder = _lib(world, tmp_path, key) + daega, done, sg_rows = _b09(root) + block = done[daega["libs"][folder]] + assert block["cover"].startswith("P:") and block["kind"] == "품셈" + lines = block["lines"] + assert all(ln.get("sk") for ln in lines) + assert lines[0]["kind"] == "labor" and lines[0]["qty"] == 0.5 + assert lines[0]["poom"] == {"qty": 0.018, "단가": 233250} # M02 에서 고친 줄 = 품셈 값 곁 + assert "poom" not in lines[1] + sub = next(ln for ln in lines if ln["kind"] == "sangeun") + assert sub["key"] in sg_rows and sub["key"].startswith(f"SG:{PCT}") and sub["qty"] == 0.5 + _same_money(block, live) + + +def test_SK_단가_덮개_속_일위대가_펼침(world, tmp_path): + if not _engine_ready(world): + pytest.skip("715-1b 엔진 입력 덧칸(단가 · 값묶음) 뒤 — 지금은 M02 SK 셈도 못 함") + key = _mine(world, full=True) + live = _calc(world["personal"], key) + root, folder = _lib(world, tmp_path, key) + daega, done, _ = _b09(root) + block = done[daega["libs"][folder]] + lines = block["lines"] + assert lines[1]["kind"] == "fixed" and lines[1]["prices"]["labor"] == pytest.approx(200000) + sc_line = next(ln for ln in lines if ln["kind"] == "daega") + assert done[sc_line["key"]]["kind"] == "사전조합" and sc_line["qty"] == 2 + _same_money(block, live) + + +def test_구조물_사본_SK_줄_업데이트_덮개_입력값_고른자재_굳힌키_그대로( + world, tmp_path, monkeypatch +): + key = _mine(world) + ref = {**MINE, "키": key} + with store.at(world["personal"]): + from M02_MasterTemplete import M02_Labor_Doc as labor + + doc = labor.clean( + { + "열": "rv", + "담은로직": [ + {"참조": ref, "수량": {"손": 2}}, + {"로직": "GF000219", "입력값": {}, "수량": {"손": 1}}, + {"로직": TWO_MATS, "수량": {"손": 1}}, + ], + } + ) + + def build(_item_): # 원본 층 → 사본 파일(구조물 원본 대신 이 일위대가 문서) + rows = source.labor_doc_rows(doc) + return _json( + { + source.LABOR_FILE: rows, + source.PRICE_FILE: source.price_bundle(rows, world["personal"]), + snapshots.VALUES_FILE: {}, + } + ) + + monkeypatch.setattr(item_copy, "build_snapshot", build) + root = _project(tmp_path / "proj", [(100, "SS000901")]) + folder = snapshots.structures_dir(root) / snapshots.folder_name("pipe@100.0") + folder.mkdir(parents=True) + values = {"GF000219": {k: {"값": v} for k, v in STONE.items()}} + picks = {TWO_MATS: {"1": "MT000001"}} + for name, data in { + **build(None), + snapshots.VALUES_FILE: values, + snapshots.PICKS_FILE: picks, + snapshots.MANIFEST_NAME: {"원본": {**_item("SS000901"), "경로": ["x"]}}, + }.items(): + (folder / name).write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + rid = refs.ref_id(ref) + frozen_key = json.loads((folder / source.LABOR_FILE).read_text("utf-8"))["참조사본"][rid] + frozen_key = frozen_key["바탕"]["로직"] + daega, _, _ = _b09(root) + combo = next(b for b in daega["blocks"] if b["id"] == daega["owners"]["pipe@100.0"]) + sk_block = next(b for b in daega["blocks"] if b["id"] == combo["lines"][0]["key"]) + cid = sk_block["lines"][0]["cid"] + cover = {sk_block["cover"]: {cid: {"qty": 0.9}}} + # SK 고침(품 0.5 → 0.7) → [재적용](업데이트) = 다시 풀어 굳힘 + _set_cover(world, key, {**COVER, "labor:LB000036#0": {"qty": 0.7}}) + item_copy.reapply(root, folder.name) + after = json.loads((folder / source.LABOR_FILE).read_text("utf-8"))["참조사본"][rid] + assert after["바탕"]["로직"] == frozen_key # 굳힌 키 그대로 + assert ( + json.loads((folder / snapshots.VALUES_FILE).read_text("utf-8"))["GF000219"] + == (values["GF000219"]) + ) + assert json.loads((folder / snapshots.PICKS_FILE).read_text("utf-8")) == picks + daega2, done, _ = _b09(root, cover) + block = next(b for b in done.values() if b["cover"] == sk_block["cover"]) + line = block["lines"][0] + assert line["cid"] == cid and line["covered"] == ["qty"] and line["qty"] == 0.9 + assert line["base"]["qty"] == 0.7 # 덮개 밑 = 새 단가산출 값(「단가산출 값으로」 되돌림 값) + assert daega2["owners"]["pipe@100.0"] in done + + +def test_서고_SK_업데이트_미리보기_다시_굳힘(world, tmp_path): + key = _mine(world) + root, folder = _lib(world, tmp_path, key) + assert library.changed(root) == [] + before = daega_inputs(root) + cover_key = next(b for b in before["blocks"] if b["id"] == before["libs"][folder])["cover"] + _set_cover(world, key, {**COVER, "labor:LB000036#0": {"qty": 0.7}}) + got = refresh.preview(root, None, [], None) + assert [(x["키"], x["판"]) for x in got["단가산출"]] == [(key, "바뀜")] + assert [c["폴더"] for c in got["사본"]] == [folder] + assert refresh.reapply_changed(root)["다시"] == ["내 단가산출"] + assert library.changed(root) == [] and [f.name for f, _ in library.copies(root)] == [folder] + after = daega_inputs(root) + block = next(b for b in after["blocks"] if b["id"] == after["libs"][folder]) + assert block["cover"] == cover_key and block["lines"][0]["qty"] == 0.7 + + +def test_내_자료집_SK_왕복_같은_금액(world, tmp_path): + key = _mine(world) + root, folder = _lib(world, tmp_path, key) + daega, _, _ = _b09(root) + block = next(b for b in daega["blocks"] if b["id"] == daega["libs"][folder]) + first, second = block["lines"][0]["cid"], block["lines"][1]["cid"] + priced = {block["cover"]: {first: {"qty": 0.9}, second: {"labor": 210000}}} + made = personal.build(root, block["cover"], priced, "내 SK 2") + doc = made["문서"] + assert doc["종류"] == "단가산출" and doc["바탕"] == {"로직": PCT} + assert doc["덮개"]["labor:LB000036#0"] == {"qty": 0.9} + assert doc["덮개"]["labor:LB000002#0"] == {"labor": 210000} + assert doc["더한줄"][0]["요소"] == "LB000002" + assert made["담음"] == {"품": 1, "단가": 1} and made["안담음"] == {"품": 0, "단가": 0} + third = block["lines"][2]["cid"] # 더한줄 인력 = 그 더한줄 수량 + cover = {block["cover"]: {first: {"qty": 0.9}, third: {"qty": 0.3}}} + doc = personal.build(root, block["cover"], cover, "내 SK 2")["문서"] + assert doc["더한줄"][0]["수량"] == 0.3 and doc["덮개"] == {"labor:LB000036#0": {"qty": 0.9}} + _, done, _ = _b09(root, cover) + want = done[daega["libs"][folder]]["sum"] + with store.at(world["personal"]): + new = personal.write(doc)["이름"] + with pytest.raises(store.StoreError) as same: + personal.write(doc) + assert same.value.status == 409 and same.value.detail["같은이름"] == new + assert new.startswith("SK-") and new != key + root2, folder2 = _lib(world, tmp_path / "b", new) + daega2, done2, _ = _b09(root2) + assert done2[daega2["libs"][folder2]]["sum"] == pytest.approx(want, abs=2) + + +def test_내_자료집_품셈_블록_SK(world, tmp_path): + root = _project(tmp_path / "proj", [(100, "SS000901")]) + _snap(root, "pipe@100.0", "SS000901", [{"로직": PCT, "승격": True, "수량": {"손": 1}}], {}) + daega = daega_inputs(root) + top = next(b for b in daega["blocks"] if b["id"] == daega["owners"]["pipe@100.0"]) + block = next(b for b in daega["blocks"] if b["id"] == top["lines"][0]["key"]) + assert block["kind"] == "품셈" and block["origin"]["로직"] == PCT + cover = {block["cover"]: {block["lines"][0]["cid"]: {"qty": 0.5}}} + doc = personal.build(root, block["cover"], cover, "내 해체")["문서"] + assert doc["바탕"] == {"로직": PCT} and doc["단위"] == "㎡" + assert doc["덮개"] == {"labor:LB000036#0": {"qty": 0.5}} + with store.at(world["personal"]): + new = personal.write(doc)["이름"] + plain = _sk(world["personal"], 이름="견줌", 바탕={"로직": PCT}, 덮개=doc["덮개"]) + a, b = _calc(world["personal"], new), _calc(world["personal"], plain) + assert a["1단위"]["계"] == b["1단위"]["계"] > 0