Files
Aislo/B09_Estimation/B09_Estimation_Expression.py
T
eomsangdonandClaude Opus 5 9cf01ab15c feat(b09): 2차 편집 ① 자재단가대비표 채택 바꾸기 — 프로젝트 단위 고친 값(estimation.edits) · 서버가 얹어 다시 계산 · ↺
- B09_Estimation_Edits: 고친 값 한 벌 — 기본 조립(cached_build) 복사본에 얹음(캐시 키에 고친 값) · 기본 벌은 그대로(골든셋 무관)
  · 얹지 못한 값은 edit_skipped 로 남김 · 값 없는 슬롯은 받지 않음(422 + 까닭)
- 새 라우터 B09_Estimation_Router_Edits(GET/PUT /estimation/edits) — Router.py 는 _build_for 가 고친 값을 얹게만 바꿈
- 화면: 줄마다 채택 슬롯 고르개 · 일괄(변동없음/1~5 단가/최소단가) · 고친 줄 「사용자」 + ↺(지우면 계산값으로)
- 곁: 사용자 식 셈(B09_Estimation_Expression — ROUND·ROUNDDOWN·INT·SQRT, eval 없음)과 본표 편집 칸(UI_DetailEdit)을 먼저 둠 — 서버 편집 문이 서기 전까지 잠자 있음
- 검증: 시험 1656 통과 · ORCA 채택 저장 → 「사용자」·↺ → ↺ 뒤 고친 값 {} 로 복구 · 값 없는 슬롯 422 「경유: 1번 원천에 값이 없어」

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 03:16:10 +09:00

85 lines
3.7 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.
"""B09 — 사용자가 글로 넣은 **식**을 안전하게 셈 (PLAN 12장 2차 ③ · STmate `wM_Edit_San` 함수 칸).
받는 것 — 수 · `+ - * /` · 괄호 · 함수 넷 `ROUND(x, n)` · `ROUNDDOWN(x, n)` · `INT(x)` · `SQRT(x)`
(STmate 식 편집기 함수 목록과 같은 이름 · 명세 13장 식 언어). 이름·속성·호출 모양이 그 밖이면 거절.
⚠ `eval` 을 쓰지 않음 — 파이썬 문법 나무를 한 마디씩 보고 아는 것만 셈.
⚠ 값은 `Decimal` — 소수를 이진수로 접지 않음(`1/1.175` 는 식 그대로 나눔).
⚠ 「좌→우 순차」 — 같은 우선순위의 곱·나눗셈은 파이썬 문법 나무가 이미 왼쪽부터 묶음(명세 7장 노임 식과 같음).
"""
from __future__ import annotations
import ast
from decimal import ROUND_FLOOR, ROUND_HALF_UP, Decimal, InvalidOperation
_MAX_LENGTH = 400
class ExpressionError(ValueError):
"""식을 셀 수 없음 — 까닭을 사람 말로."""
def _places(value: Decimal) -> Decimal:
digits = int(value)
if digits < 0 or digits > 10:
raise ExpressionError("자릿수는 0~10 사이여야 합니다")
return Decimal(1).scaleb(-digits)
def _call(name: str, args: list[Decimal]) -> Decimal:
if name == "ROUND" and len(args) == 2:
return args[0].quantize(_places(args[1]), rounding=ROUND_HALF_UP)
if name == "ROUNDDOWN" and len(args) == 2:
return args[0].quantize(_places(args[1]), rounding=ROUND_FLOOR)
if name == "INT" and len(args) == 1:
return args[0].to_integral_value(rounding=ROUND_FLOOR)
if name == "SQRT" and len(args) == 1:
if args[0] < 0:
raise ExpressionError("음수의 제곱근은 셀 수 없습니다")
return args[0].sqrt()
raise ExpressionError(f"모르는 함수이거나 인자 수가 맞지 않습니다: {name}")
def _eval(node: ast.AST) -> Decimal:
if isinstance(node, ast.Expression):
return _eval(node.body)
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return Decimal(str(node.value))
if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd)):
value = _eval(node.operand)
return -value if isinstance(node.op, ast.USub) else value
if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Sub, ast.Mult, ast.Div)):
left, right = _eval(node.left), _eval(node.right)
if isinstance(node.op, ast.Add):
return left + right
if isinstance(node.op, ast.Sub):
return left - right
if isinstance(node.op, ast.Mult):
return left * right
if right == 0:
raise ExpressionError("0 으로 나눌 수 없습니다")
return left / right
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and not node.keywords:
return _call(node.func.id.upper(), [_eval(arg) for arg in node.args])
raise ExpressionError(
"식에 쓸 수 없는 글자가 있습니다 — 수·+ - * /·괄호·ROUND·ROUNDDOWN·INT·SQRT 만"
)
def evaluate(text: str) -> Decimal:
"""식 글 → 값. 셀 수 없으면 `ExpressionError`."""
source = str(text or "").strip().replace("×", "*").replace("÷", "/")
if not source:
raise ExpressionError("식이 비었습니다")
if len(source) > _MAX_LENGTH:
raise ExpressionError("식이 너무 깁니다")
try:
tree = ast.parse(source, mode="eval")
except SyntaxError as error:
raise ExpressionError(f"식 문법이 맞지 않습니다: {error.msg}") from error
try:
return _eval(tree)
except (InvalidOperation, OverflowError) as error:
raise ExpressionError("셀 수 없는 값입니다") from error