feat(b09): 2차 편집 ② 일위대가·단산 구성행 수정 · ③ Q 식 편집 — 프로젝트 단위 · 서버가 다시 계산 · ↺
- 구성행(sheet_rows): 수량 고치기 · 줄 빼기(수량 0, 흐리게) · 줄 더하기(단가표 고르개) — 비율 줄·돌림 참조는 받지 않음
- Q 식(price_basis_q): 식은 명세 13장 식 언어 — B08 구조물도 풀이기(evaluate_sheets, Node 한 벌)를 그대로 부름 ·
소수 2자리 사사오입 확정 → 새 수량 = 수량 × 옛 Q ÷ 새 Q · 비고에 「Q(사용자) = 식 = 값」
· PriceDetail.output(시공능력 Q)을 Q 로 선 장비 줄 넷 자리(굴착기·도자·직접 작업량·암 잎)에서 실음 · 저장 모양에도 실음
- 따로 짰던 파이썬 식 셈(B09_Estimation_Expression)은 걷어냄 — 식 풀이 두 벌 금지(브레인 판정)
- 새 문: GET /estimation/edits/sheet/{code}(본표 + 줄마다 고친 값 표시) · GET /estimation/edits/search(줄 더하기 고르개)
- 화면: 본표 [편집] — 수량 칸 · ✕ · Q 식 칸 · 줄 더하기 · 고친 줄 「사용자」 + ↺
- 검증: 시험 1664 통과 · 골든셋 초록 · ORCA — 제 3 호표 보통인부 0.023→0.046 이면 5,652→9,610 · 내역 본체 122,848,989→122,857,198,
↺ 뒤 5,652 · 산근 2호표 Q 58.21→116.42 이면 1,695→847, ↺ 뒤 1,695 · 틀린 식 422 「모르는 이름: abc」 · 고친 값 {} 로 복구
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
@@ -9,26 +9,49 @@
|
||||
되돌리기 고친 값을 지우면 계산값으로 돌아감(B08 집계표 ↺ 와 같은 꼴)
|
||||
|
||||
⚠ 기본 조립은 **안 바꾼다** — 고친 값이 없으면 `cached_build` 그 벌 그대로라 골든셋이 그대로 돈다.
|
||||
⚠ 얹지 못한 고친 값(단가표에서 사라진 코드·값 없는 슬롯)은 조용히 버리지 않고 `edit_skipped` 에 남긴다.
|
||||
⚠ 얹지 못한 고친 값(단가표에서 사라진 코드·값 없는 슬롯·안 풀리는 식)은 조용히 버리지 않고 `edit_skipped` 에 남긴다.
|
||||
|
||||
구획
|
||||
adopted_slots {자재 코드: 슬롯 번호 1~6} 자재단가대비표 채택 바꾸기(`wM_Boxa`)
|
||||
adopted_slots {자재 코드: 슬롯 1~6} 자재단가대비표 채택(`wM_Boxa`)
|
||||
sheet_rows {"코드|i": {quantity}|{removed}} · 일위대가·단산 구성행(`wM_Edit_iLWi`)
|
||||
{"코드|+n": {ref, quantity}} 줄 더하기(뒤에 붙음)
|
||||
price_basis_q {"D-코드|i": {q_formula}} 단가산출 Q 식(`wM_Edit_San`)
|
||||
— 식은 **명세 13장 식 언어**(B08 구조물도 풀이기 그대로) → 소수 2자리 사사오입 확정 →
|
||||
새 수량 = 수량 × 옛 Q ÷ 새 Q(몫은 그대로)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
from dataclasses import replace
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
from B09_Estimation.B09_Estimation_PriceBook import PRICE_SLOT_COUNT, PriceKind
|
||||
from B09_Estimation.B09_Estimation_PriceBook import (
|
||||
PRICE_SLOT_COUNT,
|
||||
PriceBookError,
|
||||
PriceDetail,
|
||||
PriceKind,
|
||||
)
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build
|
||||
|
||||
EDITS_KEY = "edits"
|
||||
#: 화면·근거 표기 — B08 구조물도 집계표와 같은 말.
|
||||
USER_SOURCE = "user"
|
||||
SECTIONS = ("adopted_slots",)
|
||||
SECTIONS = ("adopted_slots", "sheet_rows", "price_basis_q")
|
||||
#: 구성행을 고칠 수 있는 본표 — 일위대가(B) · 단가산출(D).
|
||||
EDITABLE_KINDS = (PriceKind.UNIT_PRICE, PriceKind.PRICE_BASIS)
|
||||
#: 줄 더하기로 부를 수 있는 단가표 층.
|
||||
ADDABLE_KINDS = (
|
||||
PriceKind.LABOR,
|
||||
PriceKind.MATERIAL,
|
||||
PriceKind.MACHINE_HOURLY,
|
||||
PriceKind.UNIT_PRICE,
|
||||
PriceKind.PRICE_BASIS,
|
||||
PriceKind.LUMPSUM,
|
||||
)
|
||||
|
||||
|
||||
class EditError(ValueError):
|
||||
@@ -53,6 +76,55 @@ def edits_key(raw: Any) -> str:
|
||||
return json.dumps(edits, sort_keys=True, ensure_ascii=False) if edits else ""
|
||||
|
||||
|
||||
def split_key(key: str) -> tuple[str, str]:
|
||||
"""`코드|i` · `코드|+n` → (코드, 뒤 조각)."""
|
||||
code, _, tail = key.rpartition("|")
|
||||
return code, tail
|
||||
|
||||
|
||||
def _quantity(text: Any) -> Decimal:
|
||||
try:
|
||||
value = Decimal(str(text).replace(",", "").strip())
|
||||
except (InvalidOperation, ValueError) as error:
|
||||
raise EditError(f"수량이 수가 아닙니다: {text}") from error
|
||||
if not value.is_finite() or value < 0:
|
||||
raise EditError(f"수량은 0 이상이어야 합니다: {text}")
|
||||
return value
|
||||
|
||||
|
||||
def evaluate_q(formulas: dict[str, str]) -> dict[str, Decimal | str]:
|
||||
"""Q 식 여럿을 한 번에 — 명세 13장 식 언어(B08 `evaluate_sheets`, Node 한 벌) · 소수 2자리 사사오입.
|
||||
|
||||
칸마다 값(0 보다 큼) 또는 못 푼 까닭(글). ⚠ 식 풀이를 파이썬으로 다시 짜지 않음(두 벌 금지).
|
||||
"""
|
||||
if not formulas:
|
||||
return {}
|
||||
from B08_Quantity.B08_Quantity_Engine_Formula import evaluate_sheets
|
||||
|
||||
keys = list(formulas)
|
||||
rows = [
|
||||
{
|
||||
"seq": index + 1,
|
||||
"name": key,
|
||||
"formula": formulas[key],
|
||||
"destination": "reference",
|
||||
"rounding": {"mode": "round", "digits": 2},
|
||||
}
|
||||
for index, key in enumerate(keys)
|
||||
]
|
||||
solved = evaluate_sheets([{"rows": rows}])
|
||||
if solved is None:
|
||||
return {key: "식 풀이기(Node)를 못 돌렸습니다" for key in keys}
|
||||
result: dict[str, Decimal | str] = {}
|
||||
for key, row in zip(keys, solved[0]):
|
||||
if row.get("error") or row.get("amount") is None:
|
||||
result[key] = str(row.get("error") or "식 값이 없습니다")
|
||||
continue
|
||||
value = Decimal(str(row["amount"]))
|
||||
result[key] = value if value > 0 else "Q 는 0 보다 커야 합니다"
|
||||
return result
|
||||
|
||||
|
||||
def _apply_adopted_slots(build: UnitPriceBuild, values: dict[str, Any]) -> None:
|
||||
for code, slot in values.items():
|
||||
title = build.book.titles.get(code)
|
||||
@@ -66,11 +138,89 @@ def _apply_adopted_slots(build: UnitPriceBuild, values: dict[str, Any]) -> None:
|
||||
title.adopted_slot = number
|
||||
|
||||
|
||||
def _mark(build: UnitPriceBuild, code: str, index: int, **info: Any) -> None:
|
||||
build.edit_rows.setdefault(code, {}).setdefault(index, {}).update(info)
|
||||
|
||||
|
||||
def _apply_q(build: UnitPriceBuild, values: dict[str, Any]) -> None:
|
||||
formulas = {
|
||||
key: str(value.get("q_formula") or "")
|
||||
for key, value in values.items()
|
||||
if isinstance(value, dict)
|
||||
}
|
||||
solved = evaluate_q({key: text for key, text in formulas.items() if text})
|
||||
for key, text in formulas.items():
|
||||
code, tail = split_key(key)
|
||||
details = build.book.details.get(code, [])
|
||||
index = int(tail) if tail.isdigit() else -1
|
||||
if not 0 <= index < len(details) or details[index].output is None:
|
||||
build.edit_skipped.append(f"Q 식 {key} — Q 로 선 줄이 아님")
|
||||
continue
|
||||
value = solved.get(key)
|
||||
if not isinstance(value, Decimal):
|
||||
build.edit_skipped.append(f"Q 식 {key} — {value or '식이 비었음'}")
|
||||
continue
|
||||
detail = details[index]
|
||||
details[index] = replace(
|
||||
detail,
|
||||
quantity=detail.quantity * detail.output / value,
|
||||
output=value,
|
||||
note=f"{detail.note} · Q(사용자) = {text} = {value}",
|
||||
)
|
||||
_mark(build, code, index, q_formula=text, was_output=str(detail.output))
|
||||
|
||||
|
||||
def _apply_rows(build: UnitPriceBuild, values: dict[str, Any]) -> None:
|
||||
added: list[tuple[str, int, str, Any]] = []
|
||||
for key, value in values.items():
|
||||
code, tail = split_key(key)
|
||||
details = build.book.details.get(code)
|
||||
title = build.book.titles.get(code)
|
||||
if details is None or title is None or title.kind not in EDITABLE_KINDS:
|
||||
build.edit_skipped.append(f"구성행 {key} — 고칠 수 있는 본표가 아님")
|
||||
continue
|
||||
if tail.startswith("+") and tail[1:].isdigit():
|
||||
added.append((code, int(tail[1:]), key, value))
|
||||
continue
|
||||
index = int(tail) if tail.isdigit() else -1
|
||||
if not 0 <= index < len(details) or not isinstance(value, dict):
|
||||
build.edit_skipped.append(f"구성행 {key} — 줄이 없음")
|
||||
continue
|
||||
detail = details[index]
|
||||
if value.get("removed"):
|
||||
details[index] = replace(detail, quantity=Decimal(0))
|
||||
_mark(build, code, index, removed=True, was_quantity=str(detail.quantity), user=True)
|
||||
continue
|
||||
try:
|
||||
quantity = _quantity(value.get("quantity"))
|
||||
except EditError as error:
|
||||
build.edit_skipped.append(f"구성행 {key} — {error}")
|
||||
continue
|
||||
details[index] = replace(detail, quantity=quantity)
|
||||
_mark(build, code, index, was_quantity=str(detail.quantity), user=True)
|
||||
for code, _number, key, value in sorted(added):
|
||||
ref = str((value or {}).get("ref") or "")
|
||||
if ref not in build.book.titles or ref == code:
|
||||
build.edit_skipped.append(f"줄 더하기 {key} — 단가표에 없는 코드 {ref}")
|
||||
continue
|
||||
try:
|
||||
quantity = _quantity((value or {}).get("quantity"))
|
||||
except EditError as error:
|
||||
build.edit_skipped.append(f"줄 더하기 {key} — {error}")
|
||||
continue
|
||||
details = build.book.details[code]
|
||||
details.append(PriceDetail(code, ref, quantity, note="사용자가 더한 줄"))
|
||||
_mark(build, code, len(details) - 1, key=key, added=True, user=True)
|
||||
|
||||
|
||||
def apply_edits(build: UnitPriceBuild, edits: dict[str, dict[str, Any]]) -> UnitPriceBuild:
|
||||
"""고친 값을 얹은 **새 벌** — 기본 벌(캐시)은 건드리지 않음."""
|
||||
"""고친 값을 얹은 **새 벌** — 기본 벌(캐시)은 건드리지 않음. 차례: 채택 → Q 식 → 구성행."""
|
||||
edited = copy.deepcopy(build)
|
||||
edited.edit_skipped = []
|
||||
edited.edit_rows = {}
|
||||
_apply_adopted_slots(edited, edits.get("adopted_slots", {}))
|
||||
_apply_q(edited, edits.get("price_basis_q", {}))
|
||||
_apply_rows(edited, edits.get("sheet_rows", {}))
|
||||
return edited
|
||||
|
||||
|
||||
@@ -83,6 +233,17 @@ def edited_build(args: tuple, key: str) -> UnitPriceBuild:
|
||||
return apply_edits(base, json.loads(key))
|
||||
|
||||
|
||||
def row_edits(build: UnitPriceBuild, code: str) -> list[dict[str, Any]]:
|
||||
"""본표 줄마다 고친 값 표시 — 줄 차례는 상세 줄 차례와 같음(한 줄에 한 칸)."""
|
||||
marks = getattr(build, "edit_rows", {}).get(code, {})
|
||||
details = build.book.details.get(code, [])
|
||||
return [
|
||||
{"key": f"{code}|{index}", "user": False, **marks.get(index, {})}
|
||||
| ({"output": str(detail.output)} if detail.output is not None else {})
|
||||
for index, detail in enumerate(details)
|
||||
]
|
||||
|
||||
|
||||
def validate_change(build: UnitPriceBuild, section: str, key: str, value: Any) -> Any:
|
||||
"""고친 값 하나를 받기 전 검사 — 받을 수 있는 모양으로 돌려주거나 `EditError`."""
|
||||
if section not in SECTIONS:
|
||||
@@ -95,13 +256,63 @@ def validate_change(build: UnitPriceBuild, section: str, key: str, value: Any) -
|
||||
if not 1 <= number <= PRICE_SLOT_COUNT or title.slots[number - 1] is None:
|
||||
raise EditError(f"{title.name}: {value}번 원천에 값이 없어 채택할 수 없습니다")
|
||||
return number
|
||||
return value
|
||||
code, tail = split_key(key)
|
||||
title = build.book.titles.get(code)
|
||||
if title is None or title.kind not in EDITABLE_KINDS or not isinstance(value, dict):
|
||||
raise EditError(f"고칠 수 있는 본표가 아닙니다: {code}")
|
||||
if section == "price_basis_q":
|
||||
text = str(value.get("q_formula") or "").strip()
|
||||
details = build.book.details.get(code, [])
|
||||
index = int(tail) if tail.isdigit() else -1
|
||||
if not 0 <= index < len(details) or details[index].output is None:
|
||||
raise EditError("Q 로 선 줄이 아니라 Q 식을 고칠 수 없습니다")
|
||||
solved = evaluate_q({key: text}).get(key)
|
||||
if not isinstance(solved, Decimal):
|
||||
raise EditError(f"Q 식을 풀지 못했습니다: {solved}")
|
||||
return {"q_formula": text}
|
||||
if tail.startswith("+"):
|
||||
ref = str(value.get("ref") or "")
|
||||
ref_title = build.book.titles.get(ref)
|
||||
if ref_title is None or ref_title.kind not in ADDABLE_KINDS or ref == code:
|
||||
raise EditError(f"더할 수 없는 코드입니다: {ref}")
|
||||
checked = {"ref": ref, "quantity": str(_quantity(value.get("quantity")))}
|
||||
_check_cycle(build, code, ref)
|
||||
return checked
|
||||
details = build.book.details.get(code, [])
|
||||
index = int(tail) if tail.isdigit() else -1
|
||||
if not 0 <= index < len(details):
|
||||
raise EditError(f"그 본표에 없는 줄입니다: {key}")
|
||||
detail = details[index]
|
||||
if (
|
||||
detail.percent_of_labor is not None
|
||||
or detail.percent_of_material is not None
|
||||
or detail.percent_of_parent is not None
|
||||
):
|
||||
raise EditError(
|
||||
"비율 줄(제잡비·공구손료·잡품)은 수량으로 고치지 않습니다 — 산출 조건에서 고칩니다"
|
||||
)
|
||||
if value.get("removed"):
|
||||
return {"removed": True}
|
||||
return {"quantity": str(_quantity(value.get("quantity")))}
|
||||
|
||||
|
||||
def _check_cycle(build: UnitPriceBuild, code: str, ref: str) -> None:
|
||||
"""더한 줄이 돌림 참조를 만들면 거절 — 그 본표를 시험 삼아 풀어 봄."""
|
||||
trial = copy.deepcopy(build.book)
|
||||
trial.details.setdefault(code, []).append(PriceDetail(code, ref, Decimal(1)))
|
||||
try:
|
||||
trial.resolve(code)
|
||||
except PriceBookError as error:
|
||||
raise EditError(f"그 줄을 더하면 단가를 풀 수 없습니다: {error}") from error
|
||||
|
||||
|
||||
def merge_changes(
|
||||
stored: Any, build: UnitPriceBuild, changes: list[dict[str, Any]]
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""바꿀 것 여럿을 한 번에 — `value` 가 `None` 이면 그 칸을 지움(↺ 계산값으로)."""
|
||||
"""바꿀 것 여럿을 한 번에 — `value` 가 `None` 이면 그 칸을 지움(↺ 계산값으로).
|
||||
|
||||
⚠ 더한 줄의 수량만 고칠 때(`ref` 없이)는 저장된 `ref` 를 이어 씀.
|
||||
"""
|
||||
edits = normalize(stored)
|
||||
for change in changes:
|
||||
section, key = str(change.get("section") or ""), str(change.get("key") or "")
|
||||
@@ -110,5 +321,9 @@ def merge_changes(
|
||||
if change.get("value") is None:
|
||||
edits.get(section, {}).pop(key, None)
|
||||
continue
|
||||
edits.setdefault(section, {})[key] = validate_change(build, section, key, change["value"])
|
||||
value = change["value"]
|
||||
previous = edits.get(section, {}).get(key)
|
||||
if isinstance(value, dict) and isinstance(previous, dict) and "ref" in previous:
|
||||
value = {**previous, **value}
|
||||
edits.setdefault(section, {})[key] = validate_change(build, section, key, value)
|
||||
return normalize(edits)
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
"""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
|
||||
@@ -408,6 +408,7 @@ def attach_machine_share(
|
||||
if (sources or {}).get(work_item_code)
|
||||
else ""
|
||||
),
|
||||
output=hourly_output(factors),
|
||||
)
|
||||
cycle_factors[work_item_code] = factors
|
||||
return share * Decimal(100)
|
||||
|
||||
@@ -429,7 +429,11 @@ def attach_dozer_share(
|
||||
continue
|
||||
# Q 로 선 장비 줄 — D(단가산출)에 달고 B 는 D 를 1 로 부름(층 차례 X → D → B).
|
||||
book.add_output_detail(
|
||||
title_code, hourly_code, dozer_machine_hours_per_unit(factors), factors.formula_text
|
||||
title_code,
|
||||
hourly_code,
|
||||
dozer_machine_hours_per_unit(factors),
|
||||
factors.formula_text,
|
||||
output=dozer_hourly_output(factors),
|
||||
)
|
||||
return Decimal(100)
|
||||
return _ZERO
|
||||
|
||||
@@ -125,7 +125,11 @@ def _add_rock_leaf(build: Any, node: dict[str, Any]) -> None:
|
||||
for machine_code, _name in machines:
|
||||
# Q 로 선 장비 줄 — D(단가산출)에 달고 B 는 D 를 1 로 부름(층 차례 X → D → B).
|
||||
build.book.add_output_detail(
|
||||
title_code, f"X-{machine_code}", Decimal(1) / capacity, f"{note} · {source}"
|
||||
title_code,
|
||||
f"X-{machine_code}",
|
||||
Decimal(1) / capacity,
|
||||
f"{note} · {source}",
|
||||
output=capacity,
|
||||
)
|
||||
build.variants.setdefault(code, []).append(variant)
|
||||
# 표 줄은 다 읽었다 — 남는 것은 치즐뿐(자재 카탈로그가 없어 금액에 안 붙는 알려진 미결).
|
||||
|
||||
@@ -182,6 +182,9 @@ class PriceDetail:
|
||||
#: ⚠ 원문이 「재료비의 **할증수량 제외**」라 밑수가 **할증 전** 값이어야 하는데, 일위대가
|
||||
#: 층의 재료비가 곧 할증 전 값이다(할증은 자재총괄에서 한 번만 — PLAN 8-7 ㉠).
|
||||
percent_of_material: Decimal | None = None
|
||||
#: 시공능력 **Q**(소수 2자리 확정 값) — Q 로 선 장비 줄(D)에만. 수량 = 몫 ÷ Q 라 Q 식을 고치면
|
||||
#: 새 수량 = 수량 × 옛 Q ÷ 새 Q(PLAN 12장 2차 ③ · `B09_Estimation_Edits`).
|
||||
output: Decimal | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -202,7 +205,12 @@ class PriceBook:
|
||||
self.details.setdefault(detail.parent_code, []).append(detail)
|
||||
|
||||
def add_output_detail(
|
||||
self, parent_code: str, ref_code: str, quantity: Decimal, note: str = ""
|
||||
self,
|
||||
parent_code: str,
|
||||
ref_code: str,
|
||||
quantity: Decimal,
|
||||
note: str = "",
|
||||
output: Decimal | None = None,
|
||||
) -> str:
|
||||
"""**시공능력 Q** 로 선 장비 줄 — B 에 바로 안 달고 **D(단가산출)** 에 달고 B 는 D 를 1 로 부름.
|
||||
|
||||
@@ -222,7 +230,7 @@ class PriceBook:
|
||||
)
|
||||
)
|
||||
self.add_detail(PriceDetail(parent_code, basis_code, Decimal(1), note="단가산출(Q)"))
|
||||
self.add_detail(PriceDetail(basis_code, ref_code, quantity, note=note))
|
||||
self.add_detail(PriceDetail(basis_code, ref_code, quantity, note=note, output=output))
|
||||
return basis_code
|
||||
|
||||
def title(self, code: str) -> PriceTitle:
|
||||
|
||||
@@ -55,6 +55,69 @@ async def get_edits(project_id: UUID) -> JSONResponse:
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/estimation/edits/sheet/{code}")
|
||||
async def get_edit_sheet(project_id: UUID, code: str) -> JSONResponse:
|
||||
"""호표 본표 + 줄마다 고친 값 표시(`edit`) — 일위대가·단산 구성행 수정·Q 식 편집 화면이 씀.
|
||||
|
||||
본표 모양은 `/unit-prices/{code}` 와 같음(`detail_of`) — 줄 차례가 상세 줄 차례와 같아 칸이 맞음.
|
||||
"""
|
||||
from B09_Estimation.B09_Estimation_Edits import EDITABLE_KINDS, row_edits
|
||||
from B09_Estimation.B09_Estimation_PriceBook import PriceBookError
|
||||
from B09_Estimation.B09_Estimation_Router import _build_for, _with_provenance
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import detail_of
|
||||
|
||||
try:
|
||||
build = await _build_for(project_id)
|
||||
body = detail_of(build, code)
|
||||
except PriceBookError as error:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(error)})
|
||||
except Exception:
|
||||
logger.exception("B09 편집 본표 실패: project_id=%s, code=%s", project_id, code)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "본표를 못 만들었습니다."},
|
||||
)
|
||||
for row, edit in zip(body["rows"], row_edits(build, code)):
|
||||
row["edit"] = edit
|
||||
added = [
|
||||
int(mark["key"].rsplit("|+", 1)[1])
|
||||
for mark in getattr(build, "edit_rows", {}).get(code, {}).values()
|
||||
if "|+" in str(mark.get("key", ""))
|
||||
]
|
||||
body["editable"] = build.book.title(code).kind in EDITABLE_KINDS
|
||||
body["next_add_key"] = f"{code}|+{max(added, default=0) + 1}"
|
||||
return JSONResponse(content=_with_provenance({"status": "success", **body}))
|
||||
|
||||
|
||||
@router.get("/{project_id}/estimation/edits/search")
|
||||
async def search_titles(project_id: UUID, q: str = "", parent: str = "") -> JSONResponse:
|
||||
"""줄 더하기 고르개 — 단가표 제목을 이름·규격으로 찾음(노임·자재·중기·일위대가·단산·일식)."""
|
||||
from B09_Estimation.B09_Estimation_Edits import ADDABLE_KINDS
|
||||
from B09_Estimation.B09_Estimation_Router import _build_for
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import HOURLY_WAGE_SUFFIX, SOURCE_LABEL
|
||||
|
||||
words = [word for word in q.lower().split() if word]
|
||||
build = await _build_for(project_id)
|
||||
rows = []
|
||||
for code, title in build.book.titles.items():
|
||||
if title.kind not in ADDABLE_KINDS or code == parent or code.endswith(HOURLY_WAGE_SUFFIX):
|
||||
continue
|
||||
text = f"{title.name} {title.spec} {code}".lower()
|
||||
if words and all(word in text for word in words):
|
||||
rows.append(
|
||||
{
|
||||
"code": code,
|
||||
"name": title.name,
|
||||
"spec": title.spec,
|
||||
"unit": title.unit,
|
||||
"kind_label": SOURCE_LABEL.get(title.kind, ""),
|
||||
}
|
||||
)
|
||||
if len(rows) >= 40:
|
||||
break
|
||||
return JSONResponse(content={"status": "success", "rows": rows})
|
||||
|
||||
|
||||
@router.put("/{project_id}/estimation/edits")
|
||||
async def put_edits(project_id: UUID, payload: EditRequest) -> JSONResponse:
|
||||
"""고친 값 저장 — 칸마다 검사하고(값 없는 슬롯 등은 받지 않음) 구획째 갈아 끼움."""
|
||||
|
||||
@@ -128,6 +128,7 @@ def _detail_to_dict(detail: PriceDetail) -> dict[str, Any]:
|
||||
"percent_of_material": (
|
||||
None if detail.percent_of_material is None else str(detail.percent_of_material)
|
||||
),
|
||||
"output": None if detail.output is None else str(detail.output),
|
||||
}
|
||||
|
||||
|
||||
@@ -144,6 +145,7 @@ def _detail_from_dict(raw: dict[str, Any]) -> PriceDetail:
|
||||
percent_of_labor=None if labor_percent is None else Decimal(str(labor_percent)),
|
||||
percent_of_labor_target=str(raw.get("percent_of_labor_target") or "expense"),
|
||||
percent_of_material=(None if material_percent is None else Decimal(str(material_percent))),
|
||||
output=None if raw.get("output") is None else Decimal(str(raw["output"])),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -80,7 +80,10 @@ interface EditHooks {
|
||||
}
|
||||
|
||||
function detailRow(row: DetailRowDto, ctx: B09TabContext, hooks: EditHooks | null): HTMLElement {
|
||||
const tr = el("tr", row.edit?.removed ? "is-removed" : row.edit?.user ? "is-user" : "");
|
||||
const tr = el(
|
||||
"tr",
|
||||
row.edit?.removed ? "is-removed" : row.edit?.user || row.edit?.q_formula ? "is-user" : "",
|
||||
);
|
||||
const edit = row.edit;
|
||||
const quantityCell =
|
||||
hooks && edit && editing && row.unit !== "%" ? el("td") : numberCell(quantity(row.quantity));
|
||||
|
||||
@@ -266,11 +266,11 @@ export function loadPriceCompare(projectId: string): Promise<PriceCompareDto> {
|
||||
);
|
||||
}
|
||||
|
||||
/** 호표 본표 — 일위대가(B)·시간당 중기(X)는 `unit-prices`, 단가산출(D)은 `price-basis`.
|
||||
* ⚠ 2차 편집 문(`edits/sheet`)이 서면 B·D 는 그리로 바꿈 — 그때까지 편집 칸은 잠자 있음(`editable` 없음). */
|
||||
/** 호표 본표 — 일위대가(B)·단가산출(D)은 편집 문(줄마다 고친 값 표시), 시간당 중기(X)는 `unit-prices`. */
|
||||
export function loadDetail(projectId: string, code: string): Promise<DetailDto> {
|
||||
const kind = code.startsWith("D-") ? "price-basis" : "unit-prices";
|
||||
return getJson<DetailDto>(
|
||||
`/projects/${encodeURIComponent(projectId)}/estimation/${kind}/${encodeURIComponent(code)}`,
|
||||
);
|
||||
const path =
|
||||
code.startsWith("B-") || code.startsWith("D-")
|
||||
? `edits/sheet/${encodeURIComponent(code)}`
|
||||
: `unit-prices/${encodeURIComponent(code)}`;
|
||||
return getJson<DetailDto>(`/projects/${encodeURIComponent(projectId)}/estimation/${path}`);
|
||||
}
|
||||
|
||||
@@ -978,6 +978,7 @@ def build_unit_prices(
|
||||
f" {capacity['capacity_per_hour']} · {capacity['source_text']}"
|
||||
)
|
||||
),
|
||||
output=fix2(capacity["capacity_per_hour"]),
|
||||
)
|
||||
attached_capacity = True
|
||||
|
||||
|
||||
@@ -59,6 +59,87 @@ def test_지우면_계산값으로_돌아가고_캐시_키가_빈다() -> None:
|
||||
assert edits_key({"adopted_slots": {"M-1": 1}}) != ""
|
||||
|
||||
|
||||
def _book_with_basis() -> UnitPriceBuild:
|
||||
build = _build()
|
||||
book = build.book
|
||||
book.add_title(
|
||||
PriceTitle("L-1", PriceKind.LABOR, "보통인부", slots=[None] * 5 + [Decimal(100000)])
|
||||
)
|
||||
book.add_title(
|
||||
PriceTitle("X-1", PriceKind.MACHINE_BASE, "굴착기", slots=[None] * 5 + [Decimal(55700)])
|
||||
)
|
||||
book.add_detail(PriceDetail("B-1", "L-1", Decimal("0.1")))
|
||||
# D — Q 15.71 로 선 장비 줄(몫 1): 수량 1/15.71
|
||||
book.add_output_detail("B-1", "X-1", Decimal(1) / Decimal("15.71"), "Q 식", Decimal("15.71"))
|
||||
return build
|
||||
|
||||
|
||||
def test_구성행_수량을_고치고_빼고_더하면_금액이_다시_서고_지우면_돌아온다() -> None:
|
||||
base = _book_with_basis()
|
||||
before = base.book.resolve("B-1")
|
||||
changes = [
|
||||
{
|
||||
"section": "sheet_rows",
|
||||
"key": "B-1|1",
|
||||
"value": {"quantity": "0.2"},
|
||||
}, # 보통인부 0.1 → 0.2
|
||||
{"section": "sheet_rows", "key": "B-1|0", "value": {"removed": True}}, # 시멘트 뺌
|
||||
{"section": "sheet_rows", "key": "B-1|+1", "value": {"ref": "L-1", "quantity": "0.05"}},
|
||||
]
|
||||
edits = merge_changes({}, base, changes)
|
||||
edited = apply_edits(base, edits)
|
||||
after = edited.book.resolve("B-1")
|
||||
assert after.material == 0 and after.labor == Decimal(25000) # 0.2×10만 + 0.05×10만
|
||||
marks = edited.edit_rows["B-1"]
|
||||
assert marks[0]["removed"] and marks[1]["was_quantity"] == "0.1" and marks[3]["added"]
|
||||
assert base.book.resolve("B-1") == before # 기본 벌 그대로
|
||||
cleared = merge_changes(edits, base, [{**c, "value": None} for c in changes])
|
||||
assert cleared == {} and apply_edits(base, cleared).book.resolve("B-1") == before
|
||||
|
||||
|
||||
def test_Q_식을_고치면_수량이_옛_Q_를_새_Q_로_바꾼_만큼_변한다() -> None:
|
||||
base = _book_with_basis()
|
||||
key = "D-1|0"
|
||||
edits = merge_changes(
|
||||
{},
|
||||
base,
|
||||
[
|
||||
{
|
||||
"section": "price_basis_q",
|
||||
"key": key,
|
||||
"value": {"q_formula": "3600*0.2*0.7*0.85*0.55/15*2"},
|
||||
}
|
||||
],
|
||||
)
|
||||
edited = apply_edits(base, edits)
|
||||
detail = edited.book.details["D-1"][0]
|
||||
assert detail.output == Decimal("31.42")
|
||||
assert detail.quantity == Decimal(1) / Decimal("15.71") * Decimal("15.71") / Decimal("31.42")
|
||||
assert edited.edit_rows["D-1"][0]["q_formula"].startswith("3600")
|
||||
# 줄 0.1원 · 머리 원 미만 절사: 55,700 ÷ 31.42 = 1,772.7 → 1,772
|
||||
assert edited.book.resolve("D-1").expense == Decimal(1772)
|
||||
|
||||
|
||||
def test_Q_로_안_선_줄·비율_줄·돌림_참조는_받지_않는다() -> None:
|
||||
base = _book_with_basis()
|
||||
with pytest.raises(EditError):
|
||||
merge_changes(
|
||||
{}, base, [{"section": "price_basis_q", "key": "B-1|1", "value": {"q_formula": "10"}}]
|
||||
)
|
||||
with pytest.raises(EditError):
|
||||
merge_changes(
|
||||
{},
|
||||
base,
|
||||
[{"section": "sheet_rows", "key": "B-1|+1", "value": {"ref": "B-1", "quantity": "1"}}],
|
||||
)
|
||||
with pytest.raises(EditError):
|
||||
merge_changes(
|
||||
{},
|
||||
base,
|
||||
[{"section": "sheet_rows", "key": "D-1|+1", "value": {"ref": "B-1", "quantity": "1"}}],
|
||||
)
|
||||
|
||||
|
||||
def test_단가표에서_사라진_코드는_버리지_않고_남긴다() -> None:
|
||||
edited = apply_edits(_build(), {"adopted_slots": {"M-없음": 1, "M-1": 2}})
|
||||
assert len(edited.edit_skipped) == 2
|
||||
|
||||
@@ -1,24 +1,35 @@
|
||||
"""B09 사용자 식 셈 — Q 식 편집 칸이 받는 식(PLAN 12장 2차 ③ · STmate `wM_Edit_San` 함수)."""
|
||||
"""B09 Q 식 편집이 쓰는 식 — **명세 13장 식 언어**(B08 구조물도 풀이기 한 벌)로 풀고 소수 2자리 사사오입.
|
||||
|
||||
⚠ 2026-09-14 브레인 판정 — 식 풀이를 B09 에 두 벌 짜지 않음. 종전 `B09_Estimation_Expression`(파이썬 셈)은 걷어냄.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from B09_Estimation.B09_Estimation_Expression import ExpressionError, evaluate
|
||||
from B09_Estimation.B09_Estimation_Edits import evaluate_q
|
||||
|
||||
|
||||
def test_실무_Q_식을_그대로_센다() -> None:
|
||||
# 18번 §2.2 — 3600 × q 0.2 × K 0.7 × f 0.85 × E 0.55 ÷ Cm 15 = 15.708
|
||||
assert evaluate("3600*0.2*0.7*ROUND(1/1.175,2)*0.55/15") == Decimal("15.708")
|
||||
# 울진 대흥 제15호표 되메우기 Q 68.04
|
||||
assert evaluate("ROUND(3600*0.7*0.9*(1/1.25)*0.75/20, 2)") == Decimal("68.04")
|
||||
assert evaluate("INT(7.9) + SQRT(16) - ROUNDDOWN(1.239, 2)") == Decimal("9.77")
|
||||
assert evaluate("60 ÷ 4 × 0.45 × 0.8") == Decimal("5.40")
|
||||
def test_실무_Q_식을_명세_13장_언어로_풀고_2자리로_확정한다() -> None:
|
||||
got = evaluate_q(
|
||||
{
|
||||
# 18번 §2.2 — f 는 0.85 로 먼저 확정해 넣음 → 15.708 → 15.71
|
||||
"a": "3600*0.2*0.7*0.85*0.55/15",
|
||||
# 울진 대흥 제15호표 되메우기 Q 68.04
|
||||
"b": "3600*0.7*0.9*(1/1.25)*0.75/20",
|
||||
# 콘크리트믹서 Q = 60 / t × q × E = 5.40
|
||||
"c": "60/4*0.45*0.8",
|
||||
"d": "MAX(12.345, SQRT(100))",
|
||||
}
|
||||
)
|
||||
assert got == {
|
||||
"a": Decimal("15.71"),
|
||||
"b": Decimal("68.04"),
|
||||
"c": Decimal("5.4"),
|
||||
"d": Decimal("12.35"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", ["__import__('os')", "a+1", "1/0", "2**3", "", "ROUND(1)"])
|
||||
def test_모르는_글·0_나누기·빈_식은_거절한다(text: str) -> None:
|
||||
with pytest.raises(ExpressionError):
|
||||
evaluate(text)
|
||||
def test_모르는_이름·0_이하는_까닭으로_돌려준다() -> None:
|
||||
got = evaluate_q({"x": "abc+1", "y": "0*5", "z": "1/0"})
|
||||
assert all(isinstance(value, str) and value for value in got.values()), got
|
||||
|
||||
Reference in New Issue
Block a user