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
@@ -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}」 — 06 정수")
return out
def find_loops(graph: dict[str, set]) -> list[list[str]]:
"""로직 부르기의 돌고 도는 길."""
loops, state = [], {}