- 문서 칸 `수식`(옛 `사본` 은 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>
280 lines
12 KiB
Python
280 lines
12 KiB
Python
"""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)}
|