From b54950d28bbf22d8de5b58d80b8c88fd098003af Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 15:34:29 +0900 Subject: [PATCH] =?UTF-8?q?feat(719-1):=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EC=82=AC=EB=B3=B8=20SK=20=ED=92=80=EC=96=B4=20?= =?UTF-8?q?=EA=B5=B3=ED=9E=98=20=E2=80=94=20=EA=B3=A0=EC=B9=9C=20=EC=A4=84?= =?UTF-8?q?=20=3D=20=EC=82=AC=EB=B3=B8=20=EB=8B=A8=EA=B0=80.json=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=A4=84=20=C2=B7=20=EA=B5=B3=ED=9E=8C=20?= =?UTF-8?q?=ED=82=A4=20GX9#####=20(SK=20=EC=9D=B4=EB=A6=84=ED=91=9C=20?= =?UTF-8?q?=ED=95=B4=EC=8B=9C=20=C2=B7=20=EA=B0=99=EC=9D=80=20SK=20=3D=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=ED=82=A4)=20=C2=B7=20B09=20=EC=84=9C?= =?UTF-8?q?=EA=B3=A0=20manifest=20=EC=9B=90=EB=B3=B8=ED=8C=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 M02_Item_Sangeun: 참조사본 SK → M02_Sangeun_Calc.resolve 고친 줄 → 로직_단가산출.json 줄 · 사본 SK = 바탕 굳힌 키 · 덮개 0 · 원본 · 바탕판 · 짝잃음 근거 - M02_Item_Source: labor_doc_rows 가 굳힘 · price_bundle 이 굳힌 줄과 그 줄이 닿는 마스터 줄 · 고른 자재 담음 - M02_Item_Prices: 수집기 줄 하나 길(row) 떼어 둠 · 요소 없는 줄(참조 · 고정 더한줄) 건너뜀 - B09 Bill_Library: 서고 사본 manifest 원본판 = 일위대가.json 지문(source_version) - 시험 test_719_1_sk_freeze 4(임시 storage) Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Bill_Library.py | 12 +- M02_MasterTemplete/M02_Item_Prices.py | 15 ++- M02_MasterTemplete/M02_Item_Sangeun.py | 102 +++++++++++++++ M02_MasterTemplete/M02_Item_Source.py | 17 ++- resources/tester/test_719_1_sk_freeze.py | 120 ++++++++++++++++++ 5 files changed, 257 insertions(+), 9 deletions(-) create mode 100644 M02_MasterTemplete/M02_Item_Sangeun.py create mode 100644 resources/tester/test_719_1_sk_freeze.py diff --git a/B09_Estimation/B09_Estimation_Bill_Library.py b/B09_Estimation/B09_Estimation_Bill_Library.py index 78b52bec9..4b31102a2 100644 --- a/B09_Estimation/B09_Estimation_Bill_Library.py +++ b/B09_Estimation/B09_Estimation_Bill_Library.py @@ -54,6 +54,7 @@ from M02_MasterTemplete.M02_Item_Source import ( labor_doc_rows, price_bundle, read_json, + version_of, ) LIBRARY = "library" @@ -100,6 +101,12 @@ def _precombo_files(layer: str, owner: str | None, key: str) -> dict[str, Any]: } +def source_version(files: dict[str, Any]) -> str: + """서고 사본 원본판(719-1) = 일위대가.json 지문 — 굳힌 SC · SK 문서 + 풀어 굳힌 SK 줄(바탕 + M01 줄 · 바탕판) · 단가는 뺌(단가 바뀜은 [업데이트] 단가 굳힘 몫) · 값 덮기 전에 셈.""" + return version_of(files.get(LABOR_FILE)) + + def _build(kind: str, layer: str, owner: str | None, key: str) -> tuple[dict, dict]: """서고 원본 → (사본 파일들, 원본 표지).""" if kind not in KINDS: @@ -247,7 +254,7 @@ def import_copy( if isinstance(read_json(library_dir(project_root) / name / MANIFEST_NAME), dict): return {"폴더": name, "만듦": False} manifest = {"종류": kind, "원본": origin, "가져온날": now_text(), "가져온이": who} - _write(project_root, name, files, manifest, values) + _write(project_root, name, files, {**manifest, "원본판": source_version(files)}, values) return {"폴더": name, "만듦": True} @@ -293,7 +300,8 @@ def resolve(project_root: str | Path, ref: dict[str, Any], dist: Any) -> str | N got = _build(kind, layer, owner, key) except (LookupError, ValueError): return None - got = (got[0], {"종류": kind, "원본": got[1], "가져온날": now_text(), "가져온이": None}) + made = {"종류": kind, "원본": got[1], "가져온날": now_text(), "가져온이": None} + got = (got[0], {**made, "원본판": source_version(got[0])}) files, manifest = got values = haul_values(lines_of(files), ref.get("given"), ref.get("own"), dist) name = base + (f"~{tag(values)}" if values else "") diff --git a/M02_MasterTemplete/M02_Item_Prices.py b/M02_MasterTemplete/M02_Item_Prices.py index 8094536f2..a74ea02db 100644 --- a/M02_MasterTemplete/M02_Item_Prices.py +++ b/M02_MasterTemplete/M02_Item_Prices.py @@ -44,7 +44,7 @@ def _texts(row: dict) -> list[str]: out.append(row["결과"]["식"]) for item in row.get("호표", []): out.append(item["수량"]) - if not isinstance(item.get("요소"), dict): + if isinstance(item.get("요소"), str): # 단가산출 참조 · 고정 더한줄 = 요소 없음(719-1) out.append(item["요소"]) out += [extra["식"] for extra in row.get("덧줄", [])] return out @@ -200,6 +200,10 @@ class _Collector: return self.logics.add(key) self.keys.add(key) + self.row(row) + + def row(self, row: dict) -> None: + """로직 줄 하나가 닿는 것 — 마스터 로직 · 굳힌 단가산출 줄(719-1 · 마스터 밖 줄).""" if isinstance(row.get("흐름"), dict): # 흐름 로직 — 옛 칸 대신 흐름이 쓰는 것을 모음 self.flow(row) return @@ -221,12 +225,17 @@ class _Collector: self.pick(row, item["요소"], item.get("단위")) -def collect(logic_keys: list[str], materials: list[str] | None = None) -> dict[str, Any]: - """담은 로직 + 자재(MT)의 닫힌 단가 묶음 — `{파일 이름: 파일(줄은 모은 것만 · 차례 그대로)}`.""" +def collect( + logic_keys: list[str], materials: list[str] | None = None, extra: list[dict] | None = None +) -> dict[str, Any]: + """담은 로직 + 자재(MT)의 닫힌 단가 묶음 — `{파일 이름: 파일(줄은 모은 것만 · 차례 그대로)}` · + `extra` = 마스터 밖 로직 줄(굳힌 단가산출 719-1) — 그 줄이 닿는 것만 모음(줄은 안 담음).""" files, whole = master() collector = _Collector(whole) for key in logic_keys: collector.logic(key) + for row in extra or []: + collector.row(row) for code in materials or []: collector.add(code) out: dict[str, Any] = {} diff --git a/M02_MasterTemplete/M02_Item_Sangeun.py b/M02_MasterTemplete/M02_Item_Sangeun.py new file mode 100644 index 000000000..0b5962009 --- /dev/null +++ b/M02_MasterTemplete/M02_Item_Sangeun.py @@ -0,0 +1,102 @@ +"""프로젝트 사본 — M02 단가산출(SK) 풀어 굳힘(PLAN 719-1 · 요구서 내역12 원칙 3 · 4장 14). + +사본을 만들 때(구조물 사본 `M02_Item_Source.build_source` · B09 서고 사본 `Bill_Library`) 굳힌 +참조사본 안 SK 문서마다 고친 줄(`M02_Sangeun_Calc.resolve` = 바탕 사슬 + 덮개 · 더한줄 · 뺀줄)을 +풀어 사본 `단가.json` 로직 줄 하나로 굳힘 — 사본 SK 문서는 그 줄만 바탕으로 가리킴(덮개 0 · +연결 끊김). 셈은 그대로 `M02_Sangeun_Calc` 한 벌(사본 색인 = 사본 `단가.json`). + +굳힌 키 = SK 참조 이름표(`ref_id` = 층 · 주인 · 키)에서 정해지는 `GX9#####`(해시 · 겹치면 +이름표 차례로 다음 번호) — 같은 SK = 같은 굳힌 키([업데이트] 때 B09 덮개 cover · cid · 입력값 · +고른자재가 짝을 안 잃음). 원본 층 문서는 읽기만. +사본 SK 문서 = {종류 · 키 · 이름 · 단위 · 구분 · 상세구분, 바탕 {로직: 굳힌 키}, 입력값(사슬 합), +열린입력, 켜쌓기율, 고름 · 자재표시(굳힌 줄 cid), 덮개 {}, 더한줄 [], 뺀줄 [], +굳힌줄(= 단가.json 줄), 원본(원래 SK 문서 · 「M02 에서 고침」 근거), 바탕판, 짝잃음}. +단가산출 속 일위대가(SC) · SK 더한줄 = 참조 줄 그대로 → 사본 셈이 굳힌 참조사본으로 펼침. +""" + +from __future__ import annotations + +import hashlib +from typing import Any + +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_Sangeun_Doc as sd + +#: 사본 `단가.json` 안 굳힌 SK 줄 파일(`master_formula.Master` 가 그룹 「로직」 으로 읽음) +FILE = "로직_단가산출.json" +ROW = "굳힌줄" +_HEAD, _LOW, _SPAN = "GX", 900000, 99999 # GX900000 ~ GX999998(999999 = M01 저장 전 초안) + + +def frozen_key(rid: str, taken: set[str]) -> str: + """SK 이름표 → 굳힌 로직 키 — 해시 자리 · 이미 쓴 키면 다음 번호(제자리 `taken` 에 더함).""" + at = int(hashlib.sha256(rid.encode("utf-8")).hexdigest(), 16) % _SPAN + while (key := f"{_HEAD}{_LOW + at:06d}") in taken: + at = (at + 1) % _SPAN + taken.add(key) + return key + + +def is_sangeun(doc: Any) -> bool: + return isinstance(doc, dict) and doc.get("종류") == sd.TITLE + + +def freeze(copies: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]: + """참조사본 `{ref_id: 문서}` → SK 를 풀어 굳힌 참조사본(새 묶음 · 받은 것 안 고침) · + 못 푼 SK(없는 바탕 · 순환) = 그대로(사본 셈이 까닭을 냄).""" + from M01_MasterData import M01_MasterData_Store as m01 + from M02_MasterTemplete import M02_Item_Prices as prices + from M02_MasterTemplete import M02_Sangeun_Calc as calc + + sks = sorted(rid for rid, doc in copies.items() if is_sangeun(doc)) + if not sks: + return copies + whole = m01.loaded()[1] + + def read(ref: dict) -> dict | None: + return copies.get(refs.ref_id(ref)) + + out, taken = dict(copies), set() + for rid in sks: + doc = copies[rid] + try: + got = calc.resolve(doc, whole, read, (rid,)) + except sd.DocError: + continue + key = frozen_key(rid, taken) + row = prices.to_json({**got["줄"], "키": key}) + ids = sd.line_ids(row) + frozen = {k: doc.get(k) for k in ("종류", "키", "이름", "단위", "구분", "상세구분")} + frozen |= { + "바탕": {"로직": key}, + "입력값": prices.to_json(got["입력값"]), + "열린입력": list(doc.get("열린입력") or []), + "고름": {ids[int(n)]: mt for n, mt in got["고름"].items()}, + "자재표시": {ids[int(n)]: one for n, one in got["자재표시"].items()}, + "덮개": {}, + "더한줄": [], + "뺀줄": [], + ROW: row, + "원본": doc, + "바탕판": got["바탕판"], + "짝잃음": got["짝잃음"], + } + if got["켜쌓기율"] is not None: + frozen["켜쌓기율"] = prices.to_json(got["켜쌓기율"]) + out[rid] = frozen + return out + + +def rows(copies: dict[str, Any]) -> list[dict[str, Any]]: + """굳힌 SK 줄 — 사본 `단가.json` `FILE` 에 넣음(이름표 차례).""" + return [doc[ROW] for _, doc in sorted(copies.items()) if is_sangeun(doc) and ROW in doc] + + +def materials(copies: dict[str, Any]) -> list[str]: + """굳힌 SK 고른 자재 키 — 단가 묶음에 담음.""" + return [ + str(mt) + for doc in copies.values() + if is_sangeun(doc) + for mt in (doc.get("고름") or {}).values() + ] diff --git a/M02_MasterTemplete/M02_Item_Source.py b/M02_MasterTemplete/M02_Item_Source.py index 47a0196c9..489ad8ea0 100644 --- a/M02_MasterTemplete/M02_Item_Source.py +++ b/M02_MasterTemplete/M02_Item_Source.py @@ -5,7 +5,7 @@ `일위대가.json` = 층 `labor/<열id>.json` 문서(39-1c · 없으면 옛 UA 조합 줄 · 규격 열 = summary_item `spec` 열 수량을 손으로 굳힘 · 704-4) + 담은 로직 줄(M01 시스템) + 알약(가져온 때 그대로) + - 참조사본(단가승격 · 사전조합을 값으로 굳힘) · + 참조사본(단가승격 · 사전조합을 값으로 굳힘 · 단가산출 SK = 풀어 굳힌 로직 줄 719-1) · `단가.json` 계산에 닿는 마스터 줄 전부(담은 로직 + 일위대가 자재 MT) `원본판` = 모은 내용의 해시 — 지금 층 · 마스터에서 다시 모은 판과 대어 「마스터가 바뀜」 을 앎. 프로젝트가 고치는 값(`입력값.json` · `고른자재.json`)은 원본이 아니라 여기서 안 모음. @@ -93,11 +93,12 @@ def labor_rows(ua_key: str) -> dict[str, Any] | None: def labor_doc_rows(doc: dict[str, Any]) -> dict[str, Any]: """일위대가 문서(39-1a) + 담은 로직 줄 + 알약(`M02_Labor_Doc.pills` · 가져온 때 그대로 — 복사본 계산이 마스터를 안 읽게) + 참조사본 `{ref_id: 문서}`(50-3 · 원본이 바뀌어도 그대로).""" + from M02_MasterTemplete import M02_Item_Sangeun as sk from M02_MasterTemplete import M02_Labor_Doc as labor_doc from M02_MasterTemplete import M02_Labor_Ref as refs rows = {**logic_rows(doc), "알약": labor_doc.pills(doc)} - copies = refs.frozen(doc) + copies = sk.freeze(refs.frozen(doc)) # 단가산출(SK) = 풀어 굳힌 로직 줄(719-1) return {**rows, "참조사본": copies} if copies else rows @@ -162,11 +163,14 @@ def price_bundle(labor: dict[str, Any] | None, root: Path) -> dict[str, Any]: """사본 단가 묶음(`단가.json`) — 담은 로직 · 굳힌 참조 로직 · 자재 · 고른 자재 · 사전조합 자재 · 개인 자재(`root` = 원본 층 · B09 설계내역서 서고 사본도 이 길 · 204-2).""" from M02_MasterTemplete import M02_Item_Prices as prices + from M02_MasterTemplete import M02_Item_Sangeun as sk from M02_MasterTemplete import M02_Labor_Fixed as fixed from M02_MasterTemplete import M02_Labor_Ref as refs + copies = (labor or {}).get("참조사본") or {} + sk_rows = sk.rows(copies) # 굳힌 단가산출 줄(719-1) — 그 줄이 닿는 마스터 줄도 모음 logic_keys = [str(one.get("키")) for one in (labor or {}).get("로직", [])] - logic_keys += refs.frozen_logics((labor or {}).get("참조사본") or {}) # 굳힌 단가승격 로직 + logic_keys += refs.frozen_logics(copies) # 굳힌 단가승격 로직 doc = (labor or {}).get("줄") or {} materials = [str(m.get("코드")) for m in doc.get("자재") or []] materials += [ # 고른 로직 자재(103-1) 품목 줄도 — 복사본 계산이 그 단가를 씀 @@ -174,8 +178,13 @@ def price_bundle(labor: dict[str, Any] | None, root: Path) -> dict[str, Any]: ] materials += refs.frozen_materials((labor or {}).get("참조사본") or {}) # 175-2 사전조합 자재 materials += fixed.link_keys(doc) # 705-1 고정 줄이 잇는 노임 · 자재 · 기계 + materials += sk.materials(copies) wanted = logic_keys + [code for code in materials if code] - bundle = prices.to_json(prices.collect(logic_keys, materials)) if wanted else {} + bundle = ( + prices.to_json(prices.collect(logic_keys, materials, sk_rows)) if wanted or sk_rows else {} + ) + if sk_rows: + bundle[sk.FILE] = {"그룹": "로직", "원문": "자체", "줄": sk_rows} if mine := own.bundle( [c for c in materials if own.is_own(c)], root ): # 개인 자재(148-3 · 사전조합 것도 175-2) — 원본 목록이 바뀌어도 사본 그대로 diff --git a/resources/tester/test_719_1_sk_freeze.py b/resources/tester/test_719_1_sk_freeze.py new file mode 100644 index 000000000..bcaf387f8 --- /dev/null +++ b/resources/tester/test_719_1_sk_freeze.py @@ -0,0 +1,120 @@ +"""719-1 프로젝트 사본 — M02 단가산출(SK) 풀어 굳힘(`M02_Item_Sangeun` · `M02_Item_Source` · +`M02_Item_Prices` · `B09_Estimation_Bill_Library`) — 임시 storage(견본 층 · 실제 M01 읽기만). + +구조물 사본 · B09 서고 사본 셈 = M02 층 SK 셈 · 굳힌 줄은 사본 `단가.json` 로직 줄 · 원본 SK 를 +지워도 같은 값 · 같은 SK = 같은 굳힌 키(다시 복사 · SK 고친 뒤도) · 원본 층 지문 그대로 · +서고 manifest 원본판 = SK 바뀌면 다름. +""" + +# ruff: noqa: F811 — 임시 층 fixture `world` 를 들여 씀 +import hashlib +import json + +import pytest + +from B09_Estimation import B09_Estimation_Bill_Library as library +from M02_MasterTemplete import M02_Item_Prices as prices +from M02_MasterTemplete import M02_Item_Sangeun as sk +from M02_MasterTemplete import M02_Item_Source as source +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_715_sangeun_store import PCT, _sk +from resources.tester.test_m02_labor_ref import MINE, world # noqa: F401 — 같은 임시 층 + +COVER = {"labor:LB000036#0": {"qty": 0.5}} +ADD = [{"종류": "인력", "요소": "LB000002", "수량": 0.1}] + + +def _print(folder): + """층 폴더 지문 — 파일 이름 · 바이트.""" + out = hashlib.sha256() + for path in sorted(p for p in folder.rglob("*") if p.is_file()): + out.update(str(path.relative_to(folder)).encode() + path.read_bytes()) + return out.hexdigest() + + +def _top(ref, qty=2): + with store.at(ref["folder"]): + doc = labor.clean({"열": "rv", "담은로직": [{"참조": ref["ref"], "수량": {"손": qty}}]}) + return doc, labor.calc(doc, {})["줄"][0] + + +def _mine(world): + key = _sk(world["personal"], 바탕={"로직": PCT}, 덮개=COVER, 더한줄=ADD) + return key, {"folder": world["personal"], "ref": {**MINE, "키": key}} + + +def test_구조물_사본_굳힌_줄_값_같음_원본_지워도(world): + key, ref = _mine(world) + doc, live = _top(ref) + assert not live["까닭"] and live["기준"]["계"] > 0 + before = _print(world["personal"]) + rows = source.labor_doc_rows(doc) + bundle = source.price_bundle(rows, world["personal"]) + assert _print(world["personal"]) == before # 원본 층 안 건드림 + rid = refs.ref_id(ref["ref"]) + frozen = rows["참조사본"][rid] + fk = frozen["바탕"]["로직"] + assert fk == sk.frozen_key(rid, set()) and fk.startswith("GX9") + assert frozen["덮개"] == {} and frozen["더한줄"] == [] and frozen["뺀줄"] == [] + assert frozen["원본"]["덮개"]["labor:LB000036#0"]["qty"] == 0.5 # 고친 근거 + assert [r["키"] for r in bundle[sk.FILE]["줄"]] == [fk] + json.dumps(rows["참조사본"], ensure_ascii=False) # 사본 파일로 쓸 수 있는 꼴(Decimal 없음) + (world["personal"] / "sangeun" / f"{key}.json").unlink() # 원본이 사라져도 사본만 봄 + got = prices.calc(rows, json.loads(json.dumps(bundle, ensure_ascii=False)), {}, {}, None) + line = got["줄"][0] + assert not line["까닭"] + for part in ("노무비", "재료비", "경비", "계"): + assert line["기준"][part] == pytest.approx(live["기준"][part], rel=1e-9) + assert line["반영"]["계"] == pytest.approx(live["반영"]["계"], rel=1e-9) + + +def test_같은_SK_같은_굳힌_키_다른_SK_다른_키(world): + key, ref = _mine(world) + doc, _ = _top(ref) + rid = refs.ref_id(ref["ref"]) + first = source.labor_doc_rows(doc)["참조사본"][rid]["바탕"]["로직"] + with store.at(world["personal"]): # SK 를 고쳐도 굳힌 키 그대로(B09 덮개 짝 지킴) + now = store.read("sangeun", key) + store.write("sangeun", key, now["판"], {**now["문서"], "덮개": {}}) + again = source.labor_doc_rows(doc)["참조사본"][rid] + assert again["바탕"]["로직"] == first and again["원본"]["덮개"] == {} + other = refs.ref_id({"층": "company", "주인": "7", "키": key}) # 같은 번호 다른 층 + assert sk.frozen_key(other, set()) != first + taken = {first} + assert sk.frozen_key(rid, taken) != first and len(taken) == 2 # 겹치면 다음 번호 + + +def test_B09_서고_사본_값_같음_원본판(world, tmp_path): + _, ref = _mine(world) + with store.at(world["personal"]): + sc = refs.create( + "precombo", + {"이름": "조합", "단위": "㎡", "담은로직": [{"참조": ref["ref"], "수량": {"손": 2}}]}, + )["이름"] + _, live = _top({"folder": world["personal"], "ref": {**MINE, "키": sc}}, 1) + before = _print(world["personal"]) + root = tmp_path / "proj" + made = library.import_copy(root, "precombo", "personal", "7/42", sc) + assert made["만듦"] and _print(world["personal"]) == before + folder = library.library_dir(root) / made["폴더"] + read = {p.name: json.loads(p.read_text(encoding="utf-8")) for p in folder.glob("*.json")} + got = prices.calc(read[source.LABOR_FILE], read[source.PRICE_FILE], {}, {}, None)["줄"][0] + assert not got["까닭"] + assert got["기준"]["계"] == pytest.approx(live["기준"]["계"], rel=1e-9) + mark = read["manifest.json"]["원본판"] + built = library._build("precombo", "personal", "7/42", sc)[0] + assert library.source_version(built) == mark # 다시 모아도 같은 판 + again = library.import_copy(root, "precombo", "personal", "7/42", sc) + assert again == {"폴더": made["폴더"], "만듦": False} + with store.at(world["personal"]): # SK 고침 → 원본판 다름 + now = store.read("sangeun", ref["ref"]["키"]) + store.write("sangeun", ref["ref"]["키"], now["판"], {**now["문서"], "덮개": {}}) + assert library.source_version(library._build("precombo", "personal", "7/42", sc)[0]) != mark + + +def test_SK_없는_사본_그대로(): + """SK 없는 참조사본 = 받은 묶음 그대로(기존 사본 · 공사 금액 전후 같음).""" + copies = {"x": {"종류": "사전조합", "담은로직": []}} + assert sk.freeze(copies) is copies and sk.rows(copies) == [] and sk.materials(copies) == []