From 9ee6c9f3fd70f3d824248c0d25623fe0d15d9976 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 18:47:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(M02):=2050-3=20=E2=91=A0=E2=91=A1=E2=91=A4?= =?UTF-8?q?=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20=EC=A1=B0=ED=95=A9=20?= =?UTF-8?q?=EC=84=9C=EA=B3=A0=20=EC=84=9C=EB=B2=84=20=E2=80=94=20=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=EC=8A=B9=EA=B2=A9=20SP=20=C2=B7=20=EC=82=AC=EC=A0=84?= =?UTF-8?q?=EC=A1=B0=ED=95=A9=20SC=20=C2=B7=20=EC=B0=B8=EC=A1=B0=20?= =?UTF-8?q?=ED=8E=BC=EC=B9=A8=20=C2=B7=20=EA=B5=B3=ED=9E=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 층 양식 길 새 종류 promote · precombo(키 = 파일 이름 · 층마다 다음 번호) · 새로 · 복사 길 POST /layers/{층}/library/{종류} - 참조 {층 · 주인 · 키} — 위 층만 · 같은 참조 두 번 400 · 순환 422 · 쓰는 곳 있으면 지우기 409(모든 층 일위대가 · 사전조합 훑음) - calc 가 참조 줄 펼침(재귀 · 캐시 · 깊이 상한 8 · 까닭) · 알약 키 「참조|층|주인|키」 · 분모 = 가리킨 문서 단위 · 배 1 · settle TS 짝 lineId - 층 사이 복사 = 못 읽는 참조 같이 복사(본뜬) · 프로젝트 가져오기 = 참조사본으로 굳힘 · 로직 지우기 검사에 단가승격 더함 · 새 프로젝트 씨앗은 서고 뺌 - 시험 test_m02_labor_ref.py(펼침 · 순환 · 층 규칙 · 409 · 복사 · 굳힘 · 거울) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- M01_MasterData/M01_MasterData_Store_Cache.py | 22 +- M02_MasterTemplete/M02_Item_Prices.py | 42 +- M02_MasterTemplete/M02_Item_Source.py | 13 +- M02_MasterTemplete/M02_Labor_Doc.py | 96 ++++- M02_MasterTemplete/M02_Labor_Ref.py | 407 ++++++++++++++++++ M02_MasterTemplete/M02_Labor_Router.py | 45 +- .../M02_MasterTemplete_Items.py | 2 +- .../M02_MasterTemplete_Labor_Calc.ts | 24 +- .../M02_MasterTemplete_Router_Layers.py | 9 + .../M02_MasterTemplete_Store.py | 23 +- M02_MasterTemplete/M02_Template_Layers.py | 22 +- resources/tester/test_m02_labor_ref.py | 314 ++++++++++++++ 12 files changed, 966 insertions(+), 53 deletions(-) create mode 100644 M02_MasterTemplete/M02_Labor_Ref.py create mode 100644 resources/tester/test_m02_labor_ref.py diff --git a/M01_MasterData/M01_MasterData_Store_Cache.py b/M01_MasterData/M01_MasterData_Store_Cache.py index cca474f97..bd98cddc8 100644 --- a/M01_MasterData/M01_MasterData_Store_Cache.py +++ b/M01_MasterData/M01_MasterData_Store_Cache.py @@ -221,19 +221,21 @@ def check_saved(files: dict, changed: list[str], book: dict, narrowed=None) -> l def _labor_files() -> list[tuple[str, Path]]: - """모든 층(마스터 · 회사 · 개인)의 일위대가 문서 `labor/<열>.json` — (어디, 파일).""" + """모든 층(마스터 · 회사 · 개인)의 일위대가 `labor/<열>.json` · 단가승격 `promote/<키>.json` + — (어디, 파일).""" from M02_MasterTemplete import M02_Template_Layers as layers # M02 가 M01 을 부름 — 늦게 - root = layers.storage_root() - found = [("마스터", p) for p in sorted((layers.system_dir() / "labor").glob("*.json"))] - found += [(f"회사 {p.parts[-4]}", p) for p in sorted(root.glob("*/templates/labor/*.json"))] - personal = sorted(root.glob("*/*/templates/labor/*.json")) - return found + [(f"개인 {p.parts[-5]}/{p.parts[-4]}", p) for p in personal] + return [ + (f"{what} {where}", path) + for kind, what in (("labor", "일위대가"), ("promote", "단가승격")) + for where, path in layers.layer_files(kind) + ] def labor_errors(whole) -> list[str]: - """일위대가 문서가 담은 로직이 마스터에 없음(39-1d) — 로직 지우기를 막음 · 어디서 쓰는지 알림. - 전부터 깨진 것은 `save` 가 고치기 전 검사와 견주어 뺌.""" + """일위대가 · 단가승격 문서가 담은 로직이 마스터에 없음(39-1d · 50-3) — 로직 지우기를 막음 · + 어디서 쓰는지 알림 · 참조 줄(로직 없음)은 건너뜀. 전부터 깨진 것은 `save` 가 고치기 전 검사와 + 견주어 뺌.""" out = [] for where, path in _labor_files(): try: @@ -243,10 +245,12 @@ def labor_errors(whole) -> list[str]: name = (doc.get("일위대가번") if isinstance(doc, dict) else None) or path.stem for one in (doc.get("담은로직") if isinstance(doc, dict) else None) or []: key = str(one.get("로직") or "") if isinstance(one, dict) else "" + if not key: + continue try: whole.logic(key) except cm.mf.FormulaError: - out.append(f"일위대가 {where} 「{name}」 이 로직 「{key}」 을 씀 — 거기서 먼저 뺌") + out.append(f"{where} 「{name}」 이 로직 「{key}」 을 씀 — 거기서 먼저 뺌") return out diff --git a/M02_MasterTemplete/M02_Item_Prices.py b/M02_MasterTemplete/M02_Item_Prices.py index df2b05f5c..56ad2f407 100644 --- a/M02_MasterTemplete/M02_Item_Prices.py +++ b/M02_MasterTemplete/M02_Item_Prices.py @@ -244,10 +244,15 @@ def load_prices(prices: dict[str, Any]) -> Any: # ── 계산(25-2e) ───────────────────────────────────────────────────── +def _logic_keys(labor: dict[str, Any]) -> list[str]: + """담은 로직 키 — 참조 줄(로직 없음 · 50-3)은 뺌.""" + return [key for key in mcb.logic_keys(labor.get("줄") or {}) if key] + + def missing_inputs(snapshot: Any, labor: dict[str, Any], values: dict[str, Any]) -> list[dict]: """담은 로직마다 값이 없는(null · 안 적음) 입력 — `{로직, 입력}`.""" out = [] - for key in mcb.logic_keys(labor.get("줄") or {}): + for key in _logic_keys(labor): try: row = snapshot.logic(key) except mf.FormulaError: @@ -277,7 +282,7 @@ def calc( blocked = {gap["로직"] for gap in gaps} sums = dict.fromkeys(mf.COST_ITEMS, Decimal(0)) lines, stuck = [], [] - for at, key in enumerate(mcb.logic_keys(labor.get("줄") or {}), start=1): + for at, key in enumerate(_logic_keys(labor), start=1): head: dict[str, Any] = {"차례": at, "로직": key} if key in blocked: names = [gap["입력"] for gap in gaps if gap["로직"] == key] @@ -306,10 +311,37 @@ def calc( "계": got["계"], } ) + for line in _ref_lines(snapshot, labor, len(lines)): # 참조 줄(50-3) — 굳힌 사본으로 + if line["ok"]: + for name in mf.COST_ITEMS: + sums[name] += line[name] + else: + stuck.append(line["참조"]["키"]) + lines.append(line) out = {"줄": lines, **sums, "계": sum(sums.values()), "빠진입력": gaps, "멈춤": stuck} return to_json({**out, **_reflected(snapshot, labor, lines, area)}) +def _ref_lines(snapshot: Any, labor: dict[str, Any], start: int) -> list[dict]: + """참조 줄 — 기준 = 굳힌 사본(`참조사본`)을 복사본 단가로 펼친 1 단위 값 + (입력값은 승격 때 고정).""" + from M02_MasterTemplete import M02_Labor_Ref as refs + + copies = labor.get("참조사본") or {} + expand = refs.Expand(snapshot, lambda ref: copies.get(refs.ref_id(ref))) + out = [] + for at, one in enumerate((labor.get("줄") or {}).get("담은로직") or [], start=start + 1): + if not (isinstance(one, dict) and isinstance(one.get("참조"), dict)): + continue + got = expand(one["참조"]) + head = {"차례": at, "참조": one["참조"], "이름": got.get("이름"), "분모": got.get("단위")} + if got.get("기준") is None: + out.append({**head, "ok": False, "까닭": got.get("까닭")}) + continue + out.append({**head, "ok": True, "갈래": "돈", **got["기준"]}) + return out + + def _reflected(snapshot: Any, labor: dict[str, Any], lines: list[dict], area: Any) -> dict: """줄마다 수량 · 반영 + 자재 + 기준합 · 반영합 — 기준은 위 줄 그대로 · 알약은 가져온 때 것.""" from M02_MasterTemplete import M02_Labor_Doc as labor_doc @@ -318,8 +350,12 @@ def _reflected(snapshot: Any, labor: dict[str, Any], lines: list[dict], area: An pill_of = {code: pill for pill in labor.get("알약") or [] for code in pill.get("로직") or []} base = [] for line in lines: - pill = pill_of.get(line["로직"]) or {} money = {k: line[k] for k in (*mf.COST_ITEMS, "계")} if line.get("갈래") == "돈" else None + if "참조" in line: # 알약 키 = ref_id · 배 1(50-3) + head = {"참조": line["참조"], "알약": labor_doc.line_id(line), "배": 1} + base.append({**head, "분모": line.get("분모"), "기준": money}) + continue + pill = pill_of.get(line["로직"]) or {} head = {"로직": line["로직"], "알약": pill.get("키"), "배": pill.get("배") or 1} base.append({**head, "분모": pill.get("단위"), "기준": money}) mats = [] diff --git a/M02_MasterTemplete/M02_Item_Source.py b/M02_MasterTemplete/M02_Item_Source.py index 73fc41052..ccd9c7af0 100644 --- a/M02_MasterTemplete/M02_Item_Source.py +++ b/M02_MasterTemplete/M02_Item_Source.py @@ -3,7 +3,8 @@ 모으는 것 = `{파일 이름: 내용}`: `집계.json` SS 줄 + 열 정의 · `도면.json` SD · `면적.json` SA · `일위대가.json` = 층 `labor/<열id>.json` 문서(39-1c · 없으면 옛 UA 조합 줄) - + 담은 로직 줄(M01 시스템) + 알약(가져온 때 그대로) · + + 담은 로직 줄(M01 시스템) + 알약(가져온 때 그대로) + + 참조사본(단가승격 · 사전조합을 값으로 굳힘) · `단가.json` 계산에 닿는 마스터 줄 전부(담은 로직 + 일위대가 자재 MT) `원본판` = 모은 내용의 해시 — 지금 층 · 마스터에서 다시 모은 판과 대어 「마스터가 바뀜」 을 앎. 프로젝트가 고치는 값(`입력값.json` · `고른자재.json`)은 원본이 아니라 여기서 안 모음. @@ -89,10 +90,13 @@ 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_Labor_Doc as labor_doc + from M02_MasterTemplete import M02_Labor_Ref as refs - return {**logic_rows(doc), "알약": labor_doc.pills(doc)} + rows = {**logic_rows(doc), "알약": labor_doc.pills(doc)} + copies = refs.frozen(doc) + return {**rows, "참조사본": copies} if copies else rows def logic_rows(row: dict[str, Any]) -> dict[str, Any]: @@ -142,7 +146,10 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]: labor = labor_rows(str(row["일위대가"])) if row.get("일위대가") else None if labor is not None: files[LABOR_FILE] = labor + from M02_MasterTemplete import M02_Labor_Ref as refs + logic_keys = [str(one.get("키")) for one in (labor or {}).get("로직", [])] + logic_keys += refs.frozen_logics((labor or {}).get("참조사본") or {}) # 굳힌 단가승격 로직 materials = [str(m.get("코드")) for m in ((labor or {}).get("줄") or {}).get("자재") or []] wanted = logic_keys + [code for code in materials if code] files[PRICE_FILE] = prices.to_json(prices.collect(logic_keys, materials)) if wanted else {} diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index 51189cd41..cadd2ac0f 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -1,12 +1,15 @@ """M02 일위대가 문서 `labor/<열id>.json` — 정리 · 검사 · 계산 · 알약(PLAN 39-1a · 설계_정함 1장). -문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 별칭, 메모, 입력값, 수량}], +문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 별칭, 메모, 입력값, 수량} +| {참조: {층, 주인, 키}, 별칭, 메모, 수량}], 자재: [{코드, 이름, 규격, 단위, 수량}], 계산값}` — 이름은 구조물 열 이름(따로 안 둠). 수량 = `null`(면적산출 알약 칸 값 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」) | `{손: 수}` (손이 칸보다 앞섬) · 같은 로직 두 번 금지 · 입력값 = 그 로직 입력 이름만(빈 값 = 자동값). 금액: 기준 = 결과단위 그대로(서버 단독 · M01 엔진) · 반영 = 기준 × 수량 ÷ 배 · 돈 아닌 로직은 반영 없음 · 자재 기준 단가 = `master_material.element` 기본값 · 자재 줄에 값 열 다섯(`가격`) · 기준단가가 된 칸 이름(`기준칸`)도 실음(43-3). +참조 줄(단가승격 · 사전조합 · 50-3) = 기준이 가리킨 문서 1 단위 값(펼침 `M02_Labor_Ref.Expand`) · +분모 = 그 문서 단위 · 배 1 · 알약 키 = `ref_id` · 같은 참조 두 번 금지 · 위 층만. 짝(CLAUDE.md 5장 ①): 수량 · 반영 · 합 `settle` ↔ `M02_MasterTemplete_Labor_Calc.ts` — 거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`. """ @@ -19,6 +22,7 @@ from typing import Any from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store_Combo as combo_store +from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_MasterTemplete_Store as store mf = m01.cm.mf @@ -67,11 +71,27 @@ def clean(doc: Any) -> dict[str, Any]: if not isinstance(doc, dict): raise _bad("일위대가 문서가 묶음 아님") whole = m01.loaded()[1] - lines, seen = [], set() + lines, seen, here = [], set(), None for at, one in enumerate(doc.get("담은로직") or [], start=1): where = f"단가산출 로직 {at}째" if not isinstance(one, dict): raise _bad(f"{where} · 묶음 아님") + if one.get("참조") is not None and not one.get("로직"): + here = here or refs.here() + ref = refs.check_ref(one["참조"], where, here) + if refs.ref_id(ref) in seen: + raise _bad(f"{where} · 같은 참조를 두 번 담음 「{ref['키']}」") + seen.add(refs.ref_id(ref)) + memo, alias = one.get("메모"), _alias(one.get("별칭"), where) + lines.append( + { + "참조": ref, + "별칭": alias, + "메모": str(memo) if memo else None, + "수량": _qty(one.get("수량"), where), + } + ) + continue code = str(one.get("로직") or "") try: logic = whole.logic(code) @@ -84,16 +104,11 @@ def clean(doc: Any) -> dict[str, Any]: given = one.get("입력값") or {} if not isinstance(given, dict): raise _bad(f"{where} · 입력값이 묶음 아님") - memo, alias = one.get("메모"), one.get("별칭") - if alias is not None and not isinstance(alias, str): - raise _bad(f"{where} · 별칭은 글") - alias = (alias or "").strip() - if len(alias) > ALIAS_MAX: - raise _bad(f"{where} · 별칭은 {ALIAS_MAX}자까지") + memo, alias = one.get("메모"), _alias(one.get("별칭"), where) lines.append( { "로직": code, - "별칭": alias or None, + "별칭": alias, "메모": str(memo) if memo else None, "입력값": {k: v for k, v in given.items() if k in names and v not in (None, "")}, "수량": _qty(one.get("수량"), where), @@ -133,6 +148,15 @@ def clean(doc: Any) -> dict[str, Any]: return out +def _alias(alias: Any, where: str) -> str | None: + if alias is not None and not isinstance(alias, str): + raise _bad(f"{where} · 별칭은 글") + alias = (alias or "").strip() + if len(alias) > ALIAS_MAX: + raise _bad(f"{where} · 별칭은 {ALIAS_MAX}자까지") + return alias or None + + def from_combo(key: str) -> dict[str, Any]: """옛 일위대가 조합(마스터 UA = 실무문서 · 회사 UC · 개인 UP) → 일위대가 문서 몸 — 담은로직 · 단위 복사 · 입력값 {} · 수량 null · 자재 [] · `본뜬` = 그 조합(층 · 주인 · 키). @@ -161,8 +185,10 @@ def material_key(code: str) -> str: def pills(doc: dict) -> list[dict[str, Any]]: - """면적산출 알약 — 로직 물량 알약(`combo_pills` 그대로) + 자재 알약 「자재|MT키」.""" + """면적산출 알약 — 로직 물량 알약(`combo_pills` 그대로) + 참조 알약(`refs.pill`) + + 자재 알약 「자재|MT키」.""" logic = combo_store.combo_pills({"담은로직": doc.get("담은로직") or []})["물량"] + logic += [refs.pill(one["참조"]) for one in doc.get("담은로직") or [] if _is_ref(one)] mats = [ { "키": material_key(m["코드"]), @@ -230,16 +256,25 @@ def _pick(hand: Any, key: Any, given: dict[str, Decimal]) -> tuple[Decimal | Non return None, "없음" +def _is_ref(one: Any) -> bool: + return isinstance(one, dict) and isinstance(one.get("참조"), dict) + + +def line_id(one: dict) -> Any: + """줄 가름 — 로직 키 · 참조 줄은 `ref_id`(짝 `Labor_Calc.ts` `lineId`).""" + return refs.ref_id(one["참조"]) if _is_ref(one) else one.get("로직") + + def settle(result: dict, doc: dict, given: dict[str, Any]) -> dict[str, Any]: """기준이 선 계산 답에 수량 · 반영 · 합을 채움 — 수량만 바뀌면 이것만 다시 (짝: `Labor_Calc.ts` `settle` · 자재 = 재료비).""" have = {str(k): v for k, raw in given.items() if (v := _number(raw)) is not None} - hands = {one.get("로직"): one.get("수량") for one in doc.get("담은로직") or []} + hands = {line_id(one): one.get("수량") for one in doc.get("담은로직") or []} mhands = {one.get("코드"): one.get("수량") for one in doc.get("자재") or []} zero = dict.fromkeys((*COSTS, "계"), Decimal(0)) lines, mats, bases, reflects = [], [], [], [] for line in result.get("줄") or []: - qty, source = _pick(hands.get(line.get("로직")), line.get("알약"), have) + qty, source = _pick(hands.get(line_id(line)), line.get("알약"), have) base = _money(line.get("기준")) done = reflect(base, qty, _number(line.get("배")) or Decimal(1)) if base else None bases.append(base) @@ -277,6 +312,7 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]: """`doc` = 정리한 문서 · `given` = {알약 키: 값}(없으면 지금 층 면적산출 파일에서) — 줄마다 기준(서버 단독 · M01 엔진) → `settle`. 못 도는 줄은 까닭만(통째로 멈추지 않음).""" whole = m01.loaded()[1] + expand = refs.Expand(whole) by_logic = { code: pill for pill in combo_store.combo_pills({"담은로직": doc.get("담은로직") or []})["물량"] @@ -284,6 +320,9 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]: } lines = [] for one in doc.get("담은로직") or []: + if _is_ref(one): + lines.append(ref_line(one["참조"], expand(one["참조"]))) + continue code = one["로직"] pill = by_logic.get(code) or {} line = { @@ -292,17 +331,7 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]: "분모": pill.get("단위"), "배": pill.get("배") or 1, } - try: - logic = whole.logic(code) - auto = {k: v for k, v in m01.mau.values(whole, logic).items() if v is not None} - got = mf.run(whole, code, {**auto, **m01._dec(one.get("입력값") or {})}) - except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e: - lines.append({**line, "기준": None, "까닭": str(e) or type(e).__name__}) - continue - if "결과" in got: # 돈 아닌 로직 — 반영 없음 - lines.append({**line, "결과": got["결과"], "기준": None, "까닭": None}) - continue - lines.append({**line, "기준": {k: got[k] for k in (*COSTS, "계")}, "까닭": None}) + lines.append({**line, **run_line(whole, code, one.get("입력값"))}) mats = [ { "코드": one["코드"], @@ -315,6 +344,27 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]: return settle(_json({"줄": lines, "자재": mats}), doc, have) +def run_line(whole: Any, code: str, given: Any) -> dict[str, Any]: + """로직 한 줄 기준(서버 단독 · M01 엔진 · 빈 입력 = 자동값) — `{기준, 까닭}` · + 돈 아닌 로직은 `{결과, 기준: None}`(반영 없음).""" + try: + logic = whole.logic(code) + auto = {k: v for k, v in m01.mau.values(whole, logic).items() if v is not None} + got = mf.run(whole, code, {**auto, **m01._dec(given or {})}) + except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e: + return {"기준": None, "까닭": str(e) or type(e).__name__} + if "결과" in got: + return {"결과": got["결과"], "기준": None, "까닭": None} + return {"기준": {k: got[k] for k in (*COSTS, "계")}, "까닭": None} + + +def ref_line(ref: dict, got: dict[str, Any]) -> dict[str, Any]: + """참조 줄 계산 답 — 분모 = 가리킨 문서 단위 · 배 1 · `안` = 사전조합 안 줄(한 층).""" + line = {"참조": ref, "이름": got.get("이름"), "알약": refs.ref_id(ref), "분모": got.get("단위")} + line.update({"배": 1, "기준": got.get("기준"), "까닭": got.get("까닭")}) + return {**line, "안": got["안"]} if "안" in got else line + + def _json(value: Any) -> Any: """Decimal → JSON 수(정수면 int).""" if isinstance(value, Decimal): diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py new file mode 100644 index 000000000..96b819e3c --- /dev/null +++ b/M02_MasterTemplete/M02_Labor_Ref.py @@ -0,0 +1,407 @@ +"""M02 일위대가 조합 서고 — 단가승격 · 사전조합 · 참조(PLAN 50-3 · 조사 갈래 A). + +자리 = 층 양식 길 새 종류 둘(`M02_Template_Layers.LIBRARY_KINDS`) — 파일 이름 = 키: + promote `{층}/promote/SP-######.json` 단가승격 + `{종류:"단가승격", 키, 이름, 단위, 배, 본뜬, 담은로직:[{로직, 입력값}]}` — + 줄 하나 · 입력값은 승격 때 고정 · 값 = 로직 기준 ÷ 배(1 단위 값 · `배` 는 저장 때) + precombo `{층}/precombo/SC-######.json` 사전조합 + `{종류:"사전조합", 키, 이름, 단위, 본뜬, 담은로직:[{참조, 수량:{손}, 메모}]}` — + 승격 · 사전조합만 · 수량은 손(기본 1) · 값 = Σ 안 줄 값 × 수량 +참조 = `{층, 주인, 키}`(마스터 주인 null · 회사 `C` · 개인 `C/U`) — 종류는 키 머리(SP · SC) · +위 층만 가리킴(개인 → 그 개인 · 그 회사 · 마스터 / 회사 → 그 회사 · 마스터 / 마스터 → 마스터) · +같은 참조 두 번 · 순환(422)은 저장 때 막음 · 계산 때도 깊이 상한(`DEPTH`)에서 멈추고 까닭. +지우기 = 쓰는 곳(모든 층 일위대가 · 사전조합)이 있으면 409 · 층 사이 복사 = 대상 층이 못 읽는 +참조는 그 문서도 같이 복사(새 키 · `본뜬`) · 프로젝트 = 참조사본으로 굳힘(`frozen`). +""" + +from __future__ import annotations + +import json +import re +from collections.abc import Callable +from decimal import Decimal +from pathlib import Path +from typing import Any + +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Template_Layers as layers + +PREFIX = {"promote": "SP", "precombo": "SC"} +TITLE = {"promote": "단가승격", "precombo": "사전조합"} +DEPTH = 8 # 참조 깊이 상한(저장 · 계산 둘 다) +REF_HEAD = "참조|" +_KEY = re.compile(r"^(SP|SC)-(\d{6})$") +_OWNER = {"company": re.compile(r"^\d+$"), "personal": re.compile(r"^\d+/\d+$")} + +Ref = dict[str, Any] +Here = tuple[str, str | None] + + +def _bad(text: str, status: int = 400) -> store.StoreError: + return store.StoreError(status, text) + + +def kind_of(key: str) -> str: + return "promote" if str(key).startswith("SP") else "precombo" + + +def ref_id(ref: Ref) -> str: + """참조 한 줄의 이름표 — 알약 키 · 줄 가름(짝 `Labor_Calc.ts` `lineId`).""" + return f"{REF_HEAD}{ref.get('층')}|{ref.get('주인') or ''}|{ref.get('키')}" + + +# ── 자리 ────────────────────────────────────────────────────────────── +def where(folder: str | Path) -> Here: + """층 폴더 → (층, 주인) — 저장소 밖 · 프로젝트는 ("project", None).""" + path = Path(folder).resolve() + if path in (Path(store.FOLDER).resolve(), layers.system_dir().resolve()): + return "system", None + try: + parts = path.relative_to(layers.storage_root()).parts + except ValueError: + return "project", None + if len(parts) == 2 and parts[1] == layers.TEMPLATES_DIRNAME: + return "company", parts[0] + if len(parts) == 3 and parts[2] == layers.TEMPLATES_DIRNAME: + return "personal", f"{parts[0]}/{parts[1]}" + return "project", None + + +def here() -> Here: + return where(store.root()) + + +def folder_of(layer: str, owner: str | None) -> Path: + if layer == "system": + return Path(store.FOLDER) + if layer == "company": + return layers.company_dir(owner or "") + company, user = str(owner).split("/", 1) + return layers.personal_dir(company, user) + + +def ref_path(ref: Ref) -> Path: + return folder_of(ref["층"], ref.get("주인")) / kind_of(ref["키"]) / f"{ref['키']}.json" + + +def read_ref(ref: Ref) -> dict[str, Any] | None: + try: + got = json.loads(ref_path(ref).read_bytes()) + except (OSError, ValueError): + return None + return got if isinstance(got, dict) else None + + +def readable(at: Here, ref: Ref) -> bool: + """`at` 층이 `ref` 를 가리켜도 되나 — 위 층만(프로젝트는 아무것도 · 굳힌 사본만 씀).""" + layer, owner = at + if layer == "project": + return False + if ref["층"] == "system": + return True + if ref["층"] == "company": + return layer in ("company", "personal") and str(owner).split("/")[0] == ref["주인"] + return layer == "personal" and owner == ref["주인"] + + +def check_ref(raw: Any, at: str, here_: Here) -> Ref: + """참조 꼴 · 위 층만 · 있는 문서 — 아니면 400.""" + if not isinstance(raw, dict): + raise _bad(f"{at} · 참조는 {{층, 주인, 키}}") + layer, owner, key = raw.get("층"), raw.get("주인"), str(raw.get("키") or "") + if layer == "system": + owner = None + elif layer not in _OWNER or not _OWNER[layer].match(str(owner or "")): + raise _bad(f"{at} · 참조 층 · 주인 꼴이 틀림") + if not _KEY.match(key): + raise _bad(f"{at} · 참조 키는 SP-###### · SC-######") + ref = {"층": layer, "주인": None if owner is None else str(owner), "키": key} + if not readable(here_, ref): + raise _bad(f"{at} · 위 층만 가리킴 — 「{key}」({layer} {owner or ''})") + if read_ref(ref) is None: + raise _bad(f"{at} · 없는 참조 「{key}」") + return ref + + +# ── 서고 문서 정리 ───────────────────────────────────────────────────── +def check_key(kind: str, name: str) -> None: + found = _KEY.match(name) + if not found or found.group(1) != PREFIX[kind]: + raise _bad(f"{TITLE[kind]} 키는 {PREFIX[kind]}-######") + + +def clean_doc(kind: str, doc: Any) -> dict[str, Any]: + """서고 문서를 서버가 받는 꼴로 — `doc["키"]` = 파일 이름(부르는 쪽이 넣음).""" + from M01_MasterData import M01_MasterData_Store_Combo as combo_store + from M02_MasterTemplete import M02_Labor_Doc as labor + + if not isinstance(doc, dict): + raise _bad("서고 문서가 묶음 아님") + key = str(doc.get("키") or "") + check_key(kind, key) + name, unit = str(doc.get("이름") or "").strip(), str(doc.get("단위") or "").strip() + out: dict[str, Any] = {"종류": TITLE[kind], "키": key} + if kind == "promote": + lines = doc.get("담은로직") or [] + if len(lines) != 1 or not isinstance(lines[0], dict) or not lines[0].get("로직"): + raise _bad("단가승격은 단가산출 로직 한 줄") + line = labor.clean({"담은로직": lines})["담은로직"][0] + pill = (combo_store.combo_pills({"담은로직": [line]})["물량"] or [{}])[0] + out.update( + 이름=name or pill.get("이름") or line["로직"], + 단위=unit or pill.get("단위") or None, + 배=pill.get("배") or 1, # ponytail: 저장 때 적음 · 마스터 결과단위가 바뀌면 다시 저장 + 담은로직=[{"로직": line["로직"], "입력값": line["입력값"]}], + ) + else: + if not name: + raise _bad("사전조합 이름이 빔") + out.update(이름=name, 단위=unit or None, 담은로직=_combo_lines(doc, key)) + out["본뜬"] = doc["본뜬"] if isinstance(doc.get("본뜬"), dict) else None + return out + + +def _combo_lines(doc: dict, key: str) -> list[dict[str, Any]]: + from M02_MasterTemplete import M02_Labor_Doc as labor + + at_, seen, lines = here(), set(), [] + for n, one in enumerate(doc.get("담은로직") or [], start=1): + at = f"사전조합 {n}째" + if not isinstance(one, dict) or one.get("로직") or one.get("참조") is None: + raise _bad(f"{at} · 사전조합은 단가승격 · 사전조합만 담음") + ref = check_ref(one["참조"], at, at_) + if ref_id(ref) in seen: + raise _bad(f"{at} · 같은 참조를 두 번 담음 「{ref['키']}」") + seen.add(ref_id(ref)) + memo = one.get("메모") + lines.append( + { + "참조": ref, + "수량": labor._qty(one.get("수량"), at) or {"손": 1}, + "메모": str(memo) if memo else None, + } + ) + me = {"층": at_[0], "주인": at_[1], "키": key} + for one in lines: + _no_loop(one["참조"], [me, one["참조"]]) + return lines + + +def _no_loop(ref: Ref, trail: list[Ref]) -> None: + """저장 때 순환 · 깊이 — 아래로 따라가 처음 문서를 다시 만나면 422 「A → B → A」.""" + if len(trail) > DEPTH: + raise _bad(f"참조가 {DEPTH}단을 넘음 — " + " → ".join(r["키"] for r in trail), 422) + for one in (read_ref(ref) or {}).get("담은로직") or []: + child = one.get("참조") if isinstance(one, dict) else None + if not isinstance(child, dict) or not child.get("키"): + continue + if ref_id(child) == ref_id(trail[0]): + raise _bad("순환 — " + " → ".join(r["키"] for r in [*trail, child]), 422) + _no_loop(child, [*trail, child]) + + +def next_key(kind: str) -> str: + """지금 층 그 종류의 다음 키(가장 큰 번호 + 1).""" + folder = store.root() / kind + have = [int(m.group(2)) for p in folder.glob("*.json") if (m := _KEY.match(p.stem))] + return f"{PREFIX[kind]}-{max(have, default=0) + 1:06d}" + + +def create(kind: str, doc: dict[str, Any]) -> dict[str, Any]: + """지금 층에 새 키로 씀 → `store.write` 정보(+ 키) — 그새 남이 같은 키를 잡으면 다음 키.""" + for _ in range(5): + key = next_key(kind) + try: + return store.write(kind, key, "", {**doc, "키": key}) + except store.StoreError as e: + if e.status != 409 or not isinstance(e.detail, dict): + raise + raise _bad("새 키를 못 잡음 — 다시 누름", 409) + + +def summary(folder: Path, kind: str, key: str) -> dict[str, Any]: + """층 목록 한 줄에 붙일 것 — `{제목, 단위}`.""" + try: + doc = json.loads((Path(folder) / kind / f"{key}.json").read_bytes()) + except (OSError, ValueError): + return {} + return {"제목": doc.get("이름"), "단위": doc.get("단위")} + + +# ── 쓰는 곳 · 지우기 ────────────────────────────────────────────────── +def users(ref: Ref) -> list[str]: + """`ref` 를 담은 일위대가 · 사전조합 — 「어디 이름」 목록(모든 층 훑음 · 프로젝트 뺌).""" + rid, out = ref_id(ref), [] + for kind, what in (("labor", "일위대가"), ("precombo", "사전조합")): + for at, path in layers.layer_files(kind): + try: + doc = json.loads(path.read_bytes()) + except (OSError, ValueError): + continue + lines = doc.get("담은로직") if isinstance(doc, dict) else None + if any( + isinstance(one, dict) + and isinstance(one.get("참조"), dict) + and ref_id(one["참조"]) == rid + for one in lines or [] + ): + name = doc.get("일위대가번") or doc.get("이름") or path.stem + out.append(f"{what} {at} 「{name}」") + return out + + +def refuse_used(folder: Path, kind: str, key: str) -> None: + """쓰는 곳이 있으면 409 + 이름(서고 종류만).""" + if kind not in PREFIX: + return + layer, owner = where(folder) + found = users({"층": layer, "주인": owner, "키": key}) + if found: + raise _bad(f"쓰는 곳이 있어 못 지움 「{key}」 — " + " · ".join(found), 409) + + +# ── 층 사이 복사 ────────────────────────────────────────────────────── +def carry(doc: dict[str, Any], source: Here) -> dict[str, Any]: + """복사(원본 층 `source` → 지금 층) — 지금 층이 못 읽는 참조는 그 문서를 지금 층에 새 키로 + 같이 복사(`본뜬` = 원래 참조) · 참조를 새 키로 바꿈. 원본 층 읽기 권한은 부르는 쪽 몫.""" + target, done = here(), {} + + def move(ref: Ref, depth: int) -> Ref: + if readable(target, ref): + return ref + if ref_id(ref) in done: + return done[ref_id(ref)] + if depth > DEPTH or not readable(source, ref): + raise _bad(f"복사할 수 없는 참조 「{ref.get('키')}」") + got = read_ref(ref) + if got is None: + raise _bad(f"없는 참조 「{ref['키']}」") + body = {**got, "담은로직": _moved(got.get("담은로직"), move, depth + 1), "본뜬": ref} + made = create(kind_of(ref["키"]), body) + done[ref_id(ref)] = {"층": target[0], "주인": target[1], "키": made["이름"]} + return done[ref_id(ref)] + + return {**doc, "담은로직": _moved(doc.get("담은로직"), move, 1)} + + +def _moved(lines: Any, move: Callable[[Ref, int], Ref], depth: int) -> list[Any]: + return [ + {**one, "참조": move(one["참조"], depth)} + if isinstance(one, dict) and isinstance(one.get("참조"), dict) + else one + for one in lines or [] + ] + + +# ── 펼침(계산) ──────────────────────────────────────────────────────── +class Expand: + """참조 → `{이름, 단위, 기준(1 단위), 까닭, 안?}` — 한 번 셈 안에서 키별 캐시 · + 깊이 상한 · 순환은 까닭(통째로 멈추지 않음). `read` = 참조 → 문서(프로젝트는 굳힌 사본).""" + + def __init__(self, whole: Any, read: Callable[[Ref], dict | None] = read_ref) -> None: + self.whole, self.read, self.memo = whole, read, {} + + def __call__(self, ref: Ref, trail: tuple[str, ...] = ()) -> dict[str, Any]: + rid = ref_id(ref) + if rid in trail: + keys = [t.rsplit("|", 1)[-1] for t in (*trail, rid)] + return self._stop(ref, "순환 — " + " → ".join(keys)) + if len(trail) >= DEPTH: + return self._stop(ref, f"참조가 {DEPTH}단을 넘음") + if rid not in self.memo: + doc = self.read(ref) + if not isinstance(doc, dict): + return self._stop(ref, f"없는 참조 「{ref['키']}」") + if kind_of(ref["키"]) == "promote": + self.memo[rid] = self._promote(doc) + else: + self.memo[rid] = self._combo(doc, (*trail, rid)) + return self.memo[rid] + + @staticmethod + def _stop(ref: Ref, why: str) -> dict[str, Any]: + return {"이름": ref["키"], "단위": None, "기준": None, "까닭": why} + + def _promote(self, doc: dict) -> dict[str, Any]: + from M02_MasterTemplete import M02_Labor_Doc as labor + + line = (doc.get("담은로직") or [{}])[0] + got = labor.run_line(self.whole, str(line.get("로직") or ""), line.get("입력값")) + times = labor._number(doc.get("배")) or Decimal(1) + base = got.get("기준") + why = got.get("까닭") or (None if base else "돈 아닌 로직") + return { + "이름": doc.get("이름"), + "단위": doc.get("단위"), + "로직": line.get("로직"), + "기준": {k: Decimal(str(v)) / times for k, v in base.items()} if base else None, + "까닭": why, + } + + def _combo(self, doc: dict, trail: tuple[str, ...]) -> dict[str, Any]: + from M02_MasterTemplete import M02_Labor_Doc as labor + + inner, rows, why = [], [], None + for one in doc.get("담은로직") or []: + ref = one.get("참조") if isinstance(one, dict) else None + if not isinstance(ref, dict): + continue + sub = self(ref, trail) + qty = labor._number((one.get("수량") or {}).get("손")) + qty = Decimal(1) if qty is None else qty + keep = ("이름", "단위", "기준", "까닭") + inner.append({"참조": ref, "수량": qty, **{k: sub.get(k) for k in keep}}) + if sub.get("기준") is None: + why = why or f"「{sub.get('이름')}」 {sub.get('까닭')}" + continue + rows.append({k: v * qty for k, v in sub["기준"].items()}) + return { + "이름": doc.get("이름"), + "단위": doc.get("단위"), + "기준": None if why else labor._sum(rows), + "까닭": why, + "안": inner, + } + + +def pill(ref: Ref, read: Callable[[Ref], dict | None] = read_ref) -> dict[str, Any]: + """참조 줄 면적산출 알약 — 키 = `ref_id` · 이름 · 단위 = 가리킨 문서 것.""" + doc = read(ref) or {} + return { + "키": ref_id(ref), + "이름": doc.get("이름") or ref["키"], + "단위": doc.get("단위") or "", + "로직": [], + "참조": ref, + } + + +# ── 프로젝트 굳힘 ───────────────────────────────────────────────────── +def frozen(doc: dict[str, Any]) -> dict[str, dict[str, Any]]: + """일위대가 문서가 닿는 참조 문서 전부 `{ref_id: 문서}` — 가져올 때 값으로 굳힘 + (원본이 바뀌어도 프로젝트는 그대로).""" + out: dict[str, dict[str, Any]] = {} + + def walk(lines: Any, depth: int) -> None: + for one in lines or []: + ref = one.get("참조") if isinstance(one, dict) else None + if not isinstance(ref, dict) or ref_id(ref) in out or depth > DEPTH: + continue + got = read_ref(ref) + if got is not None: + out[ref_id(ref)] = got + walk(got.get("담은로직"), depth + 1) + + walk(doc.get("담은로직"), 1) + return out + + +def frozen_logics(copies: dict[str, Any]) -> list[str]: + """굳힌 단가승격이 담은 로직 키 — 프로젝트 단가 묶음에 넣음.""" + return [ + str(line["로직"]) + for rid, doc in copies.items() + if kind_of(rid.rsplit("|", 1)[-1]) == "promote" + for line in (doc.get("담은로직") or [])[:1] + if isinstance(line, dict) and line.get("로직") + ] diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index 233b1ae30..481ee952f 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -1,19 +1,22 @@ """M02 일위대가 계산 · 알약 길(PLAN 39-1a · 설계_정함 1장) — `/api/m02/layers/{층}/labor/{열}/…`. 문서 읽기 · 쓰기는 양식 길(`GET/PUT /api/m02/layers/{층}/templates/labor/{열}`) 그대로 · -여기는 저장하지 않는 셈만. 층 권한 = `Router_Items._root`(읽기 권한 · `M02_Layer_Access` 한 벌). +여기는 저장하지 않는 셈만 + 서고(단가승격 · 사전조합 · 50-3) 새로 · 복사(키는 서버가 줌 · 고침 · +지우기는 양식 길 그대로). 층 권한 = `Router_Items._root`(`M02_Layer_Access` 한 벌). ⚠ 등록은 `main.py`(로그인만). """ from __future__ import annotations -from typing import Any +from typing import Any, Literal -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from common_util.common_util_auth import verify_session from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_Layer_Access as access from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root @@ -62,3 +65,39 @@ def labor_pills( ) -> dict: """면적산출 알약 — 로직 물량 알약 「이름|단위」 + 자재 알약 「자재|MT키」(저장본).""" return _call(_root(session, layer, owner), _pills, column) + + +class LibraryBody(BaseModel): + """새로 = `문서` · 복사 = `본뜬` {층, 주인, 키}(원본 읽기 권한 · 못 읽는 참조는 같이 복사).""" + + 문서: dict[str, Any] | None = None + 본뜬: dict[str, Any] | None = None + + +def _copy(kind: str, doc: dict[str, Any], source: refs.Here, ref: dict) -> dict[str, Any]: + return refs.create(kind, refs.carry({**doc, "본뜬": ref}, source)) + + +@router.post("/layers/{layer}/library/{kind}") +def new_library( + layer: Layer, + kind: Literal["promote", "precombo"], + body: LibraryBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """서고 새로 · 복사 — 키는 서버가 그 층 다음 번호(SP · SC) · 답 `이름` = 새 키.""" + folder = _root(session, layer, owner, write=True) + if body.본뜬 is None: + return _call(folder, refs.create, kind, body.문서 or {}) + src = body.본뜬 + if src.get("층") not in ("system", "company", "personal"): + raise HTTPException(status_code=400, detail="본뜬 층이 틀림") + got = access.grant(session, src["층"], src.get("주인")) # 원본 읽기 권한 + ref = {"층": got.layer, "주인": got.owner, "키": str(src.get("키") or "")} + if refs.kind_of(ref["키"]) != kind: + raise HTTPException(status_code=400, detail="본뜬 종류가 틀림") + doc = refs.read_ref(ref) if refs._KEY.match(ref["키"]) else None + if doc is None: + raise HTTPException(status_code=404, detail=f"없는 {refs.TITLE[kind]} 「{ref['키']}」") + return _call(folder, _copy, kind, doc, (got.layer, got.owner), ref) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Items.py index 4edfb4f89..44851ff8d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Items.py @@ -176,7 +176,7 @@ def copy_item( have = store.read(kind, new_id)["문서"] body = {k: v for k, v in got.items() if k != "계산값"} body = {**body, "열": new_id, number: have.get(number)} - body = store._server(kind, body) + body = store._server(kind, body, source) with store._LOCK: atomic_write_json(store._path(kind, new_id), body) store._refresh_labor(new_id) # 면적이 일위대가 뒤에 서므로 칸 값으로 다시 diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts index dd9da6fe9..baa494f84 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts @@ -3,7 +3,8 @@ * 짝(CLAUDE.md 5장 ①): `M02_Labor_Doc.py` `settle` 과 같은 규칙 — * 거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`. * 기준(결과단위 그대로)은 서버 calc 길이 셈 · 여기는 받은 기준에 - * 수량(손이 칸보다 앞섬 · 칸 = 알약 키 값) · 반영 = 기준 × 수량 ÷ 배 · 자재 = 재료비만. */ + * 수량(손이 칸보다 앞섬 · 칸 = 알약 키 값) · 반영 = 기준 × 수량 ÷ 배 · 자재 = 재료비만. + * 참조 줄(단가승격 · 사전조합 · 50-3)은 `lineId` = 「참조|층|주인|키」(짝 `M02_Labor_Ref.ref_id`). */ export const MONEY_KEYS = ["노무비", "재료비", "경비", "계"] as const; export type Money = Record<(typeof MONEY_KEYS)[number], number>; @@ -11,8 +12,17 @@ export type QtySource = "손" | "칸" | "없음"; /** 문서의 수량 칸 — null = 면적산출 알약 · {손} = 손 입력 */ export type Qty = { 손: number } | null; +/** 참조 — 단가승격(SP) · 사전조합(SC) 문서(50-3) */ +export interface LaborRef { + 층: string; + 주인: string | null; + 키: string; +} + export interface CalcLine { + /** 참조 줄은 서버 답에 없음(빈 글로 다룸) */ 로직: string; + 참조?: LaborRef; 알약?: string | null; 분모?: string | null; 배: number; @@ -47,7 +57,7 @@ export interface LaborCalc { /** 수량만 보는 문서 조각 — 담은 로직 · 자재의 손 값 */ export interface LaborQtyDoc { - 담은로직?: { 로직: string; 수량?: Qty }[]; + 담은로직?: { 로직?: string; 참조?: LaborRef; 수량?: Qty }[]; 자재?: { 코드: string; 수량?: Qty }[]; } @@ -57,6 +67,12 @@ function num(raw: unknown): number | null { return String(raw).trim() !== "" && Number.isFinite(n) ? n : null; } +/** 줄 가름 — 로직 키 · 참조 줄은 「참조|층|주인|키」(짝 `M02_Labor_Doc.line_id`) */ +export function lineId(one: { 로직?: string; 참조?: LaborRef }): string | undefined { + if (one.참조) return `참조|${one.참조.층}|${one.참조.주인 ?? ""}|${one.참조.키}`; + return one.로직; +} + function zero(): Money { return { 노무비: 0, 재료비: 0, 경비: 0, 계: 0 }; } @@ -91,7 +107,7 @@ export function settle( const v = num(raw); if (v !== null) have[k] = v; } - const hands = new Map((doc.담은로직 ?? []).map((one) => [one.로직, one.수량 ?? null])); + const hands = new Map((doc.담은로직 ?? []).map((one) => [lineId(one), one.수량 ?? null])); const mhands = new Map((doc.자재 ?? []).map((one) => [one.코드, one.수량 ?? null])); const base = zero(); const done = zero(); @@ -99,7 +115,7 @@ export function settle( if (m) for (const k of MONEY_KEYS) sum[k] += m[k]; }; const 줄 = result.줄.map((line) => { - const [qty, source] = pick(hands.get(line.로직), line.알약, have); + const [qty, source] = pick(hands.get(lineId(line)), line.알약, have); const own = line.기준 ? reflect(line.기준, qty, num(line.배) || 1) : null; add(base, line.기준); add(done, own); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py index ac26926b7..e44597522 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py @@ -25,6 +25,7 @@ from common_util import common_util_spreadsheet as spreadsheet from common_util.common_util_auth import verify_session from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool, run_with_connection +from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_Layer_Access as access from M02_MasterTemplete import M02_MasterTemplete_Items as items_store from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router @@ -278,6 +279,8 @@ async def list_layer( row["출처"] = manifest.get(f"{row['종류']}/{row['이름']}") if (row["종류"], row["이름"]) in store.IN_USE: # 화면이 지우기 확인 창 전에 막음 row["쓰는곳"] = store.IN_USE[(row["종류"], row["이름"])] + if row["종류"] in layers.LIBRARY_KINDS: # 서고(50-3) — 보이는 이름 · 단위 + row.update(refs.summary(folder, row["종류"], row["이름"])) return {"층": layer, "양식": rows} @@ -324,6 +327,8 @@ async def save_layer_template( session, layer, project_id=project_id, user_id=user_id, owner=owner, write=True ) document = body.문서 + if layer == "project" and kind in layers.LIBRARY_KINDS: + raise HTTPException(status_code=400, detail="프로젝트는 서고 문서를 안 둠 — 굳힌 사본만") try: layers.check_skeleton(kind, document) # 빈 `{}` · 뼈대 없는 문서는 파일 안 씀 except ValueError as error: @@ -406,6 +411,10 @@ async def delete_layer_template( layers.check_name(name) except ValueError as error: raise _bad(error) from error + try: # 서고 문서를 담은 일위대가 · 사전조합이 있으면 409(50-3) + await asyncio.to_thread(refs.refuse_used, folder, kind, name) + except store.StoreError as error: + raise HTTPException(status_code=error.status, detail=error.detail) from error if not 판: raise HTTPException(status_code=400, detail="지울 양식의 판이 필요합니다.") try: diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index 95a05a85a..214f2fe6b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -153,6 +153,8 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) -> (정본 = 나무 · 머리는 경로에서 다시 만듦 · `tree.keep_paths`).""" path = _path(kind, name) _check_skeleton(kind, doc) + if kind in layers.LIBRARY_KINDS: # 서고 문서 키 = 파일 이름(50-3) + doc = {**doc, "키": name} origins = _take_origins(doc) if kind == "table" else {} if kind == "table" and name == STRUCTURE_TABLE: doc = tree.normalize_table(doc) # 경로 · 머리 줄 수 · 빈 폴더(표 저장 · 나무 고침 한 줄기) @@ -186,6 +188,10 @@ def delete(kind: str, name: str, version: str | None = None) -> None: path = _path(kind, name) if (kind, name) in IN_USE: raise StoreError(409, f"쓰는 양식이라 못 지움 「{name}」 — {IN_USE[(kind, name)]}") + if kind in layers.LIBRARY_KINDS: + from M02_MasterTemplete import M02_Labor_Ref as refs + + refs.refuse_used(root(), kind, name) with _LOCK: if not path.is_file(): raise StoreError(404, f"없는 양식 「{name}」") @@ -387,7 +393,7 @@ def copy_from( body = read(kind, source)["문서"] body = read(kind, source)["문서"] if body is None else body doc = {**body, **{k: target.get(k) for k in ("열", _KEY[kind])}} - doc = _server(kind, {k: v for k, v in doc.items() if k != "계산값"}) + doc = _server(kind, {k: v for k, v in doc.items() if k != "계산값"}, source_root) with _LOCK: atomic_write_json(_path(kind, column), doc) info = _info(kind, _path(kind, column)) @@ -493,14 +499,21 @@ def renumber() -> dict[str, list[str]]: return {"옮김": moved, "지움": gone, "바꿈": changed, "새로": made} -def _server(kind: str, doc: Any) -> Any: - """서버가 적는 값 — 통합문서 계산값(5장 ②) · 일위대가 정리 · 계산값(`M02_Labor_Doc`).""" +def _server(kind: str, doc: Any, source: Path | None = None) -> Any: + """서버가 적는 값 — 통합문서 계산값(5장 ②) · 일위대가 정리 · 계산값(`M02_Labor_Doc`) · + 서고 문서 정리(`M02_Labor_Ref` · 50-3) · `source` = 층 사이 복사의 원본 층 폴더(지금 층이 + 못 읽는 참조는 같이 복사).""" if kind in SHEETS: return _server_values(doc) - if kind == "labor": + if kind in ("labor", *layers.LIBRARY_KINDS): from M02_MasterTemplete import M02_Labor_Doc as labor + from M02_MasterTemplete import M02_Labor_Ref as refs - return labor.server(doc) + if source is not None and isinstance(doc, dict) and kind != "promote": + doc = refs.carry(doc, refs.where(source)) + if kind == "labor": + return labor.server(doc) + return refs.clean_doc(kind, doc) return doc diff --git a/M02_MasterTemplete/M02_Template_Layers.py b/M02_MasterTemplete/M02_Template_Layers.py index 8fc899387..3b90b61d6 100644 --- a/M02_MasterTemplete/M02_Template_Layers.py +++ b/M02_MasterTemplete/M02_Template_Layers.py @@ -28,7 +28,9 @@ from config import config_system LAYERS = ("system", "company", "personal", "project") # area = 면적산출 — 통합문서(구조물집계표 열마다 · `A00_Common/spreadsheet/`) # labor = 일위대가(구조물 열마다 · `M02_Labor_Doc`) -KINDS = ("table", "drawing", "structure", "labor", "area") +# promote = 단가승격(SP-) · precombo = 사전조합(SC-) — 일위대가 조합 서고(`M02_Labor_Ref`) +LIBRARY_KINDS = ("promote", "precombo") +KINDS = ("table", "drawing", "structure", "labor", "area", *LIBRARY_KINDS) TEMPLATES_DIRNAME = "templates" INITIAL_DIRNAME = "_initial" MANIFEST_NAME = "manifest.json" @@ -171,6 +173,8 @@ _SKELETON = { "structure": ("도면.entities",), "labor": ("담은로직",), "area": ("시트",), + "promote": ("담은로직",), + "precombo": ("담은로직",), } @@ -230,6 +234,17 @@ def delete_template( return True +def layer_files(kind: str) -> list[tuple[str, Path]]: + """모든 층(마스터 · 회사 · 개인 · 프로젝트 빼고)의 `kind/*.json` — (어디, 파일).""" + root = storage_root() + found = [("마스터", p) for p in sorted((system_dir() / kind).glob("[!.]*.json"))] + found += [ + (f"회사 {p.parts[-4]}", p) for p in sorted(root.glob(f"*/templates/{kind}/[!.]*.json")) + ] + personal = sorted(root.glob(f"*/*/templates/{kind}/[!.]*.json")) + return found + [(f"개인 {p.parts[-5]}/{p.parts[-4]}", p) for p in personal] + + # ── manifest ────────────────────────────────────────── @@ -283,7 +298,8 @@ def copy_templates( ) -> list[str]: """`source_dir` 층의 양식을 `target_dir` 로 복사 → 복사한 `종류/이름` 목록. - `kind`·`name` 으로 좁힘 · `only_missing` 이면 이미 있는 파일은 건너뜀(더하기만). + `kind`·`name` 으로 좁힘(안 좁히면 서고 종류 `LIBRARY_KINDS` 는 뺌 — 프로젝트는 굳힌 사본) · + `only_missing` 이면 이미 있는 파일은 건너뜀(더하기만). manifest 에 출처(층 · 이름 · 판 · `source_ref`)를 적음. 출처 manifest 의 원래 출처는 적지 않음 — 「어디서 가져왔나」 한 단계만. """ @@ -292,6 +308,8 @@ def copy_templates( rows = list_templates(source_dir) if kind is not None: rows = [row for row in rows if row["종류"] == check_kind(kind)] + else: + rows = [row for row in rows if row["종류"] not in LIBRARY_KINDS] if name is not None: rows = [row for row in rows if row["이름"] == check_name(name)] manifest = read_manifest(target_dir) diff --git a/resources/tester/test_m02_labor_ref.py b/resources/tester/test_m02_labor_ref.py new file mode 100644 index 000000000..29d115eac --- /dev/null +++ b/resources/tester/test_m02_labor_ref.py @@ -0,0 +1,314 @@ +"""일위대가 조합 서고(PLAN 50-3 · `M02_Labor_Ref`) — 단가승격 · 사전조합 · 참조 줄. + +펼침(재귀 · 순환 · 깊이) · 층 규칙(위 층만) · 쓰는 곳 지우기 409 · 층 사이 따라 복사 · +프로젝트 굳힘 · 로직 지우기 검사 · TS 짝 settle 거울. 층은 임시 폴더(마스터 · storage). +""" + +import json +from decimal import Decimal + +import pytest + +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 M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.test_m02_labor_calc_mirror import _same, _ts +from resources.tester.test_m02_structure import write_sample_table + +TEN = "GC000421" # 원/10㎡ — 배 10 +STONE = "GF000219" # 원/㎡ +PLAIN = "GC000422" # 돈 아닌 로직 +SYS = {"층": "system", "주인": None} +COMPANY = {"층": "company", "주인": "7"} +MINE = {"층": "personal", "주인": "7/42"} + + +@pytest.fixture() +def world(tmp_path, monkeypatch): + from config import config_system + + system = tmp_path / "system" + system.mkdir() + write_sample_table(system) + monkeypatch.setattr(store, "FOLDER", system) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + (tmp_path / "storage").mkdir() + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage")) + with store.at(system): + store.create_structure("rv") + return { + "system": system, + "company": layers.company_dir(7), + "personal": layers.personal_dir(7, 42), + } + + +def _promote(folder, code=TEN, **extra): + with store.at(folder): + return refs.create("promote", {"담은로직": [{"로직": code, "입력값": {}}], **extra})["이름"] + + +def _combo(folder, lines, name="조합"): + with store.at(folder): + return refs.create("precombo", {"이름": name, "담은로직": lines})["이름"] + + +def _read(folder, kind, key): + return json.loads((folder / kind / f"{key}.json").read_bytes()) + + +def _base(code): + import M01_MasterData.M01_MasterData_Store as m01 + + return labor.run_line(m01.loaded()[1], code, {})["기준"] + + +# ── 자료 틀 ──────────────────────────────────────────────────────────── +def test_승격은_로직_한_줄_단위_배_이름은_로직에서_키는_층마다_다음_번호(world): + assert _promote(world["system"]) == "SP-000001" + assert _promote(world["system"], STONE, 이름="돌") == "SP-000002" + assert _promote(world["personal"]) == "SP-000001" # 층마다 셈 + doc = _read(world["system"], "promote", "SP-000001") + assert (doc["종류"], doc["단위"], doc["배"]) == ("단가승격", "㎡", 10) + assert doc["이름"] and doc["담은로직"] == [{"로직": TEN, "입력값": {}}] + assert _read(world["system"], "promote", "SP-000002")["이름"] == "돌" + + +@pytest.mark.parametrize( + "body", + [ + {"담은로직": []}, + {"담은로직": [{"로직": TEN}, {"로직": STONE}]}, + {"담은로직": [{"로직": "GF999999"}]}, + {"담은로직": [{"참조": {**SYS, "키": "SP-000001"}}]}, + ], +) +def test_틀린_승격은_400(world, body): + _promote(world["system"]) + with store.at(world["system"]), pytest.raises(store.StoreError) as e: + refs.create("promote", body) + assert e.value.status == 400 + + +def test_사전조합_틀린_줄_400_순환_422(world): + sp = _promote(world["system"]) + one = {"참조": {**SYS, "키": sp}} + for lines in ( + [{"로직": TEN}], # 로직은 못 담음(승격 · 사전조합만) + [one, one], # 같은 참조 두 번 + [{"참조": {**SYS, "키": "SP-000009"}}], # 없는 참조 + [{"참조": {**SYS, "키": "SU-000001"}}], # 서고 키 아님 + ): + with pytest.raises(store.StoreError) as e: + _combo(world["system"], lines) + assert e.value.status == 400 + a = _combo(world["system"], [one], "가") + b = _combo(world["system"], [{"참조": {**SYS, "키": a}}], "나") + with store.at(world["system"]): + have = store.read("precombo", a) + with pytest.raises(store.StoreError) as e: + store.write( + "precombo", + a, + have["판"], + {**have["문서"], "담은로직": [{"참조": {**SYS, "키": b}}]}, + ) + assert e.value.status == 422 and f"{a} → {b} → {a}" in e.value.detail + + +def test_층_규칙은_위_층만(world): + _promote(world["system"]) + _promote(world["company"]) + _promote(world["personal"]) + ok = [{"참조": {**s, "키": "SP-000001"}} for s in (SYS, COMPANY, MINE)] + assert _combo(world["personal"], ok) == "SC-000001" # 개인 → 개인 · 회사 · 마스터 + for folder, bad in ( + (world["company"], MINE), # 회사 → 개인 X + (world["system"], COMPANY), # 마스터 → 회사 X + (world["personal"], {"층": "personal", "주인": "7/43"}), # 남의 개인 X + (world["personal"], {"층": "company", "주인": "8"}), # 남의 회사 X + ): + with pytest.raises(store.StoreError) as e: + _combo(folder, [{"참조": {**bad, "키": "SP-000001"}}]) + assert e.value.status == 400 and "위 층만" in e.value.detail + + +def test_일위대가_참조_줄_정리_같은_참조_두_번_400(world): + sp = _promote(world["system"]) + line = {"참조": {**SYS, "키": sp}, "별칭": " 승격 ", "수량": {"손": 3}} + with store.at(world["system"]): + got = labor.clean({"열": "rv", "담은로직": [line]})["담은로직"] + assert got == [{"참조": {**SYS, "키": sp}, "별칭": "승격", "메모": None, "수량": {"손": 3}}] + with pytest.raises(store.StoreError) as e: + labor.clean({"열": "rv", "담은로직": [line, line]}) + assert e.value.status == 400 + + +# ── 펼침(계산) ───────────────────────────────────────────────────────── +def test_calc_는_참조를_펼침_승격은_기준_나누기_배_사전조합은_수량_곱(world): + sp = _promote(world["system"]) + sc = _combo(world["system"], [{"참조": {**SYS, "키": sp}, "수량": {"손": 2}}], "둘") + ref = {**SYS, "키": sc} + with store.at(world["system"]): + doc = labor.clean({"열": "rv", "담은로직": [{"참조": ref, "수량": {"손": 5}}]}) + got = labor.calc(doc, {}) + one = got["줄"][0] + want = Decimal(str(_base(TEN)["계"])) / 10 * 2 + assert one["기준"]["계"] == pytest.approx(float(want)) + assert (one["분모"], one["배"], one["알약"]) == (None, 1, refs.ref_id(ref)) + assert one["반영"]["계"] == pytest.approx(float(want * 5)) + assert one["안"][0]["수량"] == 2 and one["안"][0]["참조"]["키"] == sp + pills = labor.pills(doc) + assert pills == [{"키": refs.ref_id(ref), "이름": "둘", "단위": "", "로직": [], "참조": ref}] + + +def test_펼침은_순환_깊이_없는_참조를_까닭으로_멈춤(world): + folder = world["system"] / "precombo" + for key, child in (("SC-000001", "SC-000002"), ("SC-000002", "SC-000001")): + body = {"이름": key, "담은로직": [{"참조": {**SYS, "키": child}}]} + (folder / f"{key}.json").write_text(json.dumps(body), encoding="utf-8") + import M01_MasterData.M01_MasterData_Store as m01 + + expand = refs.Expand(m01.loaded()[1]) + got = expand({**SYS, "키": "SC-000001"}) + assert got["기준"] is None and "순환" in got["까닭"] + assert "없는 참조" in expand({**SYS, "키": "SC-000009"})["까닭"] + deep = refs.Expand( + m01.loaded()[1], lambda r: {"담은로직": [{"참조": {**SYS, "키": "SC-000001"}}]} + ) + assert deep.__call__({**SYS, "키": "SC-000001"}, ())["기준"] is None + + +def test_돈_아닌_승격은_까닭(world): + sp = _promote(world["system"], PLAIN) + import M01_MasterData.M01_MasterData_Store as m01 + + got = refs.Expand(m01.loaded()[1])({**SYS, "키": sp}) + assert got["기준"] is None and got["까닭"] + + +def test_참조_줄_settle_파이썬과_TS_가_같은_답(world): + sp = _promote(world["system"]) + ref = {**SYS, "키": sp} + with store.at(world["system"]): + doc = labor.clean( + {"열": "rv", "담은로직": [{"로직": STONE, "수량": None}, {"참조": ref, "수량": None}]} + ) + answer = labor.calc(doc, {}) + values = {refs.ref_id(ref): 4} + py = labor.settle(answer, doc, values) + assert py["줄"][1]["수량출처"] == "칸" + _same(py, _ts(answer, doc, values)) + + +# ── 지우기 · 복사 · 굳힘 · 로직 지우기 ───────────────────────────────── +def test_쓰는_곳이_있으면_지우기_409_없으면_지움(world): + sp = _promote(world["system"]) + with store.at(world["personal"]): + line = {"참조": {**SYS, "키": sp}} + store.write("labor", "x1", "", {"열": "x1", "담은로직": [line], "자재": []}) + with store.at(world["system"]), pytest.raises(store.StoreError) as e: + store.delete("promote", sp) + assert e.value.status == 409 and "개인 7/42" in e.value.detail + (world["personal"] / "labor" / "x1.json").unlink() + with store.at(world["system"]): + store.delete("promote", sp) + assert not (world["system"] / "promote" / f"{sp}.json").exists() + + +def test_복사는_못_읽는_참조를_같이_복사_본뜬(world): + sp = _promote(world["personal"]) + sys_sp = _promote(world["system"]) + sc = _combo( + world["personal"], + [{"참조": {**MINE, "키": sp}}, {"참조": {**SYS, "키": sys_sp}, "수량": {"손": 2}}], + ) + with store.at(world["personal"]): + doc = store.read("precombo", sc)["문서"] + with store.at(world["company"]): + made = refs.create( + "precombo", refs.carry({**doc, "본뜬": {**MINE, "키": sc}}, ("personal", "7/42")) + ) + got = _read(world["company"], "precombo", made["이름"]) + assert got["본뜬"] == {**MINE, "키": sc} + assert [one["참조"] for one in got["담은로직"]] == [ + {**COMPANY, "키": "SP-000001"}, # 회사가 못 읽는 개인 승격 → 회사 층 새 키 + {**SYS, "키": sys_sp}, # 마스터는 그대로 + ] + assert _read(world["company"], "promote", "SP-000001")["본뜬"] == {**MINE, "키": sp} + + +def test_길_새로_복사_권한(world): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from common_util.common_util_auth import verify_session + from M02_MasterTemplete import M02_Labor_Router as labor_router + + app = FastAPI() + app.include_router(labor_router.router) + app.dependency_overrides[verify_session] = lambda: { + "user_id": 42, + "company_id": 7, + "role": "USER", + "is_master": False, + } + client = TestClient(app) + url = "/api/m02/layers/{}/library/{}" + body = {"문서": {"담은로직": [{"로직": TEN}]}} + got = client.post(url.format("personal", "promote"), json=body).json() + assert got["이름"] == "SP-000001" + assert client.post(url.format("system", "promote"), json=body).status_code == 403 + copy = {"본뜬": {**MINE, "키": "SP-000001"}} + assert client.post(url.format("personal", "promote"), json=copy).json()["이름"] == "SP-000002" + other = {"본뜬": {"층": "personal", "주인": "7/99", "키": "SP-000001"}} + assert client.post(url.format("personal", "promote"), json=other).status_code == 403 + + +def test_프로젝트는_참조사본으로_굳힘_원본을_지워도_같은_값(world): + from M02_MasterTemplete import M02_Item_Prices as prices + from M02_MasterTemplete import M02_Item_Source as source + + sp = _promote(world["system"]) + sc = _combo(world["system"], [{"참조": {**SYS, "키": sp}, "수량": {"손": 2}}]) + ref = {**SYS, "키": sc} + with store.at(world["system"]): + doc = labor.clean({"열": "rv", "담은로직": [{"참조": ref, "수량": {"손": 1}}]}) + live = labor.calc(doc, {})["줄"][0]["기준"]["계"] + rows = source.labor_doc_rows(doc) + assert set(rows["참조사본"]) == {refs.ref_id(ref), refs.ref_id({**SYS, "키": sp})} + assert refs.frozen_logics(rows["참조사본"]) == [TEN] + bundle = prices.to_json(prices.collect([TEN])) + for key in (sc, sp): # 원본이 사라져도 프로젝트는 사본만 봄 + kind = refs.kind_of(key) + (world["system"] / kind / f"{key}.json").unlink() + got = prices.calc(rows, bundle, {}, {}, None) + line = got["줄"][0] + assert line["ok"] and line["참조"] == ref + assert line["계"] == pytest.approx(live, rel=1e-9) + assert line["수량"] == 1 and line["반영"]["계"] == pytest.approx(live, rel=1e-9) + + +def test_로직_지우기_검사는_단가승격도_보고_참조_줄은_건너뜀(world): + from M01_MasterData import M01_MasterData_Store_Cache as cache + + sp = _promote(world["system"], STONE) + with store.at(world["system"]): + have = store.read("labor", "rv") + line = {"참조": {**SYS, "키": sp}} + store.write("labor", "rv", have["판"], {**have["문서"], "담은로직": [line]}) + + class Gone: # STONE 을 지운 마스터 + def logic(self, code): + if code == STONE: + raise cache.cm.mf.FormulaError("없음") + + got = cache.labor_errors(Gone()) + assert got == [f"단가승격 마스터 「{sp}」 이 로직 「{STONE}」 을 씀 — 거기서 먼저 뺌"] + + +def test_새_프로젝트_씨앗은_서고를_안_가져감(world, tmp_path): + _promote(world["system"]) + got = layers.seed_project(tmp_path / "p1") + assert not any(k.startswith(layers.LIBRARY_KINDS) for k in got["작업본"])