From aa86e02b8f745d220eb268aeadb5efc85a3ee04c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 21 Sep 2026 09:31:45 +0900 Subject: [PATCH] =?UTF-8?q?feat(M01):=20=EB=81=9D=EC=88=98=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=EB=B6=99=EC=9E=84=20=C2=B7=20=EC=A0=88=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=C2=B7=EC=A0=88=20=EA=B1=B0=EB=A5=B4=EA=B8=B0=C2=B7?= =?UTF-8?q?=ED=91=9C=20=EC=A1=B0=EA=B1=B4=20=EA=B0=92=C2=B7=EC=86=8C?= =?UTF-8?q?=EC=9C=A0=20=EA=B1=B0=EB=A6=84=C2=B7=EC=9E=90=EC=B2=B4=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 엔진 — 로직 줄의 묶음 `끝수`{대상, 자리, 방법}를 계산 끝에 붙임(비목 합마다 끊고 계 = 그 합 · 돈 아닌 로직은 결과) · 글자 끝수는 설명 그대로 · 검사가 묶음 모양을 봄 - 서버 — `GET /sections`(원문 본문 절 목록 + 그 절의 표·로직 수) · `/tables` 의 `section`(13-4 가 13-40 에 안 걸림) · `GET /table/options`(조건 값만) · `/logics` 의 `owner` - 자체 로직 — `POST /logic/new|copy|edit|delete` · 파일 `로직_자체.json` · 키 GX 서버 발급(원문번호 같아도 줄마다 다른 키) · 정본은 403 · 저장 안 한 초안은 `/calc` 에 `key` "" 로 - 저장소가 700줄을 넘어 쓰기 모양(`keep_shape`·`dump`)을 `M01_MasterData_Store_Shape.py` 로 가름 - 시험 `resources/tester/test_m01_make.py` 8건 통과 · 기존 M01 시험 25건 통과 · 일괄 시험 계산 1,291 그대로 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj --- M01_MasterData/M01_MasterData_Router.py | 67 ++++- M01_MasterData/M01_MasterData_Store.py | 146 ++++------- M01_MasterData/M01_MasterData_Store_Make.py | 239 ++++++++++++++++++ M01_MasterData/M01_MasterData_Store_Shape.py | 100 ++++++++ resources/master_data/_틀.md | 3 +- resources/master_data/_화면_계약.md | 10 +- .../master_data/scripts/master_formula.py | 44 +++- resources/tester/test_m01_make.py | 178 +++++++++++++ 8 files changed, 678 insertions(+), 109 deletions(-) create mode 100644 M01_MasterData/M01_MasterData_Store_Make.py create mode 100644 M01_MasterData/M01_MasterData_Store_Shape.py create mode 100644 resources/tester/test_m01_make.py diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index b0236019..2edbe080 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -12,6 +12,7 @@ from fastapi import APIRouter, HTTPException from pydantic import BaseModel from M01_MasterData import M01_MasterData_Store as store +from M01_MasterData import M01_MasterData_Store_Make as make router = APIRouter(prefix="/api/m01", tags=["M01 MasterData"]) @@ -39,6 +40,29 @@ class SaveBody(BaseModel): files: list[FileChanges] +class NewLogic(BaseModel): + logic: dict[str, Any] + owner: str = make.OWNERS[0] + + +class CopyLogic(BaseModel): + key: str + 이름: str = "" + owner: str = make.OWNERS[0] + + +class EditLogic(BaseModel): + key: str + version: str + logic: dict[str, Any] + owner: str = "" + + +class DeleteLogic(BaseModel): + key: str + version: str + + def _num(v): """Decimal → JSON 수(그냥 두면 글자로 나감).""" if isinstance(v, Decimal): @@ -96,8 +120,9 @@ def get_tables( page: int = 1, size: int = 30, usage: str = "", + section: str = "", ) -> dict: - return _call(store.tables, file, group, sub, detail, q, page, size, usage) + return _call(store.tables, file, group, sub, detail, q, page, size, usage, section) @router.get("/table") @@ -105,9 +130,25 @@ def get_table(file: str, key: str) -> dict: return _call(store.table, file, key) +@router.get("/table/options") +def get_table_options(file: str, key: str) -> dict: + """표 조건 칸의 값만 — 줄을 통째로 받지 않음.""" + return _call(make.table_options, file, key) + + +@router.get("/sections") +def get_sections( + book: str, division: str = "", chapter: str = "", q: str = "", limit: int = 200 +) -> dict: + """원문 절 목록 — 만들기 첫 걸음.""" + return _call(make.sections, book, division, chapter, q, limit) + + @router.get("/logics") -def get_logics(sub: str = "", detail: str = "", q: str = "", blocked: int | None = None) -> dict: - return {"logics": _call(store.logics, sub, detail, q, blocked)} +def get_logics( + sub: str = "", detail: str = "", q: str = "", blocked: int | None = None, owner: str = "" +) -> dict: + return {"logics": _call(store.logics, sub, detail, q, blocked, owner)} @router.get("/logic") @@ -147,3 +188,23 @@ def post_calc(body: CalcBody) -> dict: @router.post("/save") def post_save(body: SaveBody) -> dict: return {"files": _call(store.save, [f.model_dump() for f in body.files])} + + +@router.post("/logic/new") +def post_logic_new(body: NewLogic) -> dict: + return _call(make.logic_new, body.logic, body.owner) + + +@router.post("/logic/copy") +def post_logic_copy(body: CopyLogic) -> dict: + return _call(make.logic_copy, body.key, body.이름, body.owner) + + +@router.post("/logic/edit") +def post_logic_edit(body: EditLogic) -> dict: + return _call(make.logic_edit, body.key, body.version, body.logic, body.owner) + + +@router.post("/logic/delete") +def post_logic_delete(body: DeleteLogic) -> dict: + return _call(make.logic_delete, body.key, body.version) diff --git a/M01_MasterData/M01_MasterData_Store.py b/M01_MasterData/M01_MasterData_Store.py index fcb04e1c..aa4b2a7a 100644 --- a/M01_MasterData/M01_MasterData_Store.py +++ b/M01_MasterData/M01_MasterData_Store.py @@ -21,7 +21,10 @@ sys.path.insert(0, str(SCRIPTS)) import check_master as cm # noqa: E402 import master_keys as mk # noqa: E402 +from M01_MasterData.M01_MasterData_Store_Shape import dump, keep_shape # noqa: E402 + FOLDER: Path = cm.MASTER # 시험은 사본 폴더로 바꿈 +DRAFT = "_초안.json" # 저장 안 한 새 로직을 얹는 메모리 자리 — 파일로 쓰지 않음 _CHAPTER = re.compile(r"_(\d\d)장_(.+)\.json$") # 「_03장_토공사.json」 # ponytail: 저장은 한 번에 하나(프로세스 안 잠금) · 서버를 여럿 띄우면 파일 잠금으로 _LOCK = threading.Lock() @@ -235,6 +238,12 @@ _TABLE_HEADS = ( ) +def in_section(number, section: str) -> bool: + """원문번호가 그 절(또는 그 아래 절)인지 — 「13-4」 는 「13-4-1」 을 품고 「13-40」 은 아님.""" + number = str(number or "") + return not section or number == section or number.startswith(section + "-") + + def tables( file: str = "", group: str = "", @@ -244,9 +253,10 @@ def tables( page: int = 1, size: int = 30, usage: str = "", + section: str = "", ) -> dict: - """표 목록 — 파일 하나 또는 그룹 전체(구분 · 상세구분 · 용도 대상 · 찾기로 거름 · 장 차례대로 · 쪽 나눔). - `usage` = 용도.대상(인력 · 기계 · 재료 · 할증 · 환산 · 작업량 조건) 하나.""" + """표 목록 — 파일 하나 또는 그룹 전체(장 차례대로 · 쪽 나눔). + 거름 = 구분 · 상세구분 · 찾기 · `usage`(용도.대상 하나) · `section`(절 번호 · 아래 절 포함).""" if file: names = [file] elif group in cm.mf.TABLE_GROUPS: @@ -265,6 +275,8 @@ def tables( continue if usage and usage not in ((t.get("용도") or {}).get("대상") or []): continue + if not in_section(t.get("원문번호"), section): + continue if _hit(t, q): hits.append( { @@ -302,8 +314,8 @@ def _logic_files(files: dict[str, dict]): yield name, data -def logics(sub: str, detail: str, q: str, blocked: int | None) -> list[dict]: - """로직 목록 — 구분 · 상세구분 · 찾기로 거름(장 차례대로 · 파일을 가로지름).""" +def logics(sub: str, detail: str, q: str, blocked: int | None, owner: str = "") -> list[dict]: + """로직 목록 — 구분 · 상세구분 · 소유 · 찾기로 거름(장 차례대로 · 파일을 가로지름).""" files = cm.load(folder=FOLDER) whole = cm.mf.Master(files) out = [] @@ -311,6 +323,8 @@ def logics(sub: str, detail: str, q: str, blocked: int | None) -> list[dict]: for row in data.get("줄") or []: if (sub and row.get("구분") != sub) or (detail and row.get("상세구분") != detail): continue + if owner and row.get("소유") != owner: + continue if not _hit(row, q): continue reasons = cm.mf.check_logic(whole, row)[0] @@ -321,7 +335,16 @@ def logics(sub: str, detail: str, q: str, blocked: int | None) -> list[dict]: "file": name, **{ k: row.get(k) - for k in ("키", "원문번호", "구분", "상세구분", "이름", "결과단위", "출처") + for k in ( + "키", + "원문번호", + "구분", + "상세구분", + "이름", + "결과단위", + "출처", + "소유", + ) }, "blocked": bool(reasons), "reasons": reasons, @@ -488,6 +511,9 @@ def _swap(files: dict, key: str, row: dict, file: str | None) -> str: if str(old.get("키")) == key: items[i] = {**row, "키": key} return key + if file is None: # 저장 안 한 새 초안 — 메모리에만 얹고 셈(만들기 모달의 시험 계산) + file = DRAFT + files[file] = {"그룹": "로직", "원문": "자체", "줄": []} if file not in files or files[file].get("그룹") != "로직": raise StoreError(404, f"없는 로직 파일 「{file}」") new = mk.table_id("로직", files[file].get("원문")) + "999999" # 아직 번호 안 받은 새 로직 @@ -549,6 +575,18 @@ def _follow(row: dict, items: list[dict], file: str) -> dict: return {**row, "값": src["값"]} +def ledger_label(book: dict, tid: str, number: str, row: dict) -> str: + """대장 열쇠 — 보통은 원문번호 · 자체 로직(GX)은 한 절에 여럿이라 「번호 이름」 으로 하나씩.""" + if tid != "GX" or not number: # 원문번호 없는 줄은 빈 열쇠 — 대장이 늘 새 번호를 줌 + return number + base = f"{number} {row.get('이름') or ''}".strip() + index, label, at = mk.lookup(book), base, 1 + while (tid, label) in index: + at += 1 + label = f"{base} ({at})" + return label + + def _apply(data: dict, changes: list[dict], file: str, book: dict) -> None: """고침 · 더함(키는 대장의 다음 번호 · 원문번호 없으면 빈 글) · 지움 — 키는 바꾸지 않음.""" items = data.setdefault(items_key(data), []) @@ -570,7 +608,7 @@ def _apply(data: dict, changes: list[dict], file: str, book: dict) -> None: cols = _chapter_cols(file, data) drop = ("키", "원문번호", *cols) rest = {k: v for k, v in row.items() if k not in drop} - key = mk.issue(book, tid, number, file) + key = mk.issue(book, tid, ledger_label(book, tid, number, row), file) # 자재품목처럼 줄에 원문번호를 두지 않는 테이블은 키만 — 번호는 대장에만 head = {"키": key} if tid in mk.NO_NUMBER else {"키": key, "원문번호": number} items.append({**head, **cols, **rest}) @@ -604,101 +642,11 @@ def save(batch: list[dict]) -> list[dict]: out = [] for name in dict.fromkeys(names): old_text = (FOLDER / name).read_bytes().decode("utf-8") - text = keep_shape(old_text, before[name], after[name]) or dump(after[name]) + text = keep_shape(old_text, before[name], after[name], items_key(after[name])) or dump( + after[name] + ) tmp = FOLDER / f".{name}.tmp" tmp.write_text(text, encoding="utf-8", newline="\n") os.replace(tmp, FOLDER / name) out.append({"file": name, "version": version_of(text.encode("utf-8"))}) return out - - -# ── 쓰기 모양 ───────────────────────────────────────────────────────── -def _atom(v) -> str: - if isinstance(v, Decimal): - return str(v) - return json.dumps(v, ensure_ascii=False) - - -def _flat(v, tight: bool = False, inner: bool | None = None) -> str: - """한 줄 글 — tight = 바깥 묶음을 붙여 씀 · inner = 안쪽 묶음을 붙여 씀(None 이면 바깥과 같음).""" - inner = tight if inner is None else inner - if isinstance(v, dict): - if not v: - return "{}" - body = ", ".join(f"{_atom(k)}: {_flat(x, inner)}" for k, x in v.items()) - return "{" + body + "}" if tight else "{ " + body + " }" - if isinstance(v, list): - return "[" + ", ".join(_flat(x, inner) for x in v) + "]" - return _atom(v) - - -def _skip(text: str, pos: int) -> int: - while text[pos] in " \t\r\n": - pos += 1 - return pos - - -def keep_shape(old_text: str, before: dict, after: dict) -> str | None: - """고친 요소만 새로 씀 — 안 바뀐 요소는 옛 글 그대로(한 줄 요소는 한 줄 · 들여쓰기 · 줄바꿈). - 머리가 바뀌었거나 옛 글을 못 읽으면 None(통째로 dump).""" - key = items_key(after) - head = lambda d: {k: v for k, v in d.items() if k != key} # noqa: E731 - old_items, new_items = before.get(key) or [], after.get(key) or [] - if head(before) != head(after) or not old_items or not new_items: - return None - for m in re.finditer(rf'"{key}"\s*:\s*\[', old_text): # 머리 속 같은 이름 칸은 건너뜀 - spans, pos, dec = [], m.end(), json.JSONDecoder() - try: - while old_text[pos := _skip(old_text, pos)] != "]": - _, end = dec.raw_decode(old_text, pos) - spans.append((pos, end)) - pos = _skip(old_text, end) - pos += old_text[pos] == "," - except (IndexError, ValueError): - return None - if len(spans) == len(old_items): - break - else: - return None - nl = "\r\n" if "\r\n" in old_text else "\n" - lead = old_text[m.end() : spans[0][0]] - indent = len(lead) - len(lead.rstrip(" ")) - olds = {str(r.get("키")): (r, old_text[s:e]) for r, (s, e) in zip(old_items, spans)} - shape, parts = old_text[spans[0][0] : spans[0][1]], [] - for row in new_items: - was, text = olds.get(str(row.get("키")), (None, None)) - shape = text or shape - if was == row: - parts.append(text) - elif "\n" not in shape: - tight = shape.startswith('{"') - parts.append(_flat(row, tight, tight or ': {"' in shape)) - else: - parts.append(dump(row, indent, expand=True).replace("\n", nl)) - out = old_text[: m.end()] + lead + ("," + lead).join(parts) + old_text[spans[-1][1] :] - same = json.loads(out, parse_float=Decimal, parse_int=Decimal) == after - return out if same else None - - -def dump(v, indent: int = 0, width: int = 160, expand: bool = False) -> str: - """UTF-8 · 들여쓰기 2칸 · 한 줄에 들어가는 묶음은 한 줄로(expand 면 첫 층은 펼침).""" - flat = _flat(v) - if ( - not isinstance(v, (dict, list)) - or not v - or (not expand and indent and len(flat) + indent <= width) - ): - return flat + ("" if indent else "\n") - pad = " " * (indent + 2) - if isinstance(v, dict): - body = [f"{pad}{_atom(k)}: {dump(x, indent + 2, width)}" for k, x in v.items()] - text = "{\n" + ",\n".join(body) + "\n" + " " * indent + "}" - else: - text = ( - "[\n" - + ",\n".join(pad + dump(x, indent + 2, width) for x in v) - + "\n" - + " " * indent - + "]" - ) - return text + ("" if indent else "\n") diff --git a/M01_MasterData/M01_MasterData_Store_Make.py b/M01_MasterData/M01_MasterData_Store_Make.py new file mode 100644 index 00000000..9cf6377c --- /dev/null +++ b/M01_MasterData/M01_MasterData_Store_Make.py @@ -0,0 +1,239 @@ +"""M01 「만들기」 저장소 — 원문 절 목록 · 표 조건 값 · 자체 로직 만들기·본뜨기·고치기·지우기. + +계약: `resources/master_data/_화면_계약.md` 2장·5장 · 파일 모양: `_틀.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 = store.cm, store.mk + +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 cm.load(folder=store.FOLDER).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)]} + + +# ── 표 조건 값 ───────────────────────────────────────────────────────── +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_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}]) diff --git a/M01_MasterData/M01_MasterData_Store_Shape.py b/M01_MasterData/M01_MasterData_Store_Shape.py new file mode 100644 index 00000000..c3b8f627 --- /dev/null +++ b/M01_MasterData/M01_MasterData_Store_Shape.py @@ -0,0 +1,100 @@ +"""M01 저장 글 모양 — 고친 줄만 새로 쓰고 나머지는 옛 글 그대로(`_틀.md` 2장 · 들여쓰기 2칸). + +`M01_MasterData_Store.save` 가 씀 — 파일을 통째로 다시 쓰면 손으로 맞춘 줄 모양이 사라짐. +""" + +from __future__ import annotations + +import json +import re +from decimal import Decimal + + +def _atom(v) -> str: + if isinstance(v, Decimal): + return str(v) + return json.dumps(v, ensure_ascii=False) + + +def _flat(v, tight: bool = False, inner: bool | None = None) -> str: + """한 줄 글 — tight = 바깥 묶음을 붙여 씀 · inner = 안쪽 묶음을 붙여 씀(None 이면 바깥과 같음).""" + inner = tight if inner is None else inner + if isinstance(v, dict): + if not v: + return "{}" + body = ", ".join(f"{_atom(k)}: {_flat(x, inner)}" for k, x in v.items()) + return "{" + body + "}" if tight else "{ " + body + " }" + if isinstance(v, list): + return "[" + ", ".join(_flat(x, inner) for x in v) + "]" + return _atom(v) + + +def _skip(text: str, pos: int) -> int: + while text[pos] in " \t\r\n": + pos += 1 + return pos + + +def keep_shape(old_text: str, before: dict, after: dict, key: str) -> str | None: + """고친 요소만 새로 씀 — 안 바뀐 요소는 옛 글 그대로(한 줄 요소는 한 줄 · 들여쓰기 · 줄바꿈). + 머리가 바뀌었거나 옛 글을 못 읽으면 None(통째로 dump).""" + head = lambda d: {k: v for k, v in d.items() if k != key} # noqa: E731 + old_items, new_items = before.get(key) or [], after.get(key) or [] + if head(before) != head(after) or not old_items or not new_items: + return None + for m in re.finditer(rf'"{key}"\s*:\s*\[', old_text): # 머리 속 같은 이름 칸은 건너뜀 + spans, pos, dec = [], m.end(), json.JSONDecoder() + try: + while old_text[pos := _skip(old_text, pos)] != "]": + _, end = dec.raw_decode(old_text, pos) + spans.append((pos, end)) + pos = _skip(old_text, end) + pos += old_text[pos] == "," + except (IndexError, ValueError): + return None + if len(spans) == len(old_items): + break + else: + return None + nl = "\r\n" if "\r\n" in old_text else "\n" + lead = old_text[m.end() : spans[0][0]] + indent = len(lead) - len(lead.rstrip(" ")) + olds = {str(r.get("키")): (r, old_text[s:e]) for r, (s, e) in zip(old_items, spans)} + shape, parts = old_text[spans[0][0] : spans[0][1]], [] + for row in new_items: + was, text = olds.get(str(row.get("키")), (None, None)) + shape = text or shape + if was == row: + parts.append(text) + elif "\n" not in shape: + tight = shape.startswith('{"') + parts.append(_flat(row, tight, tight or ': {"' in shape)) + else: + parts.append(dump(row, indent, expand=True).replace("\n", nl)) + out = old_text[: m.end()] + lead + ("," + lead).join(parts) + old_text[spans[-1][1] :] + same = json.loads(out, parse_float=Decimal, parse_int=Decimal) == after + return out if same else None + + +def dump(v, indent: int = 0, width: int = 160, expand: bool = False) -> str: + """UTF-8 · 들여쓰기 2칸 · 한 줄에 들어가는 묶음은 한 줄로(expand 면 첫 층은 펼침).""" + flat = _flat(v) + if ( + not isinstance(v, (dict, list)) + or not v + or (not expand and indent and len(flat) + indent <= width) + ): + return flat + ("" if indent else "\n") + pad = " " * (indent + 2) + if isinstance(v, dict): + body = [f"{pad}{_atom(k)}: {dump(x, indent + 2, width)}" for k, x in v.items()] + text = "{\n" + ",\n".join(body) + "\n" + " " * indent + "}" + else: + text = ( + "[\n" + + ",\n".join(pad + dump(x, indent + 2, width) for x in v) + + "\n" + + " " * indent + + "]" + ) + return text + ("" if indent else "\n") diff --git a/resources/master_data/_틀.md b/resources/master_data/_틀.md index 7c730c7a..20d0f130 100644 --- a/resources/master_data/_틀.md +++ b/resources/master_data/_틀.md @@ -194,7 +194,7 @@ "중간": [{ "이름": "증가율", "식": "…" }], "호표": [{ "종류": "인력", "요소": "LB000002", "단위": "인", "수량": "…", "비목": "노무비" }], "덧줄": [{ "이름": "잡재료비", "식": "노무비 * 0.05", "비목": "재료비", "출처": "…" }], - "끝수": "원 미만 버림" + "끝수": { "대상": "계", "자리": 0, "방법": "버림", "출처": "산림품셈 13-4-1" } } ] } @@ -204,6 +204,7 @@ - `종류` = 인력 · 재료 · 기계 · 로직(하위 로직의 결과를 단가로 씀) — 요소 키의 테이블ID 그룹과 같음. - `비목` = 노무비 · 재료비 · 경비. `덧줄` 식에서 비목 합을 이름으로 씀. - `결과단위` 가 돈이 아닌 로직(㎥/시간)은 `호표` 없이 `중간` + `결과` 식. +- `끝수` = 묶음 `{대상, 자리, 방법, 출처?, 비고?}` — 엔진이 계산 맨 끝에 붙임. `대상` 은 `계`(비목 합 셋을 각각 끊고 계 = 그 합) · `결과`(돈 아닌 로직) · `자리` 는 소수 자리(0 = 원 미만) · `방법` 은 `버림`·`올림`·`반올림`. 글자 `끝수`(「손료 원 미만 버림(…)」)는 원문 근거를 적은 설명일 뿐 — 그 끊기는 식 안에 이미 있음. - `종류` 가 로직이고 `비목` 칸이 없는 호표 줄은 불러온 결과를 비목별로 나눠 얹음. 재료 고르기 — 호표 재료 줄의 `요소` 를 키 대신 조건 묶음으로 적음: diff --git a/resources/master_data/_화면_계약.md b/resources/master_data/_화면_계약.md index 42b93e99..9acb3067 100644 --- a/resources/master_data/_화면_계약.md +++ b/resources/master_data/_화면_계약.md @@ -23,7 +23,7 @@ | `GET /tables` | `file` 또는 `group` · `sub` · `detail` · `section` · `q` · `page`(1) · `size`(30) | `{file, version, total, page, size, tables: [{file, 키, 원문번호, 구분, 상세구분, 이름, 기준, 출처, 조건, 값칸, count}]}` — 파일을 가로지른 한 목록 | | `GET /table` | `file` · `key` | `{file, version, table: {표 하나 통째}}` | | `GET /table/options` | `file` · `key` | `{file, key, 이름, 조건, 범위규칙, 값칸, count, 값: {조건 이름: [값…]}}` — 표 줄을 통째로 안 받고 조건 칸의 값만 | -| `GET /sections` | `book`(필수) · `division` · `chapter` · `q` · `limit`(200 · 1000까지) | `{total, sections: [{book, division, chapter, section, title, tables, logics}]}` — 원문 절 목록 | +| `GET /sections` | `book`(필수) · `division` · `chapter` · `q` · `limit`(200 · 1000까지) | `{total, sections: [{book, division, chapter, chapterNo, section, title, tables, logics}]}` — 원문 절 목록 | | `GET /logics` | `sub` · `detail` · `q` · `blocked`(0/1) · `owner` | `{logics: [{file, 키, 원문번호, 구분, 상세구분, 이름, 결과단위, 출처, 소유, blocked, reasons}]}` — 장 차례대로 | | `GET /logic` | `key` | `{file, version, logic: {로직 한 줄 통째}, blocked, reasons, prices: {요소: 요약 \| null}}` | | `GET /elements` | `group` · `q` · `limit`(50 · 200까지) | `{total, items: [{ref, file, 원문번호, 구분, 상세구분, 이름, 규격, 단위, 값, 값칸}]}` | @@ -39,7 +39,7 @@ - `materials` = 재료 고르기 단계 — 로직 재료 줄 `요소` 가 조건 `{구분, 상세구분, 규격}` 이면 이 API 로 후보(방식 A·B·C 가 부품 `M01_MasterData_UI_Test_Pick.ts` 하나를 같이 씀 · 구분 → 상세구분 → 후보 순으로 좁힘). `값` 이 비어 있어도 후보로 둠. 옛 재료 줄(품셈재료 키)은 품셈재료 이름(없으면 이름 첫 낱말)으로 `pick?kind=price` 후보. 후보 글 = 이름 규격 · 값 있는 열 전부(물가자료 · 유통물가 …). 멈춤 까닭 `입력 「X」 없음` · `맞는 줄 0개` 는 방식 A·B·C 가 쉬운 말로 바꿔 보임(`plainReason`) · 빈 수 칸은 「값을 넣어 주세요」 안내 글. - 소요량·계수 표의 `용도`({공종, 대상, 로직키}) = 방식 B 원문 표 미리보기 머리에 안내 · 칸이 없으면 안 보임 · `/tables` 는 `usage` 로 `용도.대상` 거름(기계 `sub`·`detail` 은 인력과 같이 구분 → 상세구분). - `section` = 절 번호 거름 — 그 절과 그 아래 절만(`13-4` 는 `13-4` · `13-4-1` 만 · `13-40` 은 안 걸림). 「만들기」가 절을 고르면 그 절의 표만 보임. -- `/sections` = 만들기 첫 걸음 — 원문 본문의 절 제목 줄에서 모음(`book` = 산림품셈 · 건설품셈 · `division` = 건설품셈 부문(공통 · 토목 · 건축 · 기계설비 · 유지관리) · `chapter` = 장 번호 두 자리 「13」 · `q` = 절 번호·제목 찾기). `tables` · `logics` = 그 절(아래 절 포함)의 마스터 표·로직 수 — 0 이면 아직 안 올린 절. +- `/sections` = 만들기 첫 걸음 — 원문 본문의 절 제목 줄에서 모음(`book` = 산림품셈 · 건설품셈 · `division` = 건설품셈 부문(공통 · 토목 · 건축 · 기계설비 · 유지관리) · `chapter` = 장 번호 두 자리 「13」 · `q` = 절 번호·제목 찾기). `chapter` = 「부문 NN장 장 제목」 · `chapterNo` = 「NN」 · `tables` · `logics` = 그 절(아래 절 포함)의 마스터 표·로직 수 — 0 이면 아직 안 올린 절. - `/table/options` = 조건 값 고르기 — `고르기` 조건은 줄에 나온 값 차례대로(중복 없음) · `범위` 조건은 `[[아래, 위]…]` · `count` = 줄 수. 표 몸을 통째로 받지 않으려는 자리에만 씀(통째는 `/table`). - `owner` = 로직 `소유` 거름(`공용` 정본 · `현장` 자체) · 빈 글 = 전체. - `pick` = 고르기 창 — `price` = `재료_자재품목.json`(값 = 값 열 다섯 가운데 낮은 값 · `관급` = 관급 값 있음) ref 키 · `job` = `인력.json` 값 있는 공표 직종(구분 「미확보」 빼고) ref 키. @@ -80,9 +80,9 @@ 정본 로직(`소유` 「공용」 · `로직_산림품셈…` · `로직_건설품셈…`)은 이 길로 못 고침 — 403. 고치려면 먼저 복제. -- 자리 = `로직_자체_NN장_<장 제목>.json`(`NN` = 원문 절 번호의 장 · 원문 절이 없으면 `로직_자체_00장_자체.json`) · 없으면 서버가 머리(`{"그룹": "로직", "원문": "자체", "차례": 90, "판": <올해>, "줄": []}`)와 함께 만듦. -- 키 = 서버가 `_키대장.json` 에서 `GX` 다음 번호로 줌 — 화면은 키를 만들지 않음. -- `소유` = `현장`(기본) · `공용`. 줄의 `구분` = 「자체」 · `상세구분` = 「NN장 장 제목」 은 서버가 넣음. +- 자리 = `로직_자체.json` 한 파일(원문 장으로 나누지 않음 — 틀이 「자체」 파일에 장 이름을 두지 않음) · 없으면 서버가 머리(`{"그룹": "로직", "원문": "자체", "판": <올해>, "줄": []}`)와 함께 만듦. +- 키 = 서버가 `_키대장.json` 에서 `GX` 다음 번호로 줌 — 화면은 키를 만들지 않음. 원문 절이 같아도 줄마다 다른 키. +- `소유` = `현장`(기본) · `공용`. 줄의 `구분` = 「자체」 · `상세구분` = 「NN장 장 제목」(`출처`·`원문번호` 에서 서버가 찾음 · 원문 절이 없으면 빈 글)은 서버가 넣음. | 길 | 받음 | 줌 | | -------------------- | ----------------------- | ------------------------------------------------------------ | diff --git a/resources/master_data/scripts/master_formula.py b/resources/master_data/scripts/master_formula.py index b3fbd0c2..fed17bbe 100644 --- a/resources/master_data/scripts/master_formula.py +++ b/resources/master_data/scripts/master_formula.py @@ -426,6 +426,21 @@ def evaluate(node, env: dict, master: Master, depth: int = 0): raise FormulaError(f"모르는 마디 {kind}") +ROUND_WAYS = {"올림": ROUND_UP, "버림": ROUND_DOWN, "반올림": ROUND_HALF_UP} +ROUND_ON = ("계", "결과") + + +def cut(row: dict, value: Decimal, on: str) -> Decimal: + """줄의 `끝수` 를 붙임 — 묶음 `{대상, 자리, 방법}` 만 · 글자 끝수는 원문 근거를 적은 설명.""" + rule = row.get("끝수") + if not isinstance(rule, dict) or rule.get("대상") != on: + return value + way = ROUND_WAYS.get(str(rule.get("방법"))) + if way is None: + raise FormulaError(f"「{_name(row)}」 끝수 방법 「{rule.get('방법')}」") + return _round(value, rule.get("자리") or 0, way) + + def _inputs(row: dict, given: dict) -> dict: env = {} for spec in row.get("입력", []): @@ -463,7 +478,8 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict: for step in row.get("중간", []): env[step["이름"]] = evaluate(parse(step["식"]), env, master, depth) if "결과" in row: - return {"결과": evaluate(parse(row["결과"]["식"]), env, master, depth), "중간": env} + value = evaluate(parse(row["결과"]["식"]), env, master, depth) + return {"결과": cut(row, value, "결과"), "중간": env} sums = dict.fromkeys(COST_ITEMS, Decimal(0)) lines = [] for item in row.get("호표", []): @@ -526,6 +542,7 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict: "비목": {extra["비목"]: value}, } ) + sums = {k: cut(row, v, "계") for k, v in sums.items()} # 비목 합마다 끊고 계는 그 합 return {"줄": lines, **sums, "계": sum(sums.values()), "중간": env} @@ -679,9 +696,34 @@ def check_logic(master: Master, row: dict) -> tuple[list[str], set]: names = names | {extra["이름"]} if extra.get("비목") not in COST_ITEMS: out.append(f"{key} 덧줄 {extra['이름']} · 비목 「{extra.get('비목')}」") + out += _check_cut(row, key) return out, calls +def _check_cut(row: dict, key: str) -> list[str]: + """끝수 — 묶음이면 칸·대상·방법·자리 · 글자는 설명(검사 안 함).""" + rule = row.get("끝수") + if rule is None or isinstance(rule, str): + return [] + if not isinstance(rule, dict): + return [f"{key} 끝수 · 묶음도 글자도 아님"] + out = [] + unknown = set(rule) - {"대상", "자리", "방법", "출처", "비고"} + if unknown: + out.append(f"{key} 끝수 · 모르는 칸 {sorted(unknown)}") + on = rule.get("대상") + if on not in ROUND_ON: + out.append(f"{key} 끝수 · 대상 「{on}」 — {' · '.join(ROUND_ON)}") + elif ("결과" in row) != (on == "결과"): + out.append(f"{key} 끝수 · 대상 「{on}」 이 로직 갈래와 안 맞음") + if rule.get("방법") not in ROUND_WAYS: + out.append(f"{key} 끝수 · 방법 「{rule.get('방법')}」 — {' · '.join(ROUND_WAYS)}") + place = rule.get("자리", 0) + if not isinstance(place, (int, Decimal)) or place != int(place) or not 0 <= int(place) <= 6: + out.append(f"{key} 끝수 · 자리 「{place}」 — 0∼6 정수") + return out + + def find_loops(graph: dict[str, set]) -> list[list[str]]: """로직 부르기의 돌고 도는 길.""" loops, state = [], {} diff --git a/resources/tester/test_m01_make.py b/resources/tester/test_m01_make.py new file mode 100644 index 00000000..3ccb36cc --- /dev/null +++ b/resources/tester/test_m01_make.py @@ -0,0 +1,178 @@ +"""M01 「만들기」 API — 절 목록 · 절 거르기 · 표 조건 값 · 소유 거름 · 자체 로직 · 끝수. + +계약 `resources/master_data/_화면_계약.md` 2장·5장·6장. 임시 폴더 사본에서 씀. +""" + +from __future__ import annotations + +import shutil +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 +from M01_MasterData import M01_MasterData_Store_Make as make + +REAL = store.FOLDER +LABOR = "LB000002" # 보통인부 +SAMPLE = "GF000219" # 산림 13-4-1 메쌓기(인력) + + +@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 _draft(끝수=None) -> dict: + """보통인부 1/7 인 한 줄짜리 초안 — 저장 안 함.""" + return { + "원문번호": "", + "이름": "시험 초안", + "결과단위": "원/㎡", + "출처": "자체", + "소유": "현장", + "입력": [], + "중간": [], + "호표": [ + { + "종류": "인력", + "요소": LABOR, + "이름": "보통인부", + "단위": "인", + "수량": "1 / 7", + "비목": "노무비", + } + ], + "덧줄": [], + "끝수": 끝수, + } + + +# ── 끝수 ────────────────────────────────────────────────────────────── +def test_끝수_묶음이_계에_붙고_글자는_안_붙는다(client: TestClient) -> None: + plain = _post(client, "/api/m01/calc", {"key": "", "inputs": {}, "row": _draft()}) + assert plain["ok"] is True, plain + raw = Decimal(str(plain["sums"]["계"])) + assert raw != raw.to_integral_value() # 172068 / 7 — 원 미만이 남음 + + cut = _post( + client, + "/api/m01/calc", + {"key": "", "inputs": {}, "row": _draft({"대상": "계", "자리": 0, "방법": "버림"})}, + ) + assert Decimal(str(cut["sums"]["계"])) == raw.to_integral_value(rounding="ROUND_DOWN") + assert cut["sums"]["노무비"] == cut["sums"]["계"] # 비목 합마다 끊고 계 = 그 합 + + text = _post(client, "/api/m01/calc", {"key": "", "inputs": {}, "row": _draft("원 미만 버림")}) + assert Decimal(str(text["sums"]["계"])) == raw # 글자 끝수는 설명일 뿐 + + +def test_끝수_묶음_모양을_검사가_잡는다() -> None: + bad = {"키": "GX000001", "원문번호": "", "호표": [], "끝수": {"대상": "줄", "방법": "내림"}} + found = store.cm.mf.check_logic(store.cm.master(REAL), bad)[0] + assert any("대상" in x for x in found) and any("방법" in x for x in found) + + +# ── 절 목록 · 절 거르기 · 표 조건 값 ────────────────────────────────── +def test_절_목록과_절로_표_거르기(client: TestClient) -> None: + got = _get(client, "/api/m01/sections", book="산림품셈", chapter="13") + one = next(s for s in got["sections"] if s["section"] == "13-4-1") + assert one["title"].startswith("메쌓기") and one["chapter"] == "13장 구조물" + assert one["tables"] >= 1 and one["logics"] >= 1 + assert client.get("/api/m01/sections", params={"book": "없는품셈"}).status_code == 404 + + hits = _get(client, "/api/m01/tables", group="소요량", section="13-4", size=500)["tables"] + assert hits and all(store.in_section(t["원문번호"], "13-4") for t in hits) + assert not store.in_section("13-40", "13-4") and store.in_section("13-4", "13-4") + + +def test_표_조건_값만_받는다(client: TestClient) -> None: + listed = _get(client, "/api/m01/tables", group="소요량", section="13-4-1", size=50, q="메쌓기")[ + "tables" + ] + one = listed[0] + got = _get(client, "/api/m01/table/options", file=one["file"], key=one["키"]) + assert got["count"] >= 1 and "줄" not in got + for name, kind in got["조건"].items(): + assert name in got["값"] + if kind == "고르기": + assert len(got["값"][name]) == len(set(map(str, got["값"][name]))) + + +def test_로직_목록을_소유로_거른다(client: TestClient) -> None: + every = _get(client, "/api/m01/logics")["logics"] + assert all("소유" in x for x in every) + only = _get(client, "/api/m01/logics", owner="공용")["logics"] + assert only and all(x["소유"] == "공용" for x in only) + assert _get(client, "/api/m01/logics", owner="현장")["logics"] == [] + + +# ── 자체 로직 ───────────────────────────────────────────────────────── +def test_자체_로직_만들기_본뜨기_고치기_지우기(client: TestClient) -> None: + made = _post(client, "/api/m01/logic/new", {"logic": _draft(), "owner": "현장"}) + assert made["key"].startswith("GX") and made["file"] == make.OWN + assert made["logic"]["구분"] == "자체" and made["logic"]["소유"] == "현장" + assert (store.FOLDER / make.OWN).is_file() + + again = _post(client, "/api/m01/logic/new", {"logic": _draft(), "owner": "현장"}) + assert again["key"] != made["key"] # 원문번호가 같아도 키는 하나씩 + + copied = _post(client, "/api/m01/logic/copy", {"key": SAMPLE, "이름": "메쌓기 본뜸"}) + assert copied["key"] != SAMPLE and f"{SAMPLE} 를 본뜸" in copied["logic"]["비고"] + assert copied["logic"]["이름"] == "메쌓기 본뜸" and copied["logic"]["소유"] == "현장" + assert copied["logic"]["상세구분"] == "13장 구조물" + + fixed = dict(copied["logic"], 이름="고친 이름") + done = _post( + client, + "/api/m01/logic/edit", + {"key": copied["key"], "version": copied["version"], "logic": fixed}, + ) + assert done["logic"]["이름"] == "고친 이름" and done["logic"]["키"] == copied["key"] + + stale = {"key": copied["key"], "version": copied["version"], "logic": fixed} + _post(client, "/api/m01/logic/edit", stale, want=409) + + gone = _post( + client, "/api/m01/logic/delete", {"key": copied["key"], "version": done["version"]} + ) + assert gone["file"] == make.OWN + assert client.get("/api/m01/logic", params={"key": copied["key"]}).status_code == 404 + + +def test_정본_로직은_못_고친다(client: TestClient) -> None: + got = _get(client, "/api/m01/logic", key=SAMPLE) + body = {"key": SAMPLE, "version": got["version"], "logic": got["logic"]} + _post(client, "/api/m01/logic/edit", body, want=403) + _post(client, "/api/m01/logic/delete", {"key": SAMPLE, "version": got["version"]}, want=403) + _post(client, "/api/m01/logic/new", {"logic": _draft(), "owner": "남의것"}, want=400) + + +def test_만든_자체_로직이_시험_계산에_돈다(client: TestClient) -> None: + made = _post(client, "/api/m01/logic/new", {"logic": _draft(), "owner": "공용"}) + got = _post(client, "/api/m01/calc", {"key": made["key"], "inputs": {}}) + assert got["ok"] is True and Decimal(str(got["sums"]["노무비"])) > 0 + listed = _get(client, "/api/m01/logics", owner="공용")["logics"] + assert made["key"] in [x["키"] for x in listed]