Files
Aislo/M02_MasterTemplete/M02_Doc_Copy.py
T
eomsangdonandClaude Opus 5.5 040fc6458e 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>
2026-11-07 19:40:04 +09:00

280 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)}