feat(M01): 복사해서 만들기 엔진·서버 — 정본 로직을 별칭 꼴 식으로 펴고 되돌려 자체 로직으로 저장
- `scripts/master_copy.py` — `찾기()`·`로직()` 한 덩이를 별칭 이름으로 · 원문은 별칭표에 그대로 (자르기는 `master_formula` 파서 그대로 · 완전 같은 부르기는 합침 · 인자가 갈리면 등장 차례로 번호) - `GET /logic/formula` · `POST /logic/formula/save` — 저장은 별칭을 원문으로 되돌린 뒤 `logic/new` 재사용 - 계약 `_화면_계약.md` 7장 · 시험 `test_m01_copy.py` 8 통과(로직 1,354줄 되돌리기 전수 포함)
This commit is contained in:
@@ -114,3 +114,24 @@
|
||||
- `자리` = 소수 자리(0 = 원 미만 · 3 = 소수 넷째 자리에서).
|
||||
- `방법` — `버림` · `올림` · `반올림`.
|
||||
- 옛 줄의 글자 `끝수`(「손료 원 미만 버림(…)」)는 원문 근거를 적어 둔 설명 — 계산에 안 붙음(그 끊기는 이미 식 안에 있음).
|
||||
|
||||
## 7. 복사해서 만들기 (별칭 꼴 식)
|
||||
|
||||
정본 로직을 식 줄로 펴 보이고, 고친 식을 자체 로직(`GX`)으로 저장하는 길. 규칙은 `ref/_설계_식_복사.md` · 엔진은 `scripts/master_copy.py`.
|
||||
|
||||
| 길 | 받음 | 줌 |
|
||||
| --------------------------- | -------------------------------------------- | ------------------------------------------------------------- |
|
||||
| `GET /logic/formula` | `key` | `{file, version, key, 소유, prices, 로직, 별칭: […], 줄: […]}` |
|
||||
| `POST /logic/formula/save` | `{로직, 별칭, owner?, 이름?, 본뜬키?}` | `/logic/new` 와 같음 — `{file, version, key, logic}` |
|
||||
|
||||
- `로직` = 로직 한 줄 통째(`_틀.md` 7장) · **식 칸만** 별칭 꼴(`찾기(…)`·`로직(…)` 한 덩이가 이름 하나로). 식이 든 칸 = `호표[].수량` · `호표[].요소`(글자일 때) · `중간[].식` · `덧줄[].식` · `결과.식`.
|
||||
- `별칭` = `{이름, 종류(찾기·로직), 참조, 원문, 밑이름, 값칸, 결과단위, 자리: […]}` — `원문` 이 되돌릴 부르기 글 그대로 · `자리` 는 그 별칭이 쓰인 자리표 목록.
|
||||
- `줄` = 화면에 쌓을 평평한 목록(읽는 차례 중간 → 호표 → 덧줄 → 결과) · `{자리, 갈래, 차례, 이름, 단위, 비목, 식, 출처}` · 호표 줄은 `종류`·`요소` 가 붙음.
|
||||
- 별칭 이름 — 표·로직의 `이름` 에서 낱말 글자만 남김 · 비거나 12자를 넘으면 키를 그대로 · 완전히 같은 부르기는 하나로 합침 · 같은 표를 다른 인자로 부르면 등장 차례로 번호(`합판거푸집1`…). 입력·중간·비목·함수·표 값 칸 이름과 겹치면 뒤에 번호를 더 붙임.
|
||||
- 값 칸(`.석공`)은 별칭 뒤에 그대로 둠 — 별칭은 부르기 한 덩이까지만.
|
||||
- **별칭은 저장하지 않음** — 화면에 보일 때만 씌우는 겉치레. 저장은 별칭을 `원문` 으로 되돌린 뒤 5장 `logic/new` 를 그대로 씀(정본은 손대지 않음 · 늘 새 `GX` 줄).
|
||||
- 되돌릴 때 이미 원문으로 적힌 부르기 안 · 작은따옴표 안 · `.` 뒤 값 칸 이름은 건드리지 않음.
|
||||
- `별칭[].원문` 은 부르기 하나여야 함 — 아니면 400. 표를 바꾸면 화면이 그 별칭의 `원문` 을 새 부르기로 바꿔 보냄.
|
||||
- `본뜬키` 를 주면 비고 끝에 「`<키>` 를 본뜸」 을 붙임(5장 `logic/copy` 와 같은 말) · `이름` 을 주면 그 이름으로.
|
||||
- 답 — 200 · 400(별칭·몸이 틀림) · 404(없는 키) · 422(검사 걸림 — `/save` 와 같은 검사).
|
||||
- 복사본은 원본을 따라가지 않음(값을 그대로 박은 스냅숏) · 식 안 `로직(…)` 부르기는 참조 그대로라 그 하위 로직은 계속 정본을 봄.
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""정본 로직을 식 줄로 펴 보이고, 고친 식을 되돌려 자체 로직으로 저장 (PLAN 3-4).
|
||||
|
||||
규칙은 `resources/master_data/ref/_설계_식_복사.md` 그대로 —
|
||||
`찾기(…)`·`로직(…)` 부르기 한 덩이를 이름 하나(별칭)로 바꿔 보이고 뒤에 원문을 둠.
|
||||
별칭은 저장하지 않음(겉치레) — 되돌릴 때 별칭표의 원문으로 갈아 끼워 원래 식이 됨.
|
||||
식 자르기는 `master_formula` 파서를 그대로 씀 — 정규식으로 괄호를 세지 않음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import re
|
||||
|
||||
import master_formula as mf
|
||||
|
||||
MAX_NAME = 12 # 이 글자 수를 넘는 표 이름은 억지로 줄이지 않고 표 키를 그대로 보임
|
||||
_DROP = re.compile(r"[^\w]", re.UNICODE) # 별칭에 못 쓰는 글자(빈칸 · 괄호 · 가운뎃점 …)
|
||||
_SPACE = re.compile(r"\s+")
|
||||
_QUOTED = re.compile(r"'[^']*'")
|
||||
RESERVED = frozenset((*mf.FUNCS, *mf.COST_ITEMS, "찾기", "로직", "그리고", "또는", "계", "줄"))
|
||||
|
||||
|
||||
# ── 부르기 자리 자르기 ─────────────────────────────────────────────────
|
||||
class _Catch(mf._Parser):
|
||||
"""식을 읽으면서 `찾기(…)`·`로직(…)` 한 덩이의 자리를 적어 둠(겹친 것 포함)."""
|
||||
|
||||
def __init__(self, text: str):
|
||||
super().__init__(text)
|
||||
self.spots: list[tuple[int, int, str, str]] = []
|
||||
|
||||
def primary(self):
|
||||
self.ws()
|
||||
at = self.i
|
||||
node = super().primary()
|
||||
if node[0] in ("find", "logic"):
|
||||
self.spots.append((at, self.i, node[0], node[1]))
|
||||
return node
|
||||
|
||||
|
||||
def spots(text: str) -> list[tuple[int, int, str, str]]:
|
||||
"""식 글 안 부르기 한 덩이 — (처음, 끝, 종류, 참조).
|
||||
|
||||
겹친 것은 바깥만 · 못 읽는 글은 빈 목록.
|
||||
"""
|
||||
try:
|
||||
found = _Catch(str(text))
|
||||
found.parse()
|
||||
except (mf.FormulaError, RecursionError):
|
||||
return []
|
||||
out = [
|
||||
one
|
||||
for one in found.spots
|
||||
if not any(o[0] <= one[0] and one[1] <= o[1] and o[:2] != one[:2] for o in found.spots)
|
||||
]
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def _holes(text: str) -> list[tuple[int, int]]:
|
||||
"""별칭을 갈아 끼우면 안 되는 자리 — 이미 원문으로 적힌 부르기 안 · 작은따옴표 안."""
|
||||
return [(s, e) for s, e, _, _ in spots(text)] + [
|
||||
(m.start(), m.end()) for m in _QUOTED.finditer(text)
|
||||
]
|
||||
|
||||
|
||||
# ── 식이 든 자리 ───────────────────────────────────────────────────────
|
||||
def slots(row: dict):
|
||||
"""(자리표, 갈래, 차례, 칸 이름, 그 칸을 담은 묶음) — 번호 매기는 차례(호표 → 중간 → 덧줄)."""
|
||||
for i, one in enumerate(row.get("호표") or []):
|
||||
for col in ("수량", "요소"):
|
||||
if isinstance(one.get(col), str):
|
||||
yield f"호표[{i}].{col}", "호표", i, col, one
|
||||
for i, one in enumerate(row.get("중간") or []):
|
||||
if isinstance(one.get("식"), str):
|
||||
yield f"중간[{i}].식", "중간", i, "식", one
|
||||
for i, one in enumerate(row.get("덧줄") or []):
|
||||
if isinstance(one.get("식"), str):
|
||||
yield f"덧줄[{i}].식", "덧줄", i, "식", one
|
||||
end = row.get("결과")
|
||||
if isinstance(end, dict) and isinstance(end.get("식"), str):
|
||||
yield "결과.식", "결과", 0, "식", end
|
||||
|
||||
|
||||
def _taken(row: dict) -> set[str]:
|
||||
"""이미 식에서 쓰는 이름 — 입력 · 중간 · 덧줄 · 비목 · 함수 이름."""
|
||||
out = set(RESERVED)
|
||||
for part in ("입력", "중간", "덧줄"):
|
||||
for one in row.get(part) or []:
|
||||
name = str(one.get("이름") or "")
|
||||
if name:
|
||||
out.add(name)
|
||||
return out
|
||||
|
||||
|
||||
# ── 별칭 짓기 ─────────────────────────────────────────────────────────
|
||||
def _base(ref: str, master) -> str:
|
||||
"""별칭 밑이름 — 표·로직의 `이름` 칸에서 · 비거나 너무 길면 키를 그대로 보임."""
|
||||
try:
|
||||
name = _DROP.sub("", str(master.get(ref).get("이름") or ""))
|
||||
except mf.FormulaError:
|
||||
name = ""
|
||||
if not name or len(name) > MAX_NAME or name[0].isdigit():
|
||||
return _DROP.sub("", ref) or "표"
|
||||
return name
|
||||
|
||||
|
||||
def _free(want: str, taken: set[str]) -> str:
|
||||
name, at = want, 1
|
||||
while name in taken:
|
||||
at += 1
|
||||
name = f"{want}_{at}"
|
||||
taken.add(name)
|
||||
return name
|
||||
|
||||
|
||||
def aliases(row: dict, master) -> list[dict]:
|
||||
"""부르기마다 별칭 하나 — 완전히 같은 부르기는 합치고, 갈리면 등장 차례로 번호."""
|
||||
order: list[str] = []
|
||||
seen: dict[str, tuple[str, str]] = {} # 서명 → (종류, 참조)
|
||||
raw_of: dict[str, str] = {} # 서명 → 원문(처음 나온 글 그대로)
|
||||
where_of: dict[str, list[str]] = {}
|
||||
for where, _, _, col, box in slots(row):
|
||||
text = box[col]
|
||||
for start, end, kind, ref in spots(text):
|
||||
raw = text[start:end]
|
||||
sign = _SPACE.sub(" ", raw)
|
||||
if sign not in seen:
|
||||
order.append(sign)
|
||||
seen[sign] = (kind, ref)
|
||||
raw_of[sign] = raw
|
||||
where_of.setdefault(sign, []).append(where)
|
||||
|
||||
bunch: dict[str, list[str]] = {}
|
||||
for sign in order:
|
||||
bunch.setdefault(_base(seen[sign][1], master), []).append(sign)
|
||||
taken, name_of = _taken(row), {}
|
||||
for kind, ref in seen.values(): # 표의 값 칸 이름과 헷갈리지 않게
|
||||
if kind == "find":
|
||||
taken |= set(_about(kind, ref, master)["값칸"])
|
||||
for base, signs in bunch.items():
|
||||
gap = "_" if base[-1:].isdigit() else "" # 키를 밑이름으로 쓴 자리 — 번호가 붙지 않게
|
||||
for at, sign in enumerate(signs):
|
||||
name_of[sign] = _free(f"{base}{gap}{at + 1}" if len(signs) > 1 else base, taken)
|
||||
|
||||
out = []
|
||||
for sign in order:
|
||||
kind, ref = seen[sign]
|
||||
out.append(
|
||||
{
|
||||
"이름": name_of[sign],
|
||||
"종류": "찾기" if kind == "find" else "로직",
|
||||
"참조": ref,
|
||||
"원문": raw_of[sign],
|
||||
**_about(kind, ref, master),
|
||||
"자리": where_of[sign],
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _about(kind: str, ref: str, master) -> dict:
|
||||
"""모달에 보일 한 마디 — 표는 표 이름과 값 칸 · 로직은 로직 이름과 결과 단위."""
|
||||
try:
|
||||
row = master.get(ref)
|
||||
except mf.FormulaError:
|
||||
return {"밑이름": "", "값칸": {}, "결과단위": ""}
|
||||
if kind == "find":
|
||||
return {"밑이름": row.get("이름") or "", "값칸": row.get("값칸") or {}, "결과단위": ""}
|
||||
return {"밑이름": row.get("이름") or "", "값칸": {}, "결과단위": row.get("결과단위") or ""}
|
||||
|
||||
|
||||
# ── 펴기 · 되돌리기 ────────────────────────────────────────────────────
|
||||
def _mask(text: str, name_of: dict[str, str]) -> str:
|
||||
"""부르기 한 덩이를 별칭 이름으로 — 뒤에서부터 갈아 끼움(자리가 밀리지 않게)."""
|
||||
out = text
|
||||
for start, end, _, _ in reversed(spots(text)):
|
||||
name = name_of.get(_SPACE.sub(" ", text[start:end]))
|
||||
if name:
|
||||
out = out[:start] + name + out[end:]
|
||||
return out
|
||||
|
||||
|
||||
def _glued(text: str, m: re.Match) -> bool:
|
||||
"""앞뒤가 낱말 글자면 다른 이름의 한 토막 · 점 뒤면 표의 값 칸 이름 — 갈아 끼우지 않음."""
|
||||
side = text[m.start() - 1 : m.start()] + text[m.end() : m.end() + 1]
|
||||
return any(ch.isalnum() or ch == "_" for ch in side) or text[: m.start()].rstrip().endswith(".")
|
||||
|
||||
|
||||
def _unmask(text: str, raw_of: dict[str, str]) -> str:
|
||||
"""별칭 이름을 원문 부르기로 — 이미 원문인 부르기 안과 따옴표 안은 안 건드림."""
|
||||
if not raw_of:
|
||||
return text
|
||||
holes = _holes(text)
|
||||
catch = re.compile("|".join(re.escape(n) for n in sorted(raw_of, key=len, reverse=True)))
|
||||
out, at = [], 0
|
||||
for m in catch.finditer(text):
|
||||
if any(s <= m.start() and m.end() <= e for s, e in holes) or _glued(text, m):
|
||||
continue
|
||||
out.append(text[at : m.start()])
|
||||
out.append(raw_of[m.group()])
|
||||
at = m.end()
|
||||
out.append(text[at:])
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def spread(master, row: dict) -> dict:
|
||||
"""로직 한 줄 → 별칭 꼴 식 묶음. `로직` = 식만 별칭으로 바뀐 줄 통째 · `별칭` 에 원문."""
|
||||
marks = aliases(row, master)
|
||||
name_of = {_SPACE.sub(" ", one["원문"]): one["이름"] for one in marks}
|
||||
shown = copy.deepcopy(row)
|
||||
for _, _, _, col, box in slots(shown):
|
||||
box[col] = _mask(box[col], name_of)
|
||||
return {"로직": shown, "별칭": marks, "줄": lines(shown)}
|
||||
|
||||
|
||||
def lines(row: dict) -> list[dict]:
|
||||
"""화면에 쌓을 식 줄 — 읽는 차례(중간 → 호표 → 덧줄 → 결과)."""
|
||||
out = []
|
||||
for i, one in enumerate(row.get("중간") or []):
|
||||
out.append(_line(f"중간[{i}].식", "중간", i, one, one.get("식")))
|
||||
for i, one in enumerate(row.get("호표") or []):
|
||||
got = _line(f"호표[{i}].수량", "호표", i, one, one.get("수량"))
|
||||
out.append({**got, "종류": one.get("종류") or "", "요소": one.get("요소")})
|
||||
for i, one in enumerate(row.get("덧줄") or []):
|
||||
out.append(_line(f"덧줄[{i}].식", "덧줄", i, one, one.get("식")))
|
||||
end = row.get("결과")
|
||||
if isinstance(end, dict):
|
||||
out.append(_line("결과.식", "결과", 0, end, end.get("식")))
|
||||
return out
|
||||
|
||||
|
||||
def _line(where: str, kind: str, at: int, one: dict, text) -> dict:
|
||||
return {
|
||||
"자리": where,
|
||||
"갈래": kind,
|
||||
"차례": at,
|
||||
"이름": one.get("이름") or "",
|
||||
"단위": one.get("단위") or "",
|
||||
"비목": one.get("비목") or "",
|
||||
"식": text,
|
||||
"출처": one.get("출처") or "",
|
||||
}
|
||||
|
||||
|
||||
def gather(shown: dict, marks: list[dict]) -> dict:
|
||||
"""별칭 꼴 식 묶음 → 저장할 로직 한 줄. 별칭 원문은 부르기 하나여야 함(아무 글이나 못 넣음)."""
|
||||
raw_of: dict[str, str] = {}
|
||||
for one in marks or []:
|
||||
name, raw = str(one.get("이름") or ""), str(one.get("원문") or "")
|
||||
if not name or not raw:
|
||||
raise mf.FormulaError("별칭에 「이름」 과 「원문」 이 있어야 함")
|
||||
if mf.parse(raw)[0] not in ("find", "logic"):
|
||||
raise mf.FormulaError(f"별칭 「{name}」 의 원문이 찾기·로직 부르기가 아님 — 「{raw}」")
|
||||
if name in raw_of and raw_of[name] != raw:
|
||||
raise mf.FormulaError(f"별칭 「{name}」 이 두 가지 원문을 가리킴")
|
||||
raw_of[name] = raw
|
||||
out = copy.deepcopy(shown)
|
||||
for _, _, _, col, box in slots(out):
|
||||
box[col] = _unmask(box[col], raw_of)
|
||||
return out
|
||||
|
||||
|
||||
def check(master, row: dict) -> list[str]:
|
||||
"""펴고 그대로 되돌리면 원래 식으로 돌아오는지 — 어긋난 자리 글 목록(비면 됨)."""
|
||||
view = spread(master, row)
|
||||
again = gather(view["로직"], view["별칭"])
|
||||
out = []
|
||||
for (where, _, _, col, box), (_, _, _, _, was) in zip(slots(again), slots(row)):
|
||||
if box[col] != was[col]:
|
||||
out.append(f"{row.get('키')} {where} — 「{was[col]}」 → 「{box[col]}」")
|
||||
return out
|
||||
Reference in New Issue
Block a user