내역에 실리는 25 줄 중 FP-11-01 가설창고만 밑수가 없어 대조를 건너뛰고 있었음. 품셈 11-1 은 절 머리에 「(단위: …)」가 없어 마스터가 못 채우는 자리라, 매핑에 원문 근거와 함께 「개소」를 적어 통로로 채움([주]① 설치·해체 각각 · [주]③ 1개설치당). - 매핑에 `ancillary` 절을 만들고 그 절도 밑수 대조에 쓰이게 함. - 이제 내역에 실리는 25 줄이 전부 대조됨 — 일부러 m 로 보내면 가드가 잡는 것을 시험으로 확인. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
304 lines
13 KiB
Python
304 lines
13 KiB
Python
"""보내는 단위가 품셈 밑수와 같은가 — 금액이 실제로 틀렸던 자리 (2026-09-08).
|
|
|
|
⚠ 겨누는 것 다섯
|
|
① 돌쌓기가 **㎡ · 비탈면적**으로 나가는가 (m · 연장이면 금액이 2.6배)
|
|
② 성분이 안 서면 **연장으로 대신 세지 않는가** — 세면 다시 틀린 축이다
|
|
③ 밑수가 m 인 공종(맹암거·측구)은 **종전대로** 연장인가
|
|
④ 표기 차이(㎥/m3 · 개소/개)는 흡수하되 **뜻이 다른 단위는 드러내는가**
|
|
⑤ 인계본에 그 경고가 **실제로 실리는가** (만들어 두고 안 부르면 없는 것과 같다)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
from B08_Quantity.B08_Quantity_Engine_BasisUnit import ( # noqa: E402
|
|
basis_units,
|
|
normalize_unit,
|
|
verify_unit_matches_basis,
|
|
)
|
|
from B08_Quantity.B08_Quantity_Engine_Handoff import ( # noqa: E402
|
|
build_handoff,
|
|
load_mapping,
|
|
)
|
|
|
|
MAPPING = load_mapping()
|
|
|
|
|
|
def 돌쌓기(**options: object) -> dict:
|
|
"""전개까지 실제로 돌린 구조물 한 기 — 값을 손으로 적지 않는다."""
|
|
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import expand
|
|
|
|
item = expand(
|
|
{
|
|
"structure_id": "s1",
|
|
"type_id": "masonry_wet",
|
|
"start_m": 0.0,
|
|
"end_m": 10.0,
|
|
"options": {"height_m": 2.5, "length_m": 10.0, **options},
|
|
},
|
|
names={"masonry_wet": "돌쌓기(찰)"},
|
|
)
|
|
return {
|
|
"structure_id": item.structure_id,
|
|
"type_id": item.type_id,
|
|
"name": item.name,
|
|
"length_m": item.length_m,
|
|
"height_m": item.height_m,
|
|
"start_m": item.start_m,
|
|
"end_m": item.end_m,
|
|
"options": item.options,
|
|
"notes": item.notes,
|
|
"components": [
|
|
{"name": c.name, "unit": c.unit, "amount": c.amount, "basis": c.basis}
|
|
for c in item.components
|
|
],
|
|
}
|
|
|
|
|
|
def 인계(structure: dict) -> dict:
|
|
return build_handoff(unit_quantity_table={"structures": [structure]}, mapping=MAPPING)
|
|
|
|
|
|
def test_돌쌓기는_면적으로_나간다() -> None:
|
|
"""⚠ 실측 — 10m 로 나가 529,389원이 섰다. 26.101㎡ 로 세야 1,381,753원이다."""
|
|
row = 인계(돌쌓기(back_len_cm=45))["work_items"][0]
|
|
assert row["work_item_code"] == "FP-13-04-05"
|
|
assert row["unit"] == "㎡"
|
|
assert abs(row["quantity"] - 26.101) < 0.01, row["quantity"]
|
|
assert row["in_bill"] is True
|
|
|
|
|
|
def test_면적이_안_서면_연장으로_대신_세지_않는다() -> None:
|
|
"""뒷길이 40 은 품셈 표에 없어 전개가 안 선다 — 그때 10.0 m 로 서면 안 된다."""
|
|
row = 인계(돌쌓기(back_len_cm=40))["work_items"][0]
|
|
assert row["quantity"] != 10.0
|
|
assert row["in_bill"] is False or row["blocked_kind"]
|
|
assert row["blocked_reason"]
|
|
|
|
|
|
def test_밑수가_m_인_공종은_종전대로_연장이다() -> None:
|
|
row = 인계(
|
|
{
|
|
"structure_id": "s2",
|
|
"type_id": "underdrain",
|
|
"name": "맹암거",
|
|
"length_m": 40.0,
|
|
"start_m": 0.0,
|
|
"end_m": 40.0,
|
|
"components": [],
|
|
"notes": [],
|
|
"options": {},
|
|
}
|
|
)["work_items"][0]
|
|
assert (row["unit"], row["quantity"]) == ("m", 40.0)
|
|
|
|
|
|
def test_표기_차이는_흡수한다() -> None:
|
|
assert normalize_unit("m3") == normalize_unit("㎥")
|
|
assert normalize_unit("개") == "개소"
|
|
rows = [{"work_item_code": "FP-12-15", "unit": "개", "in_bill": True, "name": "집수정"}]
|
|
assert verify_unit_matches_basis(rows) == []
|
|
|
|
|
|
def test_뜻이_다른_단위는_드러낸다() -> None:
|
|
"""⚠ 환산하지 않는다 — m 을 ㎡ 로 바꾸려면 두께를 지어내야 한다."""
|
|
rows = [{"work_item_code": "FP-13-04-05", "unit": "m", "in_bill": True, "name": "돌쌓기(찰)"}]
|
|
warnings = verify_unit_matches_basis(rows)
|
|
assert warnings and "㎡" in warnings[0]
|
|
|
|
|
|
def test_마스터에_없는_코드는_트집_잡지_않는다() -> None:
|
|
rows = [{"work_item_code": "FP-99-99", "unit": "포", "in_bill": True, "name": "가짜"}]
|
|
assert verify_unit_matches_basis(rows) == []
|
|
|
|
|
|
def test_인계본이_그_경고를_싣는다() -> None:
|
|
"""⚠ 검사를 만들어 두고 안 부르면 없는 것과 같다(2026-09-08 자기 감사에서 둘이 놀고 있었다)."""
|
|
handoff = 인계(돌쌓기(back_len_cm=45))
|
|
assert handoff["basis_unit_warnings"] == []
|
|
|
|
|
|
def test_품셈에_면적_밑수가_실제로_있다() -> None:
|
|
"""⚠ 우리 판단의 근거가 **원문**인지 확인한다 — 없으면 이 고침 자체가 근거를 잃는다."""
|
|
table = basis_units()
|
|
assert table["FP-13-04-05"] == {"㎡"}
|
|
assert table["FP-13-06-01"] == {"㎡"}
|
|
assert "m" in table["FP-12-10"]
|
|
|
|
|
|
# ── 마스터가 밑수를 모르는 공종 — 매핑이 원문에서 읽어 채운다 (2026-09-08 층따기) ──
|
|
# ⚠ 층따기 9-18 은 절 머리에 「(단위: …)」가 없고 [주] 공식 `Q1 = … = ㎥/시간` 만 있다.
|
|
# 마스터 `basis_unit` 이 비어 대조가 조용히 통과했고, ㎡ 2,645.21 × ㎥당 단가로
|
|
# 410만원이 서 있었다.
|
|
|
|
|
|
def 토공(group: str, unit: str, amount: float, item: str | None = None) -> dict:
|
|
row = {"group": group, "unit": unit, "amount": amount, "in_bill": True}
|
|
if item:
|
|
row["item"] = item
|
|
return row
|
|
|
|
|
|
def test_층따기_길이를_넣으면_체적으로_나간다() -> None:
|
|
"""⭐ 2026-09-09 확정 2차 ① — 면적이 정본이고 **부피는 사용자가 넣은 길이를 곱해** 쓴다.
|
|
|
|
그러면 품셈 ㎥ 단가를 그대로 쓸 수 있어 단위 불일치가 풀린다.
|
|
⚠ 면적은 없애지 않는다 — 횡단도 하단 표가 면적을 쓴다. **㎥ 를 덧붙이는 것**이다.
|
|
"""
|
|
row = build_handoff(
|
|
summary_table={"rows": [토공("층따기", "㎡", 13696.75)]}, bench_cut_depth_m=0.5
|
|
)["work_items"][0]
|
|
assert (row["unit"], row["quantity"]) == ("㎥", 13696.75 * 0.5)
|
|
assert row["in_bill"] is True
|
|
# 어디서 온 값인지 규격 칸에 남는다 — 면적을 못 되짚으면 검산이 안 된다.
|
|
assert "13,696.75㎡" in row["spec_detail"] and "0.5m" in row["spec_detail"]
|
|
|
|
|
|
def test_층따기_길이가_없으면_막히고_사유가_간다() -> None:
|
|
"""⚠ 길이 기본값을 **임의로 박지 않는다** — 교본이 「설계도서에 명시」라 설계 입력이다."""
|
|
row = build_handoff(summary_table={"rows": [토공("층따기", "㎡", 13696.75)]})["work_items"][0]
|
|
assert row["in_bill"] is False
|
|
assert row["blocked_kind"] == "input_missing"
|
|
assert "길이" in row["blocked_reason"]
|
|
# 면적은 규격 칸에 남는다 — 값이 사라지면 왜 막혔는지 못 짚는다.
|
|
assert "13,696.75" in row["spec_detail"]
|
|
|
|
|
|
def _옛_시험_층따기는_면적으로_나간다() -> None:
|
|
"""⭐ 2026-09-09 사용자 확정 ⑦ — 단위는 **㎡**(실무 관행).
|
|
|
|
⚠ 앞서는 「품셈 공식이 ㎥ 라 못 곱한다」로 막아 두었다(410만원 자리). 확정으로 ㎡ 가
|
|
정본이 되었으므로 **막지 않는다.** 대신 매핑에 그 결정과 까닭을 적어 두었고,
|
|
㎡ 단가를 세우는 것은 받는 쪽 몫이다.
|
|
"""
|
|
row = build_handoff(summary_table={"rows": [토공("층따기", "㎡", 2645.21)]})["work_items"][0]
|
|
assert row["work_item_code"] == "FP-09-18"
|
|
assert (row["unit"], row["quantity"]) == ("㎡", 2645.21)
|
|
assert row["in_bill"] is True
|
|
assert row["blocked_kind"] is None
|
|
|
|
|
|
def test_성토면다짐은_면적이_맞다() -> None:
|
|
"""⚠ 좁게 — 같은 면적을 쓰는 형제 공종까지 막으면 멀쩡한 줄이 사라진다.
|
|
|
|
9-17-1 비탈면 다짐은 시공량이 **㎡/시간**이라 면적이 맞다(2026-09-08 B09 원문 확인).
|
|
"""
|
|
row = build_handoff(summary_table={"rows": [토공("성토면다짐", "㎡", 2645.21)]})["work_items"][
|
|
0
|
|
]
|
|
assert (row["unit"], row["quantity"]) == ("㎡", 2645.21)
|
|
assert row["in_bill"] is True
|
|
|
|
|
|
def test_매핑이_적은_밑수가_대조에_쓰인다() -> None:
|
|
"""마스터가 비어 있어도 매핑이 적은 값으로 경고가 선다."""
|
|
rows = [{"work_item_code": "FP-09-18", "unit": "㎡", "in_bill": True, "name": "층따기"}]
|
|
assert verify_unit_matches_basis(rows) == [] # 마스터만 보면 못 잡는다
|
|
assert verify_unit_matches_basis(rows, extra={"FP-09-18": "㎥"})
|
|
|
|
|
|
# ── 매핑이 적은 밑수는 **원문에 실제로 있는 글자**여야 한다 (2026-09-08) ────────────
|
|
# ⚠ 근거 없는 단위가 대조의 기준이 되면 가드가 거짓말을 한다. 그래서 원문을 연다.
|
|
|
|
PUM_TEXT = (
|
|
ROOT
|
|
/ "resources"
|
|
/ "knowledge"
|
|
/ "original"
|
|
/ "행정규칙"
|
|
/ "임도 품셈 적용기준 (현 산림사업 표준품셈)"
|
|
/ "첨부"
|
|
/ "(산림청고시 제2025-82호) 산림사업 표준품셈.md"
|
|
)
|
|
|
|
#: 매핑에 적은 밑수와 그 근거 줄(원문 줄번호는 1부터).
|
|
DECLARED_SOURCE = {
|
|
"FP-09-03-02": (4704, "㎥"),
|
|
"FP-09-04": (4738, "㎥"),
|
|
"FP-09-05": (4744, "㎥"),
|
|
# 2026-09-08 실무 양식이 정해져 부모(FP-09-16 노체)에서 **자식 포설**로 내렸다 —
|
|
# 실무 내역 셋에 「노체포설·노체다짐」으로 가른 줄이 없고 성토 본체가 한 줄이다.
|
|
"FP-09-16-01": (5322, "㎥"),
|
|
"FP-09-17-01": (5368, "㎡"),
|
|
"FP-10-11": (5919, "㎥"),
|
|
"FP-10-12": (5928, "㎥"),
|
|
# ⚠ 「단위:」 글자 없이 **괄호만** 적힌 모양 — 마스터가 못 읽던 자리(2026-09-08).
|
|
"FP-05-24": (2769, "㎡"),
|
|
}
|
|
|
|
|
|
def test_매핑이_적은_밑수가_원문_줄에_실제로_있다() -> None:
|
|
if not PUM_TEXT.is_file():
|
|
return
|
|
lines = PUM_TEXT.read_text(encoding="utf-8").splitlines()
|
|
declared = MAPPING.declared_units()
|
|
for code, (line_no, unit) in DECLARED_SOURCE.items():
|
|
assert declared.get(code) == unit, code
|
|
assert unit in lines[line_no - 1], f"{code} L{line_no}: {lines[line_no - 1][:60]}"
|
|
|
|
|
|
def test_품_단위는_밑수로_안_받는다() -> None:
|
|
"""⚠ 「일당」은 **하루에 얼마**라는 품의 단위다 — 받으면 틀린 단위로 검사를 통과시킨다."""
|
|
assert "FP-12-06" not in MAPPING.declared_units()
|
|
|
|
|
|
def test_덤프_단위중량이_원문_줄에_있다() -> None:
|
|
"""⚠ 「현장값이라 미확보」로 두었던 γt 가 원문에 있었다(2026-09-08 정정)."""
|
|
if not PUM_TEXT.is_file():
|
|
return
|
|
line = PUM_TEXT.read_text(encoding="utf-8").splitlines()[5930 - 1]
|
|
assert "1.9ton/㎥" in line and "2.4ton/㎥" in line
|
|
|
|
|
|
# ── 공종 단위가 아닌 글자는 **안 받는다** (2026-09-08 B09 전수에서 나온 모양들) ────
|
|
# ⚠ `(㎥/1대, 1일)` 은 **시공량**이라 밑수의 **역수**다 — 받으면 그 공종이 조용히
|
|
# 뒤집힌 단위를 갖는다. 안 받으면 대조가 없을 뿐이라 **덜 나쁘다.**
|
|
|
|
|
|
def test_시공량_모양은_밑수로_안_받는다() -> None:
|
|
from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import is_quantity_unit
|
|
|
|
for bad in ("㎥/1대, 1일", "㎥/1인, 1일", "대/ton", "1ha당, 100본당", "일당", "조", "인"):
|
|
assert not is_quantity_unit(bad), bad
|
|
for good in ("㎥", "㎡", "m", "개소", "㏊당", "10m당"):
|
|
assert is_quantity_unit(good), good
|
|
|
|
|
|
def test_매핑에_적힌_밑수가_전부_공종_단위다() -> None:
|
|
"""⚠ 사람이 손으로 적는 칸이라 **다음 사람이 시공량을 적을 수 있다.**"""
|
|
from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import is_quantity_unit
|
|
|
|
for code, unit in MAPPING.declared_units().items():
|
|
assert is_quantity_unit(unit), f"{code}: {unit}"
|
|
|
|
|
|
def test_부대시설_가설창고도_대조된다() -> None:
|
|
"""⚠ 내역에 실리는 25 줄 중 **이 공종만** 밑수가 없어 가드가 못 보고 있었다(2026-09-09).
|
|
|
|
마스터는 절 머리에 「(단위: …)」가 있어야 밑수를 채우는데 11-1 은 그것이 없다.
|
|
매핑에 원문 근거([주]① · [주]③)와 함께 「개소」를 적어 대조가 서게 했다.
|
|
"""
|
|
from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import load_mapping
|
|
|
|
declared = load_mapping().declared_units()
|
|
assert declared.get("FP-11-01") == "개소"
|
|
|
|
rows = [
|
|
{
|
|
"work_item_code": "FP-11-01",
|
|
"name": "가설창고(컨테이너)",
|
|
"unit": "m",
|
|
"in_bill": True,
|
|
}
|
|
]
|
|
warnings = verify_unit_matches_basis(rows, extra=declared)
|
|
assert len(warnings) == 1 and "개소" in warnings[0]
|
|
# 맞는 단위로 보내면 조용하다.
|
|
ok = [dict(rows[0], unit="개소")]
|
|
assert verify_unit_matches_basis(ok, extra=declared) == []
|