feat(M01): 끝수 계산 붙임 · 절 목록·절 거르기·표 조건 값·소유 거름·자체 로직 API
- 엔진 — 로직 줄의 묶음 `끝수`{대상, 자리, 방법}를 계산 끝에 붙임(비목 합마다 끊고 계 = 그 합 · 돈 아닌 로직은 결과) · 글자 끝수는 설명 그대로 · 검사가 묶음 모양을 봄
- 서버 — `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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}])
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user