- Row.judge: 별도계상 줄은 M01 · 사전 · 장부 후보 안 찾고 고정 줄 하나(품목 「프로젝트에서」 표지) · 갈래 수 그대로 - Make: 별도계상 고정 줄 = 단가 0 · 메모 「별도계상 · 품목은 프로젝트에서」(합 밖) - Picks.ts: apartPick 표지 셈 - 시험 642_3 · 642_5 · 642_6 Picks 몫을 새 꼴로 고침(옛 단언: 레미콘 자재 목록 별도계상 칸) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
297 lines
12 KiB
Python
297 lines
12 KiB
Python
"""642-3 1차 줄 판정 · 엑셀 사전 · 유가전력 MO 지킴 시험(PLAN 785-3 · 설계 642-A 5-3 셋째 줄).
|
||
|
||
- 북부청 임도 415 줄 1차 갈래 수 = 단산 268 · 일위 1 · 별도계상 64 · 재료(사전) 78 · 기계 3 · 율 1
|
||
(분석 642-C 3-1) · 판정 2차 필요 268 · 같은 EST 꼴 사방 201 · 거창 68 = 모든 줄 갈래 하나(100 %)
|
||
- 엑셀 사전 = 단가표 시트 갈래 수 · 노임 19 M01 LB 하나씩 · 경유 → MO
|
||
- 장부 짝 고른 것 꼴(새) · 옛 꼴 그대로 · 틀린 키 400 · 환산 옮김(Match.factor = Row.factor)
|
||
- 유가전력 MO — 고정 줄 이음 셈(재료비 = MO 값 · 묶음 재료 → 율 밑) · 자재 목록 MO · TS 짝 거울
|
||
엑셀은 읽기만 · 층 · storage 안 씀.
|
||
"""
|
||
|
||
import collections
|
||
import json
|
||
import os
|
||
import subprocess
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from M01_MasterData import M01_MasterData_Store as m01
|
||
from M02_MasterTemplete import M02_Hopyo_Elem as elem
|
||
from M02_MasterTemplete import M02_Hopyo_Excel as ex
|
||
from M02_MasterTemplete import M02_Hopyo_Ledger as ledger
|
||
from M02_MasterTemplete import M02_Hopyo_Match as hm
|
||
from M02_MasterTemplete import M02_Hopyo_Row as row
|
||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||
from M02_MasterTemplete import M02_Labor_Fixed as fixed
|
||
|
||
HERE = Path(__file__).resolve().parent
|
||
DOCS = HERE.parent / "knowledge" / "original" / "실무문서"
|
||
BUKBU = DOCS / "2026_실무일위대가 모음" / "2026년_북부지방산림청_임도사업_설계내역서(공통단가).xls"
|
||
SABANG = DOCS / "2026_실무일위대가 모음" / "2026년_북부지방산림청_사방사업(계류+댐)_설계내역서.xls"
|
||
GEOCHANG = (
|
||
DOCS
|
||
/ "01.산불거창(거창북상병곡)"
|
||
/ "01.내역서"
|
||
/ "00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx"
|
||
)
|
||
OIL = "MO000002" # 자동차용경유 전국평균
|
||
|
||
|
||
def _read(path: Path, keys: bool) -> dict:
|
||
data = path.read_bytes()
|
||
book = ex.read_book(path.name, data)
|
||
sheets, _ = ex._open(path.name, data, row.SHEETS)
|
||
return {**book, "사전": row.lexicon(sheets, keys)}
|
||
|
||
|
||
def _kinds(book: dict) -> collections.Counter:
|
||
"""줄마다 (갈래, 근거 첫 낱말) — 1차 ①~③ · ④ 사전."""
|
||
index = row._index(book["사전"])
|
||
out = collections.Counter()
|
||
for h in book["호표"]:
|
||
for line in h["줄"]:
|
||
kind, why, _ = row.first(line)
|
||
if kind is None:
|
||
hits = row.second(line, index)
|
||
kinds = sorted({x["갈래"] for x in hits}) or ["없음"]
|
||
kind, why = "/".join(kinds), "사전"
|
||
out[(kind, why.split(" ")[0])] += 1
|
||
return out
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def bukbu() -> dict:
|
||
return _read(BUKBU, True)
|
||
|
||
|
||
def test_북부청_415_줄_1차_갈래(bukbu):
|
||
got = _kinds(bukbu)
|
||
assert sum(got.values()) == 415
|
||
assert got == {
|
||
("단가산출", "비고"): 268,
|
||
("일위대가", "비고"): 1,
|
||
("재료", "별도계상"): 64,
|
||
("재료", "사전"): 78,
|
||
("기계", "단위"): 3,
|
||
("율", "단위"): 1,
|
||
}
|
||
|
||
|
||
def test_북부청_줄_판_판정과_후보(bukbu):
|
||
out = row.rows(bukbu)
|
||
lines = [x for v in out.values() for x in v]
|
||
assert len(out) == 68 and len(lines) == 415
|
||
verdict = collections.Counter(x["판정"] for x in lines)
|
||
assert verdict["2차 필요"] == 268 and set(verdict) <= set(row.VERDICTS)
|
||
assert all(set(x["갈래"]) <= set(row.KINDS) and x["후보"] is not None for x in lines)
|
||
first = {x["행"]: x for x in out[1]} # 제1호표(설계 2-10 기대표)
|
||
assert first[6]["갈래"] == ["재료"] and first[6]["근거"] == "별도계상"
|
||
assert first[6]["판정"] == "제안 있음" and len(first[6]["후보"]) == 1
|
||
apart = [x for x in lines if x["근거"] == "별도계상"] # 786-1 별도계상 64 줄 = 고정 줄 하나
|
||
assert len(apart) == 64 and all(
|
||
x["갈래"] == ["재료"] and x["판정"] == "제안 있음" for x in apart
|
||
)
|
||
for x in apart:
|
||
(p,) = x["후보"]
|
||
assert (p["갈래"], p["키"], p["별도계상"], p["품목"]) == (
|
||
"고정",
|
||
None,
|
||
True,
|
||
"프로젝트에서",
|
||
)
|
||
assert p["단위"] and p["단가"] == {"노무비": 0, "재료비": 0, "경비": 0} and "장부" not in x
|
||
assert all(
|
||
p["갈래"] != "고정" or "품목" not in p for x in lines for p in x["후보"] if x not in apart
|
||
)
|
||
assert first[7]["번"] == 46 and first[7]["판정"] == "2차 필요"
|
||
assert first[10]["갈래"] == ["재료"] and first[10]["근거"].startswith("사전 자재단가")
|
||
rate = next(x for x in lines if x["갈래"] == ["율"])
|
||
assert rate["판정"] == "제안 있음"
|
||
assert (
|
||
rate["후보"][0]["비목"] == "재료비" and rate["후보"][0]["기준"] == "재료비"
|
||
) # 재료비의 3 %
|
||
sub = next(x for x in lines if x["갈래"] == ["일위대가"])
|
||
assert sub["번"] == 46 and sub["후보"][0]["키"] == "제46호표" and sub["판정"] == "제안 있음"
|
||
for x in lines: # 고른 것 꼴(설계 2-1)
|
||
for p in x["후보"]:
|
||
assert {"갈래", "키", "이름", "단위", "입력값", "출처", "몫"} <= set(p)
|
||
|
||
|
||
def test_엑셀_사전_단가표_시트(bukbu):
|
||
lex = bukbu["사전"]
|
||
assert lex["꼴"] == "EST"
|
||
count = collections.Counter(x["갈래"] for x in lex["줄"])
|
||
assert count == {
|
||
"단가산출": 95,
|
||
"재료": 91,
|
||
"일위대가": 68,
|
||
"기계": 32,
|
||
"인력": 19,
|
||
"유가": 4, # 자재단가 경유 · 휘발유 + 기본재료비 둘
|
||
"환율": 3,
|
||
}
|
||
people = [x for x in lex["줄"] if x["갈래"] == "인력"]
|
||
assert all(len(x["후보"]) == 1 and x["후보"][0]["키"].startswith("LB") for x in people)
|
||
oil = next(x for x in lex["줄"] if x["갈래"] == "유가" and x["시트"] == "자재단가")
|
||
assert oil["후보"][0]["키"] == OIL
|
||
json.dumps(lex, ensure_ascii=False) # HP 에 둘 수 있는 꼴
|
||
|
||
|
||
@pytest.mark.parametrize("path, total", [(SABANG, 201), (GEOCHANG, 68)])
|
||
def test_같은_EST_꼴_모든_줄_갈래_하나(path, total):
|
||
got = _kinds(_read(path, False))
|
||
assert sum(got.values()) == total
|
||
assert not [k for k in got if "/" in k[0] or k[0] == "없음"]
|
||
|
||
|
||
# ── 장부 짝 고른 것 꼴 ─────────────────────────────────────────────────────
|
||
def test_장부_짝_고른_것_꼴과_옛_꼴():
|
||
known = ledger._known()
|
||
mat = sorted(known[1])[0]
|
||
new = ledger.check_pair(
|
||
{"갈래": "유가", "키": OIL, "배": 1, "이름": "경유", "단위": "L"}, "시험", known
|
||
)
|
||
assert new == {"갈래": "유가", "키": OIL, "입력값": {}, "배": 1, "몫": None, "이름": "경유",
|
||
"단위": "L"} # fmt: skip
|
||
rate = {"갈래": "율", "비목": "재료비", "기준": "재료", "배": 1}
|
||
assert ledger.check_pair(rate, "시험", known)["기준"] == "재료"
|
||
assert ledger.check_pair({"자재": mat, "배": 2}, "시험", known) == {
|
||
"자재": mat, "입력값": {}, "배": 2, "몫": None} # fmt: skip
|
||
for bad in (
|
||
{"갈래": "유가", "키": "MT000008"},
|
||
{"갈래": "고정", "키": "MO000002"},
|
||
{"갈래": "인력", "키": "LB999999"},
|
||
{"갈래": "재료", "키": mat, "입력값": {"x": 1}},
|
||
{"갈래": "율", "비목": "재료비"},
|
||
{"갈래": "없는갈래"},
|
||
):
|
||
with pytest.raises(ledger.store.StoreError) as got:
|
||
ledger.check_pair(bad, "시험", known)
|
||
assert got.value.status == 400
|
||
line = {"이름": "경유", "규격": "", "단위": "ℓ", "수량": 3, "단가": {"재료비": 1376}}
|
||
book = [("personal", "1/2", {ledger.key(line): {"갈래": "짝", "짝": [new]}})]
|
||
out = row.judge(line, {}, {}, book, folder="x")
|
||
assert out["판정"] == "제안 있음" and out["후보"][0]["키"] == OIL
|
||
assert out["후보"][0]["출처"] == {"장부": "personal"}
|
||
# 단산 줄 = 장부 짝이 있어도 늘 2차 블록 풀이(장부 = 1차 후보 힌트만 · 785-7)
|
||
sk = {
|
||
"이름": "야면석소운반",
|
||
"규격": "",
|
||
"단위": "㎡",
|
||
"수량": 1,
|
||
"참조": {"종류": "단산", "번": 10},
|
||
}
|
||
base = {"갈래": "기초", "키": "GC001140", "입력값": {}, "배": 1, "몫": None}
|
||
book = [("personal", "1/2", {ledger.key(sk): {"갈래": "짝", "짝": [base]}})]
|
||
out = row.judge(sk, {}, {}, book, folder="x")
|
||
assert out["판정"] == "2차 필요" and out["후보"][0]["키"] == "GC001140"
|
||
|
||
|
||
def test_환산_옮김():
|
||
for have, want, spec in (
|
||
("㎡", "㎡", ""),
|
||
("cm", "m", ""),
|
||
("㎡", "㎥", "T=20cm"),
|
||
("개", "㎥", ""),
|
||
):
|
||
assert hm.factor(have, want, spec) == row.factor(have, want, spec)
|
||
assert row.factor("cm", "m")["배"] == 0.01
|
||
q = row.quantity({"수량": 2.5, "단위": "㎡", "규격": "T=20cm"}, "㎥", 0.5)
|
||
assert q["배"] == 0.2 and q["우리"] == 0.25
|
||
|
||
|
||
# ── 유가전력 MO ─────────────────────────────────────────────────────────
|
||
def _oil_price() -> float:
|
||
return float(m01.loaded()[1].get(OIL)["값"])
|
||
|
||
|
||
def test_유가_후보():
|
||
keys = [x["키"] for x in elem.oils("경유")]
|
||
assert keys[0] == OIL and all(k.startswith("MO") for k in keys)
|
||
assert elem.lost({"갈래": "유가", "이름": "경유"})[0]["키"] == OIL
|
||
|
||
|
||
OIL_DOC = {
|
||
"열": "rv",
|
||
"담은로직": [
|
||
{"로직": "GC000421", "수량": {"손": 2}},
|
||
{
|
||
"로직": "",
|
||
"고정": {
|
||
"이름": "경유",
|
||
"단위": "L",
|
||
"단가": {"재료비": 1376},
|
||
"이음": {"유가": OIL},
|
||
}, # fmt: skip
|
||
"수량": {"손": 3},
|
||
},
|
||
{
|
||
"로직": "",
|
||
"고정": {
|
||
"이름": "잡재료",
|
||
"단위": "%",
|
||
"단가": {},
|
||
"비율": {"기준": "재료", "비목": "재료비"},
|
||
}, # fmt: skip
|
||
"수량": {"손": 10},
|
||
},
|
||
],
|
||
"자재": [{"코드": OIL, "수량": {"손": 2}, "별도계상": "직접계산"}], # 기본 = 별도계상(합 밖)
|
||
}
|
||
|
||
|
||
def test_고정_줄_유가_이음_셈():
|
||
doc = labor.clean(OIL_DOC)
|
||
fx = doc["담은로직"][1]["고정"]
|
||
assert fx["이음"] == {"노임": None, "자재": None, "기계": None, "유가": OIL}
|
||
assert fixed.group_of(fx) == "재료"
|
||
assert doc["자재"][0]["코드"] == OIL # 자재 목록 MO
|
||
out = labor.calc(doc, {})
|
||
line = out["줄"][1]
|
||
assert line["기준"]["재료비"] == pytest.approx(_oil_price()) and line["이은"] == ["재료비"]
|
||
assert out["자재"][0]["기준단가"] == pytest.approx(_oil_price())
|
||
with pytest.raises(labor.store.StoreError): # 머리 틀림 = 400
|
||
labor.clean({**OIL_DOC, "담은로직": [{**OIL_DOC["담은로직"][1], "고정": {
|
||
**OIL_DOC["담은로직"][1]["고정"], "이음": {"유가": "MT000008"}}}]}) # fmt: skip
|
||
old = labor.clean({"담은로직": [{"로직": "", "고정": {"이름": "a", "단가": {}}}]})
|
||
assert old["담은로직"][0]["고정"]["이음"] == {"노임": None, "자재": None, "기계": None}
|
||
|
||
|
||
def _ts(answer: dict, doc: dict, values: dict) -> dict:
|
||
proc = subprocess.run(
|
||
["node", os.path.join(HERE, "helper_m02_labor_calc.cjs")],
|
||
input=json.dumps({"답": answer, "문서": doc, "값": values}, ensure_ascii=False),
|
||
capture_output=True,
|
||
text=True,
|
||
encoding="utf-8",
|
||
check=True,
|
||
)
|
||
return json.loads(proc.stdout)
|
||
|
||
|
||
def _same(a, b) -> None:
|
||
if isinstance(a, dict):
|
||
assert set(a) == set(b)
|
||
for k in a:
|
||
_same(a[k], b[k])
|
||
elif isinstance(a, list):
|
||
assert len(a) == len(b)
|
||
for x, y in zip(a, b):
|
||
_same(x, y)
|
||
elif isinstance(a, (int, float)) and not isinstance(a, bool):
|
||
assert b == pytest.approx(a, rel=1e-9)
|
||
else:
|
||
assert a == b
|
||
|
||
|
||
def test_유가_율_고정_줄_TS_거울():
|
||
doc = labor.clean(OIL_DOC)
|
||
answer = labor.calc(doc, {})
|
||
py = labor.settle(answer, doc, {})
|
||
_same(py, _ts(answer, doc, {}))
|
||
rate = py["줄"][2]
|
||
oil = py["줄"][1]["반영"]["재료비"]
|
||
mats = py["자재"][0]["반영"] or 0
|
||
assert rate["기준"]["재료비"] == pytest.approx(oil + mats) # 묶음 재료 = 유가 줄 + 자재
|