- 문서 칸 `수식`(옛 `사본` 은 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>
360 lines
16 KiB
Python
360 lines
16 KiB
Python
"""M02 단가산출(SK) 로직 사본(PLAN 795-1 · 요구 655 · 656 · 요구서_655 0 · 1 장) — 서버 단독.
|
|
|
|
사본 = 저장 때 서버가 찍는 M01 닫힌 묶음(`M02_Item_Prices.collect` 다시 씀) — 문서 칸 `사본`:
|
|
{판, 시점, 로직 [M01 줄], 단가판, 단가시점, 단가 [M01 줄], 빠진?}
|
|
로직 = 바탕 로직 · 더한줄 로직 · 가져온 식 이음 · 하위 로직 · 찾기 표 · 그 밖 요소(요율 등)
|
|
단가 = 자원 단가 줄(`RES` 노임 · 자재 · 기계 · 유가전력 · 환율) = 단가 기준 시점 묶음.
|
|
셈 = M01 색인 위에 사본 줄을 얹은 `Copy`(키 · 원문번호 찾기만 사본 먼저 · M01 색인 안 고침) —
|
|
`M02_Sangeun_Calc._base_of`(바탕 줄) · `run`(엔진 · 하위 로직 · 표 · 단가)이 `over` 로 부름 ·
|
|
M01 에서 지워진 키 = 사본의 마지막 줄로 셈(표지 `지운키`) ·
|
|
사본 없는 SK(옛 SK · 바탕 참조) = 옛 길.
|
|
사본은 셈 · 저장 첫머리 `settle` 이 정함 — 저장본에 사본이 있으면 그것(화면이 보낸 것 버림) ·
|
|
없으면 받은 사본(층 복사 · 새로 · 판 다시 셈) · 그것도 없으면 저장 때 새로 찍음.
|
|
할일 칸 `사본할일`(화면 캐시 · [저장] 때 적용):
|
|
단가갱신 = 단가 줄만 지금 M01 값(지워진 키 = 그대로) ·
|
|
재생성 = 지금 M01 로 새로 찍음 — 덮개 · 식덮개 · 변수줄 · 상수 · 표값 · 까닭은 cid 열쇠라
|
|
새 사본에 그대로 얹힘 · 못 얹은 칸 = 짝잃음.
|
|
저장 때 = 사본에 없는 새 줄(더한줄 로직 더함 · 기초 바꿈)만 지금 M01 에서 더함.
|
|
길(`M02_Sangeun_Router`): POST …/sangeun/copy/{price|regen} 몸 {문서, 입력값?} →
|
|
{문서(사본할일), 전, 후, 바뀐자원 [{키, 이름, 전, 후, 지워짐?}] · 못얹은 [{cid, 까닭}]}
|
|
(저장 없음).
|
|
셈 답 덧칸 `사본` = {시점, 단가시점, M01새판, 지운키, 단가새값} · 같은 내용 지문 몫 = `same_part`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
from datetime import datetime
|
|
from decimal import Decimal
|
|
from typing import Any
|
|
|
|
from M02_MasterTemplete import M02_Item_Prices as prices
|
|
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
|
from M02_MasterTemplete import M02_Sangeun_Imported as imp
|
|
|
|
KEY = "사본"
|
|
JOB = "사본할일"
|
|
PRICE, REGEN = "단가갱신", "재생성"
|
|
RES = ("LB", "MT", "EQ", "MO", "FX") # 자원 단가 = 단가 기준 시점 묶음
|
|
_MASTER = prices.mf.Master
|
|
|
|
|
|
def whole_() -> Any:
|
|
return prices.m01.loaded()[1]
|
|
|
|
|
|
def _fp(rows: Any) -> str:
|
|
raw = json.dumps(prices.to_json(rows), ensure_ascii=False, sort_keys=True)
|
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
|
|
|
|
|
|
def _now() -> str:
|
|
return datetime.now().isoformat(timespec="seconds")
|
|
|
|
|
|
def has(doc: Any) -> bool:
|
|
copy = doc.get(KEY) if isinstance(doc, dict) else None
|
|
return isinstance(copy, dict) and isinstance(copy.get("로직"), list)
|
|
|
|
|
|
# ── 셈 색인 ──────────────────────────────────────────────────────────
|
|
class Copy:
|
|
"""M01 색인 위 사본 줄 — `get` · `logic` · `value` · `label` = 엔진 `Master` 한 벌
|
|
(사본 줄 먼저) · 그 밖 칸(자재 색인 · 찾기 답)은 속 M01 것을 읽음."""
|
|
|
|
get, label, value, logic = _MASTER.get, _MASTER.label, _MASTER.value, _MASTER.logic
|
|
|
|
def __init__(self, whole: Any, rows: list[dict], tag: Any = None) -> None:
|
|
self._whole, self._tag, self.twice = whole, tag, whole.twice
|
|
self.index, self.numbers = dict(whole.index), dict(whole.numbers)
|
|
for row in rows:
|
|
key = str(row.get("키"))
|
|
tid = key[:2]
|
|
self._own(self.index, whole.index, tid)[key] = row
|
|
if row.get("원문번호"):
|
|
self._own(self.numbers, whole.numbers, tid)[str(row["원문번호"])] = row
|
|
|
|
@staticmethod
|
|
def _own(mine: dict, theirs: dict, tid: str) -> dict:
|
|
if mine.get(tid) is theirs.get(tid):
|
|
mine[tid] = dict(theirs.get(tid) or {})
|
|
return mine[tid]
|
|
|
|
def __getattr__(self, name: str) -> Any:
|
|
return getattr(self._whole, name)
|
|
|
|
def __copy__(self) -> Copy: # 수식 고침(796-2b · `master_equation_edit.swap`) — 얕은 사본
|
|
out = object.__new__(Copy)
|
|
out.__dict__.update(self.__dict__)
|
|
return out
|
|
|
|
|
|
def _rows(copy: dict) -> list[dict]:
|
|
raw = json.dumps(prices.to_json([*copy["로직"], *(copy.get("단가") or [])]), ensure_ascii=False)
|
|
return json.loads(raw, parse_float=Decimal, parse_int=Decimal)
|
|
|
|
|
|
_CACHE: dict[tuple, Copy] = {}
|
|
_SEEN: dict[tuple, dict] = {}
|
|
_LIVE: list[Any] = [None] # 지금 M01 색인 — 바뀌면(저장 · 다시 읽음) 메모 비움(옛 색인 안 붙잡음)
|
|
|
|
|
|
def _forget(whole: Any) -> None:
|
|
while isinstance(whole, Copy):
|
|
whole = whole._whole
|
|
if whole is not _LIVE[0]:
|
|
_CACHE.clear()
|
|
_SEEN.clear()
|
|
_LIVE[0] = whole
|
|
|
|
|
|
def over(doc: Any, whole: Any) -> Any:
|
|
"""사본 있는 문서 = 사본 색인 · 없으면 `whole` 그대로(옛 길)."""
|
|
if not has(doc):
|
|
return whole
|
|
copy = doc[KEY]
|
|
tag = (copy.get("판"), copy.get("단가판"))
|
|
if isinstance(whole, Copy) and whole._tag == tag:
|
|
return whole
|
|
_forget(whole)
|
|
got = _CACHE.get((id(whole), *tag))
|
|
if got is None or got._whole is not whole:
|
|
if len(_CACHE) > 32: # ponytail: 통째 비움 · 쓰는 SK 가 많아 느려지면 LRU 로
|
|
_CACHE.clear()
|
|
got = _CACHE[(id(whole), *tag)] = Copy(whole, _rows(copy), tag)
|
|
return got
|
|
|
|
|
|
# ── 찍기 · 단가 갱신 ──────────────────────────────────────────────────
|
|
def _split(rows: list[dict]) -> dict[str, Any]:
|
|
logic = [r for r in rows if str(r.get("키"))[:2] not in RES]
|
|
cost = [r for r in rows if str(r.get("키"))[:2] in RES]
|
|
when = _now()
|
|
return {
|
|
"판": _fp(logic),
|
|
"시점": when,
|
|
"로직": logic,
|
|
"단가판": _fp(cost),
|
|
"단가시점": when,
|
|
"단가": cost,
|
|
}
|
|
|
|
|
|
def stamp(doc: dict) -> dict | None:
|
|
"""지금 M01 로 새 사본 — 바탕 로직 · 더한줄 로직 · 고정 줄 이음 · 가져온 식 줄이 닿는
|
|
닫힌 묶음 · 바탕 참조 · 닿을 것 없음 = None."""
|
|
base = doc.get("바탕") or {}
|
|
if "참조" in base:
|
|
return None
|
|
keys, links = reach(doc.get("더한줄"))
|
|
keys = [str(base["로직"]), *keys] if base.get("로직") else keys
|
|
extra = [row] if (row := imp.row_of(doc)) else []
|
|
return snap(keys, links, extra)
|
|
|
|
|
|
def reach(adds: Any) -> tuple[list[str], list[str]]:
|
|
"""더한줄이 닿는 (로직 키, 요소 키) — 로직 줄 · 인력 · 재료 · 기계 요소 · 고정 줄 이음."""
|
|
adds = [x for x in adds or [] if isinstance(x, dict)]
|
|
keys = [str(x["로직"]) for x in adds if x.get("종류") == "로직" and x.get("로직")]
|
|
links = [str(x["요소"]) for x in adds if x.get("종류") in sd.ADD_PART and x.get("요소")]
|
|
links += [str(v) for x in adds for v in ((x.get("고정") or {}).get("이음") or {}).values() if v]
|
|
return keys, links
|
|
|
|
|
|
def snap(keys: list[str], links: list[str], extra: list[dict] | None = None) -> dict | None:
|
|
"""키 · 요소 · 마스터 밖 줄이 닿는 M01 닫힌 묶음 → 사본(닿을 것 없음 = None)."""
|
|
if not (keys or links or extra):
|
|
return None
|
|
got = prices.collect(keys, links, extra)
|
|
lost = got.pop("_빠진", None)
|
|
keys = [str(r.get("키")) for data in got.values() for r in data[prices.m01.items_key(data)]]
|
|
whole = whole_() # 줄 = 셈 색인 것(파일 묶음과 같은 판)
|
|
rows = [whole.index[k[:2]][k] for k in keys if k in whole.index.get(k[:2], {})]
|
|
out = _split(prices.to_json(rows))
|
|
return out | ({"빠진": lost} if lost else {})
|
|
|
|
|
|
def refresh(copy: dict, whole: Any) -> tuple[dict, list[dict]]:
|
|
"""[단가 갱신] — 단가 줄만 지금 M01 값 · (새 사본, 바뀐자원) · 지워진 키 = 마지막 값 그대로."""
|
|
rows, changed = [], []
|
|
for row in copy.get("단가") or []:
|
|
key = str(row.get("키"))
|
|
live = whole.index.get(key[:2], {}).get(key)
|
|
if live is None:
|
|
rows.append(row)
|
|
changed.append({"키": key, "이름": row.get("이름"), "지워짐": True})
|
|
continue
|
|
new = prices.to_json(live)
|
|
if _fp(new) != _fp(row):
|
|
changed.append(
|
|
{"키": key, "이름": new.get("이름"), "전": row.get("값"), "후": new.get("값")}
|
|
)
|
|
rows.append(new)
|
|
return {**copy, "단가": rows, "단가판": _fp(rows), "단가시점": _now()}, changed
|
|
|
|
|
|
def grow(copy: dict, fresh: dict | None) -> dict:
|
|
"""저장 때 — 사본에 없는 키 줄만 새 사본(`fresh` = 지금 M01)에서 더함(있는 줄 = 그대로)."""
|
|
have = {str(r.get("키")) for r in [*copy["로직"], *(copy.get("단가") or [])]}
|
|
new = [
|
|
r
|
|
for r in [*(fresh or {}).get("로직", []), *(fresh or {}).get("단가", [])]
|
|
if str(r.get("키")) not in have
|
|
]
|
|
if not new:
|
|
return copy
|
|
logic = [*copy["로직"], *(r for r in new if str(r.get("키"))[:2] not in RES)]
|
|
cost = [*(copy.get("단가") or []), *(r for r in new if str(r.get("키"))[:2] in RES)]
|
|
return {**copy, "로직": logic, "판": _fp(logic), "단가": cost, "단가판": _fp(cost)}
|
|
|
|
|
|
def rejoin(copy: dict, adds: list) -> dict:
|
|
"""[다시 잇기](796-2e) — 표지 낀 더한 기초가 닿는 닫힌 묶음을 지금 M01 줄로 갈음(단가 줄은 더하기만)."""
|
|
keys = [
|
|
str(x["로직"]) for x in adds if isinstance(x, dict) and x.get("다시잇기") and x.get("로직")
|
|
]
|
|
fresh = snap(keys, [])
|
|
if not fresh:
|
|
return copy
|
|
new = {str(r.get("키")): r for r in fresh["로직"]}
|
|
logic = [new.pop(str(r.get("키")), r) for r in copy["로직"]] + list(new.values())
|
|
return grow({**copy, "로직": logic, "판": _fp(logic)}, fresh)
|
|
|
|
|
|
def taken(raw: Any) -> dict | None:
|
|
"""받은 사본(층 복사 · 새로) — 줄 꼴만 보고 판은 서버가 다시 셈."""
|
|
if not has({KEY: raw}):
|
|
return None
|
|
logic = [r for r in raw["로직"] if isinstance(r, dict) and str(r.get("키"))[:2] not in RES]
|
|
cost = [r for r in raw.get("단가") or [] if isinstance(r, dict) and str(r.get("키"))[:2] in RES]
|
|
keep = {k: str(raw.get(k) or "") for k in ("시점", "단가시점")}
|
|
return {**keep, "로직": logic, "판": _fp(logic), "단가": cost, "단가판": _fp(cost)}
|
|
|
|
|
|
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:
|
|
got = json.loads((store.root() / sd.KIND / f"{key}.json").read_bytes())
|
|
except (OSError, ValueError):
|
|
return None
|
|
return got[KEY] if has(got) else None
|
|
|
|
|
|
def settle(doc: Any, save: bool = False) -> Any:
|
|
"""셈 · 저장 첫머리 — 서버가 정한 사본으로 갈음(할일 적용 · 저장 때 새 줄 더함 · 할일 칸 뗌)."""
|
|
if not isinstance(doc, dict):
|
|
return doc
|
|
job = doc.get(JOB) # 모르는 할일 = 없음
|
|
out = {k: v for k, v in doc.items() if k not in (KEY, JOB)}
|
|
if "참조" in (doc.get("바탕") or {}):
|
|
return out
|
|
copy = _stored(doc.get("키")) or taken(doc.get(KEY))
|
|
if job == REGEN or (copy is None and save):
|
|
copy = stamp(out)
|
|
elif job == PRICE and copy:
|
|
copy = refresh(copy, whole_())[0]
|
|
if copy and any(isinstance(x, dict) and x.get("다시잇기") for x in out.get("더한줄") or []):
|
|
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 · 같은 판 = 메모)."""
|
|
_forget(whole)
|
|
tag = (id(whole), copy.get("판"), copy.get("단가판"))
|
|
if tag in _SEEN and _SEEN[tag]["whole"] is whole:
|
|
return _SEEN[tag]["답"]
|
|
|
|
def live(row: dict) -> Any:
|
|
key = str(row.get("키"))
|
|
return whole.index.get(key[:2], {}).get(key)
|
|
|
|
gone = [str(r.get("키")) for r in [*copy["로직"], *(copy.get("단가") or [])] if live(r) is None]
|
|
logic = any(live(r) is None or _fp(live(r)) != _fp(r) for r in copy["로직"])
|
|
cost = any(live(r) is not None and _fp(live(r)) != _fp(r) for r in copy.get("단가") or [])
|
|
got = {"M01새판": logic, "지운키": gone, "단가새값": cost}
|
|
_SEEN[tag] = {"whole": whole, "답": got}
|
|
return got
|
|
|
|
|
|
def mark(ans: dict, doc: dict) -> dict:
|
|
"""셈 답 덧칸 `사본` — 사본 없는 문서 = 칸 없음."""
|
|
if not has(doc):
|
|
return ans
|
|
copy = doc[KEY]
|
|
head = {"시점": copy.get("시점"), "단가시점": copy.get("단가시점")}
|
|
return {**ans, KEY: {**head, **state(copy, whole_())}}
|
|
|
|
|
|
def same_part(doc: Any) -> str | None:
|
|
"""같은 내용 지문 몫 — 지금 M01 과 같은 사본 = None(새로 찍을 문서와 같음) · 다르면 판."""
|
|
if not has(doc):
|
|
return None
|
|
copy = doc[KEY]
|
|
seen = state(copy, whole_())
|
|
fresh = not (seen["M01새판"] or seen["지운키"] or seen["단가새값"])
|
|
return None if fresh else f"{copy.get('판')}:{copy.get('단가판')}"
|
|
|
|
|
|
# ── [단가 갱신] · [재생성] 미리 셈(저장 없음) ──────────────────────────
|
|
def _money(ans: dict) -> dict | None:
|
|
base = ans.get("기준")
|
|
return {k: base.get(k) for k in ("노무비", "재료비", "경비", "계")} if base else None
|
|
|
|
|
|
def _lost(ans: dict) -> dict[str, dict]:
|
|
rows = [*(ans.get("짝잃음") or []), *((ans.get("시트") or {}).get("짝잃음") or [])]
|
|
return {str(x.get("cid")): x for x in rows if isinstance(x, dict)}
|
|
|
|
|
|
def preview(doc: Any, job: str, given: dict | None = None) -> dict[str, Any]:
|
|
"""{문서(할일 얹음), 전, 후, 바뀐자원 · 못얹은} — 재생성은 단가갱신을 품음."""
|
|
from M02_MasterTemplete import M02_Sangeun_Calc as calc
|
|
|
|
if not isinstance(doc, dict):
|
|
raise sd.DocError("단가산출 문서가 묶음 아님")
|
|
old = doc.get(JOB)
|
|
new = REGEN if REGEN in (old, job) else job
|
|
after_doc = {**doc, JOB: new}
|
|
before = calc.calc_doc(doc, given, False)
|
|
after = calc.calc_doc(after_doc, given, False)
|
|
out: dict[str, Any] = {"문서": after_doc, "전": _money(before), "후": _money(after)}
|
|
if cut := [
|
|
str(x.get("이름") or x.get("로직")) for x in doc.get("더한줄") or [] if x.get("끊음")
|
|
]:
|
|
out["끊은기초"] = cut # 줄은 그대로 · 자원 단가만(요구 656-1)
|
|
if job == PRICE:
|
|
copy = _stored(doc.get("키")) or taken(doc.get(KEY))
|
|
out["바뀐자원"] = refresh(copy, whole_())[1] if copy else []
|
|
else:
|
|
was = _lost(before)
|
|
out["못얹은"] = [
|
|
{"cid": c, "까닭": x.get("까닭") or "새 사본에 그 줄 없음"}
|
|
for c, x in _lost(after).items()
|
|
if c not in was
|
|
]
|
|
return out
|