Files
Aislo/resources/master_data/scripts/master_formula.py
T

687 lines
28 KiB
Python

# -*- coding: utf-8 -*-
"""마스터 로직 식 — 읽기 · 검사 · 시험 계산 (`_틀.md` 6~7장).
식은 틀 7장 범위만 읽음 — eval 쓰지 않음. 수는 Decimal(끝수 사사오입이 흔들리지 않게).
`check_master.py` 가 부름.
"""
from __future__ import annotations
import importlib
import itertools
import re
import sys
from decimal import ROUND_DOWN, ROUND_HALF_UP, ROUND_UP, Decimal
from pathlib import Path
import master_keys as mk
# 식이 부르는 요소 그룹 — 일위대가 조합(`master_combo`)은 식에 안 들어와 여기 두지 않음
# (조합은 `/rows`·`/files` 같은 요소 길이 아니라 제 길 `/combos`·`/combo` 로만 다룸)
GROUPS = ("인력", "재료", "기계", "소요량", "계수", "환율", "요율", "로직")
TABLE_GROUPS = ("소요량", "계수")
TABLE_IDS = tuple(t for t in mk.IDS if mk.GROUP_OF[t] in TABLE_GROUPS)
COST_ITEMS = ("노무비", "재료비", "경비")
BUNDLE = (*COST_ITEMS, "계")
FUNCS = ("만약", "올림", "버림", "반올림", "최소", "최대")
_NAME = re.compile(r"[\w가-힣]+")
_NUMBER = re.compile(r"\d+(?:\.\d+)?")
_HOLE = re.compile(r"\{([^{}]+)\}")
_OPS = ("<=", ">=", "==", "!=", "<", ">")
_ARITH = re.compile(r"[\d.\s+\-*/()]*\d[\d.\s+\-*/()]*") # 분수·식 값 칸(「1/1.30」)
# 흐름도 로직(줄에 「흐름」) 셈 — 서버(`M01_MasterData_Store_Flow`)가 꽂음 · 없으면 옛 셈만
# 꼴 = (master, 키, 줄, 입력) → `run` 과 같은 꼴(돈 = 줄 · 비목 합 · 계 / 돈 아님 = 결과)
FLOW_RUN = None
# 흐름 로직 입력 = 「설계 때 정할 것」 → 입력 꼴(이름 · 단위 · 고르기 · 범위 · 예시) — 서버가 꽂음
FLOW_INPUTS = None
CALC_FIELDS = ("중간", "호표", "결과", "덧줄") # 계산 칸 — 흐름 로직 줄엔 없음(입력은 둠)
def _flow_hooks() -> None:
"""흐름 셈 · 입력 갈래를 깖 — 서버가 안 깔았으면(스크립트 · 시험) 여기서 서버 모듈을 불러 깖."""
if FLOW_RUN is None or FLOW_INPUTS is None:
root = str(Path(__file__).resolve().parents[3])
if root not in sys.path:
sys.path.insert(0, root)
importlib.import_module("M01_MasterData.M01_MasterData_Store_Flow")
def inputs_of(row: dict) -> list[dict]:
"""흐름의 「설계 때 정할 것」 → 입력 꼴(이름 · 단위 · 고르기 · 범위 · 예시)."""
_flow_hooks()
return FLOW_INPUTS(row)
class FormulaError(ValueError):
pass
class EmptyInput(FormulaError):
"""값을 안 넣은(null) 입력을 씀 — 그 줄만 비우고 로직은 계속(`run`)."""
class NoRow(EmptyInput):
"""표에서 조건에 맞는 줄이 하나가 아님 — 그 찾기를 쓰는 줄만 비움(값이 비어 있는 입력과 같은 길)."""
class Blank:
"""못 구한 중간값 — 이 값을 쓰는 줄만 까닭과 함께 비움."""
def __init__(self, why: str):
self.why = why
def _plain(v) -> str:
"""사람이 읽는 값 글 — 파이썬 날글(Decimal('1000') · 따옴표) 없이."""
return format(v.normalize(), "f") if isinstance(v, Decimal) else str(v)
# ── 읽기 ──────────────────────────────────────────────────────────────
class _Parser:
def __init__(self, text: str):
self.s, self.i = text, 0
def fail(self, why: str):
raise FormulaError(f"{why} — 「{self.s}」 {self.i}자")
def ws(self):
while self.i < len(self.s) and self.s[self.i].isspace():
self.i += 1
def peek(self, word: str) -> bool:
self.ws()
return self.s.startswith(word, self.i)
def take(self, word: str) -> bool:
if self.peek(word):
self.i += len(word)
return True
return False
def need(self, word: str):
if not self.take(word):
self.fail(f"「{word}」 없음")
def parse(self):
node = self.expr()
self.ws()
if self.i != len(self.s):
self.fail("식 끝에 남은 글자")
return node
def expr(self):
node = self.both()
while self.take("또는"):
node = ("bin", "또는", node, self.both())
return node
def both(self):
node = self.compare()
while self.take("그리고"):
node = ("bin", "그리고", node, self.compare())
return node
def compare(self):
node = self.add()
for op in _OPS:
if self.take(op):
return ("bin", op, node, self.add())
return node
def add(self):
node = self.mul()
while True:
if self.take("+"):
node = ("bin", "+", node, self.mul())
elif self.take("-"):
node = ("bin", "-", node, self.mul())
else:
return node
def mul(self):
node = self.unary()
while True:
if self.take("*"):
node = ("bin", "*", node, self.unary())
elif self.take("/"):
node = ("bin", "/", node, self.unary())
else:
return node
def unary(self):
if self.take("-"):
return ("neg", self.unary())
node = self.primary()
while self.take("."):
node = ("field", node, self.quoted() if self.take("'") else self.name())
return node
def quoted(self) -> str:
end = self.s.find("'", self.i)
if end < 0:
self.fail("작은따옴표 안 닫힘")
text, self.i = self.s[self.i : end], end + 1
return text
def name(self) -> str:
self.ws()
m = _NAME.match(self.s, self.i)
if not m:
self.fail("이름 없음")
self.i = m.end()
return m.group()
def ref(self, head: bool = False) -> str:
"""키·원문번호 글자 — 괄호 밖의 `,` · `)` · `.칸` · ` 연산자 ` 앞에서 멈춤(원문번호에 빈칸·괄호가 들어감).
head = 찾기·로직 머리 — `,` · `)` 에서만 멈춤."""
start, depth = self.i, 0
while self.i < len(self.s):
ch = self.s[self.i]
if ch in "({":
depth += 1
elif ch in ")}":
if depth == 0:
break
depth -= 1
elif depth == 0 and ch == ",":
break
elif head:
pass
elif depth == 0 and ch == "." and not self.s[self.i + 1 : self.i + 2].isdigit():
# 「K.P」「T.S」 처럼 영문자 사이 점은 원문번호 글자
around = self.s[self.i - 1 : self.i + 2 : 2]
if not (len(around) == 2 and around.isascii() and around.isalpha()):
break
elif depth == 0 and re.match(r"\s[-+*/<>=!]", self.s[self.i : self.i + 2]):
break
self.i += 1
return self.s[start : self.i].strip()
def args(self) -> list[tuple[str, object]]:
out = []
while self.take(","):
key = self.quoted() if self.take("'") else self.name()
self.need("=")
out.append((key, self.expr()))
self.need(")")
return out
def primary(self):
self.ws()
if self.take("("):
node = self.expr()
self.need(")")
return node
if self.take("'"):
end = self.s.find("'", self.i)
if end < 0:
self.fail("작은따옴표 안 닫힘")
text, self.i = self.s[self.i : end], end + 1
return ("str", text)
m = _NUMBER.match(self.s, self.i)
if m:
self.i = m.end()
return ("num", Decimal(m.group()))
word = self.name()
if word == "찾기":
self.need("(")
self.ws()
return ("find", self.ref(True), self.args())
if word == "로직":
self.need("(")
self.ws()
return ("logic", self.ref(True), self.args())
if word in FUNCS:
self.need("(")
items = [self.expr()]
while self.take(","):
items.append(self.expr())
self.need(")")
return ("call", word, items)
if mk.KEY.fullmatch(word):
return ("ref", word)
if word in mk.IDS and self.s.startswith(":", self.i): # ID:원문번호 — {이름} 낀 원문번호
self.i += 1
return ("ref", f"{word}:{self.ref()}")
return ("name", word)
def parse(text) -> tuple:
if isinstance(text, (int, float, Decimal)):
return ("num", Decimal(str(text)))
return _Parser(str(text)).parse()
def walk(node):
"""식 나무의 모든 마디."""
yield node
kind = node[0]
if kind == "bin":
yield from walk(node[2])
yield from walk(node[3])
elif kind in ("neg",):
yield from walk(node[1])
elif kind == "field":
yield from walk(node[1])
elif kind == "call":
for item in node[2]:
yield from walk(item)
elif kind in ("find", "logic"):
for _, item in node[2]:
yield from walk(item)
# ── 마스터 모음 ────────────────────────────────────────────────────────
class Master:
"""요소·표·로직 찾기 — `files` = {파일 이름: 읽은 JSON}(수는 Decimal).
`ref` = 키(「LB000123」) · 「ID:원문번호」(`{이름}` 을 끼운 원문번호 · 끼운 값이 키여도 됨)."""
def __init__(self, files: dict[str, dict]):
self.index: dict[str, dict[str, dict]] = {} # 테이블ID → {키: 줄}
self.numbers: dict[str, dict[str, dict]] = {} # 테이블ID → {원문번호: 줄}
self.twice: set[tuple[str, str]] = set() # 한 절에 여럿 — 번호로 못 부름
for data in files.values():
rows = data.get("표") if data.get("그룹") in TABLE_GROUPS else data.get("줄")
for row in rows or []:
key = str(row.get("키"))
self.index.setdefault(key[:2], {}).setdefault(key, row)
if row.get("원문번호"):
number = str(row["원문번호"])
slot = self.numbers.setdefault(key[:2], {})
if number in slot:
self.twice.add((key[:2], number))
slot.setdefault(number, row)
mp.EQ.set(self) # 기계 입력이 목록 밖 원문 분류번호도 받게
def get(self, ref: str) -> dict:
tid, _, number = ref.partition(":")
if number and mk.KEY.fullmatch(number) and number[:2] == tid:
row = self.index.get(tid, {}).get(number) # 「ID:키」 — 고르기 값이 키인 자리
elif number:
if (tid, number) in self.twice:
raise FormulaError(f"원문번호 겹침 「{ref}」 — 키로 적을 것")
row = self.numbers.get(tid, {}).get(number)
else:
row = self.index.get(ref[:2], {}).get(ref)
if row is None:
raise FormulaError(f"없는 요소 「{ref}」")
return row
def label(self, ref) -> str:
"""알림 글 — 키 옆에 이름 · 고르기 조건은 조건 글."""
if isinstance(ref, dict):
return "재료 고르기 " + " · ".join(str(v) for v in ref.values() if v)
try:
row = self.get(ref)
except FormulaError:
return ref
return f"{row.get('키')} {row.get('이름') or row.get('원문번호')}"
def value(self, ref: str):
"""요소 값 · 출처 글 — 준용(관리자가 고른 키)이 있으면 그 요소 값 · 없으면 제 값."""
row = self.get(ref)
alias = row.get("준용")
if alias not in (None, ""):
target = self.get(str(alias))
name = target.get("이름") or alias
if row.get("상태") == "추정": # 값을 이미 준용 직종에서 채워 둠
return row.get("값"), f"추정(준용: {name})"
return target.get("값"), f"준용: {name}"
return row.get("값"), None
def logic(self, ref: str) -> dict:
if mk.GROUP_OF.get(mk.id_of(ref)) != "로직":
raise FormulaError(f"로직 아님 「{ref}」")
return self.get(ref)
def fill(template: str, env: dict) -> str:
def one(m):
if m.group(1) not in env:
raise FormulaError(f"원문번호의 {{{m.group(1)}}} 값 없음")
value = env[m.group(1)]
if isinstance(value, Blank):
raise EmptyInput(value.why)
return _plain(value)
return _HOLE.sub(one, template)
# ── 계산 ──────────────────────────────────────────────────────────────
def _round(x: Decimal, places, mode) -> Decimal:
return x.quantize(Decimal(1).scaleb(-int(places)), rounding=mode)
def _match(table: dict, row: dict, cond: str, want) -> bool:
if cond not in row: # 빈 조건 칸은 무엇이든 맞음
return True
have = row[cond]
if table.get("조건", {}).get(cond) == "범위":
low, high = have
rule, *more = table.get("범위규칙", "초과·이하").split(" · ")
rule = rule.split(" (")[0] # 「이상·미만 (원문 …)」 — 괄호 설명은 뺌
if rule == "이상·이하":
return (low is None or want >= low) and (high is None or want <= high)
if rule == "초과·이하":
return (low is None or want > low) and (high is None or want <= high)
if rule == "이상·미만":
# 「마지막 칸(80~100)은 이하 포함」 — 가장 윗줄만 위 끝 포함
top = max(r[cond][1] for r in table["줄"] if r.get(cond) and r[cond][1] is not None)
closed = any("이하 포함" in x for x in more) and high == top
return (low is None or want >= low) and (
high is None or want < high or (closed and want == high)
)
if rule == "이하": # 칸마다 위 끝만 — 맞는 줄 가운데 위 끝이 가장 작은 줄(find)
return (low is None or want > low) and (high is None or want <= high)
if rule == "이상·이하": # 양 끝 포함 — 이웃 칸 끝이 겹치면 위 끝이 가장 작은 줄(find)
return (low is None or want >= low) and (high is None or want <= high)
raise FormulaError(f"모르는 범위규칙 「{table.get('범위규칙')}」")
return have == want
def _name(row: dict) -> str:
return f"{row.get('키')} {row.get('원문번호')}"
def find(table: dict, conds: dict, want: str | None = None) -> dict:
for cond in conds:
if cond not in table.get("조건", {}):
raise FormulaError(f"「{_name(table)}」 에 없는 조건 「{cond}」")
hits = [row for row in table["줄"] if all(_match(table, row, c, v) for c, v in conds.items())]
if len(hits) > 1 and table.get("범위규칙", "").split(" · ")[0] in ("이하", "이상·이하"):
for c in conds:
ups = [r[c][1] for r in hits if table["조건"].get(c) == "범위" and r.get(c)]
if ups and None not in ups:
hits = [r for r in hits if not r.get(c) or r[c][1] == min(ups)]
if len(hits) > 1 and want: # 여러 줄이면 찾는 값 칸이 있는 줄만
hits = [row for row in hits if want in row]
if len(hits) != 1:
where = f"표 {table.get('키')}({table.get('원문번호')})"
what = " · ".join(f"{c} {_plain(v)}" for c, v in conds.items())
raise NoRow(
f"{where}에 {what} 인 줄이 없음"
if not hits
else f"{where}에 {what} 인 줄이 {len(hits)}개"
)
return hits[0]
def evaluate(node, env: dict, master: Master, depth: int = 0):
kind = node[0]
if kind in ("num", "str"):
return node[1]
if kind == "name":
if node[1] not in env:
raise FormulaError(f"모르는 이름 「{node[1]}」")
if env[node[1]] is None:
raise EmptyInput(f"입력 「{node[1]}」 비어 있음")
if isinstance(env[node[1]], Blank):
raise EmptyInput(env[node[1]].why)
return env[node[1]]
if kind == "ref":
value = element(master, fill(node[1], env), env)[0]
if value is None:
raise FormulaError(f"「{master.label(fill(node[1], env))}」 값 없음 — 관리자가 채울 값")
if isinstance(value, list):
raise FormulaError(
f"「{master.label(fill(node[1], env))}」 값이 범위 — 입력으로 받아야 함"
)
return value
if kind == "field":
if node[1][0] == "find":
table = master.get(fill(node[1][1], env))
conds = {k: evaluate(v, env, master, depth) for k, v in node[1][2]}
base = find(table, conds, node[2])
else:
base = evaluate(node[1], env, master, depth)
if not isinstance(base, dict) or node[2] not in base:
raise FormulaError(f"칸 「{node[2]}」 없음")
value = base[node[2]]
if isinstance(value, list):
raise FormulaError(f"칸 「{node[2]}」 값이 범위 — 입력으로 받아야 함")
if isinstance(value, str) and _ARITH.fullmatch(value):
return evaluate(parse(value), env, master, depth)
return value
if kind == "neg":
return -evaluate(node[1], env, master, depth)
if kind == "bin":
op, a = node[1], evaluate(node[2], env, master, depth)
if op == "그리고":
return a and evaluate(node[3], env, master, depth)
if op == "또는":
return a or evaluate(node[3], env, master, depth)
b = evaluate(node[3], env, master, depth)
return {
"+": lambda: a + b,
"-": lambda: a - b,
"*": lambda: a * b,
"/": lambda: a / b,
"<": lambda: a < b,
"<=": lambda: a <= b,
">": lambda: a > b,
">=": lambda: a >= b,
"==": lambda: a == b,
"!=": lambda: a != b,
}[op]()
if kind == "call":
name, items = node[1], node[2]
if name == "만약":
test = evaluate(items[0], env, master, depth)
return evaluate(items[1] if test else items[2], env, master, depth)
values = [evaluate(x, env, master, depth) for x in items]
if name in ("최소", "최대"):
return (min if name == "최소" else max)(values)
mode = {"올림": ROUND_UP, "버림": ROUND_DOWN, "반올림": ROUND_HALF_UP}[name]
return _round(values[0], values[1] if len(values) > 1 else 0, mode)
if kind == "find":
table = master.get(fill(node[1], env))
return find(table, {k: evaluate(v, env, master, depth) for k, v in node[2]})
if kind == "logic":
if depth > 20:
raise FormulaError("로직 부르기가 너무 깊음 — 돌고 도는 참조")
given = {k: evaluate(v, env, master, depth) for k, v in node[2]}
result = run(master, fill(node[1], env), given, depth + 1)
return result["결과"] if "결과" in result else {k: result[k] for k in BUNDLE}
raise FormulaError(f"모르는 마디 {kind}")
ROUND_WAYS = {"올림": ROUND_UP, "버림": ROUND_DOWN, "반올림": ROUND_HALF_UP}
ROUND_ON = ("계", "결과")
def cut(row: dict, value: Decimal, on: str) -> Decimal:
"""줄의 `끝수` 를 붙임 — 묶음 `{대상, 자리, 방법}` 만 · 글자 끝수는 원문 근거를 적은 설명."""
rule = row.get("끝수")
if not isinstance(rule, dict) or rule.get("대상") != on:
return value
way = ROUND_WAYS.get(str(rule.get("방법")))
if way is None:
raise FormulaError(f"「{_name(row)}」 끝수 방법 「{rule.get('방법')}」")
return _round(value, rule.get("자리") or 0, way)
def fill_empty(row: dict, given: dict) -> dict:
"""화면이 안 보낸 가격 입력(단위에 「원」)은 null 로 채움 — 빈 입력과 같이 그 줄만 비움."""
gap = {
s["이름"]: None
for s in row.get("입력", [])
if s["이름"] not in given and "원" in str(s.get("단위") or "")
}
return {**given, **gap}
def _inputs(row: dict, given: dict) -> dict:
env = {}
for spec in row.get("입력", []):
name = spec["이름"]
if name not in given:
raise FormulaError(f"「{_name(row)}」 입력 「{name}」 없음")
value = given[name]
if isinstance(value, str): # 화면이 글로 보낸 수 — 고르기 원래 값 · 아니면 수로
if "고르기" in spec:
value = next((o for o in spec["고르기"] if str(o) == value.strip()), value)
else:
try:
value = Decimal(value.strip())
except ArithmeticError:
pass
if isinstance(value, (int, float)):
value = Decimal(str(value))
value = mp.resolve(spec, value) # 기계 EQ 키 → 원문 분류번호
if "고르기" in spec and value not in spec["고르기"] and not mp.machine(spec, value):
raise FormulaError(f"입력 「{name}」={value} 고르기 밖")
if "범위" in spec:
low, high = spec["범위"]
if value is not None and not low <= value <= high:
raise FormulaError(f"입력 「{name}」={value} 범위 [{low}, {high}] 밖")
env[name] = value
unknown = set(given) - set(env)
if unknown:
raise FormulaError(f"「{_name(row)}」 모르는 입력 {sorted(unknown)}")
return env
def _shown(env: dict) -> dict:
"""돌려줄 중간값 — 못 구한 값은 null."""
return {k: None if isinstance(v, Blank) else v for k, v in env.items()}
def _blank_line(item: dict, why: str) -> dict:
"""비어 있는 입력·못 구한 값을 쓰는 호표 줄 — 단가·금액만 비우고 까닭."""
return {
"이름": item.get("이름", ""),
"단위": item.get("단위", ""),
"수량": None,
"단가": None,
"금액": None,
"비목": {item["비목"]: None} if "비목" in item else {},
"까닭": why,
}
def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
"""로직 한 줄 계산 — 돈 로직은 호표 줄 · 비목 합 · 계, 돈 아닌 로직은 결과 수 하나."""
row = master.logic(ref)
if isinstance(row.get("흐름"), dict): # 흐름 로직 — 깔림에 기대지 않고 스스로 깖
_flow_hooks()
return FLOW_RUN(master, ref, row, given)
env = _inputs(row, given)
for step in row.get("중간", []):
try:
env[step["이름"]] = evaluate(parse(step["식"]), env, master, depth)
except EmptyInput as e: # 못 구한 중간값 — 쓰는 줄만 비움
env[step["이름"]] = Blank(str(e))
if "결과" in row:
value = evaluate(parse(row["결과"]["식"]), env, master, depth)
return {"결과": cut(row, value, "결과"), "중간": _shown(env)}
sums = dict.fromkeys(COST_ITEMS, Decimal(0))
lines = []
for n, item in enumerate(row.get("호표", [])):
source = why = extra = None
try:
qty = evaluate(parse(item["수량"]), env, master, depth)
except EmptyInput as e: # 비어 있는 입력을 쓰는 줄만 비움
lines.append(_blank_line(item, str(e)))
continue
if item["종류"] == "로직":
try:
bundle = evaluate(parse(item["요소"]), env, master, depth)
except EmptyInput as e:
lines.append(_blank_line(item, str(e)))
continue
price = bundle["계"]
split = (
{k: qty * bundle[k] for k in COST_ITEMS}
if "비목" not in item
else {item["비목"]: qty * price}
)
else:
# 고르기 조건 묶음은 그대로 넘김 — `element` 가 대표 줄·첫 줄로 풂
# 조건 값의 `{입력}`(관경 등)도 채움 — 규격이 입력 따라 바뀌는 관 줄
try:
ref = (
{k: fill(v, env) if isinstance(v, str) else v for k, v in item["요소"].items()}
if isinstance(item["요소"], dict)
else fill(item["요소"], env)
)
except EmptyInput as e:
lines.append(_blank_line(item, str(e)))
continue
# 후보 0 · 값 빈 품목은 그 줄만 단가·금액을 비우고 까닭을 닮 — 로직은 계속 · 합에서 뺌
# (없는 키는 틀린 로직 — 지금처럼 멈춤) · 검색어 · 고름 · 단위 환산은 `master_pick`
if isinstance(ref, str) and qty != 0:
master.get(ref)
price, source, why, extra = mp.priced(master, item, ref, env, n if depth == 0 else None)
if price is None and qty == 0:
price, why = Decimal(0), None # 안 쓰는 재료 줄 — 값이 비어도 금액 0
split = {item["비목"]: None if price is None else qty * price}
for k, v in split.items():
sums[k] += v or 0
lines.append(
{
"이름": item.get("이름", ""),
"단위": item.get("단위", ""),
"수량": qty,
"단가": price,
"금액": None if price is None else qty * price,
"비목": split,
**({"출처": source} if source else {}),
**({"까닭": why} if why else {}),
**(extra or {}),
}
)
# 덧줄이 호표 줄 금액 하나를 빼고 더할 수 있게 — `줄.'호표 줄 이름'`(빈 줄은 뺌)
env["줄"] = {x["이름"]: x["금액"] for x in lines if x["이름"] and x["금액"] is not None}
blank = {x["이름"]: x["까닭"] for x in lines if x.get("까닭")}
for extra in row.get("덧줄", []):
env.update(sums)
try: # 덧줄도 값 빈 품목·빈 줄을 부르면 그 줄만 비움
value, why = evaluate(parse(extra["식"]), env, master, depth), None
env[extra["이름"]] = value
except FormulaError as e:
gone = next((n for n in blank if n in extra["식"]), None)
value, why = None, f"「{gone}」 줄 비어 못 셈 — {blank[gone]}" if gone else str(e)
blank[extra["이름"]] = why
sums[extra["비목"]] += value or 0
lines.append(
{
"이름": extra["이름"],
"단위": "식",
"수량": Decimal(1),
"단가": value,
"금액": value,
"비목": {extra["비목"]: value},
**({"까닭": why} if why else {}),
}
)
sums = {k: cut(row, v, "계") for k, v in sums.items()} # 비목 합마다 끊고 계는 그 합
return {"줄": lines, **sums, "계": sum(sums.values()), "중간": _shown(env)}
# 품셈재료 단가 — 서로 부르는 모듈이라 끝에서 읽음
from master_material import ( # noqa: E402, F401
LINKED,
PICK_KEYS,
REGION,
SLOTS,
TARIFF,
candidates,
check_pick,
check_unit,
static,
cond_text,
element,
pick,
)
import master_pick as mp # noqa: E402 — 검색어 · 고름 · 단위
from master_formula_check import ( # noqa: E402, F401 — 검사는 나눈 파일 · 이름은 여기서 그대로
_check_cut,
_check_node,
_choices,
_expand,
check_logic,
find_loops,
)