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
@@ -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)
+52
View File
@@ -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"))}
+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)}
+233
View File
@@ -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 <xlsx> 목록 파일(기본 `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))
+59
View File
@@ -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))
+187
View File
@@ -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 != "따로"})}
+168
View File
@@ -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)
+6 -3
View File
@@ -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,
+71 -6
View File
@@ -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
+2 -2
View File
@@ -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["코드"],
+4 -7
View File
@@ -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:
+8 -2
View File
@@ -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
+2
View File
@@ -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)
+16
View File
@@ -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 · 같은 판 = 메모)."""
Binary file not shown.
+5
View File
@@ -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,
@@ -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"]]
+8 -2
View File
@@ -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)
+260
View File
@@ -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["수식셈"]["까닭"]