feat(802-4a): M02 단가산출 · 일위대가 · SC · B09 사본 = 수식 문서 사본 — 문서 셈 · 수식 길 · 옮기기 도구 · 대조

- 문서 칸 `수식`(옛 `사본` 은 802-5 까지 옆에) = 값 사본(로직 · 자원 단가 줄) + 부름 닫힌 묶음 + SK 문서 · 끊음 · 따로 — `M02_Doc_Copy`(새)
  · 부름 문서 = 값 줄이 지금 M01 줄과 같으면 M01 수식 문서 · 다르면 만든 때 줄 변환
- SK 금액 = 문서 셈(`M02_Doc_Sk.on_run`) — 옛 칸(덮개 · 더한줄 …)은 파생(엔진에 넣던 고친 줄 → 문서 · 덮개 없는 바탕 = M01 문서 사본) · 수식 길 고침 = 따로(옛 칸 고침 저장 409 · 재생성 = 파생으로)
- 일위대가 · SC 로직 줄 = 문서 셈(`M02_Labor_Copy.line`) · 줄 옵션 = 문서 규칙(켜쌓기율 변수 r · 고른 자재 = 요소 키 · 별도계상 = 합밖 + 뒤 「재료비의 %」 밑엔 그 줄 금액 더함)
- [단가 갱신] = 자원 단가 줄만(문서 그대로) · [재생성] = 지금 M01 문서로(고친 문서 · 끊음 버림 알림) · 빈 SK = 빈 문서 「—」
- 수식 길 GET/POST …/sangeun/{키}/doc · /preview · /save(층 쓰기 · 서버 정본 셈) — `M02_Doc_Sk_Path` · `M02_Doc_Router`(새 · main 등록)
- B09 구조물 · 서고 사본 = `수식.json` 함께 굳힘(`M02_Doc_B09` · 원본판 안 듦) · B09 셈 그대로(문서 셈 전환 = 802-6)
- 옮기기 `M02_Doc_Migrate`(보기만 · --apply --backup · --restore) · 목록 `docs/raw/요구_1104/_목록_802-4a_M02대조.xlsx` — 층 SK 15 · 일위대가 21 · SC 4 · B09 서고 사본 3 같음 · 빈 SK 2 = 옛 0 → 「—」 · 가져온 식 1 = 802-4b
- 시험 test_802_4a(12) · test_204_2 서고 사본 파일 목록에 수식.json · test_795_1 `_fake` 가 M01 수식 문서도 같이 바꿈(M01 정본 = 수식 문서 — 옛 줄만 바꾼 흉내는 문서 사본에 안 닿음) · 옛 SK 흉내 = 사본 · 수식 둘 다 뗌

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 19:40:04 +09:00
co-authored by Claude Opus 5.5
parent d51c0b2200
commit 040fc6458e
19 changed files with 1364 additions and 23 deletions
+279
View File
@@ -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)}