feat(master_data): 검사 도구 check_master.py · 틀 판정 반영
- 검사 넷: 틀 · 본문 대조(표 허구 · 절 결손) · 로직 검사(요소·표·칸 · 찾기 조건 · 로직 입력 · 돌고 도는 참조) · 시험 계산(호표 줄 · 비목 합) - 식 읽기·계산은 master_formula.py(틀 7장 범위 · eval 없음 · Decimal) - 틀: 번호로 적은 값은 원문 이름을 비고에 · 분수·식 값 · 짝 칸 · 8장 검사 - 기계 아홉 줄 비고에 조종원직종 원문 이름(건설기계운전사) - 시험 test_masterdata_check.py: 견본 셋 손 계산 그대로 · 견본 파일 검사 통과 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj
This commit is contained in:
@@ -38,6 +38,7 @@
|
||||
- 값이 여럿인 요소(기계)는 `값` 을 묶음으로 — `{ "가격": …, "상각비계수": …, "정비비계수": …, "관리비계수": …, "손료계수": …, "주연료": …, "주연료종류": …, "잡재료율": …, "조종원": …, "조종원직종": … }` · `손료계수` = 원문 「계」.
|
||||
- 묶음 요소는 `단위` · `출처` 도 같은 모양의 묶음.
|
||||
- 원문에 없는 값을 지어 넣지 않음. 원문 「-」 = 칸을 비움(`null`).
|
||||
- 원문 이름 대신 번호로 적은 값(`조종원직종` = 노임 번호 「1048」)은 원문 이름을 `비고` 칸에.
|
||||
|
||||
## 5. 표형 요소 (소요량 · 계수)
|
||||
|
||||
@@ -64,6 +65,8 @@
|
||||
- 값 칸에 범위(「80 ∼ 100」)는 `[아래, 위]` — 로직은 설계자 입력 칸으로 받음(기본값 두지 않음).
|
||||
- 수와 글이 섞인 칸(「20(135°)」)은 값 칸에 수 · `<값 칸>원문` 칸에 원문 글.
|
||||
- 줄마다 단위가 다르면 `단위` 칸을 줄에 둠.
|
||||
- 분수·식으로 적힌 값(「L/V×2×60」)은 값 칸에 식 글자 그대로 · `<값 칸>원문` 칸에 원문.
|
||||
- 원문에 짝 표시 없이 나란히 놓인 줄은 `짝` 칸으로 묶음.
|
||||
- 수는 원문 그대로(쉼표만 뺌). 단위 환산하지 않음.
|
||||
- `주` 는 원문 글 그대로 — 계산에 쓰는 수가 들어 있으면 따로 계수 표로도 올림.
|
||||
|
||||
@@ -114,3 +117,7 @@
|
||||
- 입력 · 중간 · 비목 합은 이름 그대로.
|
||||
- 쓸 수 있는 것 — `+ - * /` · 괄호 · 비교 `< <= > >= == !=` · `그리고` `또는` · `찾기` · `로직` · `만약(조건, 참, 거짓)` · `올림` `버림` `반올림`(자리) · `최소` `최대`. 그 밖은 쓰지 않음.
|
||||
- 로직이 돌고 도는 참조를 만들지 않음.
|
||||
|
||||
## 8. 검사
|
||||
|
||||
- 고친 뒤 `scripts/check_master.py` — 틀 · 본문 대조 · 로직 검사. 로직은 `계산` 으로 손 계산과 맞댐.
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""마스터 데이터 검사 도구 (`resources/master_data/_틀.md`).
|
||||
|
||||
돌리기: ./venv/Scripts/python.exe resources/master_data/scripts/check_master.py [틀|본문|로직] [파일 …]
|
||||
./venv/Scripts/python.exe resources/master_data/scripts/check_master.py 계산 "산림품셈:13-4-1 메쌓기" 뒷길이=35 돌=견치돌 …
|
||||
|
||||
(1) 틀 — 파일 이름 · 머리 칸 · 줄 칸 · 출처 모양
|
||||
(2) 본문 — 표형 요소의 출처 절을 본문 md 에서 찾아 수를 맞댐 · 결손(본문 표에 있고 요소에 없음)·허구(요소에 있고 본문 절에 없음)
|
||||
(3) 로직 — 변수가 가리키는 요소·표·칸 · 찾기 조건 이름 · 로직 입력 · 돌고 도는 참조
|
||||
(4) 계산 — 로직 열쇠와 입력값으로 호표 줄별 수량·단가·금액 · 비목 합 (`master_formula.py`)
|
||||
수 뽑기는 `resources/knowledge/original/_pipeline/pum_md_tool.py` 의 것을 씀.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
MASTER = HERE.parent
|
||||
ROOT = MASTER.parents[1]
|
||||
COST = ROOT / "resources/knowledge/original/원가계산"
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(ROOT / "resources/knowledge/original/_pipeline"))
|
||||
|
||||
import master_formula as mf # noqa: E402
|
||||
from pum_md_tool import _NUM, md_lines # noqa: E402
|
||||
|
||||
BOOKS = (
|
||||
"산림품셈",
|
||||
"건설품셈",
|
||||
"건설노임",
|
||||
"제조노임",
|
||||
"나라장터자재",
|
||||
"오피넷유가",
|
||||
"한국은행환율",
|
||||
"조달청제비율",
|
||||
"자체",
|
||||
)
|
||||
DIVISIONS = {
|
||||
"공통": "01_공통부문",
|
||||
"토목": "02_토목부문",
|
||||
"건축": "03_건축부문",
|
||||
"기계설비": "04_기계설비부문",
|
||||
"유지관리": "05_유지관리부문",
|
||||
}
|
||||
_FILE = re.compile(rf"^({'|'.join(mf.GROUPS)})_({'|'.join(BOOKS)})(?:_(\d+장|[^_]+))?\.json$")
|
||||
_SOURCE = re.compile(
|
||||
rf"^(?:자체|(?:{'|'.join(b for b in BOOKS if b not in ('건설품셈', '자체'))})(?:\s\S.*)?|건설품셈 (?:{'|'.join(DIVISIONS)}) \S.*)$"
|
||||
)
|
||||
_IDENT = re.compile(r"\d+-\d+(?:-\d+)*")
|
||||
ROW_KEYS = {
|
||||
"요소": ("열쇠", "이름", "값", "출처"),
|
||||
"표": ("열쇠", "이름", "기준", "출처", "조건", "값칸", "줄", "주"),
|
||||
"로직": ("열쇠", "이름", "결과단위", "출처", "소유", "입력", "중간", "끝수"),
|
||||
}
|
||||
|
||||
|
||||
def load(names: list[str] | None = None) -> dict[str, dict]:
|
||||
"""첫 층 JSON 모두(밑줄 파일 빼고) — 수는 Decimal."""
|
||||
out = {}
|
||||
for path in sorted(MASTER.glob("*.json")):
|
||||
if path.name.startswith("_") or (
|
||||
names and path.name not in names and path.stem not in names
|
||||
):
|
||||
continue
|
||||
out[path.name] = json.loads(
|
||||
path.read_text(encoding="utf-8"), parse_float=Decimal, parse_int=Decimal
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def master() -> mf.Master:
|
||||
return mf.Master(load())
|
||||
|
||||
|
||||
# ── (1) 틀 ────────────────────────────────────────────────────────────
|
||||
def _source_ok(source) -> bool:
|
||||
if isinstance(source, dict):
|
||||
return all(_source_ok(v) for v in source.values())
|
||||
return isinstance(source, str) and bool(_SOURCE.match(source))
|
||||
|
||||
|
||||
def check_form(name: str, data: dict) -> list[str]:
|
||||
out = []
|
||||
m = _FILE.match(name)
|
||||
if not m:
|
||||
out.append(f"{name} · 파일 이름이 「그룹_원문[_장].json」 아님")
|
||||
elif (data.get("그룹"), data.get("원문")) != (m.group(1), m.group(2)):
|
||||
out.append(f"{name} · 머리 그룹·원문이 파일 이름과 다름")
|
||||
for head in ("그룹", "원문", "판"):
|
||||
if head not in data:
|
||||
out.append(f"{name} · 머리 「{head}」 없음")
|
||||
table = data.get("그룹") in mf.TABLE_GROUPS
|
||||
kind = "표" if table else "로직" if data.get("그룹") == "로직" else "요소"
|
||||
rows = data.get("표" if table else "줄")
|
||||
if not isinstance(rows, list):
|
||||
return out + [f"{name} · 머리 「{'표' if table else '줄'}」 목록 없음"]
|
||||
seen = set()
|
||||
for row in rows:
|
||||
key = str(row.get("열쇠"))
|
||||
where = f"{name} · {key}"
|
||||
if key in seen:
|
||||
out.append(f"{where} · 열쇠 겹침")
|
||||
seen.add(key)
|
||||
for need in ROW_KEYS[kind]:
|
||||
if need not in row:
|
||||
out.append(f"{where} · 칸 「{need}」 없음")
|
||||
if "출처" in row and not _source_ok(row["출처"]):
|
||||
out.append(f"{where} · 출처 모양 「{row['출처']}」")
|
||||
if kind == "요소" and isinstance(row.get("값"), dict):
|
||||
for bundle in ("단위", "출처"):
|
||||
if isinstance(row.get(bundle), dict) and set(row[bundle]) != set(row["값"]):
|
||||
out.append(f"{where} · 「{bundle}」 묶음 칸이 값 묶음과 다름")
|
||||
if kind == "표":
|
||||
out += _check_table(where, row)
|
||||
if kind == "로직" and "결과" not in row and "호표" not in row:
|
||||
out.append(f"{where} · 「호표」 도 「결과」 도 없음")
|
||||
return out
|
||||
|
||||
|
||||
def _check_table(where: str, table: dict) -> list[str]:
|
||||
out, conds, cols = [], table.get("조건", {}), table.get("값칸", {})
|
||||
allowed = set(conds) | set(cols) | {"단위", "짝"} | {c + "원문" for c in cols}
|
||||
for kind in conds.values():
|
||||
if kind not in ("수", "고르기", "범위"):
|
||||
out.append(f"{where} · 조건 종류 「{kind}」")
|
||||
if "범위" in conds.values() and "범위규칙" not in table:
|
||||
out.append(f"{where} · 범위 조건에 「범위규칙」 없음")
|
||||
for number, row in enumerate(table.get("줄", []), start=1):
|
||||
extra = set(row) - allowed
|
||||
if extra:
|
||||
out.append(f"{where} 줄{number} · 모르는 칸 {sorted(extra)}")
|
||||
if not set(row) & set(cols):
|
||||
out.append(f"{where} 줄{number} · 값 칸 없음")
|
||||
for cond, kind in conds.items():
|
||||
if cond not in row:
|
||||
continue
|
||||
value = row[cond]
|
||||
if kind == "범위" and not (isinstance(value, list) and len(value) == 2):
|
||||
out.append(f"{where} 줄{number} · 범위 조건 「{cond}」 이 [아래, 위] 아님")
|
||||
if kind == "수" and not isinstance(value, Decimal):
|
||||
out.append(f"{where} 줄{number} · 수 조건 「{cond}」 이 수 아님")
|
||||
for col in cols:
|
||||
value = row.get(col)
|
||||
if isinstance(value, list) and len(value) != 2:
|
||||
out.append(f"{where} 줄{number} · 값 칸 「{col}」 범위가 [아래, 위] 아님")
|
||||
return out
|
||||
|
||||
|
||||
# ── (2) 본문 ──────────────────────────────────────────────────────────
|
||||
def _key(parts: str) -> tuple[int, ...]:
|
||||
return tuple(int(p) for p in parts.split("-"))
|
||||
|
||||
|
||||
def section_of(source: str) -> tuple[Path | None, str, str]:
|
||||
"""(본문 md, 절 번호, 절 글) — 못 찾으면 md 나 글이 빔."""
|
||||
ident = _IDENT.search(source)
|
||||
if not ident:
|
||||
return None, "", ""
|
||||
ident = ident.group()
|
||||
parts = _key(ident)
|
||||
if source.startswith("산림품셈"):
|
||||
chapters = COST / "산림_표준품셈/본문"
|
||||
elif source.startswith("건설품셈"):
|
||||
chapters = COST / "건설공사_표준품셈/본문" / DIVISIONS.get(source.split()[1], "")
|
||||
else:
|
||||
return None, ident, ""
|
||||
best = None
|
||||
for md in chapters.glob(f"제{parts[0]:02d}장_*/*.md"):
|
||||
head = md.name.split("_", 1)[0]
|
||||
if not _IDENT.fullmatch(head):
|
||||
continue
|
||||
own = _key(head)
|
||||
if (
|
||||
own[0]
|
||||
and own == parts[: len(own)]
|
||||
and (best is None or len(own) > len(_key(best.name.split("_", 1)[0])))
|
||||
):
|
||||
best = md
|
||||
if best is None:
|
||||
return None, ident, ""
|
||||
lines = best.read_text(encoding="utf-8").split("\n")
|
||||
heading = re.compile(rf"^(#+)\s*{re.escape(ident)}\.?(\s|$)")
|
||||
for at, line in enumerate(lines):
|
||||
m = heading.match(line)
|
||||
if m:
|
||||
level, end = len(m.group(1)), len(lines)
|
||||
for later in range(at + 1, len(lines)):
|
||||
h = re.match(r"^(#+)\s", lines[later])
|
||||
if h and len(h.group(1)) <= level:
|
||||
end = later
|
||||
break
|
||||
return best, ident, "\n".join(lines[at:end])
|
||||
return best, ident, ""
|
||||
|
||||
|
||||
def _canon(token: str) -> str:
|
||||
return format(Decimal(token.replace(",", "")).normalize(), "f")
|
||||
|
||||
|
||||
def numbers_in(text: str) -> set[str]:
|
||||
return {_canon(t) for _, line in md_lines(text) for t in _NUM.findall(line)}
|
||||
|
||||
|
||||
def _flat(value) -> set[str]:
|
||||
if isinstance(value, Decimal):
|
||||
return {_canon(str(abs(value)))}
|
||||
if isinstance(value, str):
|
||||
return {_canon(t) for t in _NUM.findall(value)}
|
||||
if isinstance(value, list):
|
||||
return set().union(*map(_flat, value)) if value else set()
|
||||
if isinstance(value, dict):
|
||||
return set().union(*map(_flat, value.values())) if value else set()
|
||||
return set()
|
||||
|
||||
|
||||
def check_body(files: dict[str, dict]) -> list[str]:
|
||||
"""표마다 허구 · 절마다 결손."""
|
||||
out, by_section = [], {}
|
||||
for name, data in files.items():
|
||||
for table in (data.get("표") or []) if data.get("그룹") in mf.TABLE_GROUPS else []:
|
||||
where = f"{name} · {table.get('열쇠')}"
|
||||
md, ident, text = section_of(str(table.get("출처", "")))
|
||||
if not text:
|
||||
out.append(f"{where} · 본문 절 못 찾음 「{table.get('출처')}」")
|
||||
continue
|
||||
mine = _flat(table.get("줄")) | _flat(table.get("기준")) | _flat(table.get("주"))
|
||||
fake = mine - numbers_in(text)
|
||||
if fake:
|
||||
out.append(f"{where} · 허구 {len(fake)} {sorted(fake, key=Decimal)}")
|
||||
slot = by_section.setdefault((md, ident), [text, set(), []])
|
||||
slot[1] |= mine
|
||||
slot[2].append(where)
|
||||
for (md, ident), (text, mine, tables) in by_section.items():
|
||||
table_text = "\n".join(line for line in text.split("\n") if line.lstrip().startswith("|"))
|
||||
lack = numbers_in(table_text) - mine
|
||||
if lack:
|
||||
out.append(
|
||||
f"{md.name} {ident} · 결손 {len(lack)} {sorted(lack, key=Decimal)} ← {' · '.join(tables)}"
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
# ── (3) 로직 ──────────────────────────────────────────────────────────
|
||||
def check_logics(files: dict[str, dict], whole: mf.Master) -> list[str]:
|
||||
out, graph = [], {}
|
||||
for name, data in files.items():
|
||||
if data.get("그룹") != "로직":
|
||||
continue
|
||||
for row in data.get("줄", []):
|
||||
found, calls = mf.check_logic(whole, data.get("원문"), row)
|
||||
out += [f"{name} · {x}" for x in found]
|
||||
graph[f"{data.get('원문')}:{row.get('열쇠')}"] = calls
|
||||
out += [f"돌고 도는 참조 · {' → '.join(loop)}" for loop in mf.find_loops(graph)]
|
||||
return out
|
||||
|
||||
|
||||
# ── (4) 계산 ──────────────────────────────────────────────────────────
|
||||
def show(result: dict) -> str:
|
||||
if "결과" in result:
|
||||
return f"결과 {result['결과']}"
|
||||
rows = [
|
||||
f" {r['이름']} | {r['단위']} | 수량 {r['수량']:.6f} | 단가 {r['단가']} | 금액 {r['금액']:.2f}"
|
||||
for r in result["줄"]
|
||||
]
|
||||
rows += [f" {k} {result[k]:.2f}" for k in (*mf.COST_ITEMS, "계")]
|
||||
return "\n".join(rows)
|
||||
|
||||
|
||||
def _value(text: str):
|
||||
try:
|
||||
return Decimal(text)
|
||||
except ArithmeticError:
|
||||
return text
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
mode = argv[0] if argv else "전부"
|
||||
if mode == "계산":
|
||||
given = dict(arg.split("=", 1) for arg in argv[2:])
|
||||
print(show(mf.run(master(), argv[1], {k: _value(v) for k, v in given.items()})))
|
||||
return 0
|
||||
names = argv[1:] or None
|
||||
files, whole = load(names), master()
|
||||
report = {}
|
||||
if mode in ("틀", "전부"):
|
||||
report["틀"] = [x for name, data in files.items() for x in check_form(name, data)]
|
||||
if mode in ("본문", "전부"):
|
||||
report["본문"] = check_body(files)
|
||||
if mode in ("로직", "전부"):
|
||||
report["로직"] = check_logics(files, whole)
|
||||
for title, found in report.items():
|
||||
print(f"({title}) {len(found)}건")
|
||||
for line in found:
|
||||
print(" " + line)
|
||||
return 1 if any(report.values()) else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,562 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""마스터 로직 식 — 읽기 · 검사 · 시험 계산 (`_틀.md` 6~7장).
|
||||
|
||||
식은 틀 7장 범위만 읽음 — eval 쓰지 않음. 수는 Decimal(끝수 사사오입이 흔들리지 않게).
|
||||
`check_master.py` 가 부름.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import re
|
||||
from decimal import ROUND_DOWN, ROUND_HALF_UP, ROUND_UP, Decimal
|
||||
|
||||
GROUPS = ("인력", "재료", "기계", "소요량", "계수", "환율", "요율", "로직")
|
||||
TABLE_GROUPS = ("소요량", "계수")
|
||||
COST_ITEMS = ("노무비", "재료비", "경비")
|
||||
BUNDLE = (*COST_ITEMS, "계")
|
||||
FUNCS = ("만약", "올림", "버림", "반올림", "최소", "최대")
|
||||
_NAME = re.compile(r"[\w가-힣]+")
|
||||
_NUMBER = re.compile(r"\d+(?:\.\d+)?")
|
||||
_HOLE = re.compile(r"\{([^{}]+)\}")
|
||||
_OPS = ("<=", ">=", "==", "!=", "<", ">")
|
||||
|
||||
|
||||
class FormulaError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
# ── 읽기 ──────────────────────────────────────────────────────────────
|
||||
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.name())
|
||||
return node
|
||||
|
||||
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) -> str:
|
||||
"""열쇠 글자 — 괄호 밖의 `,` · `)` · `.칸` · ` 연산자 ` 앞에서 멈춤(열쇠에 빈칸·괄호가 들어감)."""
|
||||
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 depth == 0 and ch == "." and not self.s[self.i + 1 : self.i + 2].isdigit():
|
||||
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.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("(")
|
||||
head = self.name()
|
||||
self.need(":")
|
||||
return ("find", f"{head}:{self.ref()}", self.args())
|
||||
if word == "로직":
|
||||
self.need("(")
|
||||
return ("logic", self.ref(), 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 word in GROUPS and self.s.startswith(":", self.i):
|
||||
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)."""
|
||||
|
||||
def __init__(self, files: dict[str, dict]):
|
||||
self.index: dict[tuple[str, str], dict[str, dict]] = {}
|
||||
for data in files.values():
|
||||
rows = data.get("표") if data.get("그룹") in TABLE_GROUPS else data.get("줄")
|
||||
book = self.index.setdefault((data.get("그룹"), data.get("원문")), {})
|
||||
for row in rows or []:
|
||||
book.setdefault(str(row.get("열쇠")), row)
|
||||
|
||||
def get(self, ref: str) -> dict:
|
||||
group, book, key = (ref.split(":", 2) + ["", ""])[:3]
|
||||
row = self.index.get((group, book), {}).get(key)
|
||||
if row is None:
|
||||
raise FormulaError(f"없는 요소 「{ref}」")
|
||||
return row
|
||||
|
||||
def logic(self, ref: str) -> dict:
|
||||
return self.get(f"로직:{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)]
|
||||
return format(value.normalize(), "f") if isinstance(value, Decimal) else str(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
|
||||
if table.get("범위규칙", "초과·이하") != "초과·이하":
|
||||
raise FormulaError(f"모르는 범위규칙 「{table.get('범위규칙')}」")
|
||||
return (low is None or want > low) and (high is None or want <= high)
|
||||
return have == want
|
||||
|
||||
|
||||
def find(table: dict, conds: dict) -> dict:
|
||||
for cond in conds:
|
||||
if cond not in table.get("조건", {}):
|
||||
raise FormulaError(f"「{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:
|
||||
raise FormulaError(f"「{table['열쇠']}」 {conds} 맞는 줄 {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]}」")
|
||||
return env[node[1]]
|
||||
if kind == "ref":
|
||||
value = master.get(fill(node[1], env)).get("값")
|
||||
if isinstance(value, list):
|
||||
raise FormulaError(f"「{node[1]}」 값이 범위 — 입력으로 받아야 함")
|
||||
return value
|
||||
if kind == "field":
|
||||
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]}」 값이 범위 — 입력으로 받아야 함")
|
||||
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}")
|
||||
|
||||
|
||||
def _inputs(row: dict, given: dict) -> dict:
|
||||
env = {}
|
||||
for spec in row.get("입력", []):
|
||||
name = spec["이름"]
|
||||
if name not in given:
|
||||
raise FormulaError(f"「{row['열쇠']}」 입력 「{name}」 없음")
|
||||
value = given[name]
|
||||
if isinstance(value, (int, float)):
|
||||
value = Decimal(str(value))
|
||||
if "고르기" in spec and value not in spec["고르기"]:
|
||||
raise FormulaError(f"입력 「{name}」={value} 고르기 밖")
|
||||
if "범위" in spec:
|
||||
low, high = spec["범위"]
|
||||
if not low <= value <= high:
|
||||
raise FormulaError(f"입력 「{name}」={value} 범위 [{low}, {high}] 밖")
|
||||
env[name] = value
|
||||
unknown = set(given) - set(env)
|
||||
if unknown:
|
||||
raise FormulaError(f"「{row['열쇠']}」 모르는 입력 {sorted(unknown)}")
|
||||
return env
|
||||
|
||||
|
||||
def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
|
||||
"""로직 한 줄 계산 — 돈 로직은 호표 줄 · 비목 합 · 계, 돈 아닌 로직은 결과 수 하나."""
|
||||
row = master.logic(ref)
|
||||
env = _inputs(row, given)
|
||||
for step in row.get("중간", []):
|
||||
env[step["이름"]] = evaluate(parse(step["식"]), env, master, depth)
|
||||
if "결과" in row:
|
||||
return {"결과": evaluate(parse(row["결과"]["식"]), env, master, depth), "중간": env}
|
||||
sums = dict.fromkeys(COST_ITEMS, Decimal(0))
|
||||
lines = []
|
||||
for item in row.get("호표", []):
|
||||
qty = evaluate(parse(item["수량"]), env, master, depth)
|
||||
if item["종류"] == "로직":
|
||||
bundle = evaluate(parse(item["요소"]), env, master, depth)
|
||||
price = bundle["계"]
|
||||
split = (
|
||||
{k: qty * bundle[k] for k in COST_ITEMS}
|
||||
if "비목" not in item
|
||||
else {item["비목"]: qty * price}
|
||||
)
|
||||
else:
|
||||
price = master.get(fill(item["요소"], env))["값"]
|
||||
split = {item["비목"]: qty * price}
|
||||
for k, v in split.items():
|
||||
sums[k] += v
|
||||
lines.append(
|
||||
{
|
||||
"이름": item.get("이름", ""),
|
||||
"단위": item.get("단위", ""),
|
||||
"수량": qty,
|
||||
"단가": price,
|
||||
"금액": qty * price,
|
||||
"비목": split,
|
||||
}
|
||||
)
|
||||
for extra in row.get("덧줄", []):
|
||||
env.update(sums)
|
||||
value = evaluate(parse(extra["식"]), env, master, depth)
|
||||
env[extra["이름"]] = value
|
||||
sums[extra["비목"]] += value
|
||||
lines.append(
|
||||
{
|
||||
"이름": extra["이름"],
|
||||
"단위": "식",
|
||||
"수량": Decimal(1),
|
||||
"단가": value,
|
||||
"금액": value,
|
||||
"비목": {extra["비목"]: value},
|
||||
}
|
||||
)
|
||||
return {"줄": lines, **sums, "계": sum(sums.values()), "중간": env}
|
||||
|
||||
|
||||
# ── 검사 ──────────────────────────────────────────────────────────────
|
||||
def _choices(row: dict, name: str, master: Master):
|
||||
"""이름이 가질 수 있는 값 — 고르기 입력, 또는 고르기 입력만 끼운 요소 칸인 중간. 모르면 None."""
|
||||
for spec in row.get("입력", []):
|
||||
if spec["이름"] == name:
|
||||
return spec.get("고르기")
|
||||
for step in row.get("중간", []):
|
||||
if step["이름"] == name:
|
||||
node = parse(step["식"])
|
||||
if node[0] == "field" and node[1][0] == "ref":
|
||||
out = set()
|
||||
for ref in _expand(row, node[1][1], master) or []:
|
||||
try:
|
||||
out.add(str(master.get(ref)["값"][node[2]]))
|
||||
except (FormulaError, KeyError, TypeError):
|
||||
pass
|
||||
return sorted(out)
|
||||
return None
|
||||
|
||||
|
||||
def _expand(row: dict, template: str, master: Master):
|
||||
"""열쇠 틀의 {이름} 을 고를 수 있는 값으로 모두 끼움 — 못 정하면 None."""
|
||||
holes = _HOLE.findall(template)
|
||||
options = [_choices(row, h, master) for h in holes]
|
||||
if any(o is None for o in options):
|
||||
return None
|
||||
return [
|
||||
fill(template, {h: v for h, v in zip(holes, combo)})
|
||||
for combo in itertools.product(*options)
|
||||
]
|
||||
|
||||
|
||||
def _check_node(row, node, known: set, master: Master, where: str, out: list, calls: set):
|
||||
for part in walk(node):
|
||||
kind = part[0]
|
||||
if kind == "name" and part[1] not in known:
|
||||
out.append(f"{where} · 모르는 이름 「{part[1]}」")
|
||||
if kind in ("ref", "find", "logic"):
|
||||
for hole in _HOLE.findall(part[1]):
|
||||
if hole not in known:
|
||||
out.append(f"{where} · 열쇠 {{{hole}}} 가 입력·중간 아님")
|
||||
if kind == "ref":
|
||||
refs = _expand(row, part[1], master) or []
|
||||
for ref in refs:
|
||||
try:
|
||||
master.get(ref)
|
||||
except FormulaError as e:
|
||||
out.append(f"{where} · {e}")
|
||||
if kind == "field" and part[1][0] == "ref":
|
||||
for ref in _expand(row, part[1][1], master) or []:
|
||||
try:
|
||||
value = master.get(ref).get("값")
|
||||
except FormulaError:
|
||||
continue
|
||||
if not isinstance(value, dict) or part[2] not in value:
|
||||
out.append(f"{where} · 「{ref}」 에 칸 「{part[2]}」 없음")
|
||||
if kind == "find":
|
||||
if part[1].split(":")[0] not in TABLE_GROUPS:
|
||||
out.append(f"{where} · 찾기 대상 「{part[1]}」 이 표 아님")
|
||||
continue
|
||||
try:
|
||||
table = master.get(part[1])
|
||||
except FormulaError as e:
|
||||
out.append(f"{where} · {e}")
|
||||
continue
|
||||
for cond, _ in part[2]:
|
||||
if cond not in table.get("조건", {}):
|
||||
out.append(f"{where} · 「{part[1]}」 에 조건 「{cond}」 없음")
|
||||
if kind == "field" and part[1][0] == "find":
|
||||
try:
|
||||
table = master.get(part[1][1])
|
||||
except FormulaError:
|
||||
continue
|
||||
if part[2] not in table.get("값칸", {}):
|
||||
out.append(f"{where} · 「{part[1][1]}」 에 값 칸 「{part[2]}」 없음")
|
||||
if kind == "logic":
|
||||
calls.add(part[1])
|
||||
try:
|
||||
target = master.logic(part[1])
|
||||
except FormulaError as e:
|
||||
out.append(f"{where} · {e}")
|
||||
continue
|
||||
names = {s["이름"] for s in target.get("입력", [])}
|
||||
given = {k for k, _ in part[2]}
|
||||
for extra in sorted(given - names):
|
||||
out.append(f"{where} · 「{part[1]}」 에 입력 「{extra}」 없음")
|
||||
for lack in sorted(names - given):
|
||||
out.append(f"{where} · 「{part[1]}」 입력 「{lack}」 안 줌")
|
||||
if kind == "field" and part[1][0] == "logic":
|
||||
try:
|
||||
target = master.logic(part[1][1])
|
||||
except FormulaError:
|
||||
continue
|
||||
if "결과" in target:
|
||||
out.append(
|
||||
f"{where} · 돈 아닌 로직 「{part[1][1]}」 은 수 하나 — 칸 「{part[2]}」 못 씀"
|
||||
)
|
||||
elif part[2] not in BUNDLE:
|
||||
out.append(f"{where} · 로직 결과 칸 「{part[2]}」 은 {BUNDLE} 가운데 하나여야 함")
|
||||
|
||||
|
||||
def check_logic(master: Master, book: str, row: dict) -> tuple[list[str], set]:
|
||||
"""(문제 목록, 부르는 로직 열쇠) — 변수 · 찾기 조건 · 로직 입력."""
|
||||
out, calls, key = [], set(), f"{book}:{row.get('열쇠')}"
|
||||
known = {s["이름"] for s in row.get("입력", [])}
|
||||
|
||||
def one(text, where, names):
|
||||
try:
|
||||
_check_node(row, parse(text), names, master, where, out, calls)
|
||||
except FormulaError as e:
|
||||
out.append(f"{where} · 식 못 읽음: {e}")
|
||||
|
||||
for step in row.get("중간", []):
|
||||
one(step["식"], f"{key} 중간 {step['이름']}", known)
|
||||
known = known | {step["이름"]}
|
||||
if "결과" in row:
|
||||
one(row["결과"]["식"], f"{key} 결과", known)
|
||||
for item in row.get("호표", []):
|
||||
where = f"{key} 호표 {item.get('이름', '')}"
|
||||
one(item["수량"], where + " 수량", known)
|
||||
if item.get("종류") not in ("인력", "재료", "기계", "로직"):
|
||||
out.append(f"{where} · 종류 「{item.get('종류')}」")
|
||||
if item.get("비목") not in (*COST_ITEMS, None) or (
|
||||
item.get("종류") != "로직" and "비목" not in item
|
||||
):
|
||||
out.append(f"{where} · 비목 「{item.get('비목')}」")
|
||||
one(item["요소"], where + " 요소", known)
|
||||
if item.get("종류") != "로직" and not str(item["요소"]).startswith(f"{item.get('종류')}:"):
|
||||
out.append(f"{where} · 요소 그룹이 종류와 다름")
|
||||
names = known | set(COST_ITEMS)
|
||||
for extra in row.get("덧줄", []):
|
||||
one(extra["식"], f"{key} 덧줄 {extra['이름']}", names)
|
||||
names = names | {extra["이름"]}
|
||||
if extra.get("비목") not in COST_ITEMS:
|
||||
out.append(f"{key} 덧줄 {extra['이름']} · 비목 「{extra.get('비목')}」")
|
||||
return out, calls
|
||||
|
||||
|
||||
def find_loops(graph: dict[str, set]) -> list[list[str]]:
|
||||
"""로직 부르기의 돌고 도는 길."""
|
||||
loops, state = [], {}
|
||||
|
||||
def visit(node, path):
|
||||
state[node] = 1
|
||||
for nxt in sorted(graph.get(node, ())):
|
||||
if state.get(nxt) == 1:
|
||||
loops.append(path[path.index(nxt) :] + [nxt])
|
||||
elif nxt not in state:
|
||||
visit(nxt, path + [nxt])
|
||||
state[node] = 2
|
||||
|
||||
for node in sorted(graph):
|
||||
if node not in state:
|
||||
visit(node, [node])
|
||||
return loops
|
||||
@@ -42,7 +42,8 @@
|
||||
"잡재료율": "건설품셈 공통 8-4-1",
|
||||
"조종원": "건설품셈 공통 8-4-1",
|
||||
"조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048"
|
||||
}
|
||||
},
|
||||
"비고": "조종원직종 원문 이름: 건설기계운전사"
|
||||
},
|
||||
{
|
||||
"열쇠": "0201-0020",
|
||||
@@ -83,7 +84,8 @@
|
||||
"잡재료율": "건설품셈 공통 8-4-1",
|
||||
"조종원": "건설품셈 공통 8-4-1",
|
||||
"조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048"
|
||||
}
|
||||
},
|
||||
"비고": "조종원직종 원문 이름: 건설기계운전사"
|
||||
},
|
||||
{
|
||||
"열쇠": "0201-0040",
|
||||
@@ -124,7 +126,8 @@
|
||||
"잡재료율": "건설품셈 공통 8-4-1",
|
||||
"조종원": "건설품셈 공통 8-4-1",
|
||||
"조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048"
|
||||
}
|
||||
},
|
||||
"비고": "조종원직종 원문 이름: 건설기계운전사"
|
||||
},
|
||||
{
|
||||
"열쇠": "0201-0060",
|
||||
@@ -165,7 +168,8 @@
|
||||
"잡재료율": "건설품셈 공통 8-4-1",
|
||||
"조종원": "건설품셈 공통 8-4-1",
|
||||
"조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048"
|
||||
}
|
||||
},
|
||||
"비고": "조종원직종 원문 이름: 건설기계운전사"
|
||||
},
|
||||
{
|
||||
"열쇠": "0201-0070",
|
||||
@@ -206,7 +210,8 @@
|
||||
"잡재료율": "건설품셈 공통 8-4-1",
|
||||
"조종원": "건설품셈 공통 8-4-1",
|
||||
"조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048"
|
||||
}
|
||||
},
|
||||
"비고": "조종원직종 원문 이름: 건설기계운전사"
|
||||
},
|
||||
{
|
||||
"열쇠": "0201-0080",
|
||||
@@ -247,7 +252,8 @@
|
||||
"잡재료율": "건설품셈 공통 8-4-1",
|
||||
"조종원": "건설품셈 공통 8-4-1",
|
||||
"조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048"
|
||||
}
|
||||
},
|
||||
"비고": "조종원직종 원문 이름: 건설기계운전사"
|
||||
},
|
||||
{
|
||||
"열쇠": "0201-0100",
|
||||
@@ -288,7 +294,8 @@
|
||||
"잡재료율": "건설품셈 공통 8-4-1",
|
||||
"조종원": "건설품셈 공통 8-4-1",
|
||||
"조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048"
|
||||
}
|
||||
},
|
||||
"비고": "조종원직종 원문 이름: 건설기계운전사"
|
||||
},
|
||||
{
|
||||
"열쇠": "0201-0120",
|
||||
@@ -329,7 +336,8 @@
|
||||
"잡재료율": "건설품셈 공통 8-4-1",
|
||||
"조종원": "건설품셈 공통 8-4-1",
|
||||
"조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048"
|
||||
}
|
||||
},
|
||||
"비고": "조종원직종 원문 이름: 건설기계운전사"
|
||||
},
|
||||
{
|
||||
"열쇠": "0201-0200",
|
||||
@@ -370,7 +378,8 @@
|
||||
"잡재료율": "건설품셈 공통 8-4-1",
|
||||
"조종원": "건설품셈 공통 8-4-1",
|
||||
"조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048"
|
||||
}
|
||||
},
|
||||
"비고": "조종원직종 원문 이름: 건설기계운전사"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""마스터 데이터 검사 도구 — 견본 셋 손 계산 · 견본 파일 검사 통과 (`resources/master_data/scripts/check_master.py`)."""
|
||||
|
||||
import sys
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "master_data/scripts"))
|
||||
|
||||
import check_master as cm # noqa: E402
|
||||
import master_formula as mf # noqa: E402
|
||||
|
||||
SAMPLES = [
|
||||
"계수_건설품셈_공통8장.json",
|
||||
"계수_산림품셈_9장.json",
|
||||
"기계_건설품셈.json",
|
||||
"로직_건설품셈_공통8장.json",
|
||||
"로직_산림품셈_13장.json",
|
||||
"로직_산림품셈_9장.json",
|
||||
"소요량_산림품셈_13장.json",
|
||||
]
|
||||
EXCAVATOR = "건설품셈:8 굴착기(무한궤도) 시간당 경비"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def whole():
|
||||
return cm.master()
|
||||
|
||||
|
||||
def cents(x) -> str:
|
||||
return str(Decimal(x).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
|
||||
|
||||
|
||||
def test_메쌓기(whole):
|
||||
got = mf.run(
|
||||
whole,
|
||||
"산림품셈:13-4-1 메쌓기",
|
||||
{"뒷길이": 35, "돌": "견치돌", "쌓기": "골쌓기", "높이": 2, "초과증가율": 80},
|
||||
)
|
||||
assert cents(got["계"]) == "203281.20"
|
||||
|
||||
|
||||
def test_메쌓기_높이_증가율(whole):
|
||||
got = mf.run(
|
||||
whole,
|
||||
"산림품셈:13-4-1 메쌓기",
|
||||
{"뒷길이": 35, "돌": "견치돌", "쌓기": "골쌓기", "높이": 5, "초과증가율": 80},
|
||||
)
|
||||
assert [line["수량"] for line in got["줄"]] == [Decimal("0.70"), Decimal("0.56")]
|
||||
|
||||
|
||||
def test_굴착기(whole):
|
||||
got = mf.run(
|
||||
whole, EXCAVATOR, {"기계": "0201-0070", "지역": "전국평균", "보정작업": "해당 없음"}
|
||||
)
|
||||
assert [cents(got[k]) for k in ("노무비", "재료비", "경비", "계")] == [
|
||||
"35412.13",
|
||||
"26130.11",
|
||||
"24554.00",
|
||||
"86096.24",
|
||||
]
|
||||
|
||||
|
||||
def test_굴착기_암석_보정(whole):
|
||||
got = mf.run(
|
||||
whole,
|
||||
EXCAVATOR,
|
||||
{"기계": "0201-0070", "지역": "전국평균", "보정작업": "암석작업(연암・보통암・경암)"},
|
||||
)
|
||||
assert got["중간"]["손료"] == 28322
|
||||
assert cents(got["계"]) == "89864.24"
|
||||
|
||||
|
||||
def test_노체포설(whole):
|
||||
assert mf.run(whole, "산림품셈:9-16-1 노체포설 작업량", {})["결과"] == Decimal("24.255")
|
||||
got = mf.run(whole, "산림품셈:9-16-1 노체포설", {"지역": "전국평균", "보정작업": "해당 없음"})
|
||||
assert [cents(got[k]) for k in ("노무비", "재료비", "경비", "계")] == [
|
||||
"1459.99",
|
||||
"1077.31",
|
||||
"1012.33",
|
||||
"3549.63",
|
||||
]
|
||||
|
||||
|
||||
def test_범위_입력은_막음(whole):
|
||||
with pytest.raises(mf.FormulaError):
|
||||
mf.run(
|
||||
whole,
|
||||
"산림품셈:13-4-1 메쌓기",
|
||||
{"뒷길이": 35, "돌": "견치돌", "쌓기": "골쌓기", "높이": 8, "초과증가율": 120},
|
||||
)
|
||||
|
||||
|
||||
def test_견본_파일_검사_통과(whole):
|
||||
files = cm.load(SAMPLES)
|
||||
assert len(files) == len(SAMPLES)
|
||||
assert [x for name, data in files.items() for x in cm.check_form(name, data)] == []
|
||||
assert cm.check_body(files) == []
|
||||
assert cm.check_logics(files, whole) == []
|
||||
|
||||
|
||||
def test_로직_검사가_틀린_열쇠를_잡음(whole):
|
||||
row = {
|
||||
"열쇠": "시험",
|
||||
"입력": [{"이름": "지역", "고르기": ["전국평균", "없는곳"]}],
|
||||
"중간": [{"이름": "x", "식": "찾기(계수:산림품셈:9-16-1 노체포설, 없는조건='K').적용"}],
|
||||
"호표": [
|
||||
{"종류": "재료", "요소": "재료:오피넷유가:경유:{지역}", "수량": "y", "비목": "재료비"}
|
||||
],
|
||||
"덧줄": [],
|
||||
}
|
||||
found, _ = mf.check_logic(whole, "산림품셈", row)
|
||||
assert any("없는조건" in x for x in found)
|
||||
assert any("경유:없는곳" in x for x in found)
|
||||
assert any("「y」" in x for x in found)
|
||||
|
||||
|
||||
def test_돌고_도는_참조():
|
||||
assert mf.find_loops({"가": {"나"}, "나": {"가"}}) == [["가", "나", "가"]]
|
||||
Reference in New Issue
Block a user