feat(791-1): 엔진 셈 의미 넓힘 — 금액번지 · 소계 단계 · 제외 · 줄 자리 · 블록 끝전 · 결과 조정 · 변수 표시

- 새 모듈 master_formula_sum.py — 덧줄 고리(master_formula.run 에서 옮김) · 번지 · 단계 · 합밖 · 조정 · 표시 · 검사
- master_cut.py — 줄 자리 {방법, 자리} · 블록 끝전(비목버림 | 큰나머지) · 결과 조정 · 답 단계 목록
- master_formula_check.py — 새 칸 검사(없는 번지 · 단계 순서 · 자리 범위 · 끝전 · 조정)
- _틀.md 7장 「셈 의미 넓힘」 · 8장 덧줄 이름
- 지킴 시험 test_791_1 — M01 로직 1,515 지문 전후 같음 · 새 의미 단위 · 실무 견본 넷(봉화 D00102 · 울진소광 D01489 · 동부청영월 D00003 · 북부청임도 18) = 엑셀
- master_formula.py 690 → 669 줄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 17:03:36 +09:00
co-authored by Claude Opus 5.5
parent 17b6ee5460
commit c05139a971
7 changed files with 2352 additions and 38 deletions
+16 -1
View File
@@ -145,7 +145,7 @@
```
- 장 파일 줄은 갈래를 스스로 지님 — `구분` = 원문 + 부문(「산림품셈」 · 「건설품셈 공통」 · 「건설품셈 토목」 …) · `상세구분` = 「NN장 장 제목」(파일 이름 그대로 · 「13장 구조물」). 파일 머리의 `부문` · `차례` 는 목록 차례로 남김.
- 열 차례 — 표는 키 · 원문번호 · 구분 · 상세구분 · 이름 · 기준 · 출처 · 비고 · 용도 · 조건 · 범위규칙 · 값칸 · 줄 · 주 · `그룹`(소요량 파일 안의 계수 표 표시 · 맨 뒤) · 로직은 키 · 원문번호 · 구분 · 상세구분 · 이름 · 결과단위 · 출처 · 소유 · 비고 · 입력 · 중간 · 호표 · 덧줄 · 결과 · 끝수.
- 열 차례 — 표는 키 · 원문번호 · 구분 · 상세구분 · 이름 · 기준 · 출처 · 비고 · 용도 · 조건 · 범위규칙 · 값칸 · 줄 · 주 · `그룹`(소요량 파일 안의 계수 표 표시 · 맨 뒤) · 로직은 키 · 원문번호 · 구분 · 상세구분 · 이름 · 결과단위 · 출처 · 소유 · 비고 · 입력 · 중간 · 호표 · 덧줄 · 결과 · 끝수 · 단계 · 끝전 · 조정.
- 갈래 목록은 서버가 장 파일에서 모아 줌(`GET /subs?group=…` · 장 차례대로) — 화면이 이름을 품지 않음.
- 건설품셈 부문은 `구분` 칸 — 「건설품셈 건축」 · 원문번호는 절 번호만(「1-1-1」).
- 조건 종류 — `수`(같은 값) · `고르기`(글자) · `범위`(줄에 `[아래, 위]` · 끝 열림은 `null` · 포함 여부는 `범위규칙` 에 「초과·이하」처럼).
@@ -204,6 +204,20 @@
- `결과단위` 가 돈이 아닌 로직(㎥/시간)은 `호표` 없이 `중간` + `결과` 식.
- `끝수` = 묶음 `{대상, 자리, 방법, 출처?, 비고?}` — 엔진이 계산 맨 끝에 붙임. `대상` 은 `계`(비목 합 셋을 각각 끊고 계 = 그 합) · `결과`(돈 아닌 로직) · `자리` 는 소수 자리(0 = 원 미만) · `방법` 은 `버림`·`올림`·`반올림`. 글자 `끝수`(「손료 원 미만 버림(…)」)는 원문 근거를 적은 설명일 뿐 — 그 끊기는 식 안에 이미 있음.
- `종류` 가 로직이고 `비목` 칸이 없는 호표 줄은 불러온 결과를 비목별로 나눠 얹음.
셈 의미 넓힘 — 쓰는 로직에만 먹음 · 안 쓰면 옛 셈 그대로 · 엔진 `scripts/master_formula_sum.py` · 끝전 `master_cut.settle`:
- `번지`(호표 · 덧줄 줄 칸) = 금액번지 이름(「A01」 · 글자 · 숫자) — 줄 비목 금액이 그 번지에 쌓임 · 덧줄 식 `번지.A01.재료비`(칸 = 노무비 · 재료비 · 경비 · 계).
- `합밖: true`(줄 칸) = 번지엔 쌓되 비목 합 · 단계에서 뺌(STmate `->`).
- `단계`(로직 칸) = 소계 줄 목록 `[{이름, 단, 줄, 제외}]` — `단` 1~5(소계 · 계 · 합계 …) · `줄` = 담을 호표 · 덧줄 이름(이름 겹침 금지 · 한 줄은 한 단계) · 단 k = 제 `줄` + 앞에서 아직 안 담긴 아래 단 단계 · `제외: true` = 찍되 위 단계 · 비목 합에서 뺌 · 덧줄 식 `단계.'이름'.계` · 셈 답에 `단계` 목록(이름 · 단 · 노 · 재 · 경 · 계).
- 덧줄이 부르는 번지 · 단계 = 그 덧줄 앞 줄까지 — 그 줄이나 뒤 줄이 들면 검사 「단계 순서」.
- 특수 % — L% · M% · S%(처음부터 그 비목 누계 × n %) = 덧줄 `노무비 * n / 100`(덧줄 식의 비목 합 = 그 덧줄 앞 누계) · T%(바로 앞 소계 × 수량) = `단계.'소계'.계 * 수량`.
- `자리`(줄 칸) = `{방법: 버림 | 반올림 | 올림, 자리: 0~3}` — 줄 비목 금액 끝수(없으면 0.1 원 버림) · 뒤 덧줄이 보는 값도 이것.
- `끝전`(로직 칸) = `비목버림`(없음과 같음 · 비목마다 원 버림) | `큰나머지`(계 원 버림 · 모자란 원을 원 미만 큰 비목부터 1 원씩 · 같으면 경 › 재 › 노).
- `조정`(로직 칸) = `{배율: {비목: 수}, 옮김: 비목}`(둘 다 골라 씀) — 끝전 뒤 비목 × 배율 → 계 전부를 `옮김` 비목으로(「경비로 적용」) → 원 버림.
- `표시`(중간 칸) = 0~6 — 값을 그 자리 반올림해 뒤 줄에 넘김(STmate `X = 식 =`).
- 날 셈(`master_formula.run`)은 번지 · 단계 · 합밖 · 조정 · 표시까지 · 줄 자리 · 끝전은 끝수 셈(`master_cut.engine`).
- 계약단가 · 낙찰률 · 운전사 노임 5/24 는 이 의미 밖(B09 · 기계).
재료 고르기 — 호표 재료 줄의 `요소` 를 키 대신 조건 묶음으로 적음:
```json
@@ -252,6 +266,7 @@
- 로직 부르기 — `로직(GC000871, 기계='0201-0070')` · 돈 로직의 결과는 비목 묶음 `.노무비` `.재료비` `.경비` `.계` · 돈 아닌 로직은 수 하나.
- 입력 · 중간 · 비목 합은 이름 그대로.
- 덧줄에서만 `줄.'호표 줄 이름'` = 그 호표 줄의 금액 — 비목 합에서 한 줄만 빼는 식에 씀(`GF000185` 콘크리트다짐).
- 덧줄에서만 `번지.A01.칸` · `단계.'이름'.칸` — 7장 셈 의미 넓힘.
- 지역 고르기 조건(7장 `지역: "입력"`)을 쓰는 로직(부르는 로직 포함)은 입력 `자재지역` 을 두고 하위 로직에 `자재지역=자재지역` 으로 넘김 — `지역` 은 유가 고르기.
- 쓸 수 있는 것 — `+ - * /` · 괄호 · 비교 `< <= > >= == !=` · `그리고` `또는` · `찾기` · `로직` · `만약(조건, 참, 거짓)` · `올림` `버림` `반올림`(자리) · `최소` `최대`. 그 밖은 쓰지 않음.
- 로직이 돌고 도는 참조를 만들지 않음.
+42 -9
View File
@@ -22,6 +22,7 @@ from decimal import Decimal
from pathlib import Path
import master_formula as mf
import master_formula_sum as ms
_ROOT = str(Path(__file__).resolve().parents[3])
if _ROOT not in sys.path: # 스크립트 · 시험 길 — 서버는 이미 뿌리에서 돎
@@ -39,9 +40,19 @@ def down(value, places: int):
return Decimal(str(rounddown(float(value), places)))
def _parts(split: dict) -> tuple[dict, Decimal | None]:
"""줄 비목 → (비목마다 0.1 원 버림, 금액 = 그 합 · 모두 빈 값 = None)."""
cut = {k: down(v, 1) for k, v in split.items()}
def _line(value, rule: dict | None):
"""줄 값 끝수 — 줄 `자리` {방법, 자리}(791-1) · 없으면 0.1 원 버림."""
if value is None or not rule:
return down(value, 1)
way, places = rule.get("방법"), int(rule.get("자리") or 0)
if way == "버림":
return down(value, places)
return mf._round(Decimal(str(value)), places, mf.ROUND_WAYS[way])
def _parts(split: dict, rule: dict | None = None) -> tuple[dict, Decimal | None]:
"""줄 비목 → (비목마다 0.1 원 버림 · 줄 `자리` 면 그것, 금액 = 그 합 · 모두 빈 = None)."""
cut = {k: _line(v, rule) for k, v in split.items()}
kept = [v for v in cut.values() if v is not None]
return cut, (sum(kept, Decimal(0)) if kept else None)
@@ -84,6 +95,9 @@ def engine(master, ref: str, got: dict, depth: int = 0) -> dict:
if isinstance(logic.get("흐름"), dict):
return got
items, adds = logic.get("호표") or [], logic.get("덧줄") or []
rules = [x.get("자리") for x in (*items, *adds)] # 줄 자리(791-1)
new = ms.uses(logic)
skip = ms.skipped(logic) if new else set()
env = dict(got.get("중간") or {})
total = dict.fromkeys(COSTS, Decimal(0))
lines = []
@@ -100,21 +114,40 @@ def engine(master, ref: str, got: dict, depth: int = 0) -> dict:
elif n - len(items) < len(adds) and line.get("금액") is not None:
add = adds[n - len(items)]
env.update(total)
if new: # 덧줄이 부르는 번지 · 단계 = 버린 줄로
env.update(ms.scope(logic, lines))
try:
value = mf.evaluate(mf.parse(add["식"]), env, master, depth)
except _ERRORS:
value = line["금액"]
env[add["이름"]] = down(value, 1) # 뒤 덧줄 · 중간 칸 = 버린 값
env[add["이름"]] = _line(value, rules[n]) # 뒤 덧줄 · 중간 칸 = 버린 값
split = {add["비목"]: value}
cut, money = _parts(split)
cut, money = _parts(split, rules[n] if n < len(rules) else None)
for k, v in cut.items():
total[k] += v or 0
total[k] += 0 if n in skip else v or 0
if n >= len(items) and money is not None:
more = {"단가": money}
lines.append({**line, "비목": cut, "금액": money, **more})
costs = {k: mf.cut(logic, down(v, 0), "계") for k, v in total.items()}
# 중간 = 덧줄이 본 값(버린 줄 합 · 덧줄 값 · `줄`) — 시험 계산 중간 칸
return {**got, "줄": lines, **costs, "계": sum(costs.values()), "중간": env}
if not new:
costs = {k: mf.cut(logic, down(v, 0), "계") for k, v in total.items()}
# 중간 = 덧줄이 본 값(버린 줄 합 · 덧줄 값 · `줄`) — 시험 계산 중간 칸
return {**got, "줄": lines, **costs, "계": sum(costs.values()), "중간": env}
costs = settle(logic, total)
more = {"단계": ms.steps(logic, lines)}
return {**got, "줄": lines, **costs, "계": sum(costs.values()), "중간": env, **more}
def settle(logic: dict, total: dict) -> dict:
"""블록 끝전(791-1) — `끝전` 비목버림(비목마다 원 버림 · 옛 규칙) · 큰나머지(계 원 버림 뒤
모자란 원을 소수 큰 비목부터 · 같으면 경 › 재 › 노) → 로직 `끝수` 묶음 → `조정` → 원 버림."""
costs = {k: down(v, 0) for k, v in total.items()}
if logic.get("끝전") == "큰나머지":
rest = int(down(sum(total.values()), 0) - sum(costs.values()))
order = sorted(COSTS, key=lambda k: (costs[k] - total[k], COSTS[::-1].index(k)))
for k in order[:rest]:
costs[k] += 1
costs = ms.adjust(logic, {k: mf.cut(logic, v, "계") for k, v in costs.items()})
return {k: down(v, 0) for k, v in costs.items()}
def plain(got: dict) -> dict:
@@ -570,7 +570,8 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
env = _inputs(row, given, master)
for step in row.get("중간", []):
try:
env[step["이름"]] = evaluate(parse(step["식"]), env, master, depth)
value = evaluate(parse(step["식"]), env, master, depth)
env[step["이름"]] = ms.shown(step, value) # `표시` 자리 반올림(791-1)
except EmptyInput as e: # 못 구한 중간값 — 쓰는 줄만 비움
env[step["이름"]] = Blank(str(e))
if "결과" in row:
@@ -632,34 +633,11 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
**(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()} # 비목 합마다 끊고 계는 그 합
# 덧줄 · 비목 합 끊기 · 번지 · 단계 · 조정(791-1) = `master_formula_sum.extras`
sums, more = ms.extras(master, row, lines, env, sums, depth)
mb.tag(master, row, lines, env) # 줄마다 인력 · 재료 · 경비 · 하위 묶음(712-1)
mg.tag(master, row, lines, env) # 옵션으로 빠진 줄 `옵션꺼짐`(753-1)
return {"줄": lines, **sums, "계": sum(sums.values()), "중간": _shown(env)}
return {"줄": lines, **sums, "계": sum(sums.values()), "중간": _shown(env), **more}
# 품셈재료 단가 — 서로 부르는 모듈이라 끝에서 읽음
@@ -680,6 +658,7 @@ from master_material import ( # noqa: E402, F401
import master_pick as mp # noqa: E402 — 검색어 · 고름 · 단위
import master_bundle as mb # noqa: E402 — 인력 · 재료 · 경비 묶음
import master_gate as mg # noqa: E402 — 옵션으로 빠진 줄
import master_formula_sum as ms # noqa: E402 — 덧줄 · 번지 · 단계 · 조정 · 표시(791-1)
from master_formula_check import ( # noqa: E402, F401 — 검사는 나눈 파일 · 이름은 여기서 그대로
_check_cut,
_check_node,
@@ -9,6 +9,7 @@ from __future__ import annotations
import itertools
from decimal import Decimal
import master_formula_sum as ms
import master_keys as mk
from master_formula import (
_HOLE,
@@ -175,12 +176,14 @@ def check_logic(master: Master, row: dict) -> tuple[list[str], set]:
if item.get("종류") != "로직" and group != item.get("종류"):
out.append(f"{where} · 요소 그룹이 종류와 다름")
names = known | set(COST_ITEMS) | {"줄"} # 「줄」 = 호표 줄 금액 묶음(덧줄에서만)
if ms.uses(row): # 금액번지 · 소계 단계(791-1) — 덧줄에서만
names = names | {ms.ADDR, ms.STEP}
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('비목')}」")
out += _check_cut(row, key)
out += _check_cut(row, key) + ms.check(row, key)
return out, calls
@@ -0,0 +1,267 @@
# -*- coding: utf-8 -*-
"""로직 셈 의미 넓힘(PLAN 791-1 · `_틀.md` 7장 「셈 의미」) — 덧줄 고리 · 금액번지 · 소계 단계 ·
제외 · 결과금액 조정 · 변수 표시 반올림 · 검사.
`master_formula.run` 이 부름(덧줄 고리를 여기로 옮김) · 끝전(줄 자리 · 블록 끝전)은 `master_cut`.
새 칸을 안 쓰는 로직은 옛 셈 그대로 — 지킴 = M01 로직 지문(`test_791_1_engine_semantics.py`).
eval 없음 · 수는 Decimal.
"""
from __future__ import annotations
from decimal import ROUND_HALF_UP, Decimal
import master_formula as mf
COSTS = mf.COST_ITEMS
ROW_KEYS = ("단계", "끝전", "조정") # 로직 줄 칸
LINE_KEYS = ("번지", "합밖", "자리") # 호표 · 덧줄 칸
ENDS = ("비목버림", "큰나머지")
MAX_PLACE = 3 # 줄 자리 0~3
MAX_SHOW = 6 # 변수 표시 0~6
MAX_STEP = 5 # 소계 단 1~5(STmate `(=)`~`(=====)`)
ADDR, STEP = "번지", "단계" # 덧줄 식 이름 — `번지.A01.재료비` · `단계.'소계'.계`
def items(row: dict) -> list[dict]:
"""엔진 줄 차례 = 호표 하나씩 뒤 덧줄 하나씩."""
return [*row.get("호표", []), *row.get("덧줄", [])]
def uses(row: dict) -> bool:
"""새 의미(번지 · 단계 · 끝전 · 조정 · 합밖 · 줄 자리)를 쓰는 로직 — 아니면 옛 셈 그대로."""
return any(k in row for k in ROW_KEYS) or any(k in x for x in items(row) for k in LINE_KEYS)
def shown(step: dict, value):
"""중간 `표시` = 값을 n 자리 반올림해 뒤 줄에 넘김(STmate `X = 식 =`)."""
if step.get("표시") is None or not isinstance(value, Decimal):
return value
return mf._round(value, step["표시"], ROUND_HALF_UP)
def _add(acc: dict, money: dict | None) -> None:
for k, v in (money or {}).items():
if k in acc:
acc[k] += v or 0
def _bundle(acc: dict) -> dict:
return {**acc, "계": sum(acc[k] for k in COSTS)}
def _cover(row: dict) -> list[tuple[dict, set]]:
"""단계마다 (단계, 담은 줄 차례) — 단 1 = 제 `줄` · 단 k = 제 `줄` + 아직 안 담긴 아래 단 단계.
제외 단계는 위로 안 올라감."""
where = {x.get("이름"): i for i, x in enumerate(items(row)) if x.get("이름")}
out, open_ = [], []
for st in row.get(STEP) or []:
level, mine = int(st.get("단") or 1), {where[n] for n in st.get("줄") or [] if n in where}
keep = []
for low, got in open_:
if low < level:
mine |= got
else:
keep.append((low, got))
open_ = keep if st.get("제외") else [*keep, (level, mine)]
out.append((st, mine))
return out
def skipped(row: dict) -> set:
"""비목 합에서 빼는 줄 차례 — `합밖` 줄(번지엔 쌓음) · 제외 단계가 담은 줄."""
out = {i for i, x in enumerate(items(row)) if x.get("합밖")}
for st, mine in _cover(row):
if st.get("제외"):
out |= mine
return out
def total(row: dict, lines: list[dict]) -> dict:
"""비목 합 — 뺄 줄을 빼고 지금까지 줄."""
skip, acc = skipped(row), dict.fromkeys(COSTS, Decimal(0))
for i, line in enumerate(lines):
if i not in skip:
_add(acc, line.get("비목"))
return acc
def steps(row: dict, lines: list[dict]) -> list[dict]:
"""단계 값 — 지금까지 셈한 줄만(뒤 줄은 0)."""
out = []
for st, mine in _cover(row):
acc = dict.fromkeys(COSTS, Decimal(0))
for i in sorted(mine):
if i < len(lines):
_add(acc, lines[i].get("비목"))
out.append(
{"이름": st.get("이름"), "단": st.get("단"), **_bundle(acc)}
| ({"제외": True} if st.get("제외") else {})
)
return out
def scope(row: dict, lines: list[dict]) -> dict:
"""덧줄 식 이름 — `번지` {A01: 묶음} · `단계` {이름: 묶음} (지금까지 줄)."""
places = {}
for item, line in zip(items(row), lines):
if item.get(ADDR):
_add(places.setdefault(item[ADDR], dict.fromkeys(COSTS, Decimal(0))), line.get("비목"))
return {
ADDR: {k: _bundle(v) for k, v in places.items()},
STEP: {x["이름"]: {k: x[k] for k in (*COSTS, "계")} for x in steps(row, lines)},
}
def adjust(row: dict, sums: dict) -> dict:
"""결과금액 조정 — `배율` {비목: 수} 곱 → `옮김` 비목으로 계 전부(STmate 「경비로 적용」)."""
rule = row.get("조정")
if not rule:
return sums
out = dict(sums)
for k, rate in (rule.get("배율") or {}).items():
out[k] = out[k] * Decimal(str(rate))
if rule.get("옮김"):
whole = sum(out.values())
out = {k: whole if k == rule["옮김"] else Decimal(0) for k in COSTS}
return out
def extras(master, row: dict, lines: list[dict], env: dict, sums: dict, depth: int):
"""덧줄 셈(옛 `run` 고리 그대로) + 새 의미 → (비목 합, 덧칸 `{단계}`)."""
new = uses(row)
# 덧줄이 호표 줄 금액 하나를 빼고 더할 수 있게 — `줄.'호표 줄 이름'`(빈 줄은 뺌)
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("덧줄", []):
if new:
sums = total(row, lines)
env.update(scope(row, lines))
env.update(sums)
try: # 덧줄도 값 빈 품목·빈 줄을 부르면 그 줄만 비움
value, why = mf.evaluate(mf.parse(extra["식"]), env, master, depth), None
env[extra["이름"]] = value
except mf.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 {}),
}
)
if new:
sums = total(row, lines)
sums = {k: mf.cut(row, v, "계") for k, v in sums.items()} # 비목 합마다 끊고 계는 그 합
if not new:
return sums, {}
return adjust(row, sums), {STEP: steps(row, lines)}
# ── 검사 ──────────────────────────────────────────────────────────────
def _whole(v, low: int, high: int) -> bool:
return isinstance(v, (int, Decimal)) and v == int(v) and low <= int(v) <= high
def _refs(node):
"""식 안 `번지.X.칸` · `단계.'X'.칸` → (이름, X, 칸)."""
for part in mf.walk(node):
if part[0] == "field" and part[1][0] == "field" and part[1][1][0] == "name":
head = part[1][1][1]
if head in (ADDR, STEP):
yield head, part[1][2], part[2]
def check(row: dict, key: str) -> list[str]:
"""새 의미 칸 — 끝전 · 조정 · 줄 자리 · 번지 · 표시 · 단계 · 덧줄이 부르는 번지 · 단계 차례."""
out, lines = [], items(row)
end = row.get("끝전")
if end is not None and end not in ENDS:
out.append(f"{key} 끝전 「{end}」 — {' · '.join(ENDS)}")
rule = row.get("조정")
if rule is not None:
if not isinstance(rule, dict) or set(rule) - {"옮김", "배율"}:
out.append(f"{key} 조정 · 칸은 옮김 · 배율만")
else:
if rule.get("옮김") not in (None, *COSTS):
out.append(f"{key} 조정 옮김 「{rule['옮김']}」 — {' · '.join(COSTS)}")
for k, rate in (rule.get("배율") or {}).items():
if k not in COSTS or not isinstance(rate, (int, Decimal)) or rate < 0:
out.append(f"{key} 조정 배율 「{k}」={rate}")
for step in row.get("중간", []):
if step.get("표시") is not None and not _whole(step["표시"], 0, MAX_SHOW):
out.append(f"{key} 중간 {step['이름']} · 표시 「{step['표시']}」 — 0∼{MAX_SHOW} 정수")
names = [x.get("이름") for x in lines]
for item in lines:
where = f"{key} 줄 {item.get('이름', '')}"
place = item.get("자리")
if place is not None and (
not isinstance(place, dict)
or set(place) - {"방법", "자리"}
or place.get("방법") not in mf.ROUND_WAYS
or not _whole(place.get("자리", 0), 0, MAX_PLACE)
):
out.append(
f"{where} · 자리 「{place}」 — {{방법: 버림·반올림·올림, 자리: 0∼{MAX_PLACE}}}"
)
if ADDR in item and not (isinstance(item[ADDR], str) and mf._NAME.fullmatch(item[ADDR])):
out.append(f"{where} · 번지 「{item[ADDR]}」 — 글자 · 숫자만")
if "합밖" in item and not isinstance(item["합밖"], bool):
out.append(f"{where} · 합밖 「{item['합밖']}」 — 참 · 거짓")
out += _check_steps(row, key, names)
return out
def _check_steps(row: dict, key: str, names: list) -> list[str]:
out, seen, taken = [], set(), {}
for st in row.get(STEP) or []:
name = st.get("이름") if isinstance(st, dict) else None
where = f"{key} 단계 {name}"
if not isinstance(name, str) or not name.strip():
out.append(f"{key} 단계 · 이름 없음")
continue
if name in seen:
out.append(f"{where} · 같은 이름")
seen.add(name)
if set(st) - {"이름", "단", "줄", "제외"}:
out.append(f"{where} · 모르는 칸 {sorted(set(st) - {'이름', '단', '줄', '제외'})}")
if not _whole(st.get("단"), 1, MAX_STEP):
out.append(f"{where} · 단 「{st.get('단')}」 — 1∼{MAX_STEP} 정수")
for line in st.get("줄") or []:
if line not in names:
out.append(f"{where} · 없는 줄 「{line}」")
elif names.count(line) > 1:
out.append(f"{where} · 줄 이름 「{line}」 겹침 — 줄 이름을 가를 것")
elif line in taken:
out.append(f"{where} · 줄 「{line}」 이 단계 「{taken[line]}」 에도 있음")
taken.setdefault(line, name)
if out:
return out
cover = {st["이름"]: mine for st, mine in _cover(row)}
places = {}
for i, item in enumerate(items(row)):
if item.get(ADDR):
places.setdefault(item[ADDR], set()).add(i)
first = len(row.get("호표", []))
for n, extra in enumerate(row.get("덧줄", [])):
try:
node = mf.parse(extra["식"])
except mf.FormulaError:
continue # 못 읽는 식은 `check_logic` 이 알림
for head, name, slot in _refs(node):
where = f"{key} 덧줄 {extra.get('이름')}"
group = (places if head == ADDR else cover).get(name)
if group is None:
out.append(f"{where} · 없는 {head} 「{name}」")
elif any(i >= first + n for i in group):
out.append(f"{where} · {head} 「{name}」 에 이 줄이나 뒤 줄이 듦 — 단계 순서")
if slot not in mf.BUNDLE:
out.append(f"{where} · {head} 칸 「{slot}」 — {' · '.join(mf.BUNDLE)}")
return out
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,497 @@
"""791-1 엔진 셈 의미 지킴 시험 — 금액번지 · 소계 단계 · 제외 · 줄 자리 · 블록 끝전 · 결과 조정 ·
특수 % · 변수 표시.
① 전후 같음 — M01 로직 전부(입력 = 자동값 + 기본값)의 날 셈 · 끝수 셈 지문 = 고치기 전 뜬 지문
(`fixtures/m01_791_1_logic_fingerprint.json` · 마스터 데이터가 바뀌면 건너뜀 → 이 파일을
바로 돌려 다시 뜸).
② 새 의미 단위 시험 · 검사.
③ 실무 손 옮김 견본 넷 — 원문 글(STmate 숨은 G 열 · EST 보이는 글)을 사람이 로직 꼴로 옮김 ·
단가 = 그 엑셀 목록표 값 · 노 · 재 · 경 · 계 = 엑셀(`helper_m02_hopyo_golden.FILES` · 649-G2).
"""
import hashlib
import importlib
import json
import sys
from decimal import Decimal
from pathlib import Path
import pytest
from M01_MasterData import M01_MasterData_Store as m01 # scripts 길을 둠
mcut = importlib.import_module("master_cut")
mf = importlib.import_module("master_formula")
ms = importlib.import_module("master_formula_sum")
ROOT = Path(__file__).resolve().parents[2]
FINGER = Path(__file__).resolve().parent / "fixtures" / "m01_791_1_logic_fingerprint.json"
COSTS = ("노무비", "재료비", "경비", "계")
D = Decimal
# ── ① 전후 같음 ────────────────────────────────────────────────────────
def _master_hash() -> str:
h = hashlib.sha256()
for path in sorted((ROOT / "resources" / "master_data").glob("*.json")):
if not path.name.startswith("_"):
h.update(path.name.encode() + path.read_bytes())
return h.hexdigest()[:16]
def _given(whole, row) -> dict:
import master_auto as ma
given = ma.values(whole, row)
given.update({k: v for k, v in ma.defaults(whole, row).items() if v is not None})
return given
def _print(whole, row) -> str:
key = str(row["키"])
try:
raw = mf.run(whole, key, _given(whole, row))
text = json.dumps(raw, default=str, sort_keys=True, ensure_ascii=False)
cut = mcut.engine(whole, key, raw)
text += json.dumps(cut, default=str, sort_keys=True, ensure_ascii=False)
except Exception as e: # 멈춤도 지문 — 멈춤 까닭이 바뀌어도 잡음
text = f"ERR {type(e).__name__} {e}"
return hashlib.sha256(text.encode()).hexdigest()[:16]
def fingerprints() -> dict:
files, whole = m01.loaded()
rows = [r for d in files.values() if d.get("그룹") == "로직" for r in d.get("줄", [])]
return {str(r["키"]): _print(whole, r) for r in rows}
def test_M01_로직_전부_전후_같음():
saved = json.loads(FINGER.read_text(encoding="utf-8"))
if saved["마스터"] != _master_hash():
pytest.skip("마스터 데이터 바뀜 — 지문 다시 뜸: python resources/tester/test_791_1_…py")
now = fingerprints()
assert len(now) == len(saved["결과"]) >= 1515
diff = sorted(k for k in now if now[k] != saved["결과"].get(k))
assert not diff, f"셈 결과 바뀐 로직 {len(diff)} 개: {diff[:10]}"
# ── 시험 마스터 ───────────────────────────────────────────────────────
def _master(logics: list[dict], labor: dict, mats: dict | None = None) -> mf.Master:
"""시험 전용 색인 — 인력 LB99 · 자재품목 MT99(물가자료) · 로직 GX99."""
files = {
"인력.json": {
"그룹": "인력",
"줄": [
{"키": k, "이름": n, "단위": "원/일", "값": D(v)} for k, (n, v) in labor.items()
],
},
"재료_자재품목.json": {
"그룹": "재료",
"줄": [
{"키": k, "이름": n, "단위": u, "물가자료": D(v)}
for k, (n, u, v) in (mats or {}).items()
],
},
"로직_시험.json": {"그룹": "로직", "줄": logics},
}
return mf.Master(files)
def _calc(master, key: str, given: dict | None = None) -> dict:
return mcut.engine(master, key, mf.run(master, key, given or {}))
def _money(got: dict) -> dict:
return {k: got[k] for k in COSTS}
def _line(got: dict, name: str) -> dict:
return next(x for x in got["줄"] if x["이름"] == name)
def _step(got: dict, name: str) -> dict:
return next(x for x in got["단계"] if x["이름"] == name)
def _man(key, qty, name, **more):
return {
"종류": "인력",
"요소": key,
"이름": name,
"단위": "인",
"수량": qty,
"비목": "노무비",
**more,
}
def _mat(key, qty, name, **more):
return {
"종류": "재료",
"요소": key,
"이름": name,
"단위": "식",
"수량": qty,
"비목": "재료비",
**more,
}
def _machine(qty, name, bundle):
"""기계 시간당 노 · 재 · 경 = 엑셀 중기목록표 값 그대로(M02 `값묶음` 길)."""
return {
"종류": "로직",
"요소": "로직(GX990009)",
"이름": name,
"단위": "hr",
"수량": qty,
"값묶음": dict(zip(("노무비", "재료비", "경비"), map(D, bundle))),
}
_HOUR = {
"키": "GX990009",
"이름": "시간당 기계경비(시험)",
"결과단위": "원/시간",
"입력": [],
"호표": [],
}
def _logic(key: str, **body) -> dict:
return {"키": key, "이름": key, "결과단위": "원/식", "입력": [], **body}
# ── ② 새 의미 단위 시험 ────────────────────────────────────────────────
LAB = {"LB990001": ("보통인부", 100000), "LB990002": ("특별인부", 200000)}
MAT = {"MT990001": ("자재 갑", "식", 1000), "MT990002": ("자재 을", "식", 3000)}
def _base(**body) -> mf.Master:
row = _logic(
"GX990001",
호표=[
_mat("MT990001", "1.23", "갑", 번지="A01"),
_mat("MT990002", "0.5", "을", 번지="A01"),
_man("LB990001", "0.0123", "보통", 번지="A02"),
_man("LB990002", "0.01", "특별", 번지="A02"),
],
**body,
)
return _master([row], LAB, MAT)
def test_옛_로직은_새_칸_없음():
master = _base()
for item in master.logic("GX990001")["호표"]:
del item["번지"]
got = _calc(master, "GX990001")
assert "단계" not in got and not ms.uses(master.logic("GX990001"))
assert _money(got) == {"노무비": 3230, "재료비": 2730, "경비": 0, "계": 5960}
def test_번지_율_밑():
master = _base(덧줄=[{"이름": "잡재료", "식": "번지.A01.재료비 * 10 / 100", "비목": "재료비"}])
got = _calc(master, "GX990001")
assert _line(got, "잡재료")["금액"] == D("273.0")
assert got["재료비"] == 3003
def test_단계_누계_제외_합밖():
master = _base(
덧줄=[{"이름": "공구손료", "식": "단계.'인부 소계'.노무비 * 3 / 100", "비목": "경비"}],
단계=[
{"이름": "자재 소계", "단": 1, "줄": ["갑", "을"]},
{"이름": "인부 소계", "단": 1, "줄": ["보통", "특별"]},
{"이름": "경비 소계", "단": 1, "줄": ["공구손료"], "제외": True},
{"이름": "계", "단": 2},
],
)
got = _calc(master, "GX990001")
assert _step(got, "인부 소계")["계"] == D("3230.0")
assert _line(got, "공구손료")["금액"] == D("96.9")
assert _step(got, "경비 소계") == {
"이름": "경비 소계",
"단": 1,
"노무비": 0,
"재료비": 0,
"경비": D("96.9"),
"계": D("96.9"),
"제외": True,
}
assert _step(got, "계")["계"] == D("5960.0") # 제외 단계는 위로 안 올라감
assert _money(got) == {"노무비": 3230, "재료비": 2730, "경비": 0, "계": 5960}
# 합밖 = 번지엔 쌓되 합에서 뺌
row = master.logic("GX990001")
row["호표"][1]["합밖"] = True
got = _calc(master, "GX990001")
assert got["재료비"] == 1230 and _step(got, "자재 소계")["재료비"] == D("2730.0")
def test_줄_자리_셋():
for way, want in (("반올림", 124), ("올림", 124), ("버림", 123)): # 123.5
master = _base(
덧줄=[{"이름": "덧", "식": "123.5", "비목": "경비", "자리": {"방법": way, "자리": 0}}]
)
assert _line(_calc(master, "GX990001"), "덧")["금액"] == want
master = _base(
덧줄=[
{"이름": "덧", "식": "123.456", "비목": "경비", "자리": {"방법": "반올림", "자리": 2}}
]
)
assert _line(_calc(master, "GX990001"), "덧")["금액"] == D("123.46")
def test_블록_끝전_둘():
body = {"덧줄": [{"이름": "경", "식": "0.6", "비목": "경비"}]} # 노 3230.0 · 재 2730.0 · 경 0.6
assert _calc(_base(**body), "GX990001")["계"] == 5960
got = _calc(_base(끝전="큰나머지", **body), "GX990001")
assert _money(got) == {"노무비": 3230, "재료비": 2730, "경비": 0, "계": 5960}
# 원 미만이 모여 1 원이 되면 큰 나머지 비목에 · 같으면 경 › 재 › 노
body = {
"덧줄": [
{"이름": "노", "식": "0.6", "비목": "노무비"},
{"이름": "경", "식": "0.6", "비목": "경비"},
]
}
assert _calc(_base(**body), "GX990001")["계"] == 5960
got = _calc(_base(끝전="큰나머지", **body), "GX990001")
assert _money(got) == {"노무비": 3230, "재료비": 2730, "경비": 1, "계": 5961}
def test_결과금액_조정():
got = _calc(_base(조정={"옮김": "경비"}), "GX990001")
assert _money(got) == {"노무비": 0, "재료비": 0, "경비": 5960, "계": 5960}
got = _calc(_base(조정={"배율": {"노무비": D("0.5")}}), "GX990001")
assert _money(got) == {"노무비": 1615, "재료비": 2730, "경비": 0, "계": 4345}
assert mf.run(_base(조정={"옮김": "재료비"}), "GX990001", {})["재료비"] == D("5960.0")
def test_특수_퍼센트_누계와_앞_소계():
master = _base(
덧줄=[
{"이름": "L%", "식": "노무비 * 2 / 100", "비목": "경비"}, # 처음부터 노무비 누계
{"이름": "T%", "식": "단계.'소계'.계 * 0.8", "비목": "경비"}, # 바로 앞 소계 × 수량
],
단계=[{"이름": "소계", "단": 1, "줄": ["갑", "을"]}],
)
got = _calc(master, "GX990001")
assert _line(got, "L%")["금액"] == D("64.6")
assert _line(got, "T%")["금액"] == D("2184.0")
def test_변수_표시_반올림():
row = _logic(
"GX990002",
중간=[{"이름": "f", "식": "1 / 1.3", "표시": 2}, {"이름": "g", "식": "1 / 1.3"}],
결과={"식": "f * 100"},
)
got = mf.run(_master([row], LAB), "GX990002", {})
assert got["결과"] == D("77.00") and got["중간"]["g"] != D("0.77")
def test_검사():
def problems(**body) -> list[str]:
master = _base(**body)
return mf.check_logic(master, master.logic("GX990001"))[0]
assert problems(덧줄=[{"이름": "덧", "식": "번지.A01.재료비 * 1", "비목": "경비"}]) == []
assert any(
"없는 번지" in x
for x in problems(덧줄=[{"이름": "덧", "식": "번지.B09.계", "비목": "경비"}])
)
order = problems(
덧줄=[{"이름": "덧", "식": "단계.'소'.계", "비목": "경비"}],
단계=[{"이름": "소", "단": 1, "줄": ["갑", "덧"]}],
)
assert any("단계 순서" in x for x in order)
assert any(
"자리" in x
for x in problems(
덧줄=[{"이름": "덧", "식": "1", "비목": "경비", "자리": {"방법": "버림", "자리": 4}}]
)
)
assert any("끝전" in x for x in problems(끝전="합버림"))
assert any("단 「6」" in x for x in problems(단계=[{"이름": "소", "단": 6, "줄": ["갑"]}]))
assert any("없는 줄" in x for x in problems(단계=[{"이름": "소", "단": 1, "줄": ["없음"]}]))
assert any("조정 옮김" in x for x in problems(조정={"옮김": "계"}))
assert any(
"이 단계" in x
for x in problems(
단계=[{"이름": "가", "단": 1, "줄": ["갑"]}, {"이름": "나", "단": 1, "줄": ["갑"]}]
)
)
# ── ③ 실무 손 옮김 견본 넷 ─────────────────────────────────────────────
def test_봉화_D00102_합판거푸집_STmate_번지_율():
"""봉화 단가산출근거 2909~2943 행 — `&M00081& * 1.03 * 0.327 :=A01` · `&PA01M:M& * 11'%'` ·
`&PA02L:S& * 1'%'` · `(=)` · `(==)` · 총계 45,652 · 34,119 · 11,192 · 341."""
row = _logic(
"GX990101",
호표=[
_mat("MT990101", "1.03 * 0.327", "합판(내수)", 번지="A01"),
_mat("MT990102", "0.038 * 0.327", "각재", 번지="A01"),
_man("LB990101", "5 / 50", "형틀목공", 번지="A02"),
_man("LB990102", "2 / 50", "보통인부", 번지="A02"),
],
덧줄=[
{"이름": "소모자재(박리재 등)", "식": "번지.A01.재료비 * 11 / 100", "비목": "재료비"},
{
"이름": "공구손료 및 경장비 기계경비",
"식": "번지.A02.노무비 * 1 / 100",
"비목": "경비",
},
],
단계=[
{"이름": "자재비 소계", "단": 1, "줄": ["합판(내수)", "각재", "소모자재(박리재 등)"]},
{
"이름": "설치 소계",
"단": 1,
"줄": ["형틀목공", "보통인부", "공구손료 및 경장비 기계경비"],
},
{"이름": "계", "단": 2},
],
)
master = _master(
[row],
{"LB990101": ("형틀목공", 274978), "LB990102": ("보통인부", 165545)},
{"MT990101": ("합판(내수)", "㎡", 11468), "MT990102": ("각재", "㎥", 500661)},
)
assert mf.check_logic(master, row)[0] == []
got = _calc(master, "GX990101")
assert _money(got) == {"노무비": 34119, "재료비": 11192, "경비": 341, "계": 45652}
assert [x["금액"] for x in got["줄"]] == [
D(x) for x in ("3862.5", "6221.2", "27497.8", "6621.8", "1109.2", "341.1")
]
assert [x["계"] for x in got["단계"]] == [D("11192.9"), D("34460.7"), D("45653.6")]
def test_울진소광_D01489_낙석방지책_STmate_L퍼센트():
"""울진소광 32 호표(D01489) — `'공구손료 :'&S01045& * 2'%'`(S01045 단위 L% = 처음부터
노무비 누계) · `L = Q/10 =` 표시 2 · 계 줄 420,734 · 182,223 · 230,359 · 8,152
(계약단가 87.745 % 는 B09 몫)."""
row = _logic(
"GX990102",
중간=[
{"이름": "N", "식": "40"},
{"이름": "Q", "식": "200"},
{"이름": "L", "식": "Q / 10", "표시": 2},
{"이름": "Q1", "식": "360"},
{"이름": "A", "식": "5.2"},
],
호표=[
_mat("MT990103", "1", "낙석방지책"),
_man("LB990103", "1 / N", "용접공"),
_man("LB990104", "3 / N", "특별인부(지주)"),
_man("LB990105", "2 / N", "보통인부(지주)"),
_machine("1 / N * 8", "트럭탑재형 크레인 10톤", (49479, 16797, 22539)),
_man("LB990104", "4 / Q * L", "특별인부(와이어)"),
_man("LB990105", "2 / Q * L", "보통인부(와이어)"),
_man("LB990104", "4 / Q1 * A", "특별인부(철망)"),
_man("LB990105", "2 / Q1 * A", "보통인부(철망)"),
],
덧줄=[{"이름": "공구손료", "식": "노무비 * 2 / 100", "비목": "경비"}],
단계=[
{"이름": "재료비 계", "단": 2, "줄": ["낙석방지책"]},
{"이름": "인건비 소계", "단": 1, "줄": ["용접공", "특별인부(지주)", "보통인부(지주)"]},
{"이름": "크레인 소계", "단": 1, "줄": ["트럭탑재형 크레인 10톤"]},
{"이름": "지주 계", "단": 2},
{"이름": "와이어 계", "단": 2, "줄": ["특별인부(와이어)", "보통인부(와이어)"]},
{"이름": "철망 계", "단": 2, "줄": ["특별인부(철망)", "보통인부(철망)"]},
{"이름": "공구손료 계", "단": 2, "줄": ["공구손료"]},
{"이름": "합계", "단": 3},
],
)
master = _master(
[row, _HOUR],
{
"LB990103": ("용접공", 278326),
"LB990104": ("특별인부", 221506),
"LB990105": ("보통인부", 169804),
},
{"MT990103": ("낙석방지책", "경간", 227000)},
)
assert mf.check_logic(master, row)[0] == []
got = _calc(master, "GX990102")
assert _money(got) == {"노무비": 182223, "재료비": 230359, "경비": 8152, "계": 420734}
assert _line(got, "공구손료")["금액"] == D("3644.4")
assert _step(got, "지주 계")["계"] == D("49824.2")
assert _step(got, "합계")["계"] == D("420735.3")
def test_동부청영월_D00003_터파기_STmate_큰나머지_끝전():
"""동부청영월 단가산출근거 54~103 행(D00003) — `f = 1/1.3 =` · `E=…=` ·
`Q = 3600*q*K*f*E/Cm =`(표시 2) · `&X00205L/M/S& / Q * 0.9` · 계 원 버림 + 큰 나머지
5,471 · 4,764 · 310 · 397."""
row = _logic(
"GX990103",
중간=[
{"이름": "q", "식": "0.7"},
{"이름": "f", "식": "1 / 1.3", "표시": 2},
{"이름": "K", "식": "0.9"},
{"이름": "Cm", "식": "20"},
{"이름": "E", "식": "(0.7 + 0.6) / 2 - 0.05", "표시": 2},
{"이름": "Q", "식": "3600 * q * K * f * E / Cm", "표시": 2},
],
호표=[
_man("LB990106", "(0.2 + 0.26) / 2 * 0.1", "인부"),
_machine("1 / Q * 0.9", "유압식 백호우 0.7㎥", (55700, 18015, 23128)),
],
단계=[
{"이름": "인력 소계", "단": 1, "줄": ["인부"]},
{"이름": "기계 소계", "단": 1, "줄": ["유압식 백호우 0.7㎥"]},
{"이름": "계", "단": 2},
],
끝전="큰나머지",
)
master = _master([row, _HOUR], {"LB990106": ("보통인부", 165545)})
assert mf.check_logic(master, row)[0] == []
got = _calc(master, "GX990103")
assert got["중간"]["Q"] == D("52.39")
assert _money(got) == {"노무비": 4764, "재료비": 310, "경비": 397, "계": 5471}
assert _step(got, "계")["계"] == D("5471.0")
row["끝전"] = "비목버림" # 비목 원 버림이면 1 원 모자람
assert _calc(master, "GX990103")["계"] == 5470
def test_북부청임도_18_문양거푸집_EST_줄_자리():
"""북부청임도 단가산출 18 번(문양거푸집) — `5,786.1 × 2% = 116`(그 줄만 반올림 0) ·
`인부소계 : 24,467.3` · 노무비 24,467 · 재료비 5,902 · 계 30,369."""
row = _logic(
"GX990104",
호표=[
_mat("MT990104", "1 * (1 / 0.72)", "문양거푸집(ABS코팅폼)"),
_man("LB990107", "0.07", "형틀목공"),
_man("LB990108", "0.03", "보통인부"),
],
덧줄=[
{
"이름": "잡재료비",
"식": "재료비 * 2 / 100",
"비목": "재료비",
"자리": {"방법": "반올림", "자리": 0},
}
],
단계=[{"이름": "인부소계", "단": 1, "줄": ["형틀목공", "보통인부"]}],
)
master = _master(
[row],
{"LB990107": ("형틀목공", 275790), "LB990108": ("보통인부", 172068)},
{"MT990104": ("문양거푸집(ABS코팅폼)", "㎡", 4166)},
)
assert mf.check_logic(master, row)[0] == []
got = _calc(master, "GX990104")
assert _line(got, "잡재료비")["금액"] == 116
assert _step(got, "인부소계")["계"] == D("24467.3")
assert _money(got) == {"노무비": 24467, "재료비": 5902, "경비": 0, "계": 30369}
del row["덧줄"][0]["자리"] # 기본 0.1 버림이면 엑셀과 1 원 다름
assert _calc(master, "GX990104")["계"] == 30368
if __name__ == "__main__": # 지문 다시 뜸 — 마스터 데이터가 바뀐 뒤 · 엔진 고치기 전 커밋에서만
data = {"마스터": _master_hash(), "결과": fingerprints()}
FINGER.write_text(
json.dumps(data, ensure_ascii=False, indent=1, sort_keys=True) + "\n", encoding="utf-8"
)
print(len(data["결과"]), "개 지문", FINGER, file=sys.stderr)