Files
Aislo/M01_MasterData/M01_MasterData_Store_Make.py
T
eomsangdonandClaude Opus 5.5 712d3f19d5 fix(ui): 49 오른쪽 접는 패널 = 좌측 패널 손잡이 양식 · 패널 몸만 따로 스크롤 · 원문 보기 한 벌
- 49-1 공용 ui_template_side_panels — 세로 글 띠 · 접기 ▸ 걷어내고 공용 createWorkflowPanelHandle(side · right · 이름표) 손잡이를 패널 왼쪽 가장자리에 붙임 · 패널마다 손잡이 하나 · 하나만 열림 · 폭 공유 · 모두 접힘(폭 0 · 손잡이만 남음) — M01 로직 상세 · M02 일위대가 상세 · 면적산출 상세 모두 따라감
- 49-3 패널 묶음을 보이는 칸 안에 붙임(followView · 위 칸 overflow 가 CSS sticky 를 막아 JS) — 높이 = 보이는 칸 · 머리띠 아래 멈춤 · 몸만 스크롤
- 49-2 원문 md 읽기 — 빈 줄 · 주석이 붙은 표 둘을 가름(한 표로 합쳐지던 깨짐) · 칸 안 <br> = 줄바꿈
- 49-4 원문 칸 80vh 제한 걷어냄 — 표 높이 = 내용 · 가로 넓은 표만 가로 스크롤
- 49-5 원문 꼴 한 벌 — M01 둘레 글자 크기(14) 안 따르고 본문 크기(M02 와 같음)
- 확인 시나리오 ui_checks/49_오른쪽패널_양식.js · 시험 test_m01_logic_source 표 가름 한 줄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
2026-10-05 16:16:59 +09:00

389 lines
16 KiB
Python

