Files
Aislo/resources/master_data/scripts/master_text.py
T
eomsangdonandClaude Sonnet 5 b9a3fe3abb feat(M01): /text 조각 목록 더함 · 이름 식 계산 없이(PLAN 2-1 첫줄·2-2)
- master_text.py — 조각(요소·표·로직·값·기호·글) 생성기 추가, 이름글·글·금액·
  까닭은 그대로 두고 줄마다 「조각」 칸만 더함
- 만약() 은 계산되면 걸린 가지만 · 계산 안 되면(FormulaError 따위) 양쪽 다
  내보내고 깊이를 적어 접을 수 있게 함
- 자리 표시({…}) 든 요소 참조는 이름만 적고 참조(키) 자리는 비움
- mf.run 이 막혀도(재료 단가 없음 따위) row 구조만으로 이름 식·조각을 짓고
  글·금액만 비움 — 로직 부르는 줄(비목 미정)은 세 비목에 다 걸쳐 보임
- test_m01_text.py — 새 시험 6개(계약 확인·요소 알약·자리 표시·만약 둘·
  계산 막힘 no-calc 경로), 기존 10개 그대로 통과
- 검증: pytest resources/tester/test_m01_text.py 16 passed · 13-4-1·12-4·
  12-17-1 금액이 앞과 같음(calc 엔드포인트와 대조, 기존 시험 그대로) ·
  ruff format/check 통과

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj
2026-09-22 19:38:23 +09:00

