diff --git a/B09_Estimation/B09_Estimation_Bill_Library.py b/B09_Estimation/B09_Estimation_Bill_Library.py index 93289d51e..b9895c5a2 100644 --- a/B09_Estimation/B09_Estimation_Bill_Library.py +++ b/B09_Estimation/B09_Estimation_Bill_Library.py @@ -41,6 +41,7 @@ from B09_Estimation.B09_Estimation_Store import folder as store_folder from B09_Estimation.B09_Estimation_Store import ref_rows from common_util.common_util_json import atomic_write_json from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Doc_B09 as doc_b09 from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete.M02_Item_Snapshot import ( MANIFEST_NAME, @@ -241,6 +242,7 @@ def _restore(files: dict[str, Any], kind: str, old: Any) -> None: def _write(project_root: str | Path, name: str, files: dict, manifest: dict, values: dict) -> None: target = library_dir(project_root) / name + files = doc_b09.attach(files) # 수식 문서 사본(802-4a · 만든 때 · 재생성 때 새로) old = _apply(files, str(manifest.get("종류")), values) if target.is_dir(): # 끊긴 쓰기(manifest 없음) 자리 shutil.rmtree(target) diff --git a/M02_MasterTemplete/M02_Doc_B09.py b/M02_MasterTemplete/M02_Doc_B09.py new file mode 100644 index 000000000..8ac08fc40 --- /dev/null +++ b/M02_MasterTemplete/M02_Doc_B09.py @@ -0,0 +1,52 @@ +"""B09 사본(구조물 · 서고)의 수식 문서 사본 `수식.json`(PLAN 802-4a 2 · 브레인 802-4a ①) — 서버 단독. + +사본을 만들 때(`M02_Item_Source.build_source`) 단가 묶음 `단가.json` 옆에 함께 굳힘 — + {그룹: 수식문서, 문서: {키: 문서}} = 그 묶음의 로직 줄(담은 로직 · 하위 로직 · 굳힌 단가산출 GX9)이 + 닿는 닫힌 묶음 · 원본 일위대가 문서의 수식 사본 부름이 먼저(만든 때 것) · + 없으면 묶음 줄 = 지금 M01 줄이면 M01 문서 · 다르면 그 줄 변환(`M02_Doc_Copy.doc_of`). +[단가 갱신](값만) = 이 파일 안 고침 · [재생성](재적용) = 새로 찍음. +B09 셈은 지금 풀이(`단가.json` · 단가 책) 그대로 — 산근을 문서 셈으로 바꾸는 것은 802-6. +원본판(`version_of`)에는 안 듦(로직 줄은 `단가.json` 이 이미 잼 · 옛 사본이 거짓 「원본 바뀜」 안 뜸). +""" + +from __future__ import annotations + +from typing import Any + +from M02_MasterTemplete import M02_Doc_Copy as dc +from M02_MasterTemplete import M02_Doc_Sk as dsk + +FILE = "수식.json" +GROUP = "수식문서" + + +def logic_keys(bundle: dict[str, Any]) -> list[str]: + """단가 묶음의 로직 줄 키(그룹 「로직」 파일).""" + return [ + str(row.get("키")) + for data in bundle.values() + if isinstance(data, dict) and data.get("그룹") == "로직" + for row in data.get("줄") or [] + if isinstance(row, dict) and row.get("키") + ] + + +def build(bundle: dict[str, Any], labor: dict[str, Any] | None = None) -> dict[str, Any]: + """`단가.json` 묶음 → `수식.json` 내용.""" + from M02_MasterTemplete import M02_Item_Prices as prices + + keys = logic_keys(bundle) + if not keys: + return {"그룹": GROUP, "문서": {}} + doc = (labor or {}).get("줄") or {} + have = (doc.get(dc.KEY) or {}).get("부름") if dc.has(doc) else None + whole = prices.load_prices(bundle) + return dsk.norm({"그룹": GROUP, "문서": dc.closure(whole, keys, have)}) + + +def attach(files: dict[str, Any]) -> dict[str, Any]: + """사본 파일 묶음에 `수식.json` 을 더함(이미 있으면 그대로 · 단가 묶음 없으면 그대로).""" + bundle = files.get("단가.json") + if FILE in files or not isinstance(bundle, dict): + return files + return {**files, FILE: build(bundle, files.get("일위대가.json"))} diff --git a/M02_MasterTemplete/M02_Doc_Copy.py b/M02_MasterTemplete/M02_Doc_Copy.py new file mode 100644 index 000000000..cfe4bed21 --- /dev/null +++ b/M02_MasterTemplete/M02_Doc_Copy.py @@ -0,0 +1,279 @@ +"""M02 수식 문서 사본(PLAN 802-4a · 요구 664 · 665 · 요구서_664 1 장 4) — 서버 단독. + +단가산출 기초 정본 = 수식 문서(`resources/master_data/_수식문서_틀.md`) — M02 단가산출(SK) · +일위대가 · 사전조합(SC) · B09 사본은 만들 때 M01 문서를 찍어 가짐(M01 이 바뀌어도 그대로). +문서 칸 `수식`(옛 로직 사본 `사본` 은 802-5 까지 옆에 둠): + {판, 시점, 로직 [M01 줄], 단가판, 단가시점, 단가 [M01 자원 줄], ← 값 사본(`M02_Sangeun_Copy` 꼴) + 부름 {키: 문서}(닫힌 묶음 — 연결 · [재생성] 때 새로) · + SK 만: 문서(이 SK 문서 · 사용자 고침) · 끊음 {키: 문서}(이 사본 안에 굳힌 부름 · 고칠 수 있음) · + 따로(문서를 수식 길로 고침 — 옛 칸에서 다시 짓지 않음)} +셈 = `master_doc_calc.calc`(M01 과 같은 계산기) · 연결 = `master_doc_link.Links`(값 사본 색인 위) · +[단가 갱신] = 자원 단가 줄만 지금 M01(`skc.refresh`) · 문서 그대로 · [재생성] = 지금 M01 문서로. +부름 문서 = 값 사본 줄이 지금 M01 줄과 같으면 M01 문서(`수식문서/`) · 다르면(만든 때 옛 줄) 그 줄을 + 변환(`master_doc_convert` · 만든 때 그대로). +줄 규칙(요구 664 · 브레인 802-4a ④) = 일위대가 줄 옵션을 문서 안에 드러냄(`line_doc`) — + 켜쌓기율 = 변수 `r_켜쌓기`(입력) · 금액 줄 수량 × r ÷ 100 · 고른 자재 = 그 줄 단가 기호 연결 + 요소 키 · 별도계상 = 그 금액 줄 `합밖`. +""" + +from __future__ import annotations + +import copy as cp +import hashlib +import json +import sys +from pathlib import Path +from typing import Any + +from M02_MasterTemplete import M02_Item_Prices as prices +from M02_MasterTemplete import M02_Sangeun_Copy as skc + +_SCRIPTS = str(Path(__file__).resolve().parents[1] / "resources/master_data/scripts") +if _SCRIPTS not in sys.path: + sys.path.insert(0, _SCRIPTS) + +import master_auto as mau # noqa: E402 +import master_doc as md # noqa: E402 +import master_doc_calc as mc # noqa: E402 +import master_doc_convert as cv # noqa: E402 +import master_doc_link as ml # noqa: E402 + +KEY = "수식" +COSTS = ("노무비", "재료비", "경비", "계") +RATE = {"열쇠": "켜쌓기율", "기호": "r", "아래": "켜쌓기", "위": "", "이름": "켜쌓기율"} +RATE_ID = md.sym_id(RATE["기호"], RATE["아래"]) +_ERR = (Exception,) # 변환 · 셈 틀림 = 그 문서 못 셈(까닭) — 셈 길은 멈추지 않음 + + +def has(doc: Any) -> bool: + got = doc.get(KEY) if isinstance(doc, dict) else None + return isinstance(got, dict) and isinstance(got.get("부름"), dict) + + +def fp(value: Any) -> str: + raw = json.dumps(ml._norm(value), ensure_ascii=False, sort_keys=True, default=str) + return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16] + + +# ── M01 문서(정본 폴더) ──────────────────────────────────────────────── +_FOLDER: dict[str, Any] = {"자국": None, "문서": {}} + + +def folder() -> dict[str, dict]: + """M01 수식 문서 {키: 문서} — 폴더 파일 자국이 바뀌면 다시 읽음(M01 관리자 고침).""" + files = sorted(ml.FOLDER.glob("수식_*.json")) + mark = tuple((p.name, p.stat().st_mtime_ns, p.stat().st_size) for p in files) + if _FOLDER["자국"] != mark: + _FOLDER["문서"], _FOLDER["자국"] = ml.load_all(), mark + return _FOLDER["문서"] + + +def _live(key: str) -> Any: + whole = skc.whole_() + return whole.index.get(key[:2], {}).get(key) + + +def doc_of(whole: Any, key: str) -> dict | None: + """키 하나의 문서 — 값 사본 줄 = 지금 M01 줄이면 M01 문서 · 다르면 그 줄 변환 · 없으면 None.""" + try: + row = whole.logic(key) + except _ERR: + row = None + live = folder().get(key) + if row is None: + return cp.deepcopy(live) if live else None + if live and (_live(key) is None or skc._fp(_live(key)) == skc._fp(prices.to_json(row))): + return cp.deepcopy(live) + try: + return cv.convert(whole, row)[0] + except _ERR: + return cp.deepcopy(live) if live else None + + +def calls(doc: dict) -> list[str]: + """문서가 부르는 단가산출 키.""" + return [ + str(v["연결"]["키"]) + for v in doc.get("변수") or [] + if (v.get("연결") or {}).get("갈래") == "단가산출" and (v.get("연결") or {}).get("키") + ] + + +def closure(whole: Any, keys: list[str], have: dict | None = None) -> dict[str, dict]: + """키들과 그 문서가 부르는 단가산출 닫힌 묶음 {키: 문서} — `have`(만든 때 것) 먼저.""" + have, out, todo = have or {}, {}, list(keys) + while todo: + key = str(todo.pop()) + if key in out: + continue + doc = have.get(key) or doc_of(whole, key) + if doc is None: + continue + out[key] = doc + todo += [k for k in calls(doc) if k not in out] + return out + + +# ── 값 사본 색인 · 연결 ──────────────────────────────────────────────── +def values_of(copy: dict) -> dict: + """값 사본 몫만(`skc` 꼴) — 셈 색인 · [단가 갱신] 에 씀.""" + return {k: copy[k] for k in ("판", "시점", "로직", "단가판", "단가시점", "단가") if k in copy} + + +def base(whole: Any) -> Any: + while isinstance(whole, skc.Copy): + whole = whole._whole + return whole + + +def over(copy: dict, whole: Any = None) -> Any: + """값 사본 색인 — M01 색인 위 사본 줄(`skc.over` 같은 메모 · 같은 줄 = 같은 색인).""" + return skc.over({skc.KEY: values_of(copy)}, base(whole or skc.whole_())) + + +def docs_of(copy: dict) -> dict[str, dict]: + return {**(copy.get("부름") or {}), **(copy.get("끊음") or {})} + + +def run(doc: dict, copy: dict, given: dict | None = None, whole: Any = None) -> dict: + """문서 셈 — 연결 = 값 사본 · 부름 = 사본 문서(끊음 먼저).""" + try: + return mc.calc(doc, ml.Links(whole or over(copy), docs_of(copy)), given) + except _ERR as e: # 계산기는 예외 없음이 원칙 — 남은 틈도 금액 「—」 로 + return {"셈됨": False, "표지": [{"갈래": "셈 틀림", "글": str(e)}], **dict.fromkeys(COSTS)} + + +def money(ans: dict) -> dict | None: + if not ans.get("셈됨") or "결과" in ans: + return None + return {k: ans.get(k) for k in COSTS} + + +def why(ans: dict) -> str: + marks = " · ".join(f"{m.get('갈래')} — {m.get('글')}" for m in ans.get("표지") or []) + return marks or str(ans.get("까닭") or "") or " · ".join((ans.get("빈값") or {}).values()) + + +# ── 줄 규칙을 문서에(켜쌓기율 · 고른 자재 · 별도계상) ──────────────────── +def _qty_scaled(line: dict) -> bool: + return line.get("수량") is not None or str(line.get("단위") or "") != "식" + + +def line_doc(doc: dict, rate: Any = None, picks: dict | None = None, apart=()) -> dict: + """일위대가 줄 옵션을 문서 규칙으로 — 새 문서(원본 안 고침). + `picks` = {호표 차례: MT 키} · `apart` = 별도계상 금액 줄 차례.""" + if rate in (None, 100) and not picks and not apart: + return doc + doc = cp.deepcopy(doc) + lines, by_id = doc.get("금액") or [], {md.var_id(v): v for v in doc.get("변수") or []} + if rate not in (None, 100): + doc["변수"].append({**RATE, "단위": "%", "연결": {"갈래": "입력", "값": rate}}) + for line in lines: + if _qty_scaled(line): + line["수량"] = f"({line.get('수량') or 1}) × {RATE_ID} ÷ 100" + for at, key in (picks or {}).items(): + n = int(at) if str(at).isdigit() else -1 + var = by_id.get(str(lines[n].get("단가"))) if 0 <= n < len(lines) else None + link = (var or {}).get("연결") or {} + if link.get("갈래") == "요소": + keep = {k: link[k] for k in ("단위", "지역", "계약종별") if link.get(k)} + var["연결"] = {"갈래": "요소", "키": str(key), **keep} + for n in apart: + if 0 <= n < len(lines): + _apart(doc, lines, n) + return doc + + +def _plus(node, cost: str, sym: str): + """식 나무 안 `cost`(앞 줄 누계) → (cost + sym).""" + kind = node[0] + if kind == "var": + return ("bin", "+", node, ("var", sym)) if node[1] == cost else node + if kind == "bin": + return ("bin", node[1], _plus(node[2], cost, sym), _plus(node[3], cost, sym)) + if kind in ("neg", "pct"): + return (kind, _plus(node[1], cost, sym)) + if kind == "field": + return ("field", _plus(node[1], cost, sym), node[2]) + if kind == "call": + return ("call", node[1], [_plus(x, cost, sym) for x in node[2]]) + return node + + +def _apart(doc: dict, lines: list[dict], n: int) -> None: + """별도계상 = 그 줄 `합밖` · 뒤 줄이 그 비목 누계를 쓰면 그 줄 금액을 더해 씀 + (옛 엔진 = 뺀 재료 몫도 「재료비의 %」 밑에 듦 — `M02_Labor_Doc.drop_apart`).""" + line, cost = lines[n], str(lines[n].get("비목") or "") + line["합밖"] = True + if cost not in md.COSTS: + return + taken = {md.var_id(v) for v in doc.get("변수") or []} | {str(x.get("기호")) for x in lines} + sym = str(line.get("기호") or "") + if not sym: + sym = next( + s for k in range(n + 1, n + 99) if (s := md.sym_id("S", f"별도{k}")) not in taken + ) + line["기호"] = sym + for later in lines[n + 1 :]: + for slot in ("수량", "단가"): + try: + node = md.parse(later.get(slot)) + except md.DocError: + continue + if later.get(slot) not in (None, "") and cost in md.names(node): + later[slot] = md.text_of(_plus(node, cost, sym)) + + +def given_of(whole: Any, code: str, given: Any) -> dict: + """옛 `run_line` 과 같은 입력 — 빈 입력 = 자동값(M01 줄) › 받은 값.""" + from M01_MasterData import M01_MasterData_Store as m01 + + try: + auto = {k: v for k, v in mau.values(whole, whole.logic(code)).items() if v is not None} + except _ERR: + auto = {} + return {**auto, **m01._dec(given or {})} + + +def apart_of(marks: Any, mats: Any) -> list[int]: + """별도계상 금액 줄 차례 — 옛 `drop_apart` 와 같은 판정(엔진 답 `재료` 의 차례 · 자재표시).""" + from M02_MasterTemplete import M02_Labor_Doc as labor + + marks = marks if isinstance(marks, dict) else {} + return [ + n + for m in mats or [] + if isinstance(n := m.get("차례"), int) and labor.apart(marks.get(str(n))) + ] + + +# ── 찍기 · 갱신 ──────────────────────────────────────────────────────── +def stamp(values: dict | None, keys: list[str], have: dict | None = None) -> dict | None: + """값 사본(`skc.snap` 꼴) + 부름 닫힌 묶음 → `수식` 칸 · 값 사본 없음 = None.""" + if not values: + return None + whole = over({**values, "부름": {}}) + return {**values_of(values), "부름": closure(whole, keys, have)} + + +def grow(copy: dict, values: dict | None, keys: list[str]) -> dict: + """저장 때 — 새 키(더한 기초 · 줄)만 지금 M01 에서 더함 · 있는 부름 · 값 줄 그대로.""" + rows = skc.grow(values_of(copy), values) + whole = over({**rows, "부름": {}}) + return {**copy, **rows, "부름": closure(whole, keys, copy.get("부름"))} + + +def refresh(copy: dict) -> tuple[dict, list[dict]]: + """[단가 갱신] — 자원 단가 줄만 지금 M01 · 문서 그대로.""" + rows, changed = skc.refresh(values_of(copy), skc.whole_()) + return {**copy, **rows}, changed + + +def state(copy: dict) -> dict[str, Any]: + """{M01새판, 지운키, 단가새값, 문서새판} — 값 줄 · 부름 문서를 지금 M01 과 대조.""" + got = skc.state(values_of(copy), skc.whole_()) + live, base = folder(), copy.get("원본") or {} + new = {k for k, d in (copy.get("부름") or {}).items() if k in live and fp(d) != fp(live[k])} + if base.get("키") in live and fp(live[base["키"]]) != base.get("판"): + new.add(base["키"]) # 이 SK 문서를 찍은 M01 문서가 바뀜 + return {**got, "문서새판": sorted(new)} diff --git a/M02_MasterTemplete/M02_Doc_Migrate.py b/M02_MasterTemplete/M02_Doc_Migrate.py new file mode 100644 index 000000000..2836032c0 --- /dev/null +++ b/M02_MasterTemplete/M02_Doc_Migrate.py @@ -0,0 +1,233 @@ +"""옛 SK · SC · 일위대가 · B09 사본 → 수식 문서 사본 찍어 넣기 — 한 번 돌리는 도구(PLAN 802-4a 3). + +대상 = 마스터 · 회사 · 개인 층 문서(`sangeun` · `precombo` · `labor`) · B09 사본(구조물 · 서고). +층 문서 — 전 = 옛 셈 길(`M02_Doc_Sk.OLD` · 로직 사본 엔진) · 후 = 수식 사본 문서 셈: + SK = 기준(노 · 재 · 경 · 계) · SC · 일위대가 = `Labor_Doc.calc` 기준합 · 반영합(원 단위 · 반올림 6 자리). + 같음 = 파일에 `수식` 칸만 더해 씀(그 밖 칸 그대로) · 다름 · 못 셈 = 안 씀(목록 · 까닭) · + 가져온 식 SK = 802-4b(그대로) · 바탕 참조 SK · 로직 사본 없는 문서 = 필요없음 · 이미 있음 = 있음. +B09 사본 — `수식.json`(`M02_Doc_B09.build` · 만든 때 `단가.json` 줄 기준) 더함 · 견적 셈은 그대로 + (`prices.calc` 전후 같음) · 문서 대조 = 일위대가 로직 줄마다 옛 엔진(`run_line`) 대 문서 셈 + (M01 규칙 · 같은 사본 값) — 다르면 목록. +되돌릴 길 = 쓰기 전 파일 그대로 `--backup` 폴더(층 자리 그대로) · `--restore` = 다시 덮음 + (B09 = 더한 `수식.json` 을 지움). + + ./venv/Scripts/python.exe -m M02_MasterTemplete.M02_Doc_Migrate # 보기만(표 · 목록) + ./venv/Scripts/python.exe -m M02_MasterTemplete.M02_Doc_Migrate --apply --backup tmp/802_4a + ./venv/Scripts/python.exe -m M02_MasterTemplete.M02_Doc_Migrate --restore tmp/802_4a + --list 목록 파일(기본 `docs/raw/요구_1104/_목록_802-4a_M02대조.xlsx`) +""" + +from __future__ import annotations + +import json +import shutil +import sys +from collections import Counter +from pathlib import Path +from typing import Any + +from common_util.common_util_json import atomic_write_json +from M02_MasterTemplete import M02_Copy_Migrate as old +from M02_MasterTemplete import M02_Doc_B09 as doc_b09 +from M02_MasterTemplete import M02_Doc_Copy as dc +from M02_MasterTemplete import M02_Doc_Sk as dsk +from M02_MasterTemplete import M02_Sangeun_Copy as skc + +LIST = Path(__file__).resolve().parents[1] / "docs/raw/요구_1104/_목록_802-4a_M02대조.xlsx" +HEAD = ("어디", "종류", "키", "이름", "상태", "전", "후", "까닭") + + +def _money(kind: str, doc: dict) -> Any: + from M02_MasterTemplete import M02_Labor_Copy as lc + from M02_MasterTemplete import M02_Labor_Doc as labor + from M02_MasterTemplete import M02_Sangeun_Calc as calc + + if kind == "sangeun": + got = calc.calc_doc(doc, None, False) + return old._round(got.get("기준")), got.get("수식셈") + lc._LAST[0] = (None, None) + got = labor.calc(lc.settle(doc), None if kind == "labor" else {}) + return old._round({k: got.get(k) for k in ("기준합", "반영합")}), None + + +def _with_old(fn, *args): + token = dsk.OLD.set(True) + try: + return fn(*args) + finally: + dsk.OLD.reset(token) + + +def formula_of(kind: str, doc: dict) -> dict | None: + """그 문서에 찍을 수식 사본(저장 첫머리와 같은 길 · 저장 없음).""" + from M02_MasterTemplete import M02_Labor_Copy as lc + from M02_MasterTemplete import M02_Sangeun_Doc as sd + + if kind != "sangeun": + return lc.settle(doc).get(dc.KEY) + if not dsk.own(doc): + return None + clean, whole = None, None + settled = skc.settle(doc) + whole = skc.over(settled, skc.whole_()) + clean = sd.clean(settled, None, whole) + return dsk.settle(clean, whole, None, doc).get(dc.KEY) + + +def check(kind: str, path: Path) -> dict[str, Any]: + """층 문서 하나 — {상태: 있음 | 필요없음 | 가져온식 | 같음 | 다름 | 못셈, 전, 후, 수식?, 까닭?}.""" + from M02_MasterTemplete import M02_MasterTemplete_Store as store + + doc = json.loads(path.read_bytes()) + head = {"이름": doc.get("이름") or ""} + if dc.has(doc): + return {**head, "상태": "있음"} + if kind == "sangeun" and doc.get("가져온"): + return {**head, "상태": "가져온식", "까닭": "엑셀 가져온 식 = 802-4b 몫(옛 길 그대로)"} + try: + with store.at(path.parent.parent): + copy = formula_of(kind, doc) + if copy is None: + return {**head, "상태": "필요없음", "까닭": "로직 사본 없음(바탕 참조 · 손 줄만)"} + before = _with_old(_money, kind, doc)[0] + after, mark = _money(kind, {**doc, dc.KEY: copy}) + except Exception as e: # noqa: BLE001 — 못 셈 = 목록(통째로 멈추지 않음) + return {**head, "상태": "못셈", "까닭": str(e) or type(e).__name__} + row = {**head, "전": before, "후": after} + if before == after: + return {**row, "상태": "같음", "수식": copy} + why = (mark or {}).get("까닭") or "전후 금액 다름 — 안 옮김" + if kind == "sangeun" and not (doc.get("바탕") or doc.get("더한줄")): + why = "빈 SK(바탕 · 줄 없음) — 옛 0 · 새 빈 문서 「—」(요구서_664 0 장 · 브레인 802-4a ③)" + return {**row, "상태": "다름(빈 문서)", "까닭": why, "수식": copy} + return {**row, "상태": "다름", "까닭": why} + + +def b09_check(folder: Path) -> dict[str, Any]: + """B09 사본 하나 — {상태: 있음 | 필요없음 | 같음 | 다름 | 못셈, 전, 후, 수식?, 까닭?}.""" + from M02_MasterTemplete import M02_Item_Prices as prices + from M02_MasterTemplete import M02_Labor_Doc as labor + from M02_MasterTemplete.M02_Item_Source import read_json + + if (folder / doc_b09.FILE).is_file(): + return {"상태": "있음"} + own = {p.name: read_json(p) for p in folder.glob("*.json")} + bundle = own.get("단가.json") + if not isinstance(bundle, dict) or not doc_b09.logic_keys(bundle): + return {"상태": "필요없음", "까닭": "로직 줄 없음"} + try: + before = old._copy_money(own) + made = doc_b09.build(bundle, own.get("일위대가.json")) + after = old._copy_money({**own, doc_b09.FILE: made}) + whole, copy = prices.load_prices(bundle), {"부름": made["문서"]} + lines = ((own.get("일위대가.json") or {}).get("줄") or {}).get("담은로직") or [] + bad = [] + for one in [x for x in lines if isinstance(x, dict) and x.get("로직")]: + code = str(one["로직"]) + was = labor.run_line( + whole, code, one.get("입력값"), one.get("켜쌓기율"), one.get("고름") + ) + if was.get("기준") is None or code not in made["문서"]: + continue + doc = dc.line_doc(made["문서"][code], one.get("켜쌓기율"), one.get("고름")) + got = dc.money(dc.run(doc, copy, dc.given_of(whole, code, one.get("입력값")), whole)) + if old._round(got) != old._round(was["기준"]): + bad.append(f"{code} 옛 {old._round(was['기준'])} · 문서 {old._round(got)}") + except Exception as e: # noqa: BLE001 + return {"상태": "못셈", "까닭": str(e) or type(e).__name__} + row = {"전": before, "후": after, "수식": made} + if before != after: + return {**row, "상태": "다름", "까닭": "견적 셈이 바뀜 — 안 옮김"} + if bad: + return {**row, "상태": "다름", "까닭": "문서 대조 다름 — " + " / ".join(bad[:5])} + return {**row, "상태": "같음", "까닭": f"문서 {len(made['문서'])} · 견적 셈 그대로"} + + +def migrate(apply: bool = False, backup: Path | None = None) -> list[dict[str, Any]]: + """모든 층 · B09 사본 — {어디, 종류, 키, 상태, 씀, …} · `apply` = 같음(빈 문서 포함)만 씀.""" + if apply and backup is None: + raise ValueError("--apply 는 --backup 폴더가 있어야 함") + out = [] + for where_, kind, path, project in old._files(): + if project: + continue # 프로젝트 층 일위대가 = B09 몫(옛 길 · 795-4) + got = check(kind, path) + copy = got.pop("수식", None) + row = {"어디": where_, "종류": kind, "키": path.stem, **got, "씀": False} + if apply and copy is not None: + keep = old._backup_path(path, backup) + keep.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(path, keep) + atomic_write_json(path, {**json.loads(path.read_bytes()), dc.KEY: copy}) + row["씀"] = True + out.append(row) + for where_, kind, folder in old._b09_files(): + got = b09_check(folder) + made = got.pop("수식", None) + row = {"어디": where_, "종류": kind, "키": folder.name, **got, "씀": False} + if apply and made is not None and got["상태"] == "같음": + note = old._backup_path(folder / doc_b09.FILE, backup).with_suffix(".added") + note.parent.mkdir(parents=True, exist_ok=True) + note.write_text("802-4a 이 더함 — --restore 가 지움", encoding="utf-8") + atomic_write_json(folder / doc_b09.FILE, made) + row["씀"] = True + out.append(row) + return out + + +def restore(backup: Path) -> list[str]: + """`--backup` 폴더 파일을 제자리로 · 더한 `수식.json` 지움 — 손댄 파일 목록.""" + done = old.restore(backup) + for name, anchor in old._anchors().items(): + for note in sorted((backup / name).rglob("*.added")): + target = (anchor / note.relative_to(backup / name)).with_suffix(".json") + target.unlink(missing_ok=True) + done.append(f"지움 {target}") + return done + + +def table(rows: list[dict[str, Any]]) -> dict[str, dict[str, int]]: + count: Counter = Counter((r["어디"].split(" ")[0], r["종류"], r["상태"]) for r in rows) + out: dict[str, dict[str, int]] = {} + for (layer, kind, state), n in sorted(count.items()): + out.setdefault(f"{layer} {kind}", {})[state] = n + return out + + +def write_list(rows: list[dict[str, Any]], path: Path = LIST) -> Path: + from openpyxl import Workbook + + book = Workbook() + sheet = book.active + sheet.title = "요약" + sheet.append(("층 · 종류", "상태", "개수")) + for group, states in table(rows).items(): + for state, n in states.items(): + sheet.append((group, state, n)) + rows_sheet = book.create_sheet("목록") + rows_sheet.append(HEAD) + for r in rows: + cells = [r.get(k) for k in HEAD] + plain = (str, int, float, type(None)) + rows_sheet.append( + [c if isinstance(c, plain) else json.dumps(c, ensure_ascii=False) for c in cells] + ) + path.parent.mkdir(parents=True, exist_ok=True) + book.save(path) + return path + + +if __name__ == "__main__": # 브레인 · 사용자 승인 뒤만 --apply + args = sys.argv[1:] + if "--restore" in args: + print("\n".join(restore(Path(args[args.index("--restore") + 1])))) + raise SystemExit + at = Path(args[args.index("--backup") + 1]) if "--backup" in args else None + rows = migrate("--apply" in args, at) + for one in rows: + if one["상태"] not in ("있음", "필요없음", "같음"): + print(json.dumps(one, ensure_ascii=False, default=str)) + print(json.dumps(table(rows), ensure_ascii=False, indent=1)) + if "--list" in args or "--apply" not in args: + to = Path(args[args.index("--list") + 1]) if "--list" in args else LIST + print("목록", write_list(rows, to)) diff --git a/M02_MasterTemplete/M02_Doc_Router.py b/M02_MasterTemplete/M02_Doc_Router.py new file mode 100644 index 000000000..935b87bb2 --- /dev/null +++ b/M02_MasterTemplete/M02_Doc_Router.py @@ -0,0 +1,59 @@ +"""M02 단가산출(SK) 수식 길(PLAN 802-4a 4) — `M02_Doc_Sk_Path` 를 층 권한 안에서. + + GET /api/m02/layers/{층}/sangeun/{키}/doc 읽기(층 읽기) → {문서, 부름, 끊음, 셈, 사본, 판} + POST /api/m02/layers/{층}/sangeun/{키}/doc/preview 미리 셈(층 쓰기) {문서?, 끊음?, 할일?} + → {…, 전, 후, 바뀐자원?, 버림?} + POST /api/m02/layers/{층}/sangeun/{키}/doc/save 저장(층 쓰기) {문서?, 끊음?, 할일?, 판} → 읽기 꼴 +⚠ 등록은 `main.py`(로그인만 · 층 권한은 길 안에서). +""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends +from pydantic import BaseModel + +from common_util.common_util_auth import verify_session +from M02_MasterTemplete import M02_Doc_Sk_Path as path +from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root + +router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Sangeun Doc"]) + + +class DocBody(BaseModel): + 문서: dict[str, Any] | None = None + 끊음: dict[str, Any] | None = None + 할일: str | None = None + 판: str | None = None + + +@router.get("/layers/{layer}/sangeun/{key}/doc") +def read_doc( + layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) +) -> dict: + return _call(_root(session, layer, owner), path.read, key) + + +@router.post("/layers/{layer}/sangeun/{key}/doc/preview") +def preview_doc( + layer: Layer, + key: str, + body: DocBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + folder = _root(session, layer, owner, write=True) + return _call(folder, path.preview, key, body.model_dump(exclude_none=True)) + + +@router.post("/layers/{layer}/sangeun/{key}/doc/save") +def save_doc( + layer: Layer, + key: str, + body: DocBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + folder = _root(session, layer, owner, write=True) + return _call(folder, path.save, key, body.model_dump(exclude_none=True)) diff --git a/M02_MasterTemplete/M02_Doc_Sk.py b/M02_MasterTemplete/M02_Doc_Sk.py new file mode 100644 index 000000000..59a56c352 --- /dev/null +++ b/M02_MasterTemplete/M02_Doc_Sk.py @@ -0,0 +1,187 @@ +"""M02 단가산출(SK) 수식 문서 사본(PLAN 802-4a · 요구 664 · 665) — 서버 단독. + +SK `수식`(꼴 = `M02_Doc_Copy`) — `문서` = 이 SK 문서(키 = SK 키 · 옵션 값 · 줄 규칙까지 든 정본) · +`부름` = 부르는 단가산출 닫힌 묶음 · `끊음` = 이 사본에 굳힌 부름(고칠 수 있음) · 값 사본. +두 갈래: + 파생(따로 없음) — 옛 칸(바탕 · 덮개 · 뺀줄 · 더한줄 · 수식고침 …)이 정함: 셈마다 옛 칸이 낸 줄 + (`M02_Sangeun_Calc.run` 이 엔진에 넣는 고친 줄)을 문서로 옮겨(덮개 없는 바탕 = M01 문서 사본 · + 고친 줄 = `master_doc_convert`) 문서 셈 · 지금 화면(옛 칸 고침)이 그대로 돎. + 따로 — 수식 길(`M02_Doc_Router`)로 문서를 고침: 저장한 문서가 정본 · 옛 칸 고침 저장 = 409 · + [재생성] = 지금 M01 로 다시 찍어 파생으로 돌아감(고친 문서 버림). +SK 금액(기준 · 1 단위 = 기준 ÷ 배) = 문서 셈 — 일위대가 · 사전조합 · 구조물 조합이 이 값을 씀. +가져온 식 SK(엑셀 · 바탕 null) = 옛 길 그대로(802-4b) · 바탕 참조 SK = 옛 길(값 사본 없음). +""" + +from __future__ import annotations + +import copy as cp +import json +from contextvars import ContextVar +from decimal import Decimal +from typing import Any + +from M02_MasterTemplete import M02_Doc_Copy as dc +from M02_MasterTemplete import M02_Sangeun_Copy as skc +from M02_MasterTemplete import M02_Sangeun_Doc as sd + +KEY = dc.KEY +OLD_FIELDS = ( + "바탕", "입력값", "열린입력", "덮개", "뺀줄", "더한줄", "뺀더한줄", "상수덮개", "표값덮개", + "수식고침", "고름", "자재표시", "켜쌓기율", +) # fmt: skip +_STAMPED = ("원값", "바탕판", "처음수량", "M01판", "끊음") # 서버가 적는 칸 — 옛 칸 대조에서 뺌 +OLD: ContextVar[bool] = ContextVar("옛길", default=False) # 옛 셈 길(옮기기 전 금액 대조) +SAVING: ContextVar[dict | None] = ContextVar("수식길저장", default=None) # {키, 사본} — 수식 길만 + + +def own(doc: Any) -> bool: + """수식 사본을 가질 SK — 가져온 식 · 바탕 참조 SK 빼고.""" + if not isinstance(doc, dict) or doc.get("가져온"): + return False + return "참조" not in (doc.get("바탕") or {}) + + +def _strip(v: Any) -> Any: + if isinstance(v, dict): + return {k: _strip(x) for k, x in v.items() if k not in _STAMPED} + if isinstance(v, list): + return [_strip(x) for x in v] + return v + + +def old_fp(doc: dict) -> str: + """옛 칸 지문 — 따로 문서 위 옛 칸 고침 막음.""" + return dc.fp(_strip({k: doc.get(k) for k in OLD_FIELDS if doc.get(k) not in (None, {}, [])})) + + +# ── 파생 — 엔진에 넣는 고친 줄 → 문서 ───────────────────────────────── +def _set_values(doc: dict, given: dict) -> None: + for var in doc.get("변수") or []: + link = var.get("연결") or {} + if link.get("갈래") in ("선택", "입력") and var.get("열쇠") in given: + link["값"] = given[var["열쇠"]] + + +def derive(sk: dict, ctx: tuple) -> dict: + """옛 칸이 낸 고친 줄(`ctx` = run 이 엔진에 넣은 (색인, 키, 입력, 바탕 답)) → 이 SK 문서. + 덮개 없는 바탕(고친 줄 = 바탕 줄) = 그 M01 문서 사본 · 아니면 고친 줄 변환.""" + whole, key, values, got, mats = ctx + row = whole.logic(key) + given = dc.given_of(whole, key, values) + base_key = str((sk.get("바탕") or {}).get("로직") or "") + plain = None + if base_key: + try: + frozen = whole.logic(base_key) + same = {**row, "키": base_key} == frozen + except dc._ERR: + same = False + plain = dc.doc_of(whole, base_key) if same else None + if plain is not None: + doc = cp.deepcopy(plain) + _set_values(doc, given) + elif not (row.get("호표") or row.get("덧줄") or row.get("결과")): + doc = dc.md.blank(str(sk.get("키") or ""), str(sk.get("이름") or "")) + else: + doc = dc.cv.convert(whole, row, given)[0] + apart = dc.apart_of(got.get("자재표시"), mats) + doc = dc.line_doc(doc, got.get("켜쌓기율"), got.get("고름") or None, apart) + return {**doc, "키": str(sk.get("키") or key), "이름": str(sk.get("이름") or doc.get("이름"))} + + +def seed(sk: dict) -> dict | None: + """수식 사본 없는 SK(옮기기 전 · 새로)의 씨 — 로직 사본 값 + 빈 부름 · 빈 SK = 빈 문서.""" + if skc.has(sk): + return {**dc.values_of(sk[skc.KEY]), "부름": {}} + if sk.get("바탕") or sk.get("더한줄"): + return None + empty = {"판": skc._fp([]), "로직": [], "단가판": skc._fp([]), "단가": [], "부름": {}} + return {**empty, "문서": dc.md.blank(str(sk.get("키") or ""), str(sk.get("이름") or ""))} + + +def copy_of(sk: dict, ctx: tuple | None) -> dict | None: + """셈에 쓸 수식 사본 — 따로 = 저장본 · 파생 = 지금 옛 칸으로 다시 지은 문서(부름은 있던 것 먼저) · + `OLD` 켬(옮기기 대조 · 시험) = 옛 길.""" + if OLD.get() or not own(sk): + return None + got = sk.get(KEY) if dc.has(sk) else seed(sk) + if got is None: + return None + if got.get("따로") or ctx is None: + return got + try: + root = derive(sk, ctx) + except dc._ERR as e: + root = {**dc.md.blank(str(sk.get("키"))), "_틀림": str(e)} + values = dc.values_of(sk[skc.KEY]) if skc.has(sk) else dc.values_of(got) # 서버가 정한 값 + whole = dc.over(values) + calls = dc.closure(whole, dc.calls(root), got.get("부름")) + base = str((sk.get("바탕") or {}).get("로직") or "") + seen = dc.doc_of(whole, base) if base else None # 바탕 M01 문서 판(「M01 새 판」 표지) + origin = {"원본": {"키": base, "판": dc.fp(seen)}} if seen else {} + return {**got, **values, "문서": root, "부름": calls, **origin} + + +def on_run(sk: dict, ans: dict, ctx: tuple | None) -> dict: + """`M02_Sangeun_Calc.run` 끝 — 수식 사본 있는 SK 금액 = 문서 셈(기준 · 1 단위) + 덧칸 `수식셈`.""" + copy = copy_of(sk, ctx) + if copy is None: + return ans + got = dc.run(copy["문서"], copy) + money = dc.money(got) + base = {k: Decimal(str(v)) for k, v in money.items()} if money else None + times = Decimal(str(ans.get("배") or 1)) + unit = {k: v / times for k, v in base.items()} if base else None + mark = {"셈됨": bool(base), "따로": bool(copy.get("따로"))} + if not base: + mark["까닭"] = copy["문서"].get("_틀림") or dc.why(got) or "돈 아닌 문서" + return {**ans, "기준": base, "1단위": unit, "수식셈": mark} + + +# ── 저장 첫머리(옛 길 `clean_doc` 끝) ────────────────────────────────── +def stored(key: Any) -> dict | None: + from M02_MasterTemplete import M02_MasterTemplete_Store as store + + if not sd.KEY.match(str(key or "")): + return None + try: + return json.loads((store.root() / sd.KIND / f"{key}.json").read_bytes()) + except (OSError, ValueError): + return None + + +def norm(copy: dict) -> dict: + return json.loads(json.dumps(dc.ml._norm(copy), ensure_ascii=False, default=str)) + + +def settle(out: dict, whole: Any, job: Any, raw: Any) -> dict: + """저장 꼴에 `수식` 을 정함 — 수식 길 저장(`NEW`) = 그 사본 · 따로 = 저장본(옛 칸 고침 409 · + 단가갱신 = 값만 · 재생성 = 파생으로) · 그 밖 = 옛 칸으로 다시 지음(부름 = 있던 것 · 재생성 = 새로).""" + from M02_MasterTemplete import M02_Sangeun_Calc as calc + + out = {k: v for k, v in out.items() if k != KEY} + if not own(out): + return out + if not skc.has(out): # 빈 SK(바탕 · 줄 없음) = 빈 문서 — 금액 「—」(요구서_664 0 장) + empty = seed(out) + return {**out, KEY: norm(empty)} if empty else out + raw = raw if isinstance(raw, dict) else {} + mine = SAVING.get() + if mine is not None and mine.get("키") == out.get("키"): # 수식 길 저장 — 서버가 지은 사본 + return {**out, KEY: norm(mine["사본"])} + file = stored(out.get("키")) or {} + have = file.get(KEY) if dc.has(file) else (raw.get(KEY) if dc.has(raw) else None) + if have and have.get("따로") and job != skc.REGEN: + if file and old_fp(file) != old_fp(out): + raise sd.DocError( + "수식 문서로 고친 단가산출 — 옛 칸(덮개 · 줄) 고침은 수식 탭에서 · 또는 [재생성]", + 409, + ) + copy = dc.refresh(have)[0] if job == skc.PRICE else have + return {**out, KEY: norm(copy)} + box: list = [] + calc.run(out, whole, catch=box) + calls = {} if job == skc.REGEN else (have or {}).get("부름") or {} + first = {**dc.values_of(out[skc.KEY]), "부름": calls} + copy = copy_of({**out, KEY: first}, box[-1] if box else None) + return {**out, KEY: norm({k: v for k, v in (copy or first).items() if k != "따로"})} diff --git a/M02_MasterTemplete/M02_Doc_Sk_Path.py b/M02_MasterTemplete/M02_Doc_Sk_Path.py new file mode 100644 index 000000000..518c6c65b --- /dev/null +++ b/M02_MasterTemplete/M02_Doc_Sk_Path.py @@ -0,0 +1,168 @@ +"""M02 단가산출(SK) 수식 길 — 읽기 · 미리 셈 · 저장(PLAN 802-4a 4 · 요구 664) — 서버 단독. + +M01 수식 문서 길과 같은 꼴: 읽기 {문서, 셈} · 미리 셈 {문서} → {셈} · 저장 {문서} → {문서, 셈}. +몸 칸 — `문서`(이 SK 수식 문서 · 키 = SK 키) · `끊음` {키: 문서}(이 사본에 굳힌 부름 · 고칠 수 있음 · + 없는 키 = 다시 이음) · `할일` 단가갱신 | 재생성 · `판`(저장 · 그 SK 파일 판). +셈 = 서버 정본(`M02_Doc_Copy.run`) — 화면이 보낸 값 · 사본은 안 받음(문서 · 끊음 글만). +저장 = 그 SK 저장(`store.write` — 층 쓰기 권한 · 판 409 · 옛 칸 · 로직 사본 그대로) · + 문서 고침 = 따로(이 문서가 정본) · 재생성 = 지금 M01 로 다시 찍어 파생으로(고친 문서 · 끊음 버림) · + 단가갱신 = 값 줄만 지금 M01(문서 그대로). +가져온 식 SK(엑셀) = 802-4b 몫(400) · 바탕 참조 SK = 값 사본 없음(400). +""" + +from __future__ import annotations + +import json +from typing import Any + +from M02_MasterTemplete import M02_Doc_Copy as dc +from M02_MasterTemplete import M02_Doc_Sk as dsk +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Sangeun_Copy as skc +from M02_MasterTemplete import M02_Sangeun_Doc as sd + +MAX = 2_000_000 # 문서 글 크기(바이트) 상한 +JOBS = (skc.PRICE, skc.REGEN) + + +def _bad(why: str, status: int = 400) -> store.StoreError: + return store.StoreError(status, why) + + +def _file(key: str) -> dict[str, Any]: + if not sd.KEY.match(str(key or "")): + raise _bad("키(SK-######)") + return store.read(sd.KIND, key) + + +def _settled(doc: dict, job: Any = None) -> tuple[dict, Any]: + """옛 셈 길 첫머리(`calc_doc`)와 같게 — (정리한 문서, 사본 색인).""" + from M01_MasterData import M01_MasterData_Store as m01 + from M02_MasterTemplete import M02_Sangeun_Calc as calc + + raw = {**doc, skc.JOB: job} if job else doc + whole = skc.over(doc := skc.settle(raw), m01.loaded()[1]) + try: + return sd.clean(doc, None, whole), whole + except sd.DocError as e: + raise calc._fail(e) from e + + +def current(doc: dict, job: Any = None) -> dict: + """그 SK 의 지금 수식 사본(할일 적용 · 저장 없음) — 따로 = 저장한 문서 · 파생 = 옛 칸으로 지음.""" + from M02_MasterTemplete import M02_Sangeun_Calc as calc + + if not dsk.own(doc): + if doc.get("가져온"): + raise _bad("가져온 식 단가산출 — 수식 문서는 엑셀 불러오기 몫(802-4b)") + raise _bad("바탕 참조 단가산출 — 수식 사본 없음") + clean, whole = _settled(doc, job) + if not dc.has(clean): # 옮기기 전 · 새 SK — 저장 때 지을 사본을 미리 + seed = dsk.settle(clean, whole, job, doc).get(dsk.KEY) + if seed is None: + raise _bad("수식 사본을 지을 수 없음 — 바탕 로직 · 줄 없음") + clean = {**clean, dsk.KEY: seed} + box: list = [] + try: + calc.run(clean, whole, catch=box) + except sd.DocError as e: + raise calc._fail(e) from e + got = dsk.copy_of(clean, box[-1] if box else None) + return got if got is not None else clean[dsk.KEY] + + +def answer(copy: dict) -> dict[str, Any]: + """{문서, 부름, 끊음, 셈, 사본 표지} — 글 · 수는 JSON 꼴.""" + got = dc.run(copy["문서"], copy) + head = {k: copy.get(k) for k in ("시점", "단가시점")} + mark = {**head, "따로": bool(copy.get("따로")), **dc.state(copy)} + body = { + "문서": copy["문서"], + "부름": copy.get("부름") or {}, + "끊음": copy.get("끊음") or {}, + "셈": got, + "사본": mark, + } + return json.loads(json.dumps(dc.ml._norm(body), ensure_ascii=False, default=str)) + + +def read(key: str) -> dict[str, Any]: + """읽기 — {문서, 부름, 끊음, 셈, 사본, 판}.""" + got = _file(key) + return {**answer(current(got["문서"])), "판": got["판"]} + + +# ── 몸 검사 ─────────────────────────────────────────────────────────── +def _doc(raw: Any, key: str, where: str) -> dict: + if not isinstance(raw, dict): + raise _bad(f"{where} · 묶음 아님") + if len(json.dumps(raw, ensure_ascii=False, default=str).encode("utf-8")) > MAX: + raise _bad(f"{where} · 너무 큼") + for name in ("변수", "식", "금액"): + if not isinstance(raw.get(name, []), list): + raise _bad(f"{where} · 「{name}」 목록 아님") + return {**raw, "키": key} + + +def edited(copy: dict, body: dict, key: str) -> dict: + """화면 고침 얹은 사본 — 문서 · 끊음 글만 받음(값 · 부름은 서버 것).""" + if body.get("할일") not in (None, "", *JOBS): + raise _bad("할일은 단가갱신 · 재생성") + if body.get("할일") == skc.REGEN: + return copy # 재생성 = 고친 것 버림(그 사본이 이미 새로 찍힌 것) + out = {**copy} + if body.get("문서") is not None: + out["문서"] = _doc(body["문서"], key, "문서") + cut = body.get("끊음") + if cut is not None: + if not isinstance(cut, dict): + raise _bad("끊음 · {키: 문서} 묶음") + keys = set(copy.get("부름") or {}) | set(copy.get("끊음") or {}) + if bad := sorted(k for k in cut if k not in keys): + raise _bad(f"끊음 · 이 사본이 부르지 않는 단가산출 {bad}") + out["끊음"] = {k: _doc(v, k, f"끊음 「{k}」") for k, v in cut.items()} + calls = [ + *dc.calls(out["문서"]), + *(c for d in out.get("끊음", {}).values() for c in dc.calls(d)), + ] + whole = dc.over(out) + out["부름"] = dc.closure(whole, calls, copy.get("부름")) + out["따로"] = True + return out + + +def preview(key: str, body: dict) -> dict[str, Any]: + """미리 셈(저장 없음) — {셈, 전, 후, 바뀐자원?, 버림?}.""" + doc = _file(key)["문서"] + before = current(doc) + job = body.get("할일") or None + after = edited(current(doc, job) if job else before, body, key) + out = answer(after) + out |= {"전": dc.money(dc.run(before["문서"], before)), "후": dc.money(out["셈"])} + if job == skc.PRICE: + out["바뀐자원"] = dc.refresh(before)[1] + if job == skc.REGEN: + out["버림"] = { + "문서고침": bool(before.get("따로")), + "끊음": sorted(before.get("끊음") or {}), + } + return json.loads(json.dumps(dc.ml._norm(out), ensure_ascii=False, default=str)) + + +def save(key: str, body: dict) -> dict[str, Any]: + """저장(층 쓰기) — 서버가 다시 셈한 사본을 그 SK 에 씀 · {문서, 부름, 끊음, 셈, 사본, 판}.""" + got = _file(key) + if str(body.get("판") or "") != got["판"]: + raise _bad({"stale": [key], "판": got["판"]}, 409) + job = body.get("할일") or None + raw = {**got["문서"], **({skc.JOB: job} if job else {})} + token = None + if job != skc.REGEN and (body.get("문서") is not None or body.get("끊음") is not None): + mine = edited(current(got["문서"], job), body, key) + token = dsk.SAVING.set({"키": key, "사본": mine}) + try: + store.write(sd.KIND, key, got["판"], raw) + finally: + if token is not None: + dsk.SAVING.reset(token) + return read(key) diff --git a/M02_MasterTemplete/M02_Item_Snapshot.py b/M02_MasterTemplete/M02_Item_Snapshot.py index 1f7244744..cc9ebabaf 100644 --- a/M02_MasterTemplete/M02_Item_Snapshot.py +++ b/M02_MasterTemplete/M02_Item_Snapshot.py @@ -126,13 +126,16 @@ def seed_picks(labor: Any) -> dict[str, Any]: def build_snapshot(item: dict[str, Any]) -> dict[str, Any]: """원본 내용 + 프로젝트 값(입력값 · 고른자재 첫 값 = 일위대가 문서 것) — `{파일 이름: 내용}` · SS 줄이 없으면 LookupError.""" - files = build_source(item) + from M02_MasterTemplete import M02_Doc_B09 as doc_b09 + + files = doc_b09.attach(build_source(item)) # 수식 문서 사본(802-4a) labor = files.get(LABOR_FILE) return {**files, VALUES_FILE: seed_values(labor), PICKS_FILE: seed_picks(labor)} def source_files(files: dict[str, Any]) -> dict[str, Any]: - return {k: v for k, v in files.items() if k not in LOCAL_FILES} + """원본판 몫 — 수식 문서 사본(802-4a · 로직은 `단가.json` 이 잼)은 뺌.""" + return {k: v for k, v in files.items() if k not in (*LOCAL_FILES, "수식.json")} def write_snapshot(target: Path, item: dict[str, Any], owner_id: str, extra: dict) -> dict: @@ -400,7 +403,7 @@ def complete_old(folder: Path) -> bool: kept = { name: read_json(folder / name) for name in sorted(p.name for p in folder.glob("*.json")) - if name not in (MANIFEST_NAME, *LOCAL_FILES) + if name not in (MANIFEST_NAME, *LOCAL_FILES, "수식.json") # 수식 사본(802-4a) 원본판 밖 } atomic_write_json( folder / MANIFEST_NAME, diff --git a/M02_MasterTemplete/M02_Labor_Copy.py b/M02_MasterTemplete/M02_Labor_Copy.py index e5f805188..fe2a8704e 100644 --- a/M02_MasterTemplete/M02_Labor_Copy.py +++ b/M02_MasterTemplete/M02_Labor_Copy.py @@ -21,8 +21,11 @@ from __future__ import annotations import json import re +from decimal import Decimal from typing import Any +from M02_MasterTemplete import M02_Doc_Copy as dc +from M02_MasterTemplete import M02_Doc_Sk as dsk from M02_MasterTemplete import M02_Sangeun_Copy as skc KEY, JOB, PRICE, REGEN = skc.KEY, skc.JOB, skc.PRICE, skc.REGEN @@ -49,7 +52,7 @@ def stamp(doc: dict) -> dict | None: return skc.snap(list(dict.fromkeys(keys)), list(dict.fromkeys(links))) -def _stored(doc: dict) -> dict | None: +def _file(doc: dict) -> dict: from M02_MasterTemplete import M02_MasterTemplete_Store as store key = str(doc.get("키") or doc.get("열") or "") @@ -57,31 +60,57 @@ def _stored(doc: dict) -> dict | None: try: got = json.loads(store._path(kind, key).read_bytes()) except (OSError, ValueError, store.StoreError): - return None + return {} + return got if isinstance(got, dict) else {} + + +def _stored(doc: dict) -> dict | None: + got = _file(doc) return got[KEY] if skc.has(got) else None +def _keys(doc: dict) -> list[str]: + """수식 사본 부름 뿌리 — 로직 줄 키(줄 덮개 · 참조 · 고정 줄은 옛 길).""" + lines = [x for x in doc.get("담은로직") or [] if isinstance(x, dict)] + return list(dict.fromkeys(str(x["로직"]) for x in lines if x.get("로직"))) + + +def formula(doc: dict, raw: Any, job: Any, file: dict) -> dict | None: + """수식 사본(802-4a) — 값 = 로직 사본 줄 · 부름 = 있던 것(받은 것 › 저장본 · 옛 사본과 같은 차례) · + 재생성 = 새로.""" + copy = doc.get(KEY) + if not copy: + return None + have = raw.get(dc.KEY) if dc.has(raw) else (file.get(dc.KEY) if dc.has(file) else None) + seed = None if job == REGEN else (have or {}).get("부름") + return dsk.norm(dc.stamp(copy, _keys(doc), seed) or {}) + + def settle(doc: Any, save: bool = False) -> Any: """셈 · 저장 첫머리 — 서버가 정한 사본으로 갈음(할일 · 저장 때 찍음 · 새 줄 · 할일 칸 뗌).""" if not isinstance(doc, dict) or _project(): return doc job = doc.get(JOB) # 모르는 할일 = 없음 - out = {k: v for k, v in doc.items() if k not in (KEY, JOB)} - copy = skc.taken(doc.get(KEY)) or _stored(doc) + out = {k: v for k, v in doc.items() if k not in (KEY, JOB, dc.KEY)} + file = _file(doc) + copy = skc.taken(doc.get(KEY)) or (file[KEY] if skc.has(file) else None) if job == REGEN or (copy is None and save): copy = stamp(out) elif job == PRICE and copy: copy = skc.refresh(copy, skc.whole_())[0] if save and copy: copy = skc.grow(copy, stamp(out)) - return {**out, KEY: copy} if copy else out + if not copy: + return out + got = {**out, KEY: copy} + return {**got, dc.KEY: formula(got, doc, job, file)} def keep(raw: Any, out: dict) -> dict: """정리한 문서에 사본 · 할일 칸을 실어 보냄(꼴 검사는 `settle` 몫).""" if not isinstance(raw, dict): return out - got = {k: raw[k] for k in (KEY, JOB) if raw.get(k) is not None} + got = {k: raw[k] for k in (KEY, JOB, dc.KEY) if raw.get(k) is not None} return {**out, **got} @@ -93,6 +122,42 @@ def live(doc: dict) -> Any: return whole if _project() else skc.over(settle(doc), whole) +_LAST: list = [(None, None)] # (문서, 수식 사본) — 한 셈 안 줄마다 settle 안 되풀이 + + +def _formula_of(doc: dict) -> dict | None: + have, got = _LAST[0] + if have is not doc: + got = (settle(doc) or {}).get(dc.KEY) + _LAST[0] = (doc, got) + return got + + +def line(whole: Any, doc: dict, one: dict) -> dict[str, Any]: + """로직 줄 기준 — 수식 사본 있는 층 셈 = 문서 셈(줄 옵션 = 문서 규칙 · `dc.line_doc`) · + 상세 줄 · 재료 = 옛 엔진(화면 그대로 · 802-4b) · 프로젝트 층 · B09(단가 책) · 사본 없음 = 옛 길.""" + from M02_MasterTemplete import M02_Labor_Doc as labor + + code, rate, picks = one["로직"], one.get("켜쌓기율"), one.get("고름") + old = labor.drop_apart( + labor.run_line(whole, code, one.get("입력값"), rate, picks), one.get("자재표시") + ) + copy = None if _project() or dsk.OLD.get() else _formula_of(doc) + if not copy or getattr(whole, "_tag", None) != (copy.get("판"), copy.get("단가판")): + return old + base = copy["부름"].get(code) + if base is None: + return {**old, "기준": None, "까닭": f"수식 문서 없음 「{code}」"} + apart = dc.apart_of(one.get("자재표시"), old.get("재료")) + given = dc.given_of(whole, code, one.get("입력값")) + ans = dc.run(dc.line_doc(base, rate, picks, apart), copy, given, whole) + if "결과" in ans: + return old + money = dc.money(ans) + got = {k: Decimal(str(v)) for k, v in money.items()} if money else None + return {**old, "기준": got, "까닭": None if got else dc.why(ans)} + + def combo(expand: Any, doc: dict, trail: tuple[str, ...]) -> dict[str, Any]: """사전조합 1 단위 값 — 그 SC 사본 색인으로(층 셈만 · 프로젝트 = 굳힌 단가 그대로).""" from M02_MasterTemplete import M02_Labor_Ref as refs diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index 3a9a24006..c98946650 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -525,8 +525,8 @@ def calc( if one.get(patch.FIELD): # 줄 덮개(715-3) = 이름 없는 단가산출 셈 · 별도계상까지 lines.append({**line, **patch.run(whole, one, read)}) continue - got = run_line(whole, code, one.get("입력값"), one.get("켜쌓기율"), one.get("고름")) - lines.append({**line, **drop_apart(got, one.get("자재표시"))}) + got = lc.line(whole, doc, one) # 수식 사본 = 문서 셈(802-4a) · 별도계상까지 + lines.append({**line, **got}) mats = [ { "코드": one["코드"], diff --git a/M02_MasterTemplete/M02_Labor_Expand.py b/M02_MasterTemplete/M02_Labor_Expand.py index 262d193b7..903bce54f 100644 --- a/M02_MasterTemplete/M02_Labor_Expand.py +++ b/M02_MasterTemplete/M02_Labor_Expand.py @@ -112,7 +112,7 @@ class Expand: ], } - def _logic(self, one: dict, pill: dict | None) -> dict[str, Any]: + def _logic(self, one: dict, pill: dict | None, doc: dict | None = None) -> dict[str, Any]: """사전조합 안 로직 줄 — 일위대가 로직 줄과 같은 `run_line` · 배는 물량 알약 것.""" from M02_MasterTemplete import M02_Labor_Doc as labor @@ -124,11 +124,8 @@ class Expand: got = labor.drop_apart(got, fz.get("자재표시")) elif one.get(patch.FIELD): # 줄 덮개(715-3) = 이름 없는 단가산출 · 별도계상까지 got = patch.run(self.whole, one, self.read) - else: - got = labor.run_line( - self.whole, one["로직"], one.get("입력값"), one.get("켜쌓기율"), one.get("고름") - ) - got = labor.drop_apart(got, one.get("자재표시")) # 별도계상 로직 자재 뺌(222-1) + else: # 별도계상 로직 자재 뺌(222-1) · 수식 사본 SC = 문서 셈(802-4a) + got = lc.line(self.whole, doc or {}, one) times = labor._number(pill.get("배")) or Decimal(1) base = got.get("기준") return { @@ -158,7 +155,7 @@ class Expand: head = {"참조": ref} sub = self(ref, trail) if not one.get(patch.FIELD) else patch.ref(one, self) elif one.get("로직"): # 로직 줄(51-11) = 1 단위 값(기준 ÷ 배 · 승격과 같음) · 덮개 있음 - head, sub = {"로직": one["로직"]}, self._logic(one, by_logic.get(one["로직"])) + head, sub = {"로직": one["로직"]}, self._logic(one, by_logic.get(one["로직"]), doc) elif fixed.is_fixed(one): # 고정 줄(705-3) = 줄 단가 · 비율 줄 = 끝에(`ratio_row`) head, sub = {"고정": one["고정"]}, fixed.line(self.whole, one["고정"]) else: diff --git a/M02_MasterTemplete/M02_Sangeun_Calc.py b/M02_MasterTemplete/M02_Sangeun_Calc.py index 3b87bfb92..cb4f6d15d 100644 --- a/M02_MasterTemplete/M02_Sangeun_Calc.py +++ b/M02_MasterTemplete/M02_Sangeun_Calc.py @@ -32,6 +32,7 @@ import re from decimal import Decimal from typing import Any +from M02_MasterTemplete import M02_Doc_Sk as dsk # 수식 사본(802-4a) from M02_MasterTemplete import M02_Labor_Doc as labor, M02_Labor_Fixed as fixed from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_MasterTemplete_Store as store @@ -361,6 +362,7 @@ def run( detail: bool = False, line: bool = False, seen: bool = True, + catch: list | None = None, ) -> dict[str, Any]: """SK 셈(저장 안 함) — `run_line` 답 + `{1단위, 배, 정체, 짝잃음, 바탕판}` · 줄마다 `cid` · 고친 칸 `덮개`(칸 이름) · `원값`. 틀린 문서 · 순환 = `sd.DocError` · @@ -383,7 +385,8 @@ def run( if rates is not None: # 율 더한줄(778-4) — 밑을 넣고 다시 head["짝잃음"] = head["짝잃음"] + rates out = labor.run_line(over, key, values, got["켜쌓기율"], got["고름"] or None) - eng = out.get("줄") or [] + eng, ctx = out.get("줄") or [], (over, key, values, got, out.get("재료")) # 802-4a 고친 줄 + catch is None or catch.append(ctx) seen = ( views.text_of(over, key, values, got["고름"], got["정체"], eng) if detail and seen else {} ) @@ -412,7 +415,8 @@ def run( ] base = doc.get("바탕") or {} tag = tag_of(whole, base["로직"]) if "로직" in base else (base.get("참조") or {}).get("키") - return {**out, **head, **seen, "1단위": unit, "빠진줄": gone, "꼬리표": tag} + ans = {**out, **head, **seen, "1단위": unit, "빠진줄": gone, "꼬리표": tag} + return dsk.on_run(doc, ans, ctx) # 수식 사본 SK = 문서 셈 금액(802-4a) def _group(item: dict, line: dict) -> str | None: @@ -547,6 +551,7 @@ def clean_doc(doc: Any, here_: refs.Here) -> dict[str, Any]: """저장 꼴(`Labor_Ref.clean_doc` 가 부름) — 검사 · 참조 · 순환 · 서버가 `원값` · `바탕판`.""" from M01_MasterData import M01_MasterData_Store as m01 + raw, job = doc, doc.get(skc.JOB) if isinstance(doc, dict) else None # 수식 사본(802-4a) whole = skc.over(doc := skc.settle(doc, True), m01.loaded()[1]) # 사본(795-1) try: rough = sd.clean(doc, None, whole) @@ -566,6 +571,7 @@ def clean_doc(doc: Any, here_: refs.Here) -> dict[str, Any]: out |= const.stamp(base["줄"], out) # 바탕판 · M01판(790-2) · 식 칸 엔진 식(792-1) ratio_of(out, base["줄"], whole) # 단위 못 바꿈(계수 없음) = 저장 막음(728-2) _stamp_adds(out, base, whole) + out = dsk.settle(out, whole, job, raw) except sd.DocError as e: raise _fail(e) from e return out diff --git a/M02_MasterTemplete/M02_Sangeun_Const.py b/M02_MasterTemplete/M02_Sangeun_Const.py index ad1c34f46..7bf6d4650 100644 --- a/M02_MasterTemplete/M02_Sangeun_Const.py +++ b/M02_MasterTemplete/M02_Sangeun_Const.py @@ -234,6 +234,8 @@ def clean_more(raw: Any, out: dict) -> dict: out["까닭"] = why if isinstance(raw.get("사본"), dict): # 로직 사본(795-1) — 셈 · 저장 첫머리 settle 이 정함 out["사본"] = raw["사본"] + if isinstance(raw.get("수식"), dict): # 수식 사본(802-4a) — 저장 끝 `M02_Doc_Sk.settle` 이 정함 + out["수식"] = raw["수식"] out = eqm.clean(raw, out) # 수식 고침(796-2b) return imp.clean(raw, fx.clean(raw, out)) # 식 칸(792-1) · 가져온 식(793-2) diff --git a/M02_MasterTemplete/M02_Sangeun_Copy.py b/M02_MasterTemplete/M02_Sangeun_Copy.py index 7bdf2e193..0d01e49e9 100644 --- a/M02_MasterTemplete/M02_Sangeun_Copy.py +++ b/M02_MasterTemplete/M02_Sangeun_Copy.py @@ -261,9 +261,25 @@ def settle(doc: Any, save: bool = False) -> Any: copy = rejoin(copy, out["더한줄"]) if save and copy: copy = grow(copy, stamp(out)) + out = _formula_job(out, job) return {**out, KEY: copy} if copy else out +def _formula_job(out: dict, job: Any) -> dict: + """수식 사본(802-4a · `M02_Doc_Sk`) 할일 — 재생성 = 파생으로(부름 새로 · 고친 문서 버림) · + 단가갱신 = 따로 문서의 값 줄도 지금 M01.""" + from M02_MasterTemplete import M02_Doc_Copy as dc + + got = out.get(dc.KEY) if dc.has(out) else None + if got is None or job not in (PRICE, REGEN): + return out + if job == REGEN: + got = {k: v for k, v in got.items() if k not in ("문서", "끊음", "따로")} | {"부름": {}} + elif got.get("따로"): + got = dc.refresh(got)[0] + return {**out, dc.KEY: got} + + # ── 표지 ────────────────────────────────────────────────────────────── def state(copy: dict, whole: Any) -> dict[str, Any]: """{M01새판, 지운키, 단가새값} — 사본 줄과 지금 M01 줄 대조(같은 M01 · 같은 판 = 메모).""" diff --git a/docs/raw/요구_1104/_목록_802-4a_M02대조.xlsx b/docs/raw/요구_1104/_목록_802-4a_M02대조.xlsx new file mode 100644 index 000000000..c060790c8 Binary files /dev/null and b/docs/raw/요구_1104/_목록_802-4a_M02대조.xlsx differ diff --git a/main.py b/main.py index 927c277c4..7a6eeaaee 100644 --- a/main.py +++ b/main.py @@ -579,6 +579,11 @@ app.include_router( __import__("M02_MasterTemplete.M02_Sangeun_Router", fromlist=["router"]).router, dependencies=protected, ) +# 단가산출(SK) 수식 문서 길(PLAN 802-4a) — 로그인 · 층 권한은 길 안에서 +app.include_router( + __import__("M02_MasterTemplete.M02_Doc_Router", fromlist=["router"]).router, + dependencies=protected, +) # 회사에 올리기 요청 · 승인(PLAN 706-1) — 로그인만 · 층 권한은 길 안에서 app.include_router( __import__("M02_MasterTemplete.M02_Share_Request", fromlist=["router"]).router, diff --git a/resources/tester/test_204_2_b09_bill_library.py b/resources/tester/test_204_2_b09_bill_library.py index dc7e7baf3..be8ad1b95 100644 --- a/resources/tester/test_204_2_b09_bill_library.py +++ b/resources/tester/test_204_2_b09_bill_library.py @@ -82,7 +82,8 @@ def test_사전조합_고름_사본_들여옴_새_호표_다시_고르면_재사 folder = made["폴더"] assert made == {"폴더": f"precombo@system@-@{world['key']}", "만듦": True} copy = library.library_dir(root) / folder - assert {p.name for p in copy.iterdir()} == {"일위대가.json", "단가.json", "manifest.json"} + names = {"일위대가.json", "단가.json", "수식.json", "manifest.json"} # 수식 사본(802-4a) + assert {p.name for p in copy.iterdir()} == names got, block = _block(root, folder) assert (block["kind"], block["name"], block["unit"]) == ("사전조합", "서고 조합", "㎡") kinds = [(ln["kind"], ln.get("qty")) for ln in block["lines"]] diff --git a/resources/tester/test_795_1_sk_copy.py b/resources/tester/test_795_1_sk_copy.py index 7e403df2f..f0148bf0a 100644 --- a/resources/tester/test_795_1_sk_copy.py +++ b/resources/tester/test_795_1_sk_copy.py @@ -32,12 +32,18 @@ def _live(): def _fake(monkeypatch, rows=(), drop=()): - """M01 바꿈 흉내 — 지금 색인 위에 바뀐 줄 · 지운 키(정본 파일 안 고침).""" + """M01 바꿈 흉내 — 지금 색인 위에 바뀐 줄 · 지운 키(정본 파일 안 고침) · M01 수식 문서도 같이 + (802-4a — 수식 사본 SK 의 M01 정본 = 수식 문서 · 바뀐 줄은 그 줄 변환 문서로).""" + from M02_MasterTemplete import M02_Doc_Copy as dc + files, live = m01.loaded() fake = skc.Copy(live, [copy.deepcopy(r) for r in rows]) for key in drop: skc.Copy._own(fake.index, live.index, key[:2]).pop(key, None) monkeypatch.setattr(m01, "loaded", lambda: (files, fake)) + docs = {k: v for k, v in dc.folder().items() if k not in drop} + docs |= {str(r["키"]): dc.cv.convert(fake, copy.deepcopy(r))[0] for r in rows if "호표" in r} + monkeypatch.setattr(dc, "folder", lambda: docs) return fake @@ -165,7 +171,7 @@ def test_old_sk_without_copy_old_path(world, monkeypatch): key = _sk(me, 바탕={"로직": PCT}) path = me / "sangeun" / f"{key}.json" raw = json.loads(path.read_bytes()) - old = {k: v for k, v in raw.items() if k != "사본"} + old = {k: v for k, v in raw.items() if k not in ("사본", "수식")} # 795 앞 SK = 둘 다 없음 path.write_text(json.dumps(old, ensure_ascii=False), encoding="utf-8") fresh = _sk(me, 바탕={"로직": PCT}) assert _sum(me, key) == _sum(me, fresh) diff --git a/resources/tester/test_802_4a_m02_doc.py b/resources/tester/test_802_4a_m02_doc.py new file mode 100644 index 000000000..1c86c5261 --- /dev/null +++ b/resources/tester/test_802_4a_m02_doc.py @@ -0,0 +1,260 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""802-4a M02 단가산출 · 일위대가 · B09 사본 = 수식 문서 — 임시 storage · M01 은 메모리 덮개. + +① 찍기 = M01 문서 사본(금액 = M01 문서 셈) ② 고침 = 사본 문서(M01 안 바뀜 · 옛 칸 고침 409) +③ [단가 갱신] = 자원 단가만(문서 그대로) ④ [재생성] = 지금 M01 문서 · 고친 것 버림 +⑤ 단가산출 연결 = [재생성] 때 따라감 · 끊기 = 사본 안에 굳힘 ⑥ SC · 일위대가 = 문서 셈(옛 금액 같음 · +줄 규칙 켜쌓기율 · 고른 자재 · 별도계상) ⑦ B09 사본 `수식.json` ⑧ 권한 ⑨ 지움 견딤 · 빈 SK 「—」. +""" + +from __future__ import annotations + +import copy + +import pytest +from fastapi import HTTPException + +from M02_MasterTemplete import M02_Doc_B09 as doc_b09 +from M02_MasterTemplete import M02_Doc_Copy as dc +from M02_MasterTemplete import M02_Doc_Router as router +from M02_MasterTemplete import M02_Doc_Sk as dsk +from M02_MasterTemplete import M02_Doc_Sk_Path as path +from M02_MasterTemplete import M02_Item_Snapshot as snap +from M02_MasterTemplete import M02_Item_Source as source +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from resources.tester.test_795_1_sk_copy import _calc, _changed, _doc, _fake, _live, _save, _sk +from resources.tester.test_795_2_sc_copy import LB, PCT, _double, _labor, _sc +from resources.tester.test_795_2_sc_copy import _calc as _sc_calc +from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 + +FORM = "GC000211" # 합판거푸집(옵션 많음) +BASIC = "GC001148" # 굴착기 시간당 +ADD = {"종류": "로직", "로직": BASIC, "수량": 2, "입력값": {}, "이름": "sub2임시 굴착기"} + + +def _sum(folder, key): + return _calc(folder, _doc(folder, key))["기준"]["계"] + + +def _at(folder, fn, *args): + with store.at(folder): + return fn(*args) + + +def _twice(doc, at=0): + """금액 줄 하나 수량 × 2 — 사용자 고침 흉내.""" + doc = copy.deepcopy(doc) + line = doc["금액"][at] + line["수량"] = f"({line.get('수량') or 1}) × 2" + return doc + + +def test_stamp_is_m01_doc_copy(world): + """① 만들 때 M01 문서를 찍음 — 키 = SK · 옵션 값 · 금액 = M01 문서 셈 = 옛 금액.""" + me = world["personal"] + key = _sk(me, 바탕={"로직": FORM}, 입력값={"유형": "복잡", "사용횟수": "3회"}) + got = _doc(me, key)[dc.KEY] + root, m01 = got["문서"], dc.folder()[FORM] + assert root["키"] == key and [v["기호"] for v in root["변수"]] == [ + v["기호"] for v in m01["변수"] + ] + assert next(v for v in root["변수"] if v["열쇠"] == "유형")["연결"]["값"] == "복잡" + assert not got.get("따로") and got["로직"] and got["단가"] + m01_calc = dc.mc.calc(root, dc.ml.Links(_live(), dc.folder())) + assert _sum(me, key) == m01_calc["계"] + token = dsk.OLD.set(True) + try: + assert _sum(me, key) == m01_calc["계"] # 옛 엔진 금액과 같음 + finally: + dsk.OLD.reset(token) + read = _at(me, path.read, key) + assert read["셈"]["계"] == m01_calc["계"] and read["사본"]["따로"] is False + + +def test_edit_goes_to_copy_doc(world): + """② 고침 = 그 사본 문서(따로) · M01 문서 그대로 · 옛 칸 고침 저장 409 · 다시 열어 같음.""" + me = world["personal"] + key = _sk(me, 바탕={"로직": PCT}) + was, m01_before = _sum(me, key), dc.fp(dc.folder()[PCT]) + read = _at(me, path.read, key) + pre = _at(me, path.preview, key, {"문서": _twice(read["문서"])}) + assert pre["전"]["계"] == was and pre["후"]["계"] > was + saved = _at(me, path.save, key, {"문서": _twice(read["문서"]), "판": read["판"]}) + assert saved["사본"]["따로"] is True and saved["셈"]["계"] == pre["후"]["계"] + assert _sum(me, key) == pre["후"]["계"] # SK 금액(일위대가가 쓰는 값) = 고친 문서 + assert dc.fp(dc.folder()[PCT]) == m01_before + assert _at(me, path.read, key)["문서"] == saved["문서"] + doc = _doc(me, key) + with pytest.raises(store.StoreError) as e: + _save(me, key, {**doc, "입력값": {"x": 1}, "덮개": {"labor:LB000036#0": {"qty": 3}}}) + assert e.value.status == 409 + _save(me, key, {**doc, "이름": "sub2임시 이름만"}) # 머리 칸 = 그대로 받음 + assert _sum(me, key) == pre["후"]["계"] + with pytest.raises(store.StoreError) as e: # 판 다름 + _at(me, path.save, key, {"문서": read["문서"], "판": read["판"]}) + assert e.value.status == 409 + + +def test_price_refresh_values_only(world, monkeypatch): + """③ [단가 갱신] = 자원 단가만 — M01 문서 바뀜은 안 따라감 · 바뀐자원 · 누르기 전 그대로.""" + me = world["personal"] + key = _sk(me, 바탕={"로직": PCT}) + was = _sum(me, key) + root = _at(me, path.read, key)["문서"] + _fake(monkeypatch, [_changed(_live().logic(PCT)), _double(LB)]) + assert _sum(me, key) == was + pre = _at(me, path.preview, key, {"할일": "단가갱신"}) + assert [x["키"] for x in pre["바뀐자원"]] == [LB] and pre["문서"] == root + fresh = _sum(me, _sk(me, 바탕={"로직": PCT})) # 새 문서 + 새 단가 + assert pre["후"]["계"] != was and pre["후"]["계"] != fresh + read = _at(me, path.read, key) + got = _at(me, path.save, key, {"할일": "단가갱신", "판": read["판"]}) + assert got["셈"]["계"] == pre["후"]["계"] == _sum(me, key) and got["문서"] == root + assert got["사본"]["단가새값"] is False and PCT in got["사본"]["문서새판"] + + +def test_regen_takes_m01_doc_drops_edits(world, monkeypatch): + """④ [재생성] = 지금 M01 문서로 · 고친 문서 버림(알림) · 파생으로 돌아감.""" + me = world["personal"] + key = _sk(me, 바탕={"로직": PCT}) + read = _at(me, path.read, key) + _at(me, path.save, key, {"문서": _twice(read["문서"], 1), "판": read["판"]}) + _fake(monkeypatch, [_changed(_live().logic(PCT))]) + fresh = _sum(me, _sk(me, 바탕={"로직": PCT})) + pre = _at(me, path.preview, key, {"할일": "재생성", "문서": {"아무": "버려짐"}}) + assert pre["버림"]["문서고침"] is True and pre["후"]["계"] == fresh + read = _at(me, path.read, key) + got = _at(me, path.save, key, {"할일": "재생성", "판": read["판"]}) + assert got["사본"]["따로"] is False and got["셈"]["계"] == fresh == _sum(me, key) + assert got["사본"]["M01새판"] is False and got["사본"]["문서새판"] == [] + + +def test_link_follows_on_regen_unlink_freezes(world, monkeypatch): + """⑤ 단가산출 연결 = 부름 사본(M01 바뀌어도 그대로 · [재생성] 때 따라감) · 끊기 = 사본 안 고침.""" + me = world["personal"] + key = _sk(me, 바탕={"로직": FORM}, 더한줄=[copy.deepcopy(ADD)]) + was = _sum(me, key) + read = _at(me, path.read, key) + assert BASIC in read["부름"] + cut = {BASIC: _twice(read["부름"][BASIC])} + pre = _at(me, path.preview, key, {"끊음": cut}) + assert pre["후"]["계"] > was + saved = _at(me, path.save, key, {"끊음": cut, "판": read["판"]}) + assert saved["끊음"][BASIC] == {**cut[BASIC], "키": BASIC} and _sum(me, key) == pre["후"]["계"] + _fake(monkeypatch, [_changed(_live().logic(BASIC))]) # 원본 바뀜 + assert _sum(me, key) == pre["후"]["계"] # 끊은 것 · 연결 모두 그대로(누르기 전) + with pytest.raises(store.StoreError) as e: # 부르지 않는 키 끊기 + _at(me, path.preview, key, {"끊음": {"GC999999": read["부름"][BASIC]}}) + assert e.value.status == 400 + read = _at(me, path.read, key) + pre = _at(me, path.preview, key, {"할일": "재생성"}) + assert pre["버림"]["끊음"] == [BASIC] + got = _at(me, path.save, key, {"할일": "재생성", "판": read["판"]}) + assert got["끊음"] == {} and got["셈"]["계"] > was # 연결 = 새 원본 따라감 + + +def test_sc_and_labor_use_doc(world, monkeypatch): + """⑥ SC · 일위대가 로직 줄 = 문서 셈(부름 사본) · 옛 엔진 금액과 같음 · M01 바뀌어도 그대로.""" + me = world["personal"] + sc = _sc(me, [{"로직": PCT, "수량": {"손": 1}}]) + _labor(me, [{"로직": FORM, "수량": {"손": 2}, "입력값": {"유형": "보통"}}]) + stored = _at(me, store.read, "precombo", sc)["문서"] + assert dc.has(stored) and PCT in stored[dc.KEY]["부름"] + runs = [] + real = dc.run + monkeypatch.setattr(dc, "run", lambda *a, **k: runs.append(1) or real(*a, **k)) + new = (_sc_calc(me, sc)["기준합"], _sc_calc(me, "sub2임시")["반영합"]) + assert runs + token = dsk.OLD.set(True) + try: + assert (_sc_calc(me, sc)["기준합"], _sc_calc(me, "sub2임시")["반영합"]) == new + finally: + dsk.OLD.reset(token) + _fake(monkeypatch, [_changed(_live().logic(PCT))]) + assert _sc_calc(me, sc)["기준합"] == new[0] + + +@pytest.mark.parametrize( + "code, rate, picks, marks", + [ + ("GC000261", 90, None, None), # 켜쌓기율 = 변수 r · 수량 × r ÷ 100 + ("GF000156", None, {"3": "MT004176", "4": "MT003834"}, None), # 고른 자재 = 요소 키 + ("GC000665", None, None, None), # 별도계상(기본) = 합밖 · 「재료비의 %」 밑엔 듦 + ], +) +def test_line_rules_in_doc(code, rate, picks, marks): + """⑥ 일위대가 줄 옵션 = 문서 규칙(브레인 ④) — 옛 `run_line` · `drop_apart` 와 같은 금액.""" + whole = _live() + old = labor.drop_apart(labor.run_line(whole, code, {}, rate, picks), marks) + assert old["기준"] + apart = dc.apart_of(marks, old.get("재료")) + doc = dc.line_doc(dc.folder()[code], rate, picks, apart) + if rate: + assert dc.RATE_ID in str(doc["금액"][0]["수량"]) + if apart: + assert all(doc["금액"][n].get("합밖") for n in apart) + got = dc.mc.calc(doc, dc.ml.Links(whole, dc.folder()), dc.given_of(whole, code, {})) + assert {k: got[k] for k in dc.COSTS} == {k: old["기준"][k] for k in dc.COSTS} + assert dc.line_doc(dc.folder()[code]) is dc.folder()[code] # 옵션 없음 = 그 문서 + + +def test_b09_copy_has_doc_file(world, monkeypatch): + """⑦ B09 사본 = 단가 묶음 옆 `수식.json`(만든 때 부름) · 원본판엔 안 듦 · M01 바뀌어도 그대로.""" + me = world["personal"] + doc = _labor(me, [{"로직": PCT, "수량": {"손": 2}}]) + rows = source.labor_doc_rows(doc) + files = doc_b09.attach({"일위대가.json": rows, "단가.json": source.price_bundle(rows, me)}) + made = files[doc_b09.FILE] + assert made["그룹"] == "수식문서" and PCT in made["문서"] + assert doc_b09.FILE not in snap.source_files(files) + assert doc_b09.attach(files) is files # 이미 있음 = 그대로 + _fake(monkeypatch, [_changed(_live().logic(PCT))]) + again = doc_b09.build(source.price_bundle(rows, me), rows) + assert again["문서"][PCT] == made["문서"][PCT] # 일위대가 수식 사본 = 만든 때 + + +def _session(role="USER"): + return {"role": role, "company_id": 7, "user_id": 42} + + +def test_permission_layer(world): + """⑧ 읽기 = 층 읽기 · 미리 셈 · 저장 = 층 쓰기(마스터 = 시스템 관리자 · 회사 = 회사 관리자).""" + me = world["personal"] + key = _sk(me, 바탕={"로직": PCT}) + body = router.DocBody(문서=_at(me, path.read, key)["문서"]) + for layer in ("system", "company"): + for fn in (router.preview_doc, router.save_doc): + with pytest.raises(HTTPException) as e: + fn(layer, key, body, None, _session()) + assert e.value.status_code == 403 + assert router.read_doc("personal", key, None, _session())["셈"]["셈됨"] + got = router.preview_doc("personal", key, body, None, _session()) + assert got["후"] == got["전"] + + +def test_delete_tolerant_and_blank(world): + """⑨ 무엇을 지워도 오류 없음 — 변수 지움 = 정의 필요 · 금액 「—」 · 저장됨 · 빈 SK = 「—」.""" + me = world["personal"] + key = _sk(me, 바탕={"로직": PCT}) + read = _at(me, path.read, key) + gone = copy.deepcopy(read["문서"]) + gone["변수"] = [v for v in gone["변수"] if v["열쇠"] != "w_지붕잇기공"] + pre = _at(me, path.preview, key, {"문서": gone}) + assert pre["셈"]["셈됨"] is False and pre["후"] is None + assert any(m["갈래"] == "정의 필요" for m in pre["셈"]["표지"]) + _at(me, path.save, key, {"문서": gone, "판": read["판"]}) + got = _calc(me, _doc(me, key)) + assert got["기준"] is None and got["수식셈"]["셈됨"] is False + lost = copy.deepcopy(read["문서"]) + lost["변수"].append({"열쇠": "b", "기호": "b", "연결": {"갈래": "단가산출", "키": "GC999999"}}) + lost["금액"].append({"이름": "없는 기초", "비목": "묶음", "단가": "b"}) + pre = _at(me, path.preview, key, {"문서": lost}) + assert any(m["갈래"] == "연결 끊김" for m in pre["셈"]["표지"]) + for bad in ({"문서": "글"}, {"문서": {"금액": "x"}}, {"끊음": []}, {"할일": "몰라"}): + with pytest.raises(store.StoreError) as e: + _at(me, path.preview, key, bad) + assert e.value.status == 400 + empty = _sk(me) + got = _calc(me, _doc(me, empty)) + assert got["기준"] is None and "빈 문서" in got["수식셈"]["까닭"]