Files
Aislo/resources/master_data/scripts/master_sheet_line.py
T
eomsangdonandClaude Opus 5.5 8c519c0ab2 feat(714-1b): 장비 줄 펼침 덧칸 — 부른 기계경비 로직 계산서 줄 · 시간당 소계 · 계 · 키 · 인자
- 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>
2026-11-01 16:32:57 +09:00

195 lines
8.1 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`(엔진 줄 덧칸 그대로).
- `펼침`(장비 줄만 · 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