483 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""사람이 읽는 식 줄 — 비목 묶음마다 「이름 : 단가 * 수량식 = 금액」 과 소계.
줄마다 글 두 꼴 —
· `글` 값 꼴: 수는 화면과 같은 자릿수(소수 넷째 자리까지 · 천 단위 쉼표) · `찾기`·`로직`·
중간 값·요소는 글자로 내보내지 않고 그 자리에서 나온 수로 적음
· `이름글` 이름 꼴: 「석공노임 * 메쌓기인력.석공 * (1 + 증가율) = 석공비」 — 표 찾기·로직
부르기는 별칭 이름(`master_copy` 3-4 규칙) · 중간 값·입력은 그 이름 그대로
두 꼴은 같은 줄에 나란히 있고 `짝` 번호가 붙음 — 화면 두 칸이 줄 맞춤으로 놓임.
줄마다 `조각`(PLAN 2-1 조각 계약) 도 같이 줌 — kind(요소·표·로직·값·기호·글) · 글 · 참조.
알약(요소·표·로직)이 될 자리는 구조만 보고 짓고, `만약()` 갈래만 골라야 해서 계산이 필요함 —
계산이 안 되면(FormulaError 따위) 갈래 양쪽을 다 내보내고 깊이를 적어 접을 수 있게 함.
호표 줄 계산 자체가 막혀도(재료 단가 없음 따위) 이름 식·조각은 `row` 구조만으로 그리고
`글`·`금액`만 비움 — `만약() 과 무관` 인 그 60개 로직도 이름 식은 보이게.
끝수는 줄이 아니라 소계에 녹여 적음. 계산 자체는 `master_formula.run` 이 한 벌만 함.
"""
from __future__ import annotations
from decimal import Decimal
import master_copy as mc
import master_formula as mf
PLACES = Decimal("0.0001") # 화면 `formatNumber` 와 같음(소수 넷째 자리까지)
PRICE_TAIL = {"인력": "노임", "기계": "손료"} # 단가 자리의 이름 꼬리(그 밖은 「단가」)
ELEM_KEYS = ("LB", "EQ", "MT", "MP", "MO") # 요소 테이블 — 그 밖의 열쇠(QC · QF …)는 별칭 이름
ELEM_TAIL = {"LB": "노임", "EQ": "손료"} # 식 안 요소 키의 이름 꼬리(그 밖은 「단가」)
_PREC = {"또는": 1, "그리고": 2, "==": 3, "!=": 3, "<": 3, "<=": 3, ">": 3, ">=": 3}
_PREC |= {"+": 4, "-": 4, "*": 5, "/": 5}
_RIGHT_TIGHT = ("-", "/") # 오른쪽 짝은 같은 세기라도 괄호가 필요함
_CALC_ERRORS = (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) # 계산 못 함
def fmt(value) -> str:
"""화면에 보이는 자릿수 그대로 — 소수 넷째 자리까지 · 뒤 0 은 떼고 · 천 단위 쉼표."""
if not isinstance(value, Decimal):
return str(value)
cut = value.quantize(PLACES) if value.as_tuple().exponent < -4 else value
sign, digits = ("-", -cut) if cut < 0 else ("", cut)
whole, _, rest = f"{digits.normalize():f}".partition(".")
if "E" in whole or "e" in whole: # 아주 큰 수 — 지수 꼴을 펴서 씀
whole, _, rest = f"{digits:f}".rstrip("0").rstrip(".").partition(".")
return sign + f"{int(whole):,}" + (f".{rest}" if rest else "")
# ── 식을 글로 — 값 꼴과 이름 꼴 ────────────────────────────────────────
def render(node, env: dict, master, depth: int = 0) -> str:
"""식 한 마디 → 값 꼴 글. 값이 정해지는 마디(요소 · 찾기 · 로직 · 이름)는 그 수로 바뀜."""
return _draw(node, node, env, master, depth)
def _draw(show, real, env: dict, master, depth: int) -> str:
kind = show[0]
if kind == "num":
return fmt(show[1])
if kind == "str":
return f"'{show[1]}'"
if kind == "neg":
return "-" + _draw(show[1], real[1], env, master, depth)
if kind == "bin":
return _bin(show, real, env, master, depth, _draw)
if kind == "call" and show[1] == "만약": # 걸린 쪽만 적음
at = 1 if mf.evaluate(real[2][0], env, master, depth) else 2
return _draw(show[2][at], real[2][at], env, master, depth)
return _value(real, env, master, depth)
def named(show, real, env: dict, master, depth: int = 0) -> str:
"""식 한 마디 → 이름 꼴 글. `show` = 부르기를 별칭으로 갈아 끼운 나무 · `real` = 원래 나무.
이름·중간 값은 그대로 · 표 찾기·로직 부르기는 별칭 이름 · 요소는 그 이름."""
kind = show[0]
if kind == "name":
return show[1]
if kind == "ref":
return _elem_name(master, show[1])
if kind == "field":
return f"{named(show[1], real[1], env, master, depth)}.{show[2]}"
if kind == "neg":
return "-" + named(show[1], real[1], env, master, depth)
if kind == "bin":
return _bin(show, real, env, master, depth, named)
if kind == "call":
if show[1] == "만약": # 값 꼴과 같은 쪽만 적어 두 칸이 짝이 되게
at = 1 if mf.evaluate(real[2][0], env, master, depth) else 2
return named(show[2][at], real[2][at], env, master, depth)
args = ", ".join(named(s, r, env, master, depth) for s, r in zip(show[2], real[2]))
return f"{show[1]}({args})"
if kind in ("num", "str"):
return _draw(show, real, env, master, depth)
return _value(real, env, master, depth) # 별칭이 안 붙은 부르기 — 값으로
def _elem_name(master, ref: str) -> str:
"""식 안 요소 한 줄의 이름 — 「석공노임」 꼴(같은 이름의 중간 값과 헷갈리지 않게)."""
if (
str(ref)[:2] not in ELEM_KEYS
): # 별칭 이름이 표 열쇠 그대로인 것(QC…) — 밑이름을 풀어쓰지 않음
return ref
try:
name = mc.plain(master.get(ref).get("이름"))
except mf.FormulaError:
name = ""
return f"{name}{ELEM_TAIL.get(str(ref)[:2], '단가')}" if name else ref
def _value(node, env: dict, master, depth: int) -> str:
value = mf.evaluate(node, env, master, depth)
if isinstance(value, dict): # 로직 부르기 묶음 — 줄 금액에 쓰는 것은 「계」
value = value.get("계")
return fmt(value)
def _bin(show, real, env: dict, master, depth: int, draw) -> str:
_, op, left, right = show
if op == "/" and right[0] == "num" and right[1] == 100: # 「… / 100」 은 퍼센트로
return f"{_side(left, real[2], show, env, master, depth, draw, False)} %"
return (
f"{_side(left, real[2], show, env, master, depth, draw, False)} {op} "
f"{_side(right, real[3], show, env, master, depth, draw, True)}"
)
def _side(show, real, parent, env: dict, master, depth: int, draw, right: bool) -> str:
text = draw(show, real, env, master, depth)
if show[0] != "bin":
return text
mine, up = _PREC[show[1]], _PREC[parent[1]]
if mine < up or (mine == up and right and parent[1] in _RIGHT_TIGHT):
return f"({text})"
return text
# ── 조각(알약) — PLAN 2-1 조각 계약 · 2-2 「이름 식을 계산 없이」 ──────────
_PIECE_SYM = {"+": "+", "-": "-", "*": "×", "/": "÷"}
def _p_val(text: str) -> dict:
return {"kind": "값", "글": text}
def _p_sym(text: str) -> dict:
return {"kind": "기호", "글": text}
def _alias_index(marks: list[dict]) -> dict[str, dict]:
"""별칭 이름 → 별칭 한 줄(`master_copy.aliases` 결과) — 조각이 참조를 되찾을 때 씀."""
return {m["이름"]: m for m in marks}
def _elem_piece(master, ref: str) -> dict:
"""요소 알약 — 글은 이름(못 찾아도 이름만). 자리 표시(`{…}`) 든 참조 14파일은
값(참조 키)을 못 구하므로 이름만 적고 참조 자리는 비움."""
piece = {"kind": "요소", "글": _elem_name(master, ref)}
if "{" in str(ref):
return piece
try:
row = master.get(ref)
except mf.FormulaError:
return piece
key = str(row.get("키") or ref)
piece["참조"] = {"테이블": key[:2], "키": key}
return piece
def _alias_piece(mark: dict, env: dict, master, column: str | None) -> dict:
"""표 찾기·로직 부르기 알약 — 글은 별칭 이름. 참조는 다시 찾아 봐서 되면만 붙임."""
piece = {"kind": "표" if mark["종류"] == "찾기" else "로직", "글": mark["이름"]}
try:
node = mf.parse(mark["원문"])
ref = mf.fill(node[1], env)
if mark["종류"] == "찾기":
table = master.get(ref)
conds = {k: mf.evaluate(v, env, master, 0) for k, v in node[2]}
row = mf.find(table, conds, column)
piece["참조"] = {
"테이블": ref[:2],
"키": ref,
"행": row.get("키") or row.get("원문번호"),
"열": column,
}
else:
piece["참조"] = {"테이블": ref[:2], "키": ref}
except _CALC_ERRORS:
pass # 다시 못 찾음 — 별칭 이름만 남김(값 자리 비움)
return piece
def _if_pieces(show, real, env: dict, master, alias_by_name: dict, ifdepth: int) -> list[dict]:
"""만약() 조각 — 계산되면 걸린 가지만 · 안 되면 양쪽 다(깊이를 적어 접을 수 있게)."""
try:
taken = mf.evaluate(real[2][0], env, master, 0)
except _CALC_ERRORS:
deeper = ifdepth + 1
left = _pieces(show[2][1], real[2][1], env, master, alias_by_name, deeper)
right = _pieces(show[2][2], real[2][2], env, master, alias_by_name, deeper)
for one in (*left, *right):
one["깊이"] = deeper
return [*left, _p_sym("/"), *right]
at = 1 if taken else 2
return _pieces(show[2][at], real[2][at], env, master, alias_by_name, ifdepth)
def _side_pieces(
show, real, parent_show, env: dict, master, alias_by_name: dict, ifdepth: int, right: bool
):
inner = _pieces(show, real, env, master, alias_by_name, ifdepth)
if show[0] != "bin":
return inner
mine, up = _PREC[show[1]], _PREC[parent_show[1]]
if mine < up or (mine == up and right and parent_show[1] in _RIGHT_TIGHT):
return [_p_sym("("), *inner, _p_sym(")")]
return inner
def _bin_pieces(show, real, env: dict, master, alias_by_name: dict, ifdepth: int) -> list[dict]:
op = show[1]
if op == "/" and show[3][0] == "num" and show[3][1] == 100: # 「… / 100」 은 퍼센트로
left = _side_pieces(show[2], real[2], show, env, master, alias_by_name, ifdepth, False)
return [*left, {"kind": "글", "글": "%"}]
left = _side_pieces(show[2], real[2], show, env, master, alias_by_name, ifdepth, False)
right = _side_pieces(show[3], real[3], show, env, master, alias_by_name, ifdepth, True)
return [*left, _p_sym(_PIECE_SYM.get(op, op)), *right]
def _pieces(show, real, env: dict, master, alias_by_name: dict, ifdepth: int = 0) -> list[dict]:
"""식 한 마디 → 조각 목록. 요소·표·로직 부르기는 알약(참조가 됨) · 나머지는 값·기호·글."""
kind = show[0]
if kind == "num":
return [_p_val(fmt(show[1]))]
if kind == "str":
return [_p_val(f"'{show[1]}'")]
if kind == "neg":
return [_p_sym("-"), *_pieces(show[1], real[1], env, master, alias_by_name, ifdepth)]
if kind == "bin":
return _bin_pieces(show, real, env, master, alias_by_name, ifdepth)
if kind == "call" and show[1] == "만약":
return _if_pieces(show, real, env, master, alias_by_name, ifdepth)
if kind == "ref":
return [_elem_piece(master, show[1])]
if kind == "field" and show[1][0] == "name" and show[1][1] in alias_by_name:
return [_alias_piece(alias_by_name[show[1][1]], env, master, show[2])]
if kind == "name" and show[1] in alias_by_name:
return [_alias_piece(alias_by_name[show[1]], env, master, None)]
try:
return [_p_val(_value(real, env, master, 0))]
except _CALC_ERRORS:
return [_p_val("")] # 그 밖(값 계산 자체가 막힘) — 빈 값 조각으로 줄이 안 죽게
def _formula_pieces(text, env: dict, master, name_of: dict, alias_by_name: dict) -> list[dict]:
"""식 글 한 줄의 조각 — 별칭을 씌운 나무와 원래 나무를 나란히 읽음(`_names` 와 짝)."""
real = mf.parse(text)
try:
show = mf.parse(mc.mask(text, name_of))
except mf.FormulaError:
show = real
return _pieces(show, real, env, master, alias_by_name)
# ── 줄 세우기 ─────────────────────────────────────────────────────────
def _env_of(master, row: dict, given: dict, depth: int = 0) -> dict:
"""입력 + 중간 — `run` 과 같은 차례로 다시 셈(덧줄 전 상태)."""
env = mf._inputs(row, given)
for step in row.get("중간", []):
env[step["이름"]] = mf.evaluate(mf.parse(step["식"]), env, master, depth)
return env
def _why(item: dict, line: dict) -> str:
"""단가를 못 구했거나 0 인 줄의 까닭 한 마디."""
if line["수량"] == 0:
return "수량 0 — 이 조건에서는 안 쓰는 줄"
if line["단가"] not in (0, None):
return ""
where = item.get("요소")
where = "재료 고르기 조건" if isinstance(where, dict) else f"「{where}」"
return f"단가 0 — {where} 값이 아직 없음"
def _label(item: dict, line: dict) -> str:
spec = str(item.get("규격") or "").strip()
name = str(line.get("이름") or item.get("이름") or "")
return f"{name}({spec})" if spec else name
def _shares(line: dict) -> list[tuple[str, Decimal]]:
"""글로 적을 (비목, 몫) — 비목이 나뉜 로직 줄은 몫이 없는 비목을 건너뜀.
몫이 전부 0 이면 호표 줄이 사라지지 않게 첫 비목에 0 원 한 줄을 남김."""
parts = list(line["비목"].items())
if len(parts) <= 1:
return parts
return [(part, money) for part, money in parts if money != 0] or parts[:1]
def _line_text(item: dict, line: dict, env: dict, master, part: str | None) -> str:
"""「단가 * 수량식 = 금액」 — 비목이 나뉜 로직 줄은 그 비목 몫만."""
qty = render(mf.parse(item["수량"]), env, master)
money = line["비목"][part] if part else line["금액"]
price = line["단가"] if part is None or line["수량"] == 0 else money / line["수량"]
return f"{fmt(price)} * {qty} = {fmt(money)}"
def _line_names(item: dict, line: dict, env: dict, master, part: str | None, name_of: dict) -> str:
"""같은 줄을 변수 이름으로 — 「석공노임 * 메쌓기인력.석공 * (1 + 증가율) = 석공비」."""
name = mc.plain(item.get("이름") or line.get("이름")) or "값"
tail = PRICE_TAIL.get(str(item.get("종류") or ""), "단가")
price = f"{name}{part or ''}{tail}"
money = f"{name}{part}" if part else f"{name}비"
return f"{price} * {_names(item['수량'], env, master, name_of)} = {money}"
def _extra_text(extra: dict, line: dict, env: dict, master) -> str:
return f"{render(mf.parse(extra['식']), env, master)} = {fmt(line['금액'])}"
def _extra_names(extra: dict, env: dict, master, name_of: dict) -> str:
got = mc.plain(extra.get("이름")) or "덧줄"
return f"{_names(extra['식'], env, master, name_of)} = {got}"
def _names(text, env: dict, master, name_of: dict) -> str:
"""식 글 한 줄을 이름 꼴로 — 별칭을 씌운 나무와 원래 나무를 나란히 읽음."""
real = mf.parse(text)
try:
show = mf.parse(mc.mask(text, name_of))
except mf.FormulaError: # 별칭을 씌우면 못 읽는 글 — 값 꼴로 물러남
show = real
return named(show, real, env, master)
def lines(files: dict, key: str, given: dict) -> dict:
"""로직 한 줄의 읽는 식 — 비목 묶음마다 줄과 소계 · 돈 아닌 로직은 「결과」 묶음 하나.
호표 줄 계산이 막히면(재료 단가 없음 따위) `row` 구조만으로 이름 식·조각을 짓고
`글`·`금액`은 비움(입력 자체가 없는 경우는 지금처럼 그대로 막힘 — `_env_of` 에서 걸림)."""
master = mf.Master(files)
row = master.logic(key)
env = _env_of(master, row, given)
marks = mc.aliases(row, master)
name_of = mc.names(marks)
alias_by_name = _alias_index(marks)
try:
result = mf.run(master, key, given)
except _CALC_ERRORS:
result = None
if "결과" in row:
조각 = _formula_pieces(row["결과"]["식"], env, master, name_of, alias_by_name)
got = mc.plain(row.get("이름")) or "결과"
이름글 = f"{_names(row['결과']['식'], env, master, name_of)} = {got}"
if result is not None:
one = {
"이름": row.get("이름") or "",
"글": f"{render(mf.parse(row['결과']['식']), env, master)} = {fmt(result['결과'])}",
"이름글": 이름글,
"금액": result["결과"],
"조각": 조각,
}
group = {
"비목": "결과",
"줄": [one],
"소계": result["결과"],
"끝수": _cut_note(row, "결과"),
}
= result["결과"]
else:
one = {
"이름": row.get("이름") or "",
"글": "",
"이름글": 이름글,
"금액": None,
"조각": 조각,
}
group = {"비목": "결과", "줄": [one], "소계": None, "끝수": ""}
= None
return _paired({"ok": True, "groups": [group], "계": , "별칭": marks})
made: dict[str, list] = {name: [] for name in mf.COST_ITEMS}
items = row.get("호표") or []
if result is not None:
shown = result["줄"]
env["줄"] = {r["이름"]: r["금액"] for r in shown if r["이름"]}
for item, line in zip(items, shown):
조각 = _formula_pieces(item["수량"], env, master, name_of, alias_by_name)
for part, money in _shares(line):
split = part if len(line["비목"]) > 1 else None
one = {
"이름": _label(item, line),
"글": _line_text(item, line, env, master, split),
"이름글": _line_names(item, line, env, master, split, name_of),
"금액": money,
"조각": 조각,
}
why = _why(item, line)
made[part].append({**one, "까닭": why} if why else one)
_extras(row, shown[len(items) :], made, env, master, name_of, alias_by_name)
소계 = {name: result[name] for name in mf.COST_ITEMS}
끝수 = _cut_note(row)
= result["계"]
else:
for item in items:
parts = (
list(mf.COST_ITEMS)
if item.get("종류") == "로직" and "비목" not in item
else [item.get("비목")]
)
조각 = _formula_pieces(item["수량"], env, master, name_of, alias_by_name)
for part in parts:
if part not in mf.COST_ITEMS:
continue
split = part if len(parts) > 1 else None
made[part].append(
{
"이름": _label(item, {}),
"글": "",
"이름글": _line_names(item, {}, env, master, split, name_of),
"금액": None,
"조각": 조각,
}
)
for extra in row.get("덧줄") or []:
made[extra["비목"]].append(
{
"이름": extra["이름"],
"글": "",
"이름글": (
f"{_names(extra['식'], env, master, name_of)} = "
f"{mc.plain(extra.get('이름')) or '덧줄'}"
),
"금액": None,
"조각": _formula_pieces(extra["식"], env, master, name_of, alias_by_name),
}
)
소계 = dict.fromkeys(mf.COST_ITEMS, None)
끝수 = ""
= None
groups = [
{"비목": name, "줄": made[name], "소계": 소계[name], "끝수": 끝수}
for name in mf.COST_ITEMS
if made[name]
]
return _paired({"ok": True, "groups": groups, "계": , "별칭": marks})
def _paired(answer: dict) -> dict:
"""줄마다 짝 번호 — 두 칸(이름 꼴 · 값 꼴)이 같은 차례로 놓이게 1 부터."""
at = 0
for group in answer["groups"]:
for one in group["줄"]:
at += 1
one["짝"] = at
return answer
def _extras(
row: dict, shown: list, made: dict, env: dict, master, name_of: dict, alias_by_name: dict
) -> None:
"""덧줄 — 앞선 줄까지의 비목 합을 넣고 한 줄씩(식 안의 비목 이름은 그 수로 바뀜)."""
sums = {name: sum((one["금액"] for one in made[name]), Decimal(0)) for name in mf.COST_ITEMS}
for extra, line in zip(row.get("덧줄") or [], shown):
env.update(sums)
one = {
"이름": extra["이름"],
"글": _extra_text(extra, line, env, master),
"이름글": _extra_names(extra, env, master, name_of),
"금액": line["금액"],
"조각": _formula_pieces(extra["식"], env, master, name_of, alias_by_name),
}
made[extra["비목"]].append(one)
sums[extra["비목"]] += line["금액"]
env[extra["이름"]] = line["금액"]
def _cut_note(row: dict, on: str = "계") -> str:
"""소계에 붙일 끝수 한 마디 — 끝수는 따로 줄로 보이지 않음."""
rule = row.get("끝수")
if not isinstance(rule, dict) or rule.get("대상") != on:
return ""
place = int(rule.get("자리") or 0)
where = "원 미만" if place == 0 else f"소수 {place + 1}째 자리에서"
return f"{where} {rule.get('방법')}"