- 서버가 내역 줄 성분 단가에 율을 얹어 다시 셈(줄 칸이 전체 칸보다 우선) · 수량은 그대로 - 공용 작은 버튼 글자색 상속 — 어두운 화면에서 검정 글씨로 묻히던 것 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
339 lines
15 KiB
Python
339 lines
15 KiB
Python
"""B09 원가계산 — **사용자가 고친 값** 한 벌 (PLAN 12장 2차 · 2026-09-14 브레인 판정).
|
|
|
|
전부 **프로젝트 단위**다 — 라이브러리 층을 만들지 않는다(B09 는 그 프로젝트의 내역·단가라 다른
|
|
프로젝트로 가져갈 것이 없음. 양식·라이브러리는 B08 구조물도에만 있음).
|
|
|
|
저장 자리 산출 조건 `estimation.edits` — 한 파일 한 구획
|
|
얹는 자리 서버가 기본 조립(`cached_build`) 뒤 **복사본에** 고친 값을 얹고 다시 계산
|
|
(캐시 키에 고친 값이 들어감 — 브라우저 값을 받아 적지 않음)
|
|
되돌리기 고친 값을 지우면 계산값으로 돌아감(B08 집계표 ↺ 와 같은 꼴)
|
|
|
|
⚠ 기본 조립은 **안 바꾼다** — 고친 값이 없으면 `cached_build` 그 벌 그대로라 골든셋이 그대로 돈다.
|
|
⚠ 얹지 못한 고친 값(단가표에서 사라진 코드·값 없는 슬롯·안 풀리는 식)은 조용히 버리지 않고 `edit_skipped` 에 남긴다.
|
|
|
|
구획
|
|
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,
|
|
PriceBookError,
|
|
PriceDetail,
|
|
PriceKind,
|
|
)
|
|
from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build
|
|
|
|
EDITS_KEY = "edits"
|
|
#: 화면·근거 표기 — B08 구조물도 집계표와 같은 말.
|
|
USER_SOURCE = "user"
|
|
SECTIONS = ("adopted_slots", "sheet_rows", "price_basis_q", "bill_rates")
|
|
#: 단가표(조립)에 얹는 구획 — 캐시 키는 이것만. `bill_rates` 는 내역 줄에 얹음(`B09_Estimation_BillRates`).
|
|
BUILD_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):
|
|
"""고친 값을 받을 수 없음 — 저장하지 않고 까닭을 돌려줌."""
|
|
|
|
|
|
def normalize(raw: Any) -> dict[str, dict[str, Any]]:
|
|
"""저장본을 아는 구획만 남긴 모양으로 — 모르는 구획·빈 구획은 버림."""
|
|
edits: dict[str, dict[str, Any]] = {}
|
|
if not isinstance(raw, dict):
|
|
return edits
|
|
for section in SECTIONS:
|
|
values = raw.get(section)
|
|
if isinstance(values, dict) and values:
|
|
edits[section] = {str(key): value for key, value in values.items()}
|
|
return edits
|
|
|
|
|
|
def edits_key(raw: Any) -> str:
|
|
"""캐시 키 — 같은 고친 값이면 같은 글(차례 무관)."""
|
|
edits = {key: value for key, value in normalize(raw).items() if key in BUILD_SECTIONS}
|
|
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)
|
|
number = int(slot) if str(slot).isdigit() else 0
|
|
if title is None or title.kind is not PriceKind.MATERIAL:
|
|
build.edit_skipped.append(f"채택 슬롯 {code} — 단가표에 없는 자재")
|
|
continue
|
|
if not 1 <= number <= PRICE_SLOT_COUNT or title.slots[number - 1] is None:
|
|
build.edit_skipped.append(f"채택 슬롯 {code} — {slot}번 슬롯에 값이 없음")
|
|
continue
|
|
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
|
|
|
|
|
|
@lru_cache(maxsize=8)
|
|
def edited_build(args: tuple, key: str) -> UnitPriceBuild:
|
|
"""프로젝트 조립 한 벌 — `args` 는 `cached_build` 인자 그대로, `key` 는 `edits_key`."""
|
|
base = cached_build(*args)
|
|
if not key:
|
|
return base
|
|
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:
|
|
raise EditError(f"모르는 구획: {section}")
|
|
if section == "adopted_slots":
|
|
title = build.book.titles.get(key)
|
|
if title is None or title.kind is not PriceKind.MATERIAL:
|
|
raise EditError(f"단가표에 없는 자재입니다: {key}")
|
|
number = int(value) if str(value).isdigit() else 0
|
|
if not 1 <= number <= PRICE_SLOT_COUNT or title.slots[number - 1] is None:
|
|
raise EditError(f"{title.name}: {value}번 원천에 값이 없어 채택할 수 없습니다")
|
|
return number
|
|
if section == "bill_rates":
|
|
from B09_Estimation.B09_Estimation_BillRates import validate_rate
|
|
|
|
try:
|
|
return validate_rate(value)
|
|
except ValueError as error:
|
|
raise EditError(str(error)) from error
|
|
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` 이면 그 칸을 지움(↺ 계산값으로).
|
|
|
|
⚠ 더한 줄의 수량만 고칠 때(`ref` 없이)는 저장된 `ref` 를 이어 씀.
|
|
"""
|
|
edits = normalize(stored)
|
|
for change in changes:
|
|
section, key = str(change.get("section") or ""), str(change.get("key") or "")
|
|
if not key:
|
|
raise EditError("고칠 칸이 비었습니다")
|
|
if change.get("value") is None:
|
|
edits.get(section, {}).pop(key, None)
|
|
continue
|
|
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)
|