Files
eomsangdonandClaude Sonnet 5.5 e23fec8c9c feat(786-1): 별도계상 줄 품목 고르기 없앰 — 후보 고정 줄 하나 · SC 합 밖 고정 줄
- 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>
2026-11-05 07:17:50 +09:00

297 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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) # 묶음 재료 = 유가 줄 + 자재