- master_sheet_line 장비(기계경비) 줄에 「펼침」 = {키, 이름, 단위, 입력, 줄[비목 · 이름 · 글 · 금액], 소계, 계} · 못 셈 = 까닭
- 셈 결과(master_text.lines)를 그대로 실음 — 조종원 환산 같은 규칙을 여기 두지 않음
- annotate 가 files 를 받음(펼침 계산서) · M01 /text · M02 /logic-lib/sheet 같은 KEYS
- 시험 test_714_1_sheet_line 펼침 · 두 길 같은 수(JSON 수 규칙)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
195 lines
8.1 KiB
Python
195 lines
8.1 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""계산서 줄 덧칸 — 「쓰는 입력」 · 묶음 · 기준단위 · 환산 근거(PLAN 714-1 · 요구서 4장 5 · 8).
|
||
|
||
`master_text.lines` 답(비목 묶음 줄)에 줄마다 덧칸을 얹음 — M01 `/text` 와 M02 `/logic-lib/sheet`
|
||
두 길이 같은 함수(`annotate`)를 거쳐 같은 줄 꼴(`KEYS`)을 냄. 셈은 `master_formula.run` 한 벌.
|
||
- `쓰는입력` = 그 줄 값(수량 식 · 요소 · 덧줄 식)에 쓰인 로직 입력 이름 — 중간값을 거슬러 감 ·
|
||
덧줄이 부르는 비목 합 · 줄 금액은 거스르지 않음(율 식에 직접 쓴 입력만).
|
||
- `묶음` · `장비` = 712-1 `master_bundle`(엔진 줄 덧칸 그대로).
|
||
- `펼침`(장비 줄만 · 714-1b) = 부른 기계경비 로직의 계산서 줄(조종원 · 주연료 · 잡재료비 · 기계손료 …) ·
|
||
시간당 비목 소계 · 계 · 부른 키 · 인자 — 셈 결과를 그대로 실음(환산 규칙을 여기 두지 않음).
|
||
- `기준단위` = 그 줄 수량 식이 찾는 표의 기준(「10㎡」 · 712-3 `master_basis.basis`) · 답 머리
|
||
`기준단위` = 로직 결과단위 분모(「10㎡」 · 여럿이면 「㎡ · ㎥」).
|
||
- `환산근거` = 표 기준배 ↔ 결과단위 배(「10㎡당 → 1㎡당 ÷10」) · 재료 품목 단위 ↔ 줄 단위
|
||
(712-2 `master_unit.note`) · 없으면 「」.
|
||
줄 차례 맞춤 = `master_text.lines` 와 같은 차례(호표 줄마다 `_shares` 비목 · 뒤 덧줄).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from decimal import Decimal
|
||
|
||
import master_basis as mbs
|
||
import master_bundle as mb
|
||
import master_formula as mf
|
||
import master_text as mtx
|
||
import master_unit as mu
|
||
|
||
KEYS = ("쓰는입력", "묶음", "장비", "기준단위", "환산근거", "펼침")
|
||
_HOLE = re.compile(r"\{([^{}]+)\}")
|
||
_ERRORS = (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError)
|
||
|
||
|
||
def _names(expr) -> tuple[set[str], set[str]]:
|
||
"""식 하나의 (이름 · 찾는 표 키) — `{입력}` 구멍 이름도 셈."""
|
||
if isinstance(expr, dict): # 재료 고르기 조건 묶음 — 값 글의 구멍만
|
||
return {h for v in expr.values() for h in _HOLE.findall(str(v))}, set()
|
||
if expr in (None, ""):
|
||
return set(), set()
|
||
try:
|
||
tree = mf.parse(expr)
|
||
except mf.FormulaError:
|
||
return set(_HOLE.findall(str(expr))), set()
|
||
names, tables = set(), set()
|
||
for node in mf.walk(tree):
|
||
if node[0] == "name":
|
||
names.add(node[1])
|
||
elif node[0] in ("find", "logic", "ref"):
|
||
names |= set(_HOLE.findall(node[1]))
|
||
if node[0] == "find":
|
||
tables.add(node[1])
|
||
return names, tables
|
||
|
||
|
||
def _closure(row: dict, exprs: list) -> tuple[list[str], list[str]]:
|
||
"""식들이 쓰는 (입력 이름 · 표 키) — 중간값은 그 식으로 거슬러 감 · 입력 차례대로."""
|
||
steps = {s["이름"]: s["식"] for s in row.get("중간", [])}
|
||
inputs = [s["이름"] for s in row.get("입력", [])]
|
||
seen, tables, todo = set(), [], list(exprs)
|
||
while todo:
|
||
names, found = _names(todo.pop())
|
||
tables += [t for t in sorted(found) if t not in tables]
|
||
for name in names - seen:
|
||
seen.add(name)
|
||
if name in steps:
|
||
todo.append(steps[name])
|
||
return [n for n in inputs if n in seen], tables
|
||
|
||
|
||
def _times(x: Decimal) -> str:
|
||
return format(x.normalize(), "f")
|
||
|
||
|
||
def _per_text(per: list[tuple[Decimal, str]]) -> str:
|
||
return " · ".join(dict.fromkeys(f"{_times(n) if n != 1 else ''}{u}" for n, u in per))
|
||
|
||
|
||
def _basis(master, tables: list[str], env: dict) -> dict | None:
|
||
for ref in tables:
|
||
try:
|
||
got = mbs.basis(master.get(mf.fill(ref, env) if "{" in ref else ref))
|
||
except (*_ERRORS, mf.EmptyInput):
|
||
continue
|
||
if got["갈래"] == "단위":
|
||
return got
|
||
return None
|
||
|
||
|
||
def _note(basis: dict | None, per: list, line: dict) -> str:
|
||
notes = []
|
||
if basis and len(per) == 1:
|
||
times, one = per[0]
|
||
if mu.plain(basis["기준단위"]) == mu.plain(mbs.unit(one)) and basis["기준배"] != times:
|
||
ratio = basis["기준배"] / times
|
||
how = f"÷{_times(ratio)}" if ratio > 1 else f"×{_times(1 / ratio)}"
|
||
notes.append(
|
||
f"{_times(basis['기준배'])}{basis['기준단위']}당 → {_times(times)}{one}당 {how}"
|
||
)
|
||
goods = line.get("품목") or {}
|
||
if goods.get("단위") and line.get("단위"):
|
||
notes.append(mu.note(goods["단위"], line["단위"]))
|
||
return " · ".join(n for n in notes if n)
|
||
|
||
|
||
def _fields(master, row, exprs, env, per, line: dict, bundle: tuple[str, bool] | None) -> dict:
|
||
uses, tables = _closure(row, exprs)
|
||
basis = _basis(master, tables, env)
|
||
out = {
|
||
"쓰는입력": uses,
|
||
"기준단위": f"{_times(basis['기준배']) if basis['기준배'] != 1 else ''}{basis['기준단위']}"
|
||
if basis
|
||
else None,
|
||
"환산근거": _note(basis, per, line),
|
||
}
|
||
if bundle:
|
||
out["묶음"] = bundle[0]
|
||
if bundle[1]:
|
||
out["장비"] = True
|
||
return out
|
||
|
||
|
||
def _spread(files: dict, master, item: dict, env: dict) -> dict:
|
||
"""장비 줄이 부른 기계경비 로직 한 장 — `{키, 이름, 단위, 입력, 줄, 소계, 계}` · 못 셈 = 까닭."""
|
||
node = mf.parse(item["요소"])
|
||
key = node[1]
|
||
try:
|
||
key = mf.fill(key, env) if "{" in key else key
|
||
given = {k: mf.evaluate(v, env, master, 1) for k, v in node[2]}
|
||
got = mtx.lines(files, key, given)
|
||
except (*_ERRORS, mf.EmptyInput) as e:
|
||
return {"키": key, "까닭": str(e) or type(e).__name__}
|
||
if not got.get("ok"):
|
||
return {"키": key, "입력": given, "까닭": got.get("reason")}
|
||
groups = got.get("groups") or []
|
||
return {
|
||
"키": key,
|
||
"이름": master.logic(key).get("이름") or "",
|
||
"단위": _per_text(mbs.result_per(master.logic(key).get("결과단위"))) or None,
|
||
"입력": given,
|
||
"줄": [
|
||
{"비목": g["비목"], **{k: one.get(k) for k in ("이름", "글", "금액")}}
|
||
for g in groups
|
||
for one in g["줄"]
|
||
],
|
||
"소계": {g["비목"]: g.get("소계") for g in groups},
|
||
"계": got.get("계"),
|
||
}
|
||
|
||
|
||
def annotate(files: dict, key: str, given: dict, answer: dict) -> dict:
|
||
"""`master_text.lines` 답에 줄마다 `KEYS` 덧칸 · 머리 `기준단위` — 못 셈이면 답 그대로."""
|
||
if not answer.get("ok") or "groups" not in answer:
|
||
return answer
|
||
master = mf.Master(files)
|
||
row = master.logic(key)
|
||
per = mbs.result_per(row.get("결과단위"))
|
||
answer["기준단위"] = _per_text(per) or None
|
||
try:
|
||
env = mtx._env_of(master, row, given)
|
||
except _ERRORS:
|
||
env = {}
|
||
if "결과" in row:
|
||
for line in answer["groups"][0]["줄"]:
|
||
line.update(_fields(master, row, [row["결과"]["식"]], env, per, {}, None))
|
||
return answer
|
||
try:
|
||
result = mf.run(master, key, given)
|
||
except _ERRORS:
|
||
result = None
|
||
slots = {g["비목"]: iter(g["줄"]) for g in answer["groups"]}
|
||
items = row.get("호표") or []
|
||
for n, item in enumerate(items):
|
||
line = result["줄"][n] if result else {}
|
||
if result:
|
||
parts = [p for p, _ in mtx._shares(line)]
|
||
elif item.get("종류") == "로직" and "비목" not in item:
|
||
parts = list(mf.COST_ITEMS)
|
||
else:
|
||
parts = [item.get("비목")]
|
||
exprs = [item.get("수량"), item.get("요소")]
|
||
got = _fields(master, row, exprs, env, per, line, mb.bundle(master, item, env))
|
||
if got.get("장비"):
|
||
got["펼침"] = _spread(files, master, item, env)
|
||
for part in parts:
|
||
target = next(slots.get(part, iter(())), None)
|
||
if target is not None:
|
||
target.update(got)
|
||
for extra in row.get("덧줄") or []:
|
||
target = next(slots.get(extra["비목"], iter(())), None)
|
||
if target is not None:
|
||
target.update(
|
||
_fields(master, row, [extra["식"]], env, per, {}, mb.bundle(master, extra))
|
||
)
|
||
return answer
|