Files
Aislo/resources/master_data/scripts/master_sheet_line.py
T
eomsangdonandClaude Opus 5.5 9875743181 feat(714-1): 계산서 줄 덧칸 — 쓰는 입력 · 묶음 · 기준단위 · 환산 근거 · M01 /text 와 M02 계산서 같은 줄 꼴
- 새 master_sheet_line.annotate — master_text.lines 답에 줄마다 덧칸(줄 차례 = 호표 _shares 비목 · 뒤 덧줄) · 머리 기준단위(결과단위 분모)
- 쓰는입력 = 수량 식 · 요소 · 덧줄 식이 쓰는 로직 입력(중간값 거슬러 감) · 기준단위 = 줄이 찾는 표 기준(712-3 basis) · 환산근거 = 표 기준배 ↔ 결과단위 배 · 재료 품목 단위 ↔ 줄 단위(712-2 note)
- M01 /text = 새 Store_Sheet 가 감쌈(Store.py · master_text.py 700 줄 근처라 안 늘림) · M02 /logic-lib/sheet 줄 칸에 같은 KEYS
- 지킴 시험 test_714_1_sheet_line(두 길 같은 덧칸)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:04:02 +09:00

162 lines
6.5 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.
# -*- 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`(엔진 줄 덧칸 그대로).
- `기준단위` = 그 줄 수량 식이 찾는 표의 기준(「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 annotate(master, key: str, given: dict, answer: dict) -> dict:
"""`master_text.lines` 답에 줄마다 `KEYS` 덧칸 · 머리 `기준단위` — 못 셈이면 답 그대로."""
if not answer.get("ok") or "groups" not in answer:
return answer
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))
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