"""M01 「만들기」 저장소 — 원문 절 목록 · 표 조건 값 · 자체 로직 만들기·본뜨기·고치기·지우기
· 복사해서 만들기(정본 로직을 별칭 꼴 식으로 펴 보이고 되돌려 저장).
계약: `resources/master_data/_화면_계약.md` 2장·5장·7장 · 파일 모양: `_틀.md` 7장.
자체 로직은 `로직_자체.json` 한 파일 · 키는 `_키대장.json` 의 `GX` 다음 번호(서버가 줌).
"""
from __future__ import annotations
import re
from datetime import date
from pathlib import Path
from M01_MasterData import M01_MasterData_Store as store
cm, mk, mcp = store.cm, store.mk, store.mcp
OWN = "로직_자체.json" # 자체 로직 한 자리 — 이 파일 줄만 고치고 지울 수 있음
OWNERS = ("현장", "공용")
BODY = mk._BODY # resources/knowledge/original/원가계산
BOOKS = {"산림품셈": "산림_표준품셈", "건설품셈": "건설공사_표준품셈"}
_HEAD = re.compile(r"^#{2,6}\s*(\d+(?:-\d+)*)\.?\s+(\S.*?)\s*$")
_CHAPTER_DIR = re.compile(r"^제(\d\d)장_(.+)$")
# ── 원문 절 목록 ───────────────────────────────────────────────────────
def _chapters(book: str):
"""(부문, 장 번호, 장 제목, 폴더) — 건설품셈은 부문 폴더 아래."""
root = BODY / BOOKS[book] / "본문"
tops = sorted(root.glob("*부문")) if book == "건설품셈" else [root]
for top in tops:
division = top.name.split("_", 1)[1].removesuffix("부문") if book == "건설품셈" else ""
for folder in sorted(top.glob("제*장_*")):
m = _CHAPTER_DIR.match(folder.name)
if m:
yield division, m.group(1), m.group(2).replace("_", " "), folder
_CACHE: dict[str, list[dict]] = {}
def _all_sections(book: str) -> list[dict]:
"""원문 본문 제목 줄에서 모은 절 — 한 번 세워 두고 다시 씀(원문은 안 바뀜)."""
if book in _CACHE:
return _CACHE[book]
out, seen = [], set()
for division, number, title, folder in _chapters(book):
for md in sorted(folder.glob("*.md")):
for line in md.read_text(encoding="utf-8").split("\n"):
m = _HEAD.match(line)
if not m or m.group(1).split("-")[0] != str(int(number)):
continue
if (division, m.group(1)) in seen:
continue
seen.add((division, m.group(1)))
out.append(
{
"book": book,
"division": division,
"chapter": f"{division} {number}장 {title}".strip(),
"chapterNo": number,
"section": m.group(1),
"title": m.group(2),
}
)
_CACHE[book] = out
return out
def _counts() -> dict[tuple[str, str], list[int]]:
"""(구분, 절 번호) → [표 수, 로직 수] — 아래 절까지 세도록 절 번호의 모든 앞마디에 얹음."""
out: dict[tuple[str, str], list[int]] = {}
for data in store.loaded()[0].values():
group = data.get("그룹")
at = 0 if group in cm.mf.TABLE_GROUPS else 1 if group == "로직" else None
if at is None:
continue
for row in data.get(store.items_key(data)) or []:
number, sub = str(row.get("원문번호") or ""), str(row.get("구분") or "")
parts = number.split("-")
for upto in range(1, len(parts) + 1):
out.setdefault((sub, "-".join(parts[:upto])), [0, 0])[at] += 1
return out
def sections(book: str, division: str = "", chapter: str = "", q: str = "", limit: int = 200):
"""원문 절 목록 — 만들기 첫 걸음(절을 고르면 그 절의 표만 보임)."""
if book not in BOOKS:
raise store.StoreError(404, f"없는 원문 「{book}」")
if division and division not in cm.DIVISIONS:
raise store.StoreError(404, f"없는 부문 「{division}」")
counts, hits = _counts(), []
for one in _all_sections(book):
if division and one["division"] != division:
continue
if chapter and one["chapterNo"] != str(chapter).zfill(2):
continue
if q and q.lower() not in f"{one['section']} {one['title']}".lower():
continue
sub = " ".join(x for x in (book, one["division"]) if x)
tables, logics = counts.get((sub, one["section"]), [0, 0])
hits.append({**one, "tables": tables, "logics": logics})
return {"total": len(hits), "sections": hits[: min(max(limit, 1), 1000)]}
# ── 로직 원문(출처 글 → 품셈 본문 절) ──────────────────────────────────
_NUMBER = re.compile(r"(?<![\d-])\d+(?:-\d+)+(?![\d-])")
def _blocks(lines: list[str]) -> list[dict]:
"""md 줄 → 화면 조각 — 제목 {h} · 표 {table} · 글 {p} · 주석 줄은 뺌 ·
빈 줄 · 주석이 표를 끊음(붙은 표 둘을 한 표로 안 합침) · `<br>` = 줄바꿈."""
out: list[dict] = []
joinable = False
for line in lines:
text = re.sub(r"<br\s*/?>", "\n", line.strip())
if not text or text.startswith("<!--"):
joinable = False
continue
if text.startswith("#"):
out.append({"h": len(text) - len(text.lstrip("#")), "text": text.lstrip("#").strip()})
elif text.startswith("|"):
cells = [c.strip() for c in text.strip("|").split("|")]
if all(re.fullmatch(r":?-{3,}:?", c) for c in cells):
continue # 머리 · 몸 가름 줄
if not joinable:
out.append({"table": []})
out[-1]["table"].append(cells)
joinable = True
continue
else:
out.append({"p": text})
joinable = False
return out
def _section(book: str, division: str, number: str) -> dict | None:
"""번호 절을 품셈 본문에서 찾음 — 없으면 끝 마디를 하나씩 떼며 위 절로(두 마디까지)."""
parts = number.split("-")
folders = [f for d, no, _t, f in _chapters(book) if d == division and int(no) == int(parts[0])]
for upto in range(len(parts), 1, -1):
want = "-".join(parts[:upto])
for folder in folders:
for md in sorted(folder.glob("*.md")):
lines = md.read_text(encoding="utf-8").split("\n")
for i, line in enumerate(lines):
m = _HEAD.match(line)
if not m or m.group(1) != want:
continue
level = len(line) - len(line.lstrip("#"))
end = next(
(
j
for j in range(i + 1, len(lines))
if _HEAD.match(lines[j])
and len(lines[j]) - len(lines[j].lstrip("#")) <= level
),
len(lines),
)
return {
"number": number,
"matched": want,
"file": md.relative_to(BODY.parent).as_posix(),
"blocks": _blocks(lines[i:end]),
}
return None
def source(src: str) -> dict:
"""로직 출처 글(예 「건설품셈 토목 1-1-2」 · 「산림품셈 3-4-2 1」)로 찾은 품셈 원문 절 —
번호가 여럿이면(「8-1-5 · 8-1-6」) 절마다 · 못 찾으면 found 빈 목록(화면 「원문 연결 없음」)."""
words = src.split()
book = words[0] if words else ""
if book not in BOOKS:
return {"source": src, "found": []}
division = (
next((d for d in cm.DIVISIONS if src.startswith(f"{book} {d} ")), "")
if book == "건설품셈"
else ""
)
found = [_section(book, division, n) for n in dict.fromkeys(_NUMBER.findall(src))]
return {"source": src, "found": [x for x in found if x]}
# ── 표 조건 값 ─────────────────────────────────────────────────────────
def table_options(file: str, key: str) -> dict:
"""표 조건 칸의 값만 — 표 몸을 통째로 받지 않으려는 자리(만들기 입력 제안)."""
table = store.table(file, key)["table"]
conds = table.get("조건") or {}
values: dict[str, list] = {name: [] for name in conds}
for row in table.get("줄") or []:
for name in conds:
value = row.get(name)
if value is None or value in values[name]:
continue
values[name].append(value)
return {
"file": file,
"key": key,
"이름": table.get("이름"),
"조건": conds,
"범위규칙": table.get("범위규칙"),
"값칸": table.get("값칸") or {},
"count": len(table.get("줄") or []),
"값": values,
}
# ── 자체 로직 ──────────────────────────────────────────────────────────
def _own_path() -> Path:
return store.FOLDER / OWN
def _make_file() -> None:
"""자체 로직 파일이 없으면 머리만 두고 만듦."""
path = _own_path()
if path.is_file():
return
head = {"그룹": "로직", "원문": "자체", "판": str(date.today().year), "줄": []}
path.write_text(store.dump(head), encoding="utf-8", newline="\n")
def _find(key: str) -> tuple[str, dict]:
got = store.logic(key)
return got["file"], got["logic"]
def _own_only(file: str, key: str) -> None:
if file != OWN:
raise store.StoreError(403, f"정본 로직 「{key}」 은 못 고침 — 본뜬 뒤 고칠 것")
def _clean(logic: dict, owner: str, key: str | None = None) -> dict:
"""서버가 잡는 칸 — 키·소유·구분·상세구분. 화면이 보낸 것은 안 씀."""
if not isinstance(logic, dict) or not str(logic.get("이름") or "").strip():
raise store.StoreError(400, "로직 줄에 「이름」 이 없음")
if owner not in OWNERS:
raise store.StoreError(400, f"모르는 소유 「{owner}」 — {' · '.join(OWNERS)}")
number = str(logic.get("원문번호") or "")
rest = {
k: v for k, v in logic.items() if k not in ("키", "원문번호", "구분", "상세구분", "소유")
}
head = {"키": key, "원문번호": number} if key else {"원문번호": number}
one = {**head, "구분": "자체", "상세구분": _chapter_of(logic), "소유": owner, **rest}
order = mk.ROW_ORDER["로직"] # 열 차례는 `_틀.md` 2장
return {
**{k: one[k] for k in order if k in one},
**{k: v for k, v in one.items() if k not in order},
}
def _chapter_of(logic: dict) -> str:
"""상세구분 — 원문 절이 속한 장(「13장 구조물」) · 원문 절이 없으면 빈 글."""
number = str(logic.get("원문번호") or "")
source = str(logic.get("출처") or "")
book = next((b for b in BOOKS if source.startswith(b)), "")
if not number or not book:
return ""
at = int(number.split("-")[0])
division = next((d for d in cm.DIVISIONS if source.startswith(f"{book} {d} ")), "")
for one_division, chapter, title, _ in _chapters(book):
if one_division == division and int(chapter) == at:
return f"{chapter}장 {title}"
return ""
def _write(changes: list[dict]) -> dict:
"""자체 파일 한 벌 저장 — 검사는 `/save` 와 같음(422 면 아무것도 안 씀)."""
fresh = not _own_path().is_file()
_make_file()
try:
out = store.save([{"file": OWN, "version": store.read(OWN)[1], "changes": changes}])
except store.StoreError:
if fresh:
_own_path().unlink(missing_ok=True)
raise
return out[0]
def _keys() -> list[str]:
if not _own_path().is_file():
return []
return [str(r.get("키")) for r in store.read(OWN)[0].get("줄") or []]
def logic_new(logic: dict, owner: str = OWNERS[0]) -> dict:
"""새 자체 로직 — 키는 서버가 `GX` 다음 번호로 줌."""
before = set(_keys())
done = _write([{"op": "add", "row": _clean(logic, owner)}])
key = next((k for k in reversed(_keys()) if k not in before), "")
return {**done, "key": key, "logic": store.logic(key)["logic"] if key else {}}
def logic_copy(key: str, 이름: str = "", owner: str = OWNERS[0]) -> dict:
"""본떠 만들기 — 정본도 본뜰 수 있음 · 비고 끝에 본뜬 키를 적음."""
_, row = _find(key)
note = " · ".join(x for x in (str(row.get("비고") or ""), f"{key} 를 본뜸") if x)
copy = {**row, "비고": note}
if 이름:
copy["이름"] = 이름
return logic_new(copy, owner)
def logic_edit(key: str, version: str, logic: dict, owner: str = "") -> dict:
file, row = _find(key)
_own_only(file, key)
if store.read(OWN)[1] != version:
raise store.StoreError(409, {"stale": [OWN]})
fixed = _clean(logic, owner or str(row.get("소유") or OWNERS[0]), key)
done = _write([{"op": "edit", "key": key, "row": fixed}])
return {**done, "logic": store.logic(key)["logic"]}
# ── 복사해서 만들기 ────────────────────────────────────────────────────
def logic_formula(key: str) -> dict:
"""로직 한 줄을 별칭 꼴 식 묶음으로 펴 보임 — 되돌릴 원문은 `별칭` 에 그대로(정본은 안 바뀜)."""
got = store.logic(key)
master = store.loaded()[1]
return {
"file": got["file"],
"version": got["version"],
"key": key,
"소유": got["logic"].get("소유") or "",
"prices": got["prices"],
**mcp.spread(master, got["logic"]),
}
def _gather(logic: dict, marks: list[dict]) -> dict:
try:
return mcp.gather(logic, marks)
except cm.mf.FormulaError as e:
raise store.StoreError(400, str(e)) from e
def logic_formula_preview(logic: dict, marks: list[dict]) -> dict:
"""별칭을 원문으로 되돌린 로직 줄만 — 저장 안 함(시험 계산 전 미리보기)."""
return {"logic": _gather(logic, marks)}
def logic_from_formula(
logic: dict, marks: list[dict], owner: str = OWNERS[0], 이름: str = "", 본뜬키: str = ""
) -> dict:
"""고친 식 묶음을 자체 로직으로 저장 — 별칭을 원문으로 되돌린 뒤 `logic_new` 를 그대로 씀."""
row = _gather(logic, marks)
if 이름:
row["이름"] = 이름
if 본뜬키:
row["비고"] = " · ".join(x for x in (str(row.get("비고") or ""), f"{본뜬키} 를 본뜸") if x)
return logic_new(row, owner)
def logic_delete(key: str, version: str) -> dict:
file, _ = _find(key)
_own_only(file, key)
if store.read(OWN)[1] != version:
raise store.StoreError(409, {"stale": [OWN]})
return _write([{"op": "delete", "key": key}])
# ── 식으로 만들기 ──────────────────────────────────────────────────────
def logic_draft(
text: str,
decided: dict | None = None,
head: dict | None = None,
save: bool = False,
owner: str = OWNERS[0],
) -> dict:
"""줄로 적은 식 → 변수 · 검사(`_화면_계약.md` 8장).
`head`(이름 · 결과단위 …)가 오면 로직 한 줄까지 미리 보이고, `save` 면 다 정해진 때만
자체 로직으로 저장(키 GX 는 서버) · 덜 정했거나 걸리면 422 로 아무것도 안 씀.
"""
import master_draft as draft # store 가 scripts 를 길에 올린 뒤라야 함
got = draft.read(text, decided, head)
if head is None:
return {**got, "logic": None}
row, bad = draft.옮기기(text, decided, head)
got["문제"] = got["문제"] + [x for x in bad if x not in got["문제"]]
got["다됨"] = not got["문제"]
if save and got["문제"]:
raise store.StoreError(
422, {"errors": [f"{x['줄']}줄 · {x['말']}" for x in got["문제"]], "문제": got["문제"]}
)
if not save:
return {**got, "logic": row or None}
return {**got, **logic_new(row, owner)}