From c57f0ea06d785aafbe2fbe3dc5c080424252ec87 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 21 Sep 2026 20:02:12 +0900 Subject: [PATCH] =?UTF-8?q?feat(M01):=20=EB=B3=B5=EC=82=AC=ED=95=B4?= =?UTF-8?q?=EC=84=9C=20=EB=A7=8C=EB=93=A4=EA=B8=B0=20=EC=97=94=EC=A7=84?= =?UTF-8?q?=C2=B7=EC=84=9C=EB=B2=84=20=E2=80=94=20=EC=A0=95=EB=B3=B8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=EC=9D=84=20=EB=B3=84=EC=B9=AD=20=EA=BC=B4=20?= =?UTF-8?q?=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=ED=8E=B4=EA=B3=A0=20=EB=90=98?= =?UTF-8?q?=EB=8F=8C=EB=A0=A4=20=EC=9E=90=EC=B2=B4=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `scripts/master_copy.py` — `찾기()`·`로직()` 한 덩이를 별칭 이름으로 · 원문은 별칭표에 그대로 (자르기는 `master_formula` 파서 그대로 · 완전 같은 부르기는 합침 · 인자가 갈리면 등장 차례로 번호) - `GET /logic/formula` · `POST /logic/formula/save` — 저장은 별칭을 원문으로 되돌린 뒤 `logic/new` 재사용 - 계약 `_화면_계약.md` 7장 · 시험 `test_m01_copy.py` 8 통과(로직 1,354줄 되돌리기 전수 포함) --- M01_MasterData/M01_MasterData_Router.py | 22 ++ M01_MasterData/M01_MasterData_Store.py | 1 + M01_MasterData/M01_MasterData_Store_Make.py | 37 ++- resources/master_data/_화면_계약.md | 21 ++ resources/master_data/scripts/master_copy.py | 271 +++++++++++++++++++ resources/tester/test_m01_copy.py | 196 ++++++++++++++ 6 files changed, 545 insertions(+), 3 deletions(-) create mode 100644 resources/master_data/scripts/master_copy.py create mode 100644 resources/tester/test_m01_copy.py diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index aea8f757..a3b78c69 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -63,6 +63,16 @@ class DeleteLogic(BaseModel): version: str +class FormulaLogic(BaseModel): + """복사해서 만들기 — 별칭 꼴 식이 든 로직 줄과 그 별칭표(되돌릴 원문).""" + + 로직: dict[str, Any] + 별칭: list[dict[str, Any]] = [] + owner: str = make.OWNERS[0] + 이름: str = "" + 본뜬키: str = "" + + def _num(v): """Decimal → JSON 수(그냥 두면 글자로 나감).""" if isinstance(v, Decimal): @@ -214,3 +224,15 @@ def post_logic_edit(body: EditLogic) -> dict: @router.post("/logic/delete") def post_logic_delete(body: DeleteLogic) -> dict: return _call(make.logic_delete, body.key, body.version) + + +@router.get("/logic/formula") +def get_logic_formula(key: str) -> dict: + """복사해서 만들기 — 로직 한 줄을 별칭 꼴 식 묶음으로.""" + return _call(make.logic_formula, key) + + +@router.post("/logic/formula/save") +def post_logic_formula_save(body: FormulaLogic) -> dict: + """고친 식 묶음을 자체 로직(`GX`)으로 — 별칭은 원문으로 되돌려 저장.""" + return _call(make.logic_from_formula, body.로직, body.별칭, body.owner, body.이름, body.본뜬키) diff --git a/M01_MasterData/M01_MasterData_Store.py b/M01_MasterData/M01_MasterData_Store.py index f1624c9d..752cb45d 100644 --- a/M01_MasterData/M01_MasterData_Store.py +++ b/M01_MasterData/M01_MasterData_Store.py @@ -19,6 +19,7 @@ SCRIPTS = Path(__file__).resolve().parents[1] / "resources/master_data/scripts" sys.path.insert(0, str(SCRIPTS)) import check_master as cm # noqa: E402 +import master_copy as mcp # noqa: E402,F401 (`_Store_Make` 가 `store.mcp` 로 씀) import master_keys as mk # noqa: E402 import master_text as mtx # noqa: E402 diff --git a/M01_MasterData/M01_MasterData_Store_Make.py b/M01_MasterData/M01_MasterData_Store_Make.py index 9cf6377c..fcffcff4 100644 --- a/M01_MasterData/M01_MasterData_Store_Make.py +++ b/M01_MasterData/M01_MasterData_Store_Make.py @@ -1,6 +1,7 @@ -"""M01 「만들기」 저장소 — 원문 절 목록 · 표 조건 값 · 자체 로직 만들기·본뜨기·고치기·지우기. +"""M01 「만들기」 저장소 — 원문 절 목록 · 표 조건 값 · 자체 로직 만들기·본뜨기·고치기·지우기 +· 복사해서 만들기(정본 로직을 별칭 꼴 식으로 펴 보이고 되돌려 저장). -계약: `resources/master_data/_화면_계약.md` 2장·5장 · 파일 모양: `_틀.md` 7장. +계약: `resources/master_data/_화면_계약.md` 2장·5장·7장 · 파일 모양: `_틀.md` 7장. 자체 로직은 `로직_자체.json` 한 파일 · 키는 `_키대장.json` 의 `GX` 다음 번호(서버가 줌). """ @@ -12,7 +13,7 @@ from pathlib import Path from M01_MasterData import M01_MasterData_Store as store -cm, mk = store.cm, store.mk +cm, mk, mcp = store.cm, store.mk, store.mcp OWN = "로직_자체.json" # 자체 로직 한 자리 — 이 파일 줄만 고치고 지울 수 있음 OWNERS = ("현장", "공용") @@ -231,6 +232,36 @@ def logic_edit(key: str, version: str, logic: dict, owner: str = "") -> dict: return {**done, "logic": store.logic(key)["logic"]} +# ── 복사해서 만들기 ──────────────────────────────────────────────────── +def logic_formula(key: str) -> dict: + """로직 한 줄을 별칭 꼴 식 묶음으로 펴 보임 — 되돌릴 원문은 `별칭` 에 그대로(정본은 안 바뀜).""" + got = store.logic(key) + master = cm.mf.Master(cm.load(folder=store.FOLDER)) + return { + "file": got["file"], + "version": got["version"], + "key": key, + "소유": got["logic"].get("소유") or "", + "prices": got["prices"], + **mcp.spread(master, got["logic"]), + } + + +def logic_from_formula( + logic: dict, marks: list[dict], owner: str = OWNERS[0], 이름: str = "", 본뜬키: str = "" +) -> dict: + """고친 식 묶음을 자체 로직으로 저장 — 별칭을 원문으로 되돌린 뒤 `logic_new` 를 그대로 씀.""" + try: + row = mcp.gather(logic, marks) + except cm.mf.FormulaError as e: + raise store.StoreError(400, str(e)) from e + 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) diff --git a/resources/master_data/_화면_계약.md b/resources/master_data/_화면_계약.md index dab73f7e..bf6a0184 100644 --- a/resources/master_data/_화면_계약.md +++ b/resources/master_data/_화면_계약.md @@ -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` 와 같은 검사). +- 복사본은 원본을 따라가지 않음(값을 그대로 박은 스냅숏) · 식 안 `로직(…)` 부르기는 참조 그대로라 그 하위 로직은 계속 정본을 봄. diff --git a/resources/master_data/scripts/master_copy.py b/resources/master_data/scripts/master_copy.py new file mode 100644 index 00000000..8631fdb6 --- /dev/null +++ b/resources/master_data/scripts/master_copy.py @@ -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 diff --git a/resources/tester/test_m01_copy.py b/resources/tester/test_m01_copy.py new file mode 100644 index 00000000..1f446699 --- /dev/null +++ b/resources/tester/test_m01_copy.py @@ -0,0 +1,196 @@ +"""M01 복사해서 만들기(PLAN 3-4) — 별칭 꼴 식 펴기 · 되돌려 자체 로직으로 저장. + +계약 `resources/master_data/_화면_계약.md` 7장 · 규칙 `resources/master_data/ref/_설계_식_복사.md`. +못박는 것 + - 13-4-1 을 식으로 펴 아무것도 안 고치고 되돌려 저장하면 원본과 계산 결과가 같음. + - 12-4 처럼 같은 표를 다른 인자로 쓰는 로직은 별칭이 번호로 갈림. + - 로직 전부(1,354줄)가 펴고 되돌리면 원래 식 글자 그대로 돌아옴. +""" + +from __future__ import annotations + +import shutil +import sys +from decimal import Decimal +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from M01_MasterData import M01_MasterData_Router as router_module +from M01_MasterData import M01_MasterData_Store as store + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "master_data/scripts")) + +import master_copy as mcp # noqa: E402 + +REAL = store.FOLDER +STACK = "GF000219" # 13-4-1 메쌓기(인력) — 같은 표를 같은 인자로 두 번(별칭 하나로 합침) +FORM = "GF000160" # 12-4 합판거푸집 — 같은 표를 다른 인자로 일곱 번(번호로 갈림) + + +@pytest.fixture +def client(tmp_path: Path, monkeypatch) -> TestClient: + for path in REAL.glob("*.json"): + if not path.name.startswith("_") or path.name == store.mk.BOOK.name: + shutil.copy(path, tmp_path / path.name) + monkeypatch.setattr(store, "FOLDER", tmp_path) + app = FastAPI() + app.include_router(router_module.router) + return TestClient(app) + + +def _get(client: TestClient, url: str, **params) -> dict: + res = client.get(url, params=params) + assert res.status_code == 200, res.text + return res.json() + + +def _post(client: TestClient, url: str, body: dict, want: int = 200) -> dict: + res = client.post(url, json=body) + assert res.status_code == want, res.text + return res.json() + + +def _inputs(key: str) -> dict: + """그 로직의 일괄 시험 입력 한 벌(`check_master.시험입력`).""" + row = store.cm.master(REAL).logic(key) + return {k: str(v) for k, v in store.cm.시험입력(row).items()} + + +def _sums(client: TestClient, key: str, inputs: dict) -> dict: + got = _post(client, "/api/m01/calc", {"key": key, "inputs": inputs}) + assert got["ok"] is True, got + return got["sums"] + + +# ── 펴기 ────────────────────────────────────────────────────────────── +def test_별칭이_식에서_이름_하나로_보이고_원문은_뒤에_남는다(client: TestClient) -> None: + got = _get(client, "/api/m01/logic/formula", key=STACK) + names = {one["이름"]: one for one in got["별칭"]} + assert set(names) == {"메쌓기인력", "높이에대한증가율표"} + + hit = names["메쌓기인력"] + assert hit["참조"] == "QF000421" + assert hit["원문"] == "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기)" + assert hit["자리"] == ["호표[0].수량", "호표[1].수량"] # 완전히 같은 부르기는 하나로 합침 + assert "석공" in hit["값칸"] + + 가로 = {one["자리"]: one["식"] for one in got["줄"]} + assert 가로["호표[0].수량"] == "메쌓기인력.석공 * (1 + 증가율 / 100)" # 값 칸은 그대로 둠 + assert "찾기(" not in 가로["중간[0].식"] + assert "높이에대한증가율표.증가율" in 가로["중간[0].식"] + + +def test_같은_표를_다른_인자로_쓰면_별칭이_번호로_갈린다(client: TestClient) -> None: + got = _get(client, "/api/m01/logic/formula", key=FORM) + 형틀 = [one for one in got["별칭"] if one["참조"] == "QF000357"] + assert [one["이름"] for one in 형틀] == [f"합판거푸집{n}" for n in range(1, 8)] + assert [one["원문"] for one in 형틀][:2] == [ + "찾기(QF000357, 구분='합판')", + "찾기(QF000357, 구분='각재')", + ] + # 같은 표를 같은 인자로 두 번 쓰는 자리(QF000358)는 번호 없이 하나 + 한벌 = [one for one in got["별칭"] if one["참조"] == "QF000358"] + assert len(한벌) == 1 and 한벌[0]["자리"] == ["중간[0].식", "중간[1].식"] + + 가로 = {one["자리"]: one["식"] for one in got["줄"]} + assert 가로["호표[0].수량"] == "합판거푸집1.기준수량_1회사용 * 재료율" + assert 가로["호표[1].수량"] == "합판거푸집2.기준수량_1회사용 * 재료율" + + +def test_로직_부르기도_별칭으로_보인다(client: TestClient) -> None: + got = _get(client, "/api/m01/logic/formula", key="GC000994") + 부름 = [one for one in got["별칭"] if one["종류"] == "로직"] + assert 부름, got["별칭"] + assert all(one["원문"].startswith("로직(") for one in 부름) + 요소 = [one.get("요소") for one in got["줄"] if one["갈래"] == "호표"] + assert any(str(one) in {x["이름"] for x in 부름} for one in 요소) # 호표 요소도 별칭으로 + + +def test_로직_전부가_펴고_되돌리면_원래_식으로_돌아온다() -> None: + files = store.cm.load(folder=REAL) + master = store.cm.mf.Master(files) + bad, rows = [], 0 + for data in files.values(): + if data.get("그룹") != "로직": + continue + for row in data.get("줄") or []: + rows += 1 + bad += mcp.check(master, row) + assert rows > 1000, rows + assert not bad, bad[:5] + + +# ── 되돌려 저장 ──────────────────────────────────────────────────────── +def test_안_고치고_되돌려_저장하면_원본과_계산이_같다(client: TestClient) -> None: + inputs = _inputs(STACK) + was = _sums(client, STACK, inputs) + + view = _get(client, "/api/m01/logic/formula", key=STACK) + made = _post( + client, + "/api/m01/logic/formula/save", + {"로직": view["로직"], "별칭": view["별칭"], "본뜬키": STACK, "이름": "메쌓기 복사본"}, + ) + key = made["key"] + assert key.startswith("GX"), made + assert made["logic"]["소유"] == "현장" and made["logic"]["구분"] == "자체" + assert f"{STACK} 를 본뜸" in made["logic"]["비고"] + + # 별칭은 저장되지 않음 — 식은 원문 그대로 + 식 = made["logic"]["호표"][0]["수량"] + assert 식 == "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공 * (1 + 증가율 / 100)" + assert Decimal(str(_sums(client, key, inputs)["계"])) == Decimal(str(was["계"])) + assert Decimal(str(was["계"])) > 0 + + # 원본은 그대로 + assert _get(client, "/api/m01/logic", key=STACK)["logic"]["호표"][0]["수량"] == 식 + + +def test_식을_고쳐_저장하면_그_값으로_계산된다(client: TestClient) -> None: + inputs = _inputs(FORM) + was = _sums(client, FORM, inputs) + + view = _get(client, "/api/m01/logic/formula", key=FORM) + row = view["로직"] + row["호표"][6]["수량"] = "합판거푸집6.기준수량_1회사용 * 인력율 * 2" # 형틀목공 품 두 배 + made = _post( + client, + "/api/m01/logic/formula/save", + {"로직": row, "별칭": view["별칭"], "본뜬키": FORM}, + ) + saved = made["logic"]["호표"][6]["수량"] + assert saved == "찾기(QF000357, 구분='형틀목공').기준수량_1회사용 * 인력율 * 2" + + now = _sums(client, made["key"], inputs) + assert Decimal(str(now["노무비"])) > Decimal(str(was["노무비"])) + assert Decimal(str(now["재료비"])) == Decimal(str(was["재료비"])) + + +def test_별칭_원문이_부르기가_아니면_막는다(client: TestClient) -> None: + view = _get(client, "/api/m01/logic/formula", key=STACK) + marks = [{**one} for one in view["별칭"]] + marks[0]["원문"] = "1 + 1" + got = _post( + client, + "/api/m01/logic/formula/save", + {"로직": view["로직"], "별칭": marks}, + want=400, + ) + assert "찾기" in str(got["detail"]) + + +def test_정본_로직은_이_길로도_안_바뀐다(client: TestClient) -> None: + """저장은 늘 새 자체 로직(`GX`) — 본뜬 원본 파일은 판본까지 그대로.""" + before = _get(client, "/api/m01/logic", key=STACK) + view = _get(client, "/api/m01/logic/formula", key=STACK) + _post( + client, + "/api/m01/logic/formula/save", + {"로직": view["로직"], "별칭": view["별칭"], "본뜬키": STACK}, + ) + after = _get(client, "/api/m01/logic", key=STACK) + assert after["version"] == before["version"] + assert after["logic"] == before["logic"]