- 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
85 lines
3.7 KiB
Python
85 lines
3.7 KiB
Python
"""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
|