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:
2026-09-21 09:31:45 +09:00
co-authored by Claude Opus 5
parent 964552c2ba
commit aa86e02b8f
8 changed files with 678 additions and 109 deletions
+47 -99
View File
@@ -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")