test(B08): tmp/tests 중 tester 에 없던 47 개를 resources/tester 로 옮김

tmp/ 가 창끼리 안 건너가는 것이 확정돼(랩탑이 시간 두고 두 번 확인) 시험·예외가
저절로 건너가도록 git 안으로 옮김. 사용자 확정.

- 내용은 하나도 안 고침 — 자리만 옮김. tmp/tests 는 남겨 둠.
- 같은 이름이 이미 있던 64 개는 랩탑 것을 그대로 두고 건너뜀.
- helper_b05_*.js 둘은 랩탑이 .cjs 로 이미 올린 것과 **줄바꿈만 다른 같은 내용**이라
  복사본을 도로 뺌(시험이 .cjs 를 부름).
- resources/tester/ 에서 전체 1176 통과 · 29 건너뜀 · 실패 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-09 17:15:57 +09:00
co-authored by Claude Opus 5
parent a5eb559f3d
commit d7cb14f416
47 changed files with 8511 additions and 0 deletions
+85
View File
@@ -0,0 +1,85 @@
"""부대시설 다섯 줄 — 법이 요구하는데 안 내던 것 (2026-09-09 사용자 확정 ⑬).
⚠ 겨누는 것 여섯
① 다섯 줄이 **개소를 안 넣어도 선다** — 빠진 것이 화면에 보이게
② 개소를 **지어내지 않는다** — `연장÷500` 같은 산식을 쓰지 않음
③ ⚠ 사유를 **갈라 적는다** — 「품셈에 공종이 없음」과 「개소 미입력」은 할 일이 다름
④ 법정 의무인 줄은 **그 사실이 사유에 적힌다**
⑤ 공종이 있는 하나(가설창고)는 **값이 서고 코드가 붙는다**
⑥ 인계에도 **실려 나간다** — 빼면 받는 쪽이 빠진 줄을 못 봄
"""
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_Handoff import build_handoff # noqa: E402
from B08_Quantity.B08_Quantity_Engine_Preparation import ( # noqa: E402
STATUS_READY,
ancillary_rows,
build_table,
)
def (counts: dict | None = None) -> dict:
return {row["item"]: row for row in ancillary_rows(counts)}
def test_다섯_줄이_개소_없이도_선다() -> None:
rows = ()
assert set(rows) == {
"국가지점번호판",
"임도 안내판",
"차단기",
"가설창고(컨테이너)",
"수방대책 자재",
}
assert all(row["amount"] is None for row in rows.values())
def test_개소를_지어내지_않는다() -> None:
"""⚠ 임도규정이 「필요시 거리를 조정」이라 `연장÷500` 은 확정 산식이 아니다."""
for row in ().values():
assert row["amount"] is None
assert "개소가 아직 입력되지 않았습니다" in row["reason"]
def test_사유를_갈라_적는다() -> None:
"""⚠ 「품셈에 공종이 없음」과 「개소 미입력」은 **다음에 할 일이 다르다**."""
rows = ({"national_point_sign": 3})
sign = rows["국가지점번호판"]
assert sign["amount"] == 3.0
# 개소는 채워졌으니 그 사유는 사라지고, 공종이 없다는 사유만 남는다.
assert "개소가 아직 입력되지 않았습니다" not in sign["reason"]
assert "품셈에 그 이름의 공종이 없음" in sign["reason"]
def test_법정_의무가_사유에_적힌다() -> None:
rows = ()
assert "법정 의무" in rows["국가지점번호판"]["reason"]
assert "제26조제5항" in rows["국가지점번호판"]["reason"]
assert "법정 의무" in rows["임도 안내판"]["reason"]
# 차단기는 교본만 있고 법령에 없다 — 「법정」이라 적으면 거짓이 된다.
assert "법정 의무" not in rows["차단기"]["reason"]
def test_공종이_있는_하나는_값이_선다() -> None:
row = ({"site_container": 2})["가설창고(컨테이너)"]
assert (row["work_item_code"], row["amount"], row["status"]) == ("FP-11-01", 2.0, STATUS_READY)
def test_준비공_표와_인계에_함께_실린다() -> None:
"""⚠ 빼면 받는 쪽이 「빠진 줄」을 못 본다 — 값이 없어도 사유와 함께 간다."""
table = build_table({}, [], [], None, None, {"site_container": 1})
names = [row["item"] for row in table["rows"]]
assert "국가지점번호판" in names
rows = build_handoff(preparation_table=table)["work_items"]
handed = {row["name"]: row for row in rows}
assert "국가지점번호판" in handed
assert handed["국가지점번호판"]["in_bill"] is False
assert handed["가설창고(컨테이너)"]["work_item_code"] == "FP-11-01"
+169
View File
@@ -0,0 +1,169 @@
"""막자갈(뒤채움 조약돌) — **품셈이 정한 두께 범위 안에 드는가** (2026-09-09).
⚠⚠ **이 시험은 「같은지」가 아니라 「범위 안인지」를 잰다.** 우리 막자갈은 품셈과 **다른 식**이다.
품셈 13-4-4 [주]⑨ 는 뒤채움 조약돌 두께를 **직고별 범위**로만 준다(상부 20~40㎝ ·
하부 직고별 30~140㎝). 두 길이 **같은 자리에 도착하는지**를 본다.
⚠⚠ **2026-09-09 오후 식이 갈아탔다** — 랩탑 보조가 확정 5차 작은 것 3 으로 막자갈을
「입적 − 몸통 − 고임돌」에서 **뒷채움 사다리꼴**로 바꿨다(정본 여섯 탭 상 0.30 · 하 0.45).
그래서 아래 기록을 전부 다시 쟀다 — **옛 값(0.278~0.943)은 지금 값이 아니다.**
⚠ **등호로 바꾸지 말 것** — 등호로 두면 우리 식이 품셈 식에 끌려간다. 우리 식은 우리 식이다.
⚠ **범위 밖으로 나가면 「틀림」이 아니라 「봐야 함」이다** — 우리 식이 맞을 수도 있다.
다만 **두께식이 바뀌면 조용히 새 나갈 자리**라(값은 계속 나오므로 아무도 못 본다)
그 자리에 이 그물을 둔다.
⚠ **이 시험이 잰 범위는 직고 1.0~7.5 m · 뒷길이 35~75㎝ 다.**
**검산은 「어디까지 쟀는지」를 함께 적지 않으면 다음 사람이 착시를 본다** — 앞서 4.0 까지만
재고 「다 범위 안」이라 한 적이 있다(그때 식으로는 4.5 부터 밖이었다).
⚠ **큰돌쌓기(13-6)는 이 시험의 대상이 아니다** — 두께를 **직경 위 끝**으로 보고(`BOULDER_MASONRY`)
막자갈도 안 낸다. 그래서 높이 한계가 없어도 이 자리에 안 걸린다(2026-09-09 실측 확인).
품셈 13-4-4 [주]⑨ <성토의 경우 뒤채움 조약돌의 두께>
직고 1.5m 상부 20~40 · 하부 30~60 ⇒ 평균 0.25~0.50 m
직고 3.0m 상부 20~40 · 하부 45~75 ⇒ 평균 0.33~0.58 m
직고 5.0m 상부 20~40 · 하부 60~100 ⇒ 평균 0.40~0.70 m
직고 7.0m 상부 20~40 · 하부 80~140 ⇒ 평균 0.50~0.90 m
"""
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_UnitQuantity import stone_masonry # noqa: E402
#: (직고 상한, 평균두께 범위) — 원문 상·하부 범위의 평균이다.
PUMSEM_RANGE_M: tuple[tuple[float, float, float], ...] = (
(1.5, 0.25, 0.50),
(3.0, 0.33, 0.58),
(5.0, 0.40, 0.70),
(7.0, 0.50, 0.90),
)
def 범위(height_m: float) -> tuple[float, float]:
for limit, low, high in PUMSEM_RANGE_M:
if height_m <= limit:
return low, high
return PUMSEM_RANGE_M[-1][1], PUMSEM_RANGE_M[-1][2]
def 두께(height_m: float, back_cm: int) -> float:
"""우리 막자갈을 **면적으로 나눈 값** — 품셈이 말하는 「뒤채움 두께」와 같은 축."""
components, _ = stone_masonry(
height_m,
10.0,
{"height_m": height_m, "length_m": 10.0, "back_len_cm": back_cm},
wet=True,
face="성토",
)
got = {c.name: c.amount for c in components}
return got["막자갈"] / got["돌쌓기"]
def test_3m_까지는_품셈_범위_안에_든다() -> None:
"""⚠ 실패하면 「틀렸다」가 아니라 **「봐야 한다」**는 뜻이다 — 두께식이 바뀐 자리를 알리는 그물.
⚠ **3.0m 까지인 것이 우연이 아니다** — 정본 여섯 탭을 전수 확인한 구간이 **H=1.0~3.0**
이다(`STONE_BACKFILL_WIDTH_M` 머리 주석). 근거가 있는 구간에서는 품셈 범위와 맞고,
근거가 없는 구간에서 갈린다.
"""
for height, back in ((1.0, 45), (1.2, 35), (2.5, 45), (3.0, 45), (3.0, 55), (3.0, 75)):
low, high = 범위(height)
got = 두께(height, back)
assert low <= got <= high, (
f"H={height} 3={back}㎝ 뒤채움 두께 {got:.3f}m 가 품셈 13-4-4 [주]⑨ 범위"
f" {low}~{high}m 밖 — 우리 식이 맞을 수도 있으니 **틀림이 아니라 봐야 함**"
)
def test_3m_를_넘으면_범위_아래로_나간다() -> None:
"""⚠⚠ **지금 상태를 기록해 두는 시험이다**(2026-09-09 오후 실측).
⚠ **방향이 뒤집혔다** — 옛 식은 높은 벽에서 품셈 범위 **위**로 나갔는데, 새 식은
**아래**로 나간다. 새 식은 두께가 H 에 안 붙기 때문이다.
H 1.0 0.364 · 1.5 0.364 (안) · 2.0~3.0 0.359 (안)
H 3.5~5.0 0.354 · 6.0~7.0 0.348 · 7.5 0.342 (전부 **밖 — 아래**)
까닭은 새 막자갈이 **(상 0.30 + 하 0.45) ÷ 2 × H × 연장** 이라 두께(= 막자갈 ÷ 비탈면적)가
**0.375 ÷ √(1+경사²)** 로 거의 붙박이인 데 있다. 직고가 커지면 표준경사가 커져
비탈면적만 늘어 **오히려 조금씩 얇아진다.** 품셈 표는 반대로 직고와 함께 두꺼워진다.
⚠ **틀렸다고 단정하지 않는다** — 정본 여섯 탭이 근거이고 품셈 표는 **범위**다. 다만
정본 전수 확인 구간이 **H=1.0~3.0** 이고 갈리는 자리도 **3.0m 부터**라 겹친다 —
**높은 벽의 정본 근거가 없다**는 뜻이므로 확정 5차 작은 것 3 을 다시 볼 때 이 자리를 볼 것.
⚠ 이 시험이 깨지면(범위 안으로 들어오면) **두께식이 바뀐 것**이니 위 기록을 고칠 것.
"""
안쪽 = 두께(3.0, 45)
바깥 = 두께(3.5, 45)
assert 범위(3.0)[0] <= 안쪽 <= 범위(3.0)[1]
assert 바깥 < 범위(3.5)[0]
assert 0.35 < 바깥 < 0.36
def test_직고가_커져도_두께가_거의_안_움직인다() -> None:
"""⚠ **뒤집힌 시험이다** — 종전에는 「직고가 커지면 두꺼워진다」였다(품셈 표와 같은 방향).
새 식에서는 막자갈 **물량**이 H 에 비례해 늘지만 **두께**(물량 ÷ 비탈면적)는 거의
붙박이다. 이 사실 자체를 못 박아, 나중에 누가 「두께가 직고를 따라간다」고 잘못
읽는 것을 막는다.
"""
thicknesses = [두께(h, 45) for h in (1.2, 2.5, 4.0, 6.0)]
assert max(thicknesses) - min(thicknesses) < 0.02
# ⚠ 물량 자체는 직고를 따라 는다 — 두께가 안 움직인다고 물량까지 굳은 것이 아니다.
부피 = [두께(h, 45) * h for h in (1.2, 2.5, 4.0, 6.0)]
assert 부피 == sorted(부피)
def test_뒷길이는_두께를_안_움직인다() -> None:
"""⚠ 옛 식에서는 뒷길이가 밑수였다 — 지금은 정본 폭 붙박이라 아예 안 본다."""
assert len({round(두께(2.5, b), 6) for b in (35, 45, 55, 60, 75)}) == 1
def test_등호가_아니라_범위다() -> None:
"""⚠ 이 시험의 뜻을 못 박는다 — 품셈 값과 **같지 않아도 된다**.
같아지길 요구하면 우리 식(정본 뒷채움 사다리꼴)이 품셈 식에 끌려간다.
"""
low, high = 범위(2.5)
got = 두께(2.5, 45)
assert got != low and got != high # 경계값과 같을 이유가 없다
assert low < got < high
def test_근거_구간_밖이면_사유가_뜬다() -> None:
"""⚠ **값은 계속 나온다** — 사유가 없으면 아무도 못 본다.
교본 7-3 이 「찰 3.0m 이하 · 메 2.0m 이하」로 두지만 **코드가 높이를 막지 않고**,
표준경사표(13-4-4 [주]⑪)는 직고 7m 까지 칸을 준다 — 높은 벽이 실제로 설 수 있다.
막지도 눅이지도 않고 **정본 확인 구간 밖이라는 사실**만 사유로 낸다(임의 확정 금지).
"""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import stone_masonry
def 사유(height_m: float) -> list[str]:
_, notes = stone_masonry(
height_m,
10.0,
{"height_m": height_m, "length_m": 10.0, "back_len_cm": 45},
wet=True,
face="성토",
)
return [n for n in notes if "정본 확인 구간" in n]
assert 사유(3.0) == [] # 근거가 있는 구간에서는 잔소리하지 않는다
= 사유(3.5)
assert len() == 1
assert "13-4-4 [주]⑨" in [0] and "실무자 확인" in [0]
def test_큰돌쌓기는_이_식을_안_쓴다() -> None:
"""⚠ 높이 한계가 없는 것은 큰돌쌓기뿐이라 **거기까지 번지면 살아 있는 자리**가 된다.
큰돌쌓기(13-6)는 막자갈 줄 자체를 안 낸다 — 이 갈림이 거기로 안 번진다.
"""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import boulder_masonry
components, _ = boulder_masonry(
5.0, 10.0, {"height_m": 5.0, "length_m": 10.0, "stone_cm": "60~80"}
)
assert not any(c.name == "막자갈" for c in components)
+277
View File
@@ -0,0 +1,277 @@
"""보내는 단위가 품셈 밑수와 같은가 — 금액이 실제로 틀렸던 자리 (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}"
+105
View File
@@ -0,0 +1,105 @@
"""버림 콘크리트 — 빠뜨리고 있던 줄 (2026-09-09 사용자 확정 ⑭).
⚠ 겨누는 것 다섯
① 기본은 **넣는다** — 정한 적 없는 프로젝트에서 줄이 사라지지 않아야 한다
② 「안 넣음」으로 두면 **빠진다**
③ 두께는 **0.10m**(KDS 44 90 00) — 임의 값이 아니다
④ 근거 문구에 **잠정임이 드러난다**(폭이 잡석다짐 폭이라야 하나 그 값이 아직 없다)
⑤ ⚠ 채움 콘크리트와 **섞이지 않는다** — 타설 줄은 버림 몫만 세운다
"""
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_Handoff import build_handoff # noqa: E402
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402
BLINDING_THICKNESS_M,
boulder_masonry,
stone_masonry,
wants_blinding,
)
def 성분(**options: object) -> dict:
components, _ = stone_masonry(
2.5, 10.0, {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45, **options}, wet=True
)
return {c.name: c for c in components}
def test_기본은_넣는다() -> None:
"""⚠ 빈 칸을 「빼기」로 읽으면 저장해 둔 적 없는 프로젝트에서 줄이 통째로 사라진다."""
assert wants_blinding({}) is True
assert "버림콘크리트" in 성분()
def test_안_넣음으로_두면_빠진다() -> None:
for value in ("안 넣음", "제외", "false", "no"):
assert wants_blinding({"blinding_concrete": value}) is False, value
assert "버림콘크리트" not in 성분(blinding_concrete="안 넣음")
def test_두께는_원문값이다() -> None:
"""KDS 44 90 00 「구조물 시공이 원활하도록 **100 mm 두께**의 버림콘크리트를 타설」."""
assert BLINDING_THICKNESS_M == 0.10
got = 성분()["버림콘크리트"]
# 기초 폭(터파기 폭) × 연장 × 두께 — 폭이 근거 문구에 적혀 있으므로 그 값으로 검산한다.
assert got.unit == ""
assert got.amount > 0
def test_폭은_하단_길이에서_온다() -> None:
"""⚠ 「폭은 잡석다짐 폭」(KCS 34 50 05)이고 그 폭이 확정 ⑪ 로 **하단 길이**가 됐다.
옛 잠정(터파기 폭 = 평균두께 + 0.2)을 지운 자리다 — 근거 문구에 어디서 온 폭인지 적힌다.
"""
got = 성분()["버림콘크리트"]
하부두께 = 0.45 + 0.30 + 0.30 * (2.5 - 1.0) # 1.20
assert abs(got.amount - 하부두께 * 10.0 * BLINDING_THICKNESS_M) < 1e-9
assert "KDS" in got.basis and "하단 길이" in got.basis
assert "잠정" not in got.basis
def test_기초잡석은_두께가_없어_안_선다() -> None:
"""⚠ 폭은 생겼지만 **두께가 원문에 없다** — 지어내지 않고 사유로 드러낸다."""
_, notes = stone_masonry(
2.5, 10.0, {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45}, wet=True
)
assert any("기초잡석(12-25)" in n and "두께가 원문에 없음" in n for n in notes)
def test_큰돌쌓기에도_선다() -> None:
components, _ = boulder_masonry(
2.5, 10.0, {"height_m": 2.5, "length_m": 10.0, "stone_cm": "60~80"}
)
assert any(c.name == "버림콘크리트" for c in components)
def test_타설_줄은_버림_몫만_센다() -> None:
"""⚠ 채움 콘크리트가 섞이면 이중계상 — 그 공종 품에 이미 들어 있을 수 있다."""
item = 성분()
structure = {
"structure_id": "s1",
"type_id": "masonry_wet",
"name": "돌쌓기(찰)",
"length_m": 10.0,
"height_m": 2.5,
"start_m": 0.0,
"end_m": 10.0,
"options": {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45},
"notes": [],
"components": [
{"name": c.name, "unit": c.unit, "amount": c.amount, "basis": c.basis}
for c in item.values()
],
}
rows = build_handoff(unit_quantity_table={"structures": [structure]})["work_items"]
placing = [row for row in rows if row["name"] == "콘크리트 타설"]
assert len(placing) == 1
assert abs(placing[0]["quantity"] - item["버림콘크리트"].amount) < 1e-6
assert abs(placing[0]["quantity"] - item["채움콘크리트"].amount) > 1e-6
@@ -0,0 +1,152 @@
"""**코드가 없으면 반드시 막힘 표시가 붙는다** (2026-09-09 랩탑 메인 제보로 세운 불변식).
⚠⚠ 오늘 세운 규칙 「막혔다고 말하기 전에 `blocked_kind` 를 볼 것」의 **뒤집힌 얼굴**이다.
보는 쪽을 그렇게 고쳤으면 **다는 쪽도 빠짐없이 달아야** 한다. 안 그러면 받는 쪽이
「코드도 없고 막힘 표시도 없는 **멀쩡한 줄**」로 읽어 **금액이 조용히 빠진다.**
실제로 그랬다 — `측구터파기 · 굴삭기+브레카` 가 `work_item_code: null` · `blocked_kind: null`
이었고 화면에 아무 말도 안 떴다. 인계본의 `bill_flag_warnings`·`unmatched_work_items` 에는
**이미 실려 있었다** — 목록은 있는데 **줄에 표시가 없었던 것**이다. 줄 단위로 보는 쪽은
목록을 안 본다.
⚠ 예외 둘 — 이 둘만 코드 없이 서도 된다.
① **묶음 줄**(`composite_parts`) — 무엇으로 묶이는지 조각에 적혀 있다
② **내역에 안 서는 줄**(`in_bill: False`) — 합계·무대처럼 **막힌 게 아니라 안 세는 것**.
다만 그쪽도 **까닭은 있어야** 한다(`in_bill_reason` 또는 `blocked_kind`).
"""
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_Handoff import build_handoff # noqa: E402
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402
def 집계(item: str | None, group: str = "흙깎기") -> dict:
return {"rows": [{"group": group, "item": item, "unit": "", "amount": 100.0}]}
def 인계(**kwargs) -> list[dict]:
return build_handoff(**kwargs)["work_items"]
def test_시공법을_못_고른_줄에_막힘_표시가_붙는다() -> None:
"""⚠ 실측 자리 — 「굴삭기+브레카」는 지반 갈래가 아니라 **시공법**이라 매핑이 안 맞았다."""
rows = 인계(summary_table=집계("리핑암"))
row = next(r for r in rows if r["name"] == "흙깎기")
assert row["work_item_code"] is None
assert row["blocked_kind"] == "input_missing" # 시공법을 고르면 풀린다
assert "시공법" in row["blocked_reason"]
def test_시공법을_고르면_코드가_붙고_표시가_사라진다() -> None:
# ⚠ 시공법 값은 **영문 키**다(`ripping`·`blasting`) — 한글을 넣으면 안 풀린다.
rows = 인계(summary_table=집계("리핑암"), ground_methods={"리핑암": "ripping"})
row = next(r for r in rows if r["name"] == "흙깎기")
assert row["work_item_code"] is not None
assert row["blocked_kind"] is None
def test_품셈을_못_이은_줄은_다른_갈래로_적힌다() -> None:
"""⚠ 「사용자가 고르면 풀림」과 「우리가 매핑을 못 이음」은 **다음에 할 일이 다르다**."""
rows = 인계(summary_table=집계(None, group="지장목제거"))
row = next(r for r in rows if r["name"] == "지장목제거")
assert row["work_item_code"] is None
assert row["blocked_kind"] == "unit_data_missing"
assert "못 이었습니다" in row["blocked_reason"]
def test_불변식_모든_줄에_대해_선다() -> None:
"""⚠ 한 자리를 고치는 것으로 끝내지 않는다 — **줄 빌더 여덟 곳 전부**를 건다."""
unit = build_table(
[
{
"structure_id": "s1",
"type_id": "masonry_wet",
"start_m": 0.0,
"end_m": 10.0,
"options": {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45},
},
{
"structure_id": "w1",
"type_id": "retaining_wall",
"start_m": 20.0,
"end_m": 30.0,
"options": {"height_m": 2.0, "length_m": 10.0, "form": "반중력식"},
},
]
)
haul = {
"rows": [
{"equipment": "dump_truck", "ground": "토사", "volume_m3": 50.0},
{"equipment": "free_haul", "ground": "토사", "volume_m3": 10.0},
{"equipment": "모르는장비", "ground": "토사", "volume_m3": 5.0},
]
}
rows = 인계(
summary_table=집계("리핑암"),
haul_table=haul,
unit_quantity_table=unit,
preparation_table={"rows": []},
)
나쁜 = [
r["name"]
for r in rows
if not r.get("work_item_code")
and not r.get("composite_parts")
and r.get("in_bill")
and not r.get("blocked_kind")
]
assert 나쁜 == [], f"코드도 막힘 표시도 없이 내역에 서는 줄: {나쁜}"
# 내역에 안 서는 줄도 **까닭 없이** 있으면 안 된다.
까닭없음 = [
r["name"]
for r in rows
if not r.get("in_bill")
and not r.get("blocked_kind")
and not str(r.get("in_bill_reason") or "").strip()
]
assert 까닭없음 == [], f"안 서는 까닭이 안 적힌 줄: {까닭없음}"
def test_무대는_막힌_것이_아니다() -> None:
"""⚠ 무대(소운반 20m 이내)는 **품에 포함**이라 안 세우는 것이지 막힌 것이 아니다.
여기에 막힘 표시를 달면 「만들어야 할 것」 목록에 올라 없는 일이 생긴다.
"""
rows = 인계(haul_table={"rows": [{"equipment": "free_haul", "ground": "토사", "volume_m3": 10.0}]})
row = next(r for r in rows if r["haul_equipment"] == "free_haul")
assert row["in_bill"] is False
assert row["blocked_kind"] is None
assert "품에 포함" in row["in_bill_reason"]
def test_물량_0_인_구조물은_내역에_안_선다() -> None:
"""⚠ 물넘이포장이 면적을 안 받아 **`0.0 ㎡` 로 내역에 서고 있었다**(2026-09-09 감사).
코드가 붙어 있어 **0 원 줄**이 만들어지고 화면에는 「값이 있는 줄」로 보인다 —
0 은 「없음」과 구별이 안 된다(오늘 표토제거에서 겪은 자리와 같은 결).
줄은 그대로 넘기되 **내역에서 빼고 까닭을 적는다.**
"""
unit = build_table(
[
{
"structure_id": "f1",
"type_id": "ford_pavement",
"start_m": 0.0,
"end_m": 10.0,
"options": {"thickness_cm": 20, "length_m": 10.0},
}
]
)
row = next(r for r in 인계(unit_quantity_table=unit) if r["name"] == "물넘이포장")
assert row["quantity"] == 0.0
assert row["in_bill"] is False
assert "물량이 0" in row["in_bill_reason"]
assert row["blocked_kind"] == "input_missing"
@@ -0,0 +1,92 @@
"""계수 갈래·채움콘 강도·돌 중량 근거 (2026-09-08 사용자 확정 2차 ⑤·⑨·⑩).
⚠ 겨누는 것 여섯
① **빈 칸은 「안 정함」**이다 — 기본값은 등록부가 아니라 계산 쪽이 갖는다
② ⑨ 기본은 **품셈 열**(야면석 고임돌 0.11 · 채움 0.15)
③ ⑨ 「실무 관행」으로 두면 건설품셈 참고자료 열(0.15 · 0.20)로 덮이고 사유가 뜬다
④ ⑩ 강도 기본은 **210**, 180 을 고르면 그 값이 이긴다
⑤ ⚠ 아는 값이 아니면 **조용히 넘어가지 않는다**
⑥ ⑤ 돌 중량은 값을 그대로 두되 **어디서 온 값인지**가 줄에 적힌다
"""
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_UnitQuantity import ( # noqa: E402
FILL_CONCRETE_MPA_DEFAULT,
fill_concrete_mpa,
stone_masonry,
wants_practice_coefficients,
)
def 성분(**options: object) -> tuple[dict, list[str]]:
components, notes = stone_masonry(
2.5, 10.0, {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45, **options}, wet=True
)
return {c.name: c for c in components}, notes
def test_빈_칸은_안_정함이다() -> None:
assert wants_practice_coefficients({}) is False
assert fill_concrete_mpa({})[0] == FILL_CONCRETE_MPA_DEFAULT == "210"
def test_기본은_품셈_열이다() -> None:
"""야면석을 고르면 품셈 계수(고임돌 0.11 · 채움 0.15)가 선다 — 확정 ⑨."""
got, _ = 성분(stone_kind="야면석·호박돌")
돌쌓기 = got["돌쌓기"].amount
assert abs(got["고임돌"].amount - 돌쌓기 * 0.11) < 0.01
assert abs(got["채움콘크리트"].amount - 돌쌓기 * 0.15) < 0.01
def test_실무_관행으로_두면_덮이고_사유가_뜬다() -> None:
"""⚠ 값이 조용히 바뀌면 안 된다 — 덮어썼다는 사실이 사유로 나온다."""
got, notes = 성분(stone_kind="야면석·호박돌", stone_coeff_basis="실무 관행")
돌쌓기 = got["돌쌓기"].amount
assert abs(got["고임돌"].amount - 돌쌓기 * 0.15) < 0.01
assert abs(got["채움콘크리트"].amount - 돌쌓기 * 0.20) < 0.01
assert any("실무 관행" in n for n in notes)
def test_강도는_기본_210이고_고르면_그_값() -> None:
기본, _ = 성분()
assert 기본["채움콘크리트"].spec == "210"
assert "확정 ⑩" in 기본["채움콘크리트"].basis
고름, _ = 성분(fill_concrete_mpa="180")
assert 고름["채움콘크리트"].spec == "180"
assert "고른 값" in 고름["채움콘크리트"].basis
def test_모르는_강도는_조용히_넘어가지_않는다() -> None:
강도, basis = fill_concrete_mpa({"fill_concrete_mpa": "240"})
assert 강도 == "210"
assert "240" in basis
def test_돌_중량은_값을_두고_근거를_남긴다() -> None:
"""⚠ 품셈·교본에 돌중량표가 없다 — 값은 그대로 두고 출처를 줄에 단다(확정 ⑤).
⚠ 2026-09-09 확정 5차 큰 것 7 로 **관측표를 보는 자리가 야면석 계열로 좁아졌다** —
종류를 안 고르면 계산식(뒷길이 × 0.77 × 2.65)으로 선다. 출처를 다는 못은 그대로이되
**어느 조건에서 다는지**가 달라졌으므로 조건을 명시한다.
"""
got, _ = 성분(stone_kind="야면석·호박돌")
야면석 = got["야면석·호박돌"]
assert abs(야면석.amount - got["돌쌓기"].amount * 0.88) < 1e-9
assert "울진" in 야면석.basis and "품셈·교본에는 돌중량표가 없음" in 야면석.basis
assert 야면석.source == "uljin_library"
def test_안_고르면_계산식으로_서고_그_사실이_적힌다() -> None:
"""⚠ 값이 서는 것으로 끝나면 **관측값인지 계산값인지**를 화면에서 못 가른다."""
got, notes = 성분()
= got[""]
assert .source == "" # 관측 출처가 아니다
assert "0.77" in .basis and "2.65" in .basis
assert any("계산식" in n for n in notes)
@@ -0,0 +1,104 @@
"""채집석 공제 — 캐서 쓴 돌만큼 사토가 준다 (2026-09-09 사용자 확정 ②).
⚠⚠ **공제는 사토에서 한 번만 한다.** B08 은 소요량(㎥ **양수**)을 내기만 하고 빼지 않는다.
빼는 자리는 유토곡선의 사토뿐이다 — 실어 내는 몫에서 먼저 빼고 모자라면 자연방토에서.
⚠ 겨누는 것 다섯
① 기본은 「캔다」 — 정한 적 없으면 공제가 선다(법이 「가급적 현장 채취」)
② 「구입」으로 둔 구조물은 **공제 없음** — 구조물마다 갈린다
③ ⚠ **부호를 안 넘긴다** — 양수로 준다(음수로 주면 받는 쪽에서 두 번 뒤집힌다)
④ 자재총괄에 **안 섞인다** — 자재는 `material` 만 모은다
⑤ 인계 맨 위에 **한 값으로** 실린다(구조물마다 갈리므로 합산해서 하나로)
"""
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_Handoff import build_handoff # noqa: E402
from B08_Quantity.B08_Quantity_Engine_MaterialSummary import ( # noqa: E402
build_table as build_material_table,
)
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402
COLLECTED_STONE_KEY,
build_table,
is_collected_stone,
stone_masonry,
)
def 구조물(**options: object) -> dict:
return {
"structure_id": "s1",
"type_id": "masonry_wet",
"start_m": 0.0,
"end_m": 10.0,
"options": {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45, **options},
}
def 성분(**options: object) -> dict:
components, _ = stone_masonry(
2.5, 10.0, {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45, **options}, wet=True
)
return {c.name: c for c in components}
def test_기본은_캔다() -> None:
"""법이 그쪽을 권한다 — 별표2 「야면석 등은 **가급적 현장에서 채취·사용**」."""
assert is_collected_stone({}) is True
assert "채집석" in 성분()
def test_구입으로_두면_공제가_없다() -> None:
for value in ("구입", "purchase", "buy"):
assert is_collected_stone({"stone_supply": value}) is False, value
assert "채집석" not in 성분(stone_supply="구입")
def test_양수로_낸다() -> None:
"""⚠ 실무 시트가 `−274.66` 이라 부호를 넘기면 **두 번 뒤집힌다.**"""
got = 성분()["채집석"]
assert got.unit == ""
assert got.amount > 0
def test_여기서_빼지_않는다는_것이_근거에_적힌다() -> None:
basis = 성분()["채집석"].basis
assert "사토에서 한 번만" in basis
def test_자재총괄에_안_섞인다() -> None:
"""⚠ 자재는 `material` 만 모은다 — 채집석이 자재로 서면 같은 돌을 두 번 센다."""
unit = build_table([구조물()])
material = build_material_table(unit)
assert all(row["name"] != "채집석" for row in material["rows"])
def test_구조물마다_갈리고_합산해서_하나로_간다() -> None:
unit = build_table(
[
구조물(),
{
"structure_id": "s2",
"type_id": "masonry_dry",
"start_m": 20.0,
"end_m": 30.0,
"options": {
"height_m": 2.0,
"length_m": 10.0,
"back_len_cm": 35,
"stone_supply": "구입",
},
},
]
)
only_collected = 성분()["채집석"].amount
assert unit[COLLECTED_STONE_KEY] == round(only_collected, 3)
assert build_handoff(unit_quantity_table=unit)["collected_stone_deduction_m3"] == round(
only_collected, 3
)
@@ -0,0 +1,286 @@
"""토공집계표·프로젝트 설정 검사 — PLAN 8-11·8-13·8-7.
여기서 못 박는 것 셋
· 암 갈래 **개수를 코드에 안 박음** — 공사마다 다르다(울진 2 · 거창 5 · BOM 1).
· 반영률 **기본 100 %** — 실무 관측 80/50/80 은 기본값이 아니다(★법대로).
· 무대는 **집계에는 오르되 내역 줄이 아니다** — 품셈 1-2-7.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import ( # noqa: E402
SummaryInput,
build_rows,
build_table,
haul_check,
totals_by_unit,
)
from common_util.common_util_project_settings import ( # noqa: E402
APPLICATION_RATIO_KEYS,
ROCK_CLASS_SETS,
application_ratio,
default_settings,
load_settings,
quantity_settings,
rock_classes,
save_section,
)
def 토적표합계() -> dict[str, float]:
return {
"cut_soil_volume_m3": 2526.99,
"cut_rock_volume_m3": 3512.09,
"ditch_soil_volume_m3": 90.51,
"ditch_rock_volume_m3": 101.22,
"adjusted_total_m3": 6511.06,
"fill_volume_m3": 16836.47,
"diverted_m3": 5801.21,
}
def 사면합계() -> dict[str, float]:
return {
"face_dressing_fill": 13518.6,
"face_dressing_cut": 5433.7,
"tree_removal_fill": 13518.6,
"tree_removal_cut": 5433.7,
"bench_cut_fill": 13518.6,
}
# ── 암 갈래 — 개수를 코드에 박지 않는다 ─────────────────────────────
def test_비율이_없으면_암을_쪼개지_않음() -> None:
"""지어낸 비율로 나누지 않는다 — 「암」 한 줄로 낸다."""
rows = build_rows(
SummaryInput(
earthwork_totals=토적표합계(),
rock_classes=list(ROCK_CLASS_SETS["geochang5"]),
rock_ratios_pct={},
)
)
cut = [r for r in rows if r.group == "흙깎기"]
assert [r.item for r in cut] == ["토사", ""]
assert cut[1].amount == pytest.approx(3512.09)
def test_울진2갈래() -> None:
rows = build_rows(
SummaryInput(
earthwork_totals=토적표합계(),
rock_classes=list(ROCK_CLASS_SETS["uljin2"]),
rock_ratios_pct={"연암": 20, "발파암": 80},
)
)
cut = [r for r in rows if r.group == "흙깎기"]
assert [r.item for r in cut] == ["토사", "연암", "발파암"]
assert cut[1].amount == pytest.approx(3512.09 * 0.2)
assert cut[2].amount == pytest.approx(3512.09 * 0.8)
def test_거창5갈래() -> None:
"""같은 코드가 갈래 수만 바꿔 선다 — 개수를 박지 않았다는 증거."""
rows = build_rows(
SummaryInput(
earthwork_totals=토적표합계(),
rock_classes=list(ROCK_CLASS_SETS["geochang5"]),
rock_ratios_pct={"풍화암": 10, "연암": 60, "보통암": 25, "경암": 5},
)
)
cut = [r for r in rows if r.group == "흙깎기"]
assert [r.item for r in cut] == ["토사", "풍화암", "연암", "보통암", "경암"]
assert sum(r.amount for r in cut[1:]) == pytest.approx(3512.09)
def test_비율_합이_100이_아니어도_총량은_보존() -> None:
"""설계자가 60/30 만 넣어도 암 총량이 새면 안 된다 — 준 비율끼리 안분한다."""
rows = build_rows(
SummaryInput(
earthwork_totals=토적표합계(),
rock_classes=list(ROCK_CLASS_SETS["uljin2"]),
rock_ratios_pct={"연암": 60, "발파암": 30},
)
)
cut = [r for r in rows if r.group == "흙깎기"]
assert sum(r.amount for r in cut[1:]) == pytest.approx(3512.09)
# 60/30 이 실제로는 66.7/33.3 으로 돈다 — 값이 말없이 바뀌므로 비고에 드러낸다.
assert cut[1].amount == pytest.approx(3512.09 * 60 / 90)
assert "90" in cut[1].note and "안분" in cut[1].note
def test_비율_합이_100이면_비고가_비어_있음() -> None:
"""제대로 넣었는데 안내가 뜨면 잡음이 된다."""
rows = build_rows(
SummaryInput(
earthwork_totals=토적표합계(),
rock_classes=list(ROCK_CLASS_SETS["uljin2"]),
rock_ratios_pct={"연암": 20, "발파암": 80},
)
)
cut = [r for r in rows if r.group == "흙깎기"]
assert all(r.note == "" for r in cut[1:])
# ── 반영률 — 기본 100 % ─────────────────────────────────────────
def test_반영률_기본은_100퍼센트() -> None:
rows = build_rows(SummaryInput(earthwork_totals=토적표합계(), slope_totals=사면합계()))
compaction = next(r for r in rows if r.group == "성토면다짐")
assert compaction.amount == pytest.approx(13518.6)
assert compaction.note == "" # 기본이면 비고에 아무것도 안 적는다
def test_반영률을_주면_곱해지고_비고에_남음() -> None:
"""실무 시트가 비고란에 적던 그 자리다 — 누가 정한 값인지 보이게 한다."""
rows = build_rows(
SummaryInput(
earthwork_totals=토적표합계(),
slope_totals=사면합계(),
application_ratios={"fill_slope_compaction": 0.8},
)
)
compaction = next(r for r in rows if r.group == "성토면다짐")
assert compaction.amount == pytest.approx(13518.6 * 0.8)
assert "80" in compaction.note
def test_초류종자살포는_성토_절토_따로() -> None:
rows = build_rows(
SummaryInput(
earthwork_totals=토적표합계(),
slope_totals=사면합계(),
application_ratios={"seed_spray_fill": 0.5, "seed_spray_cut": 1.0},
)
)
seed = next(r for r in rows if r.group == "초류종자살포")
assert seed.amount == pytest.approx(13518.6 * 0.5 + 5433.7)
# ── 무대 — 집계에는 오르되 내역 줄이 아니다 ──────────────────────
def test_무대는_내역줄이_아님() -> None:
"""품셈 1-2-7 — 소운반 20m 는 품에 포함이라 붙일 단가가 없다."""
source = SummaryInput(
earthwork_totals=토적표합계(),
haul_rows=[
{"equipment": "free_haul", "ground": "토사", "volume_m3": 871, "average_distance_m": 11.94},
{"equipment": "dozer", "ground": "토사", "volume_m3": 1170, "average_distance_m": 43.66},
{"equipment": "dump_truck", "ground": "", "volume_m3": 1714, "average_distance_m": 318.6},
],
)
rows = build_rows(source)
free = next(r for r in rows if r.group.startswith("무대"))
assert free.in_bill is False
assert "내역 제외" in free.note
assert free.amount == pytest.approx(871)
assert all(r.in_bill for r in rows if r.group in ("도자운반", "덤프운반"))
def test_무대를_내되_검산에_씀() -> None:
"""무대를 아예 안 내면 `무대+도자+덤프 = 총 운반토량` 검산이 죽는다."""
source = SummaryInput(
earthwork_totals=토적표합계(),
haul_rows=[
{"equipment": "free_haul", "volume_m3": 1000},
{"equipment": "dozer", "volume_m3": 2000},
{"equipment": "dump_truck", "volume_m3": 2801.21},
],
)
check = haul_check(source, 토적표합계())
assert check["hauled_total_m3"] == pytest.approx(5801.21)
assert check["difference_m3"] == pytest.approx(0.0, abs=0.01)
def test_평균운반거리가_비고에_남음() -> None:
rows = build_rows(
SummaryInput(
earthwork_totals=토적표합계(),
haul_rows=[{"equipment": "dozer", "ground": "토사", "volume_m3": 1170, "average_distance_m": 43.66}],
)
)
dozer = next(r for r in rows if r.group == "도자운반")
assert "43.66" in dozer.note
# ── 표 모양 ─────────────────────────────────────────────────────
def test_열_구성은_실무_시트_그대로() -> None:
table = build_table(SummaryInput(earthwork_totals=토적표합계()))
assert table["columns"] == ["구분", "공종", "규격", "단위", "", "비고"]
def test_단위를_섞어_더하지_않음() -> None:
rows = build_rows(SummaryInput(earthwork_totals=토적표합계(), slope_totals=사면합계()))
totals = totals_by_unit(rows)
assert set(totals) == {"", ""}
# ── 프로젝트 설정 ────────────────────────────────────────────────
def test_기본설정_모양(tmp_path: Path) -> None:
settings = default_settings()
assert settings["schema_version"] == 1
assert set(settings) == {"schema_version", "quantity", "estimation"}
quantity = settings["quantity"]
# override 는 기본이 None — 「안 정했으면 config 정본을 쓴다」는 뜻.
assert quantity["conversion_factors_override"] is None
assert quantity["haul_limits_m_override"] is None
# 반영률 기본 100. 실무 관측 80/50/80 을 넣지 않는다.
assert quantity["application_ratios_pct"] == {key: 100 for key in APPLICATION_RATIO_KEYS}
# estimation 은 자리만 — 채우는 것은 B09 몫.
# ⚠ 「연도」가 아니라 **판**을 가리킨다 — 제비율은 연중에도 개정된다(현행판 2026-04-13).
assert settings["estimation"]["rate_dataset"] is None
assert "rate_year" not in settings["estimation"]
def test_파일이_없으면_기본값(tmp_path: Path) -> None:
assert load_settings(tmp_path) == default_settings()
def test_깨진_파일이어도_화면은_서야_함(tmp_path: Path) -> None:
(tmp_path / "project_settings.json").write_text("{ 망가짐", encoding="utf-8")
assert load_settings(tmp_path) == default_settings()
def test_한_구획만_갈아끼움(tmp_path: Path) -> None:
"""두 페이지가 같은 파일을 쓴다 — 통째로 덮으면 상대 값이 사라진다."""
save_section(tmp_path, "estimation", {"rate_dataset": {"dataset_id": "rates", "effective_date": "2026-04-13"}})
save_section(tmp_path, "quantity", {"rock_class_set": "uljin2"})
stored = json.loads((tmp_path / "project_settings.json").read_text(encoding="utf-8"))
# 남의 구획이 살아 있다 — B08 이 저장해도 B09 값이 안 지워진다.
assert stored["estimation"]["rate_dataset"]["effective_date"] == "2026-04-13"
assert stored["quantity"]["rock_class_set"] == "uljin2"
def test_모르는_구획은_거부(tmp_path: Path) -> None:
with pytest.raises(ValueError):
save_section(tmp_path, "hacked", {})
def test_암갈래는_세트에서(tmp_path: Path) -> None:
save_section(tmp_path, "quantity", {"rock_class_set": "uljin2", "rock_classes": None})
settings = quantity_settings(tmp_path)
settings["rock_classes"] = None
assert rock_classes(settings) == list(ROCK_CLASS_SETS["uljin2"])
def test_반영률_읽기() -> None:
settings = default_settings()["quantity"]
assert application_ratio(settings, "obstacle_removal") == pytest.approx(1.0)
settings["application_ratios_pct"]["obstacle_removal"] = 80
assert application_ratio(settings, "obstacle_removal") == pytest.approx(0.8)
@@ -0,0 +1,184 @@
"""토적표 엔진 검사 — PLAN 8-4b·8-16.
정답지는 거창 실무 토적표(오솔길 `1.BOM` 과 1:1)다. 평균단면적법이 실물과 같은 값을
내는지, 첫 측점에 체적이 없는지, 값을 자르지 않는지를 못 박는다.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_EarthworkTable import ( # noqa: E402
StationArea,
build_rows,
build_table,
totals,
)
from config.config_system_design import EARTHWORK_CONVERSION_FACTORS # noqa: E402
SOIL_C = EARTHWORK_CONVERSION_FACTORS["soil"]["compacted"]
def 거창_앞_네_측점() -> list[StationArea]:
"""거창 `1.BOM` 앞부분 — 절토 토사만 있고 측구는 단면 0.18㎡ 고정인 구간.
BOM 값: 0(0.00) · 0+10(1.89) · 1(1.73) · 1+10(1.45), 거리 10m 등간.
"""
return [
StationArea(chainage_m=0.0, cut_soil_area_m2=0.00, ditch_area_m2=0.0),
StationArea(chainage_m=10.0, cut_soil_area_m2=1.89, ditch_area_m2=0.18),
StationArea(chainage_m=20.0, cut_soil_area_m2=1.73, ditch_area_m2=0.18),
StationArea(chainage_m=30.0, cut_soil_area_m2=1.45, ditch_area_m2=0.18),
]
def test_첫_측점은_체적이_없음() -> None:
"""앞 측점이 없으면 평균을 낼 수 없다 — 실무 토적표도 첫 행 체적이 비어 있다."""
rows = build_rows(거창_앞_네_측점())
assert rows[0].distance_m == 0.0
assert rows[0].cut_soil_volume_m3 == 0.0
assert rows[0].adjusted_total_m3 == 0.0
def test_평균단면적법_실무값_재현() -> None:
"""BOM 0+10 행: 단면적 1.89 → 체적 9.45 → 보정 8.50 (거리 10m)."""
rows = build_rows(거창_앞_네_측점())
row = rows[1]
assert row.distance_m == pytest.approx(10.0)
assert row.cut_soil_volume_m3 == pytest.approx(9.45) # (0.00+1.89)/2 × 10
assert row.cut_soil_adjusted_m3 == pytest.approx(9.45 * SOIL_C)
assert row.cut_soil_adjusted_m3 == pytest.approx(8.505) # BOM 표기 8.50
def test_평균단면적법_다음_측점() -> None:
"""BOM 1 행: (1.89+1.73)/2 × 10 = 18.10 → 보정 16.29."""
row = build_rows(거창_앞_네_측점())[2]
assert row.cut_soil_volume_m3 == pytest.approx(18.10)
assert row.cut_soil_adjusted_m3 == pytest.approx(16.29)
def test_측구_단면_0_18이_체적으로() -> None:
"""측구 0.18㎡ 가 이어지면 10m 당 1.80㎥ — BOM 10열과 같다."""
rows = build_rows(거창_앞_네_측점())
assert rows[2].ditch_soil_volume_m3 == pytest.approx(1.80)
assert rows[2].ditch_soil_adjusted_m3 == pytest.approx(1.62)
def test_보정량계는_네_갈래_합() -> None:
row = build_rows(거창_앞_네_측점())[2]
assert row.adjusted_total_m3 == pytest.approx(
row.cut_soil_adjusted_m3
+ row.cut_rock_adjusted_m3
+ row.ditch_soil_adjusted_m3
+ row.ditch_rock_adjusted_m3
)
assert row.adjusted_total_m3 == pytest.approx(16.29 + 1.62) # BOM 15열 17.91
def test_누가토량은_차인토량의_누계() -> None:
"""유토곡선의 원본이 이 열이다 — 누계가 어긋나면 곡선이 통째로 틀린다."""
rows = build_rows(거창_앞_네_측점())
running = 0.0
for row in rows:
running += row.balance_m3
assert row.cumulative_m3 == pytest.approx(running)
def test_성토가_있으면_유용토는_작은_쪽() -> None:
stations = [
StationArea(chainage_m=0.0),
StationArea(chainage_m=10.0, cut_soil_area_m2=2.0, fill_area_m2=8.0),
]
row = build_rows(stations)[1]
assert row.adjusted_total_m3 == pytest.approx(10.0 * SOIL_C) # 절취 10㎥ → 보정 9㎥
assert row.fill_volume_m3 == pytest.approx(40.0)
assert row.diverted_m3 == pytest.approx(9.0) # 둘 중 작은 쪽
assert row.balance_m3 == pytest.approx(9.0 - 40.0)
def test_암은_토사와_다른_계수() -> None:
"""토사 0.90 · 리핑암 1.15 — 한 계수로 뭉치면 암 물량이 틀린다."""
stations = [
StationArea(chainage_m=0.0),
StationArea(chainage_m=10.0, cut_rock_area_m2=2.0, cut_rock_kind="ripping_rock"),
]
row = build_rows(stations)[1]
assert row.cut_rock_volume_m3 == pytest.approx(10.0)
assert row.cut_rock_adjusted_m3 == pytest.approx(
10.0 * EARTHWORK_CONVERSION_FACTORS["ripping_rock"]["compacted"]
)
def test_측구_안분은_절토_토사암_비율() -> None:
"""측구 지반이 따로 안 오므로 그 측점 절토 비율로 가른다(엔진 주석의 TODO 자리)."""
stations = [
StationArea(chainage_m=0.0),
StationArea(
chainage_m=10.0,
cut_soil_area_m2=3.0,
cut_rock_area_m2=1.0,
ditch_area_m2=0.4,
cut_rock_kind="ripping_rock",
),
]
row = build_rows(stations)[1]
assert row.ditch_soil_area_m2 == pytest.approx(0.3)
assert row.ditch_rock_area_m2 == pytest.approx(0.1)
def test_값을_자르지_않을것() -> None:
"""품셈 1-2-2 는 표기 규칙이다 — 엔진은 전정밀로 둔다(PLAN 8-16)."""
stations = [StationArea(chainage_m=0.0), StationArea(chainage_m=7.0, cut_soil_area_m2=1.0)]
row = build_rows(stations)[1]
assert row.cut_soil_volume_m3 == pytest.approx(3.5)
assert row.cut_soil_adjusted_m3 == pytest.approx(3.15)
assert repr(row.cut_soil_adjusted_m3) != "3.2"
def test_합계행() -> None:
rows = build_rows(거창_앞_네_측점())
total = totals(rows)
assert total["distance_m"] == pytest.approx(30.0)
assert total["cut_soil_volume_m3"] == pytest.approx(
sum(r.cut_soil_volume_m3 for r in rows)
)
def test_표_모양() -> None:
table = build_table(거창_앞_네_측점())
assert table["method"] == "average_end_area"
assert table["station_count"] == 4
assert table["conversion_factors"] is EARTHWORK_CONVERSION_FACTORS
assert len(table["rows"]) == 4
assert "cut_soil_adjusted_m3" in table["rows"][1]
def test_설계결과에서_담기() -> None:
"""B06 설계 결과의 키 이름을 그대로 받는다 — 이름이 어긋나면 0 이 조용히 들어간다."""
design = {
"cut_soil_area_m2": 1.5,
"cut_rock_area_m2": 0.5,
"fill_area_m2": 2.0,
"ditch_area_m2": 0.18,
"cut_rock_kind": "blasting_rock",
}
area = StationArea.from_design(20.0, design)
assert area.chainage_m == 20.0
assert area.cut_soil_area_m2 == 1.5
assert area.cut_rock_kind == "blasting_rock"
def test_측점_순서가_뒤죽박죽이어도_이정순으로() -> None:
stations = [
StationArea(chainage_m=20.0, cut_soil_area_m2=1.0),
StationArea(chainage_m=0.0),
StationArea(chainage_m=10.0, cut_soil_area_m2=2.0),
]
rows = build_rows(stations)
assert [r.chainage_m for r in rows] == [0.0, 10.0, 20.0]
+120
View File
@@ -0,0 +1,120 @@
"""거푸집 사용횟수 검사 — 품셈 1-7-1 · 2026-09-07 ⑩.
⚠ 사용횟수는 **관측값이 아니라 법**이다 — 품셈 1-7-1 이 구조물 종류별로 정해 둔다.
⚠⚠ **횟수별 재료 환산은 여기서 하지 않는다.** 품셈 12-4 의 비율(%)은 일위대가 재료비에
걸리는 값이라, B08 이 곱해 넘기면 B09 가 또 곱해 두 번 준다.
"""
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_Formwork import ( # noqa: E402
FORMWORK_NAMES,
NOTE_REUSE_MISSING,
FormworkTable,
annotate,
load_formwork_table,
shoring_status,
)
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402
def 옹벽() -> dict:
return {
"structure_id": "w1",
"type_id": "retaining_wall",
"start_m": 100.0,
"end_m": 110.0,
"options": {"form": "반중력식", "height_m": 2.0, "length_m": 10.0},
}
def test_원문에_이름이_있는_구조물은_횟수가_붙음() -> None:
"""품셈 1-7-1 의 3회 줄에 「옹벽」이 그대로 있다."""
table = build_table([옹벽()], {"retaining_wall": "옹벽"})
forms = [c for s in table["structures"] for c in s["components"] if c["name"] in FORMWORK_NAMES]
assert forms
assert all(c["reuse_count"] == 3 for c in forms)
assert all("1-7-1" in c["reuse_note"] for c in forms)
assert table["formwork_reuse_missing"] == []
def test_횟수별_비율을_여기서_곱하지_않을것() -> None:
"""⚠ 이 시험이 이 파일의 핵심 — 합판 3회 46.1 % 를 여기서 곱하면 B09 와 겹쳐 두 번 준다.
B08 이 내는 것은 **접촉 면적 그대로**이고 횟수는 옆에 적기만 한다."""
table = build_table([옹벽()], {"retaining_wall": "옹벽"})
euroform = next(
c for s in table["structures"] for c in s["components"] if c["name"] == "유로폼"
)
assert euroform["amount"] == 32.0 # 3.20 ㎡/m × 10m — 46.1 % 를 곱하지 않았다
# 비율표는 데이터에 있되 값에 안 걸린다.
assert load_formwork_table().reuse_ratio_pct["plywood"]["3"] == 46.1
def test_모르는_종류는_지어내지_않고_미확보() -> None:
structures = [
{
"type_id": "듣도보도못한구조물",
"name": "무엇",
"components": [{"name": "합판거푸집", "unit": "", "amount": 5.0}],
}
]
notes, missing = annotate(structures)
assert missing == ["듣도보도못한구조물"]
assert structures[0]["components"][0]["reuse_count"] is None
assert structures[0]["components"][0]["reuse_note"] == NOTE_REUSE_MISSING
def test_거푸집이_없는_구조물은_건드리지_않음() -> None:
structures = [
{
"type_id": "retaining_wall",
"components": [{"name": "콘크리트", "unit": "", "amount": 1.0}],
}
]
notes, missing = annotate(structures)
assert notes == [] and missing == []
assert "reuse_count" not in structures[0]["components"][0]
def test_이름은_정확히_일치로만_본다() -> None:
"""부분일치면 「거푸집씻기」(공사용수 항목)가 거푸집으로 잡힌다."""
structures = [
{
"type_id": "retaining_wall",
"components": [{"name": "거푸집씻기", "unit": "", "amount": 1.0}],
}
]
annotate(structures)
assert "reuse_count" not in structures[0]["components"][0]
def test_파일이_없으면_전부_미확보() -> None:
structures = [
{
"type_id": "retaining_wall",
"components": [{"name": "유로폼", "unit": "", "amount": 1.0}],
}
]
notes, missing = annotate(structures, FormworkTable())
assert missing == ["retaining_wall"]
# ── 동바리 ──────────────────────────────────────────────────────────
def test_동바리는_대상이_없으면_없다고_말할것() -> None:
"""0 으로 적으면 「대상이 없음」과 「값이 0」이 구별되지 않는다."""
status = shoring_status()
assert status["applicable"] is False
assert status["reason"]
assert set(status["pending_types"]) == {"box_culvert", "ford_bridge"}
def test_표에도_동바리_상태가_실림() -> None:
assert build_table([옹벽()])["shoring"]["applicable"] is False
@@ -0,0 +1,77 @@
"""규준틀 재료 — **세는 것은 확정이고 수량만 몰랐던 자리** (2026-09-09).
품셈 11-2·11-3 [주]④ 「재료량은 **설계수량에 따른다**」 ⇒ 품셈이 값을 안 주는 것이지
「안 센다」가 아니다. 근주이식·임목파쇄(**셀지 말지가 설계 판단**)와 성격이 다르다.
그래서 **제안값을 보이고 고칠 수 있게** 둔다 — 확정 ⑨·⑩ 과 같은 틀.
⚠ 겨누는 것 여섯
① 개소가 서면 재료도 선다
② ⚠ 개소가 안 서면 재료도 안 선다 — 밑수가 그 줄이다
③ 값은 **제안값**이고 산출 조건이 이긴다
④ ⚠ 제안값이 **실무 관측값이지 법정 기준이 아님**이 사유에 적힌다
⑤ 손율은 원문값이 함께 적힌다(비탈 50% · 수평 80%)
⑥ 자재 축으로 간다(할증은 자재총괄에서 한 번만)
"""
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_Preparation import ( # noqa: E402
FRAME_MATERIAL_SUGGESTED,
frame_material_rows,
)
규준틀 = [
{"item": "비탈 규준틀", "unit": "개소", "amount": 28.0},
{"item": "수평 규준틀", "unit": "개소", "amount": 33.0},
]
def 이름별(rows: list[dict]) -> dict:
return {(row["source"], row["name"]): row for row in rows}
def test_개소가_서면_재료도_선다() -> None:
rows = 이름별(frame_material_rows(규준틀))
assert abs(rows[("비탈 규준틀", "각재 50×50")]["amount"] - 28.0 * 0.0044) < 1e-9
assert abs(rows[("수평 규준틀", "")]["amount"] - 33.0 * 0.03) < 1e-9
def test_개소가_안_서면_재료도_안_선다() -> None:
"""⚠ 밑수가 개소 줄이다 — 개소가 없으면 재료를 지어내지 않는다."""
assert frame_material_rows([{"item": "비탈 규준틀", "amount": None}]) == []
def test_산출_조건이_이긴다() -> None:
rows = 이름별(frame_material_rows(규준틀, {"": 0.05}))
assert abs(rows[("비탈 규준틀", "")]["amount"] - 28.0 * 0.05) < 1e-9
assert "고른 값" in rows[("비탈 규준틀", "")]["basis"]
def test_관측값이지_법정_기준이_아님이_적힌다() -> None:
"""⚠ 값만 박고 근거를 안 보이면 「대신 페이지에 남길 것」 지시를 어기는 것이다."""
basis = 이름별(frame_material_rows(규준틀))[("비탈 규준틀", "각재 50×50")]["basis"]
assert "실무 관측값" in basis and "법정 기준 아님" in basis
assert "설계수량에 따른다" in basis
def test_손율이_원문값으로_적힌다() -> None:
rows = 이름별(frame_material_rows(규준틀))
assert "손율 50%" in rows[("비탈 규준틀", "판재 T12")]["basis"]
assert "손율 80%" in rows[("수평 규준틀", "판재 T12")]["basis"]
def test_자재_축으로_간다() -> None:
assert all(row["destination"] == "material" for row in frame_material_rows(규준틀))
def test_제안값이_실무_관측값과_같다() -> None:
"""울진 소광 §8 — 각재 50×50 0.0044㎥ · 판재 T12 0.0029㎥ · 못 0.03㎏(개소당)."""
assert FRAME_MATERIAL_SUGGESTED["각재 50×50"][0] == 0.0044
assert FRAME_MATERIAL_SUGGESTED["판재 T12"][0] == 0.0029
assert FRAME_MATERIAL_SUGGESTED[""][0] == 0.03
+40
View File
@@ -0,0 +1,40 @@
"""갈래 이름 별칭 — 우리 「리핑암」 ↔ 일위대가 「파쇄암」 (2026-09-08).
⚠ 겨누는 것 넷
① 인계본에 별칭이 실린다 — 받는 쪽이 이름만 달라 못 붙이던 자리(도자 운반 ≒ 30만원)
② ⚠ 갈래 이름 **자체는 안 바뀐다** — 흙깎기(FP-09-04)가 「리핑암」으로 서 있다
③ 근거가 데이터에 적혀 있다(품셈 10-11 f 「파쇄암 1/1.35」 = 10-12 [주]③ 「암절취 1.35」)
④ 별칭 표가 비어도 인계본은 그 칸을 갖는다(빈 칸과 없는 칸을 가른다)
"""
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_Handoff import build_handoff, load_mapping # noqa: E402
from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import WorkItemMapping # noqa: E402
def test_인계본에_별칭이_실린다() -> None:
aliases = build_handoff()["ground_class_aliases"]
assert "파쇄암" in aliases["리핑암"]["names"]
def test_갈래_이름_자체는_안_바뀐다() -> None:
"""⚠ 이름을 갈면 흙깎기 매핑(FP-09-04 리핑암)이 어긋난다 — 잇기만 한다."""
mapping = load_mapping()
assert mapping.for_earthwork("흙깎기", "리핑암")["work_item_code"] == "FP-09-04"
assert mapping.for_earthwork("흙깎기", "파쇄암") is None
def test_근거가_데이터에_적혀_있다() -> None:
basis = load_mapping().ground_aliases["aliases"]["리핑암"]["basis"]
assert "10-11" in basis and "10-12" in basis and "1.35" in basis
def test_표가_비어도_칸은_있다() -> None:
assert build_handoff(mapping=WorkItemMapping())["ground_class_aliases"] == {}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,211 @@
"""인계 계약 — **내보내는 키가 계약에 적힌 것과 같은지** (2026-09-07 ㉒ 앞).
⚠ 오늘 이 병이 계약 양쪽에서 하나씩 났다.
· 우리 쪽 — 레지스트리 키(`back_len_cm`)와 엔진이 읽는 키가 달라 **저장값이 안 닿음**.
· 받는 쪽 — 우리가 보낸 `composite_parts`·`quantity_gross` 등을 **안 읽고 버림**.
**「보내는 쪽은 보냈는데 받는 쪽이 안 읽는」 자리는 양쪽 다 조용하다.**
⇒ 키를 늘릴 때 **계약에 안 적고 늘리면 받는 쪽이 영영 모른다.** 이 시험이 그것을 막는다.
"""
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_Handoff import build_handoff # noqa: E402
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit # noqa: E402
#: **작업 공종 줄**이 내보내는 칸. 늘릴 때는 계약(8-2)에도 적고 받는 쪽에 알릴 것.
WORK_ITEM_KEYS = {
"work_item_code",
"name",
"spec",
"unit",
"quantity",
"quantity_gross",
"application_ratio_pct",
"application_ratio_breakdown",
"quantity_breakdown",
"ground_class",
"excavation_method",
"haul_distance_m",
"haul_equipment",
"station_from",
"station_to",
"spec_detail",
"composite_parts",
"structure_kind",
"variant_axis",
"variant_value",
# ⚠ 갈래 축이 둘 이상인 자리(돌쌓기 = 뒷길이 × 돌 종류) — 2026-09-08 추가.
"secondary_axes",
"spec_class",
"spec_class_basis",
"blocked_kind",
"blocked_reason",
"composite_not_ready",
"in_bill",
"in_bill_reason",
"origin",
}
#: **자재 줄**이 내보내는 칸. ⚠ 여기에 `work_item_code` 가 들어가면 안 된다(축이 다르다).
MATERIAL_KEYS = {
"material_name",
"spec",
"unit",
"net_amount",
"total_amount",
"surcharge_pct",
"surcharge_note",
"supply_type",
"install_by",
"source_structure",
}
def _rows() -> dict:
unit = build_unit(
[
{
"structure_id": "w1",
"type_id": "retaining_wall",
"start_m": 100.0,
"end_m": 110.0,
"options": {"form": "반중력식", "height_m": 2.0, "length_m": 10.0},
}
],
{"retaining_wall": "옹벽"},
)
from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table as build_material
haul = {
"rows": [
{
"equipment": "dozer",
"ground": "토사",
"volume_m3": 10.0,
"average_distance_m": 40.0,
"in_bill": True,
}
]
}
summary = {"rows": [{"group": "흙깎기", "item": "토사", "unit": "", "amount": 5.0}]}
return build_handoff(
summary_table=summary,
haul_table=haul,
unit_quantity_table=unit,
material_table=build_material(unit),
)
def test_작업_공종_줄의_칸이_계약과_같을것() -> None:
"""⚠ 계약에 없는 칸을 늘리면 받는 쪽이 영영 모른다."""
for row in _rows()["work_items"]:
extra = set(row) - WORK_ITEM_KEYS
assert not extra, f"계약에 없는 칸: {extra}"
missing = WORK_ITEM_KEYS - set(row)
assert not missing, f"계약에 있는데 안 보내는 칸: {missing}"
def test_자재_줄의_칸이_계약과_같을것() -> None:
for row in _rows()["materials"]:
assert set(row) == MATERIAL_KEYS, set(row) ^ MATERIAL_KEYS
def test_자재_줄에_공종코드가_없을것() -> None:
"""축이 둘이라는 것은 주석이 아니라 계약으로 지킨다."""
assert "work_item_code" not in MATERIAL_KEYS
for row in _rows()["materials"]:
assert "work_item_code" not in row
def test_막힌_까닭이_세_갈래_중_하나일것() -> None:
"""받는 쪽이 「입력하면 풀리는 것」과 「우리가 만들어야 하는 것」을 갈라야 한다."""
from B08_Quantity.B08_Quantity_Engine_Handoff import (
BLOCKED_FORMULA_MISSING,
BLOCKED_INPUT_MISSING,
BLOCKED_UNIT_DATA_MISSING,
)
allowed = {None, BLOCKED_INPUT_MISSING, BLOCKED_UNIT_DATA_MISSING, BLOCKED_FORMULA_MISSING}
for row in _rows()["work_items"]:
assert row["blocked_kind"] in allowed
def test_막힌_줄에는_사유가_반드시_있을것() -> None:
"""갈래만 있고 사유가 없으면 받는 쪽 화면이 빈다."""
for row in _rows()["work_items"]:
if row["blocked_kind"]:
assert row["blocked_reason"], row["name"]
# ── 갈래 키를 우리가 조립하지 않는다 (2026-09-07 계약 변경) ─────────
#
# ⚠ 품셈 원문이 물결표를 섞어 쓴다 — 13-06-01·02 는 ``(U+223C), 13-06-03 은 ``(U+FF5E).
# 두 창이 각자 키 문자열을 만들면 **글자 하나로 영영 안 맞는다.**
# ⇒ 우리는 **축과 저장 원본값**만 보내고, 원문을 읽는 쪽이 그 표기를 흡수한다.
def _structure_rows() -> list[dict]:
out = []
for type_id, options in (
("masonry_wet", {"height_m": 1.5, "length_m": 10.0, "back_len_cm": "45"}),
(
"boulder_masonry",
{"height_m": 2.0, "length_m": 10.0, "stone_cm": "60~80", "bond": "찰쌓기"},
),
):
unit = build_unit(
[
{
"structure_id": "x",
"type_id": type_id,
"start_m": 0.0,
"end_m": 10.0,
"options": options,
}
],
{type_id: type_id},
)
out.extend(build_handoff(unit_quantity_table=unit)["work_items"])
return out
def test_축과_원본값을_그대로_보낼것() -> None:
rows = {row["variant_axis"]: row for row in _structure_rows() if row["variant_axis"]}
assert rows["back_len_cm"]["variant_value"] == "45"
assert rows["stone_cm"]["variant_value"] == "60~80"
def test_갈래_키_문자열을_코드에_붙이지_말것() -> None:
"""⚠ 이 시험이 계약 변경을 지킨다 — `#55cm이하`·`#직경60㎝이상∼80㎝미만` 을 만들면 깨진다.
⚠ **메/찰은 예외다** — 그것은 키 표기가 아니라 **공종 자체가 갈리는 의미 판정**이라
코드가 `FP-13-06-01`/`-02` 로 통째로 달라진다.
"""
for row in _structure_rows():
code = str(row["work_item_code"] or "")
assert "#" not in code, f"갈래 키를 코드에 붙였다: {code}"
assert "" not in code and "cm" not in code
def test_원본값을_가공하지_말것() -> None:
"""물결표·공백을 우리가 손대면 받는 쪽이 원문과 대조를 못 한다."""
rows = {row["variant_axis"]: row for row in _structure_rows() if row["variant_axis"]}
raw = rows["stone_cm"]["variant_value"]
assert raw == "60~80" # 물결표 종류를 바꾸거나 「㎝」를 붙이지 않는다
def test_돌_종류를_둘째_축으로_보낼것() -> None:
"""⚠ 품셈 13-4 품은 [주]② 로 **깬돌·깬잡석 전용**이고 돌 종류로 갈리는 표는 13-5 다.
어느 공종으로 볼지는 사용자 확정 대기라 **공종을 바꾸지 않고 원본값만** 실어 보낸다.
"""
rows = {row["variant_axis"]: row for row in _structure_rows() if row["variant_axis"]}
axes = rows["back_len_cm"]["secondary_axes"] or []
assert [item["axis"] for item in axes] == ["stone_kind"]
@@ -0,0 +1,76 @@
"""운반이 두 축에 실리던 자리 (2026-09-09 실측).
⚠⚠ 토공집계표는 실무 토적집계 모양이라 「무대·도자운반·덤프운반」을 함께 싣고,
인계본에는 운반표(FP-10-11·FP-10-12)가 **같은 물량으로 또 실렸다.**
실측 — 도자 17.389·61.130 · 덤프 37.511·122.417 이 두 축에 각각 있었고 **둘 다 in_bill**.
⚠ 겨누는 것 넷
① 집계 쪽 운반 줄은 **내역 줄이 아니다**
② 그래도 **값은 남는다** — 검산(무대+도자+덤프 = 총 운반토량)이 그 값을 씀
③ 사유가 적힌다 — 「내역 줄은 운반표 쪽」
④ 운반표 쪽은 그대로 내역 줄로 선다
"""
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_Handoff import build_handoff # noqa: E402
집계 = {
"rows": [
{"group": "도자운반", "unit": "", "amount": 17.39, "in_bill": True},
{"group": "덤프운반", "unit": "", "amount": 37.51, "in_bill": True},
{"group": "무대(종방향유용토)", "unit": "", "amount": 14.76, "in_bill": False},
{"group": "성토", "unit": "", "amount": 100.0, "in_bill": True},
]
}
운반 = {
"rows": [
{
"equipment": "dozer",
"ground": "토사",
"volume_m3": 15.65,
"natural_m3": 17.39,
"conversion_c": 0.9,
"average_distance_m": 43.66,
"in_bill": True,
}
]
}
def 줄들() -> dict:
rows = build_handoff(summary_table=집계, haul_table=운반)["work_items"]
return {row["name"]: row for row in rows}
def test_집계_쪽_운반은_내역_줄이_아니다() -> None:
rows = 줄들()
assert rows["도자운반"]["in_bill"] is False
assert rows["덤프운반"]["in_bill"] is False
def test_값은_남는다() -> None:
"""⚠ 빼 버리면 검산이 안 된다 — 무대를 그렇게 둔 것과 같은 규칙."""
assert 줄들()["도자운반"]["quantity"] == 17.39
def test_사유가_적힌다() -> None:
reason = 줄들()["도자운반"]["in_bill_reason"]
assert "내역 줄은 운반표 쪽" in reason
def test_운반표_쪽은_그대로_선다() -> None:
row = 줄들()["dozer 운반"]
assert row["in_bill"] is True and row["work_item_code"] == "FP-10-11"
assert row["quantity"] == 17.39
def test_토공_줄은_안_건드린다() -> None:
"""⚠ 성토처럼 집계에만 있는 줄은 그대로 내역 줄이다 — 운반 계열만 가른다."""
assert 줄들()["성토"]["in_bill"] is True
+209
View File
@@ -0,0 +1,209 @@
"""유토곡선에 넘길 구조물 몫 — 채집석 공제 · 구조물 잔토 (2026-09-08).
⚠ 겨누는 것 다섯
① 둘 다 **양수**로 낸다 — 빼고 더하는 것은 받는 쪽(B06) 몫
② ⚠ 「아직 안 옴(None)」과 「없음(0)」을 가른다
③ **측점별로도** 낸다 — 총량만 주면 잔량 비례로 흩어져 운반거리가 틀어진다
④ 구조물이 구간이라 **가운데 측점**을 자리로 본다
⑤ 칸 이름이 받는 쪽과 같은 낱말이다
"""
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_HaulInputs import ( # noqa: E402
STRUCTURE_SPOIL_KEY,
STRUCTURE_SPOIL_POINTS_KEY,
haul_inputs,
)
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402
COLLECTED_STONE_KEY,
build_table,
)
def (*ranges: tuple[float, float]) -> dict:
return build_table(
[
{
"structure_id": f"s{index}",
"type_id": "masonry_wet",
"start_m": start,
"end_m": end,
"options": {
"height_m": 2.5,
"length_m": end - start,
"back_len_cm": 45,
"foundation": "기초유",
},
}
for index, (start, end) in enumerate(ranges)
]
)
def test_둘_다_양수로_낸다() -> None:
got = haul_inputs(((0.0, 10.0)))
assert got[COLLECTED_STONE_KEY] > 0
assert got[STRUCTURE_SPOIL_KEY] > 0
def test_아직_안_옴과_없음을_가른다() -> None:
"""⚠ 0 으로 눅이면 받는 쪽이 「없다」와 「못 받았다」를 못 가른다."""
빈값 = haul_inputs(None)
assert 빈값[STRUCTURE_SPOIL_KEY] is None
assert 빈값[COLLECTED_STONE_KEY] is None
assert 빈값[STRUCTURE_SPOIL_POINTS_KEY] == []
def test_측점별로도_낸다() -> None:
got = haul_inputs(((0.0, 10.0), (100.0, 110.0)))
points = got[STRUCTURE_SPOIL_POINTS_KEY]
assert [row["chainage_m"] for row in points] == [5.0, 105.0]
assert abs(sum(row["spoil_m3"] for row in points) - got[STRUCTURE_SPOIL_KEY]) < 0.01
def test_측점_차례로_선다() -> None:
"""⚠ 받는 쪽이 잔량에 얹을 때 차례가 어긋나면 운반거리가 틀어진다."""
points = haul_inputs(((100.0, 110.0), (0.0, 10.0)))[STRUCTURE_SPOIL_POINTS_KEY]
assert [row["chainage_m"] for row in points] == [5.0, 105.0]
def test_칸_이름이_받는_쪽과_같다() -> None:
assert STRUCTURE_SPOIL_KEY == "structure_spoil_m3"
assert COLLECTED_STONE_KEY == "collected_stone_deduction_m3"
def test_지반_갈래를_함께_낸다() -> None:
"""⚠ 새 판정이 아니라 **구조물터파기가 쓰는 그 값**이다 — 두 벌로 짜면 갈린다."""
unit = build_table(
[
{
"structure_id": "s1",
"type_id": "masonry_wet",
"start_m": 0.0,
"end_m": 10.0,
"options": {
"height_m": 2.5,
"length_m": 10.0,
"back_len_cm": 45,
"foundation": "기초유",
},
}
],
None,
None,
{0.0: "ripping_rock", 10.0: "ripping_rock"},
)
point = haul_inputs(unit)[STRUCTURE_SPOIL_POINTS_KEY][0]
assert point["ground_type"] == "ripping_rock"
assert point["ground_label"] == "암절취"
assert "암절취" in point["ground_basis"]
def test_섞인_구조물은_모름으로_보낸다() -> None:
"""⚠ 터파기에서 다수결로 안 고른 그 규칙 그대로 — 받는 쪽이 「모르는 몫」으로 드러낸다."""
unit = build_table(
[
{
"structure_id": "s1",
"type_id": "masonry_wet",
"start_m": 0.0,
"end_m": 10.0,
"options": {
"height_m": 2.5,
"length_m": 10.0,
"back_len_cm": 45,
"foundation": "기초유",
},
}
],
None,
None,
{0.0: "soil", 10.0: "blasting_rock"},
)
point = haul_inputs(unit)[STRUCTURE_SPOIL_POINTS_KEY][0]
assert point["ground_type"] is None
assert "섞여" in point["ground_basis"]
def test_상태를_값으로_적어_보낸다() -> None:
"""⚠⚠ 유토곡선 잔량은 **다짐상태**인데 이 값은 **자연상태**다 — 그냥 더하면 섞인다.
말로만 두면 다음 사람이 못 본다. 총량에도, 측점마다도 상태를 적는다.
"""
from B08_Quantity.B08_Quantity_Engine_HaulInputs import (
VOLUME_BASIS_KEY,
VOLUME_BASIS_NATURAL,
)
got = haul_inputs(((0.0, 10.0)))
assert got[VOLUME_BASIS_KEY] == VOLUME_BASIS_NATURAL == "natural"
assert all(row[VOLUME_BASIS_KEY] == "natural" for row in got[STRUCTURE_SPOIL_POINTS_KEY])
# 빈 값일 때도 칸은 있다 — 받는 쪽이 「상태를 모른다」와 「값이 없다」를 안 헷갈리게.
assert haul_inputs(None)[VOLUME_BASIS_KEY] == "natural"
def test_채집석을_갈래별로_낸다() -> None:
"""⚠ 축을 맞추려면 갈래가 있어야 한다 — 받는 쪽이 ×C 로 다짐 축에 맞춰 뺀다."""
from B08_Quantity.B08_Quantity_Engine_HaulInputs import (
COLLECTED_STONE_BY_GROUND_KEY,
COLLECTED_STONE_UNKNOWN_KEY,
)
unit = build_table(
[
{
"structure_id": "s1",
"type_id": "masonry_wet",
"start_m": 0.0,
"end_m": 10.0,
"options": {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45},
}
],
None,
None,
{0.0: "ripping_rock", 10.0: "ripping_rock"},
)
got = haul_inputs(unit)
assert got[COLLECTED_STONE_BY_GROUND_KEY]["ripping_rock"] > 0
assert got[COLLECTED_STONE_UNKNOWN_KEY] == 0.0
# 갈래별 합이 총량과 같다 — 어느 쪽으로도 새지 않는다.
assert abs(sum(got[COLLECTED_STONE_BY_GROUND_KEY].values()) - got[COLLECTED_STONE_KEY]) < 0.01
def test_갈래를_못_가른_몫은_따로_낸다() -> None:
"""⚠ 계수가 없으므로 환산하지 않는다 — 받는 쪽이 그 사실을 알아야 한다."""
from B08_Quantity.B08_Quantity_Engine_HaulInputs import (
COLLECTED_STONE_BY_GROUND_KEY,
COLLECTED_STONE_UNKNOWN_KEY,
)
unit = build_table(
[
{
"structure_id": "s1",
"type_id": "masonry_wet",
"start_m": 0.0,
"end_m": 10.0,
"options": {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45},
}
],
None,
None,
{0.0: "soil", 10.0: "blasting_rock"},
)
got = haul_inputs(unit)
assert got[COLLECTED_STONE_BY_GROUND_KEY] == {}
assert got[COLLECTED_STONE_UNKNOWN_KEY] > 0
def test_원문이_없다는_사실을_값과_함께_보낸다() -> None:
"""⚠ 벽 입적 ↔ 원바닥 암 관계를 정한 원문이 없다 — 받는 쪽이 그 한계를 알아야 한다."""
basis = haul_inputs(((0.0, 10.0)))["collected_stone_basis"]
assert "원문이 없음" in basis and "실무 시트는 그냥 뺌" in basis
+84
View File
@@ -0,0 +1,84 @@
"""운반 줄은 자연상태로 선다 (2026-09-09).
⚠⚠ **두 자가 섞여 있던 자리다.** 유토곡선은 **다짐상태**로 쌓고(운반거리를 그 기준으로 재야
맞는다) **내역서에 오르는 수량은 자연상태**다 —
`config_system_design` 5-4-3 「운반거리 산정 시 모든 수량은 다짐상태로 환산해 계산하고,
**내역서에 적용하는 수량은 자연상태로 한다**」.
⚠ 겨누는 것 넷
① 되돌린 값(`natural_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_Handoff import build_handoff # noqa: E402
def (**extra: object) -> dict:
row = {
"equipment": "dump_truck",
"ground": "리핑암",
"volume_m3": 140.78,
"average_distance_m": 318.6,
"in_bill": True,
}
row.update(extra)
return {"rows": [row]}
def (**extra: object) -> dict:
rows = build_handoff(haul_table=(**extra))["work_items"]
return next(row for row in rows if row["name"] == "dump_truck 운반")
def test_되돌린_값이_오면_그것으로_선다() -> None:
row = (natural_m3=122.42, conversion_c=1.15)
assert row["quantity"] == 122.42
def test_쓴_계수가_근거에_적힌다() -> None:
row = (natural_m3=122.42, conversion_c=1.15)
assert "÷ C 1.15" in row["spec_detail"] and "자연상태" in row["spec_detail"]
def test_되돌릴_계수가_없으면_다짐_그대로_두고_사유() -> None:
"""⚠ 토사 계수로 눅이면 근거 없이 금액이 움직인다."""
row = ()
assert row["quantity"] == 140.78
assert "다짐상태 그대로" in row["spec_detail"]
def test_거리는_안_바뀐다() -> None:
"""거리는 다짐 기준으로 재는 것이 맞다 — 상태를 되돌려도 그대로다."""
assert (natural_m3=122.42, conversion_c=1.15)["haul_distance_m"] == 318.6
def test_갈래를_통로로_보낸다() -> None:
"""⚠⚠ 운반 단가가 지반 갈래로 갈리는데 `spec` 문자열만 보내면 받는 쪽이 못 고른다.
「버림」에서 275,584원이 사라졌던 그 자리와 같다 — 갈래는 `variant_value` 한 통로로만.
"""
row = (natural_m3=122.42, conversion_c=1.15)
assert row["variant_axis"] == "ground_class"
assert row["variant_value"] == "리핑암"
# ⚠ 이름은 우리 갈래 그대로 — 일위대가의 「파쇄암」과는 인계본 별칭이 이어 준다.
assert row["spec"] == "리핑암"
def test_갈래가_없는_줄은_칸이_비어_있다() -> None:
"""무대처럼 갈래가 없는 자리에 빈 문자열을 넣지 않는다 — `None` 이라야 「없음」이다."""
rows = build_handoff(haul_table={"rows": [{"equipment": "dozer", "volume_m3": 10.0}]})[
"work_items"
]
row = next(r for r in rows if r["name"] == "dozer 운반")
assert row["variant_axis"] is None and row["variant_value"] is None
+307
View File
@@ -0,0 +1,307 @@
"""운반 가중평균 검사 — PLAN 8-3·8-7 ㉡.
실무는 (운반수단 × 지반유형)별 **가중평균 1개**를 내역에 올린다. 단순평균이 아니다.
무대는 값은 내되 **내역 줄이 되지 않는다**(품셈 1-2-7).
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_HaulSummary import ( # noqa: E402
build_table,
check_against_plan,
summary_input_rows,
)
def plan() -> dict:
"""띠 셋 + 장거리 이동 하나. 지반유형은 `HaulPlan` 이 이미 안분해 둔 값이다."""
return {
"blocks": [
{
"bands": [
{
"equipment": "free_haul",
"haul_distance_m": 12.0,
"haul_from_m": 0.0,
"haul_to_m": 12.0,
"ea_m3": 800.0,
"rr_m3": 0.0,
"br_m3": 0.0,
},
{
"equipment": "dozer",
"haul_distance_m": 40.0,
"haul_from_m": 20.0,
"haul_to_m": 60.0,
"ea_m3": 600.0,
"rr_m3": 400.0,
"br_m3": 0.0,
},
{
"equipment": "dozer",
"haul_distance_m": 50.0,
"haul_from_m": 60.0,
"haul_to_m": 110.0,
"ea_m3": 400.0,
"rr_m3": 0.0,
"br_m3": 0.0,
},
]
}
],
"transfers": [
{
"equipment": "dump_truck",
"haul_distance_m": 300.0,
"from_m": 100.0,
"to_m": 400.0,
"ea_m3": 1000.0,
"rr_m3": 0.0,
"br_m3": 500.0,
}
],
"hauled_m3": 2200.0,
"transferred_m3": 1500.0,
}
def test_가중평균이지_단순평균이_아님() -> None:
"""도자 토사 = 600㎥@40m + 400㎥@50m → (600·40+400·50)/1000 = 44m."""
rows = build_table(plan())["rows"]
dozer_soil = next(r for r in rows if r["equipment"] == "dozer" and r["ground"] == "토사")
assert dozer_soil["volume_m3"] == pytest.approx(1000.0)
assert dozer_soil["average_distance_m"] == pytest.approx(44.0)
assert dozer_soil["average_distance_m"] != pytest.approx(45.0) # 단순평균이면 45
def test_지반유형별로_갈림() -> None:
"""`HaulPlan` 이 안분해 둔 토사·리핑암·발파암을 그대로 쓴다 — 다시 판정하지 않는다."""
rows = build_table(plan())["rows"]
dozer = {r["ground"] for r in rows if r["equipment"] == "dozer"}
assert dozer == {"토사", "리핑암"}
dump = {r["ground"] for r in rows if r["equipment"] == "dump_truck"}
assert dump == {"토사", "발파암"}
def test_무대는_내역줄이_아님() -> None:
"""품셈 1-2-7 — 소운반 20m 는 품에 포함. 인력운반 10-6 도 「초과분」이다."""
table = build_table(plan())
free = next(r for r in table["rows"] if r["equipment"] == "free_haul")
assert free["in_bill"] is False
assert free["volume_m3"] == pytest.approx(800.0) # 값은 낸다
assert all(r["in_bill"] for r in table["rows"] if r["equipment"] != "free_haul")
def test_내역줄_개수는_무대를_뺀_수() -> None:
table = build_table(plan())
assert table["bill_row_count"] == len([r for r in table["rows"] if r["in_bill"]])
assert table["bill_row_count"] == 4 # 도자 토사·리핑암 · 덤프 토사·발파암
def test_근거줄이_함께_나옴() -> None:
"""어느 구간이 그 평균을 만들었는지 되짚을 수 있어야 한다."""
table = build_table(plan())
legs = table["legs"]
assert len(legs) == 6 # 무대1 + 도자3 + 덤프2
assert {leg["source"] for leg in legs} == {"band", "transfer"}
dozer_legs = [leg for leg in legs if leg["equipment"] == "dozer" and leg["ground"] == "토사"]
assert sorted(leg["distance_m"] for leg in dozer_legs) == [40.0, 50.0]
def test_줄_순서가_늘_같음() -> None:
"""내역 줄 순서가 매번 달라지면 대조를 못 한다."""
first = [(r["equipment"], r["ground"]) for r in build_table(plan())["rows"]]
second = [(r["equipment"], r["ground"]) for r in build_table(plan())["rows"]]
assert first == second
assert first[0][0] == "free_haul" # 무대가 맨 앞
def test_검산_무대를_넣어야_합이_맞음() -> None:
"""무대를 안 내면 이 대조가 죽는다 — 그래서 값은 내고 내역 줄만 뺀다."""
table = build_table(plan())
check = check_against_plan(table, plan())
assert check.hauled_total_m3 == pytest.approx(3700.0) # 800+1000+400+1000+500
assert check.plan_total_m3 == pytest.approx(3700.0)
assert check.difference_m3 == pytest.approx(0.0)
assert check.details["free_haul"] == pytest.approx(800.0)
def test_집계표_입력으로_줄임() -> None:
"""토공집계표는 근거 줄을 안 쓴다 — 내역 줄만 넘긴다.
⚠ 2026-09-09 에 칸이 **둘 늘었다** — 다짐·자연 두 상태를 함께 넘긴다
(`natural_m3`·`volume_basis`). 내역서 수량은 자연상태라(config 5-4-3) 받는 쪽이
어느 상태인지 스스로 봐야 한다. 근거 줄(`legs`)을 안 넘긴다는 계약은 그대로다.
"""
rows = summary_input_rows(build_table(plan()))
assert all(
set(row)
== {
"equipment",
"ground",
"volume_m3",
"average_distance_m",
"natural_m3",
"volume_basis",
# 쓴 계수도 함께 온다 — 받는 쪽이 되짚을 수 있어야 한다.
"conversion_c",
}
for row in rows
)
assert len(rows) == 5 # 무대 포함(집계표에는 오른다)
def test_계획이_비면_빈_표() -> None:
table = build_table(None)
assert table["rows"] == []
assert table["legs"] == []
# ── 저장 정본 모양 (2026-09-07 실증에서 잡은 자리) ──────────────────
#
# ⚠ 정본에 저장되는 것은 유토곡선 한 벌(`mass_haul`)이고 **배분은 그 안의 `haul_plan`** 이다.
# 바깥 껍데기를 그대로 넘기면 `blocks` 를 못 찾아 **운반 표가 영영 0줄**이 된다.
# [확정] 전에는 어차피 빈 표라 화면에서 티가 안 나던 자리다.
def 저장정본() -> dict:
"""서버 계산이 실제로 내는 모양 — `mass_haul` 바깥에 `haul_plan` 이 들어 있다."""
return {
"basis": "cross",
"cut_natural_m3": 5000.0,
"haul_plan": {
"hauled_m3": 3724.0,
"blocks": [
{
"bands": [
{
"equipment": "free_haul",
"haul_distance_m": 10.0,
"ea_m3": 3.9,
"rr_m3": 0,
"br_m3": 0,
},
{
"equipment": "dozer",
"haul_distance_m": 40.0,
"ea_m3": 7.8,
"rr_m3": 0,
"br_m3": 0,
},
{
"equipment": "dump_truck",
"haul_distance_m": 288.12,
"ea_m3": 3712.29,
"rr_m3": 0,
"br_m3": 0,
},
]
}
],
"transfers": [],
},
}
def test_바깥_껍데기를_그대로_주면_빈_표가_된다() -> None:
"""⚠ 이 시험이 그 사고를 못 박는다 — 빈 표는 「계획이 없다」와 구별이 안 된다."""
assert build_table(저장정본())["rows"] == []
def test_haul_plan_을_벗겨_주면_세_줄이_선다() -> None:
table = build_table(저장정본()["haul_plan"])
equipment = [
row.equipment if hasattr(row, "equipment") else row["equipment"] for row in table["rows"]
]
assert equipment == ["free_haul", "dozer", "dump_truck"]
assert table["bill_row_count"] == 2 # 무대는 내역 줄이 아니다
# ── 암 운반 (2026-09-07 실증) ────────────────────────────────────────
#
# 앞선 실증에서는 토사만 나와 **(수단 × 지반유형) 갈래가 실물로 갈리는 것을 못 봤다**.
# 암이 유용토에 실리는 노선으로 다시 돌려 6줄로 갈리는 것과 보정계수가 물리는 것을 확인했고,
# 그 모양을 여기 못 박는다.
def 암_운반계획() -> dict:
"""서버 계산이 실제로 낸 모양(리핑암 노선) — 띠마다 지반별 물량이 함께 온다."""
return {
"hauled_m3": 7179.0,
"blocks": [
{
"bands": [
{
"equipment": "free_haul",
"haul_distance_m": 10.0,
"ea_m3": 14.16,
"rr_m3": 18.09,
"br_m3": 0.0,
},
{
"equipment": "dozer",
"haul_distance_m": 40.0,
"ea_m3": 28.32,
"rr_m3": 36.19,
"br_m3": 0.0,
},
{
"equipment": "dump_truck",
"haul_distance_m": 439.92,
"ea_m3": 988.08,
"rr_m3": 6094.15,
"br_m3": 0.0,
},
]
}
],
"transfers": [],
}
def test_수단과_지반유형으로_갈려_여섯_줄이_섬() -> None:
table = build_table(암_운반계획())
pairs = [(row["equipment"], row["ground"]) for row in table["rows"]]
assert ("dump_truck", "리핑암") in pairs
assert ("free_haul", "리핑암") in pairs
assert len(table["rows"]) == 6
def test_암도_무대는_내역에_안_섬() -> None:
"""지반이 암이어도 무대는 품에 포함이다 — 지반유형이 그 규칙을 바꾸지 않는다."""
table = build_table(암_운반계획())
free = [row for row in table["rows"] if row["equipment"] == "free_haul"]
assert len(free) == 2 # 토사·리핑암
assert all(not row["in_bill"] for row in free)
assert table["bill_row_count"] == 4
def test_지반별_합이_총_운반토량과_맞을것() -> None:
table = build_table(암_운반계획())
total = sum(row["volume_m3"] for row in table["rows"])
assert total == pytest.approx(7179.0, abs=0.02) # 띠 값 자체의 반올림 몫
def test_보정계수가_다짐환산에_물릴것() -> None:
"""⚠ 실증 산수 — 리핑암 1.15 · 발파암 1.30 이 그대로 곱해진다.
자연토량 토사 1,240 · 암 5,560 인 같은 노선에서
리핑암: 1,240×0.9 + 5,560×1.15 = 1,116 + 6,394 = 7,510
발파암: 1,240×0.9 + 5,560×1.30 = 1,116 + 7,228 = 8,344
서버 계산이 낸 값과 자릿수까지 맞았다. 계수가 바뀌면 이 시험이 깨진다.
"""
from config.config_system_design import EARTHWORK_CONVERSION_FACTORS as F
assert F["soil"]["compacted"] == 0.9
assert F["ripping_rock"]["compacted"] == 1.15
assert F["blasting_rock"]["compacted"] == 1.30
assert 1240 * 0.9 + 5560 * 1.15 == pytest.approx(7510.0)
assert 1240 * 0.9 + 5560 * 1.30 == pytest.approx(8344.0)
@@ -0,0 +1,79 @@
"""마스터 밑수 판독 — 「인」과 「칸 이어붙임」 두 자리 (2026-09-09).
⚠⚠ **「인」은 품의 단위이지 공종 밑수가 아니다.** 원문 대조로 넷이 오독으로 드러났다 —
8-6-2 드론방제·8-6-3 지상방제 「(단위 : 인)」(그 「인」은 **소요인력**) ·
13-2-4 야면석 채집 「(단위: 인 당)」(표 안이 **㎡당·㎥당** 두 줄).
⚠ 넓게 「인」을 버리면 **2-2-5 천공기가 사라진다** — 「천공인부 **1인당** 1대」는 숫자가 붙었다.
⇒ **숫자가 붙었을 때만** 인정한다.
⚠⚠ **칸을 이어 붙여 읽지 않는다.** 앞 칸의 **값**과 뒤 칸의 **단위**가 붙어 없는 밑수가 생겼다 —
13-2-4 … 0.28 | **0.36** **㎥당** | 0.60 … ⇒ 「0.36㎥당」(원문에 없는 값)
5-19-2 15 | **30**(표토두께 ㎝) **㎥ 당** | … ⇒ 「30㎥당」(두께를 밑수로 읽음)
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import ( # noqa: E402
detect_basis,
person_basis_ok,
)
MASTER = json.loads(
(ROOT / "resources" / "data_work_item_master" / "work_item_master_2026-01-01.json").read_text(
encoding="utf-8"
)
)
def 밑수(code: str) -> list[tuple]:
for item in MASTER["work_items"]:
if item["work_item_code"] == code:
return [
(t.get("basis_unit"), t.get("basis_quantity")) for t in (item.get("tables") or [])
]
return []
def test_숫자가_붙어야_인을_인정한다() -> None:
assert person_basis_ok("1", "") is True
assert person_basis_ok("", "") is False
assert person_basis_ok(None, "") is False
# 「인」이 아닌 단위는 숫자가 없어도 그대로 — 종전 동작을 안 바꾼다.
assert person_basis_ok("", "") is True
def test_방제_둘은_밑수가_안_선다() -> None:
"""「(단위 : 인)」은 소요인력이지 밑수가 아니다 — 미확보가 정직하다."""
assert all(unit != "" for unit, _ in 밑수("FP-08-06-02"))
assert all(unit != "" for unit, _ in 밑수("FP-08-06-03"))
def test_천공기는_그대로_선다() -> None:
"""⚠ 넓게 버리면 사라지는 자리 — 「천공인부 1인당 1대」는 숫자가 붙었다."""
assert ("", 1.0) in 밑수("FP-02-02-05")
def test_칸을_이어_붙여_읽지_않는다() -> None:
"""앞 칸 값 + 뒤 칸 단위 = 없는 밑수. 두 실례를 그대로 잰다."""
table = {
"headers": ["뒷 길 이(㎝)", "25", "35", "45", "55", "60", ""],
"rows": [["인 부", "㎡당", "0.11", "0.17", "0.22", "0.28", "0.36"], ["㎥당", "0.60"]],
}
assert detect_basis(table) == (None, None)
두께표 = {
"headers": ["구 분", "직 종", "표 토 두 께(㎝)", "비고", ""],
"rows": [["15", "30", "", "", ""], ["㎥ 당", "보통인부", "0.14", "0.11", ""]],
}
assert 두께표 and detect_basis(두께표) == (None, None)
def test_한_칸_안의_밑수는_그대로_읽는다() -> None:
"""「100㎥당」처럼 한 칸에 다 있는 것은 종전대로 선다."""
assert detect_basis({"headers": ["(단위: 100㎥당)"], "rows": []}) == (100.0, "")
@@ -0,0 +1,100 @@
"""콘크리트를 자재 축에 세움 · 할증 이름 잇기 (2026-09-09 확정 3차 ⑥).
⚠⚠ **재료비가 통째로 빠져 있던 자리다.** 타설 줄(품셈 12-1)은 **품만** 주고 재료를 안 준다
(서브 일위대가도 재료 0원). 콘크리트를 자재 축에 안 보내면 레미콘 값이 어디에도 없다.
⚠ 겨누는 것 여섯
① 콘크리트 셋이 자재총괄에 선다
② 배합은 여전히 분해 안 함(㉢) — 「콘크리트 ㎥」에서 멈춘다
③ ⚠ 타설 줄과 **겹치지 않는다** — 그쪽은 품, 이쪽은 재료
④ 할증은 **레미콘일 때만** 붙는다 — 비빔은 시멘트·골재가 각각 할증됨
⑤ 이형철근 D13·D16 은 할증표 「이형철근」 줄로 이어진다(품셈 1-3-1, 규격 안 가림)
⑥ ⚠ 이름을 **바꾸지 않는다** — 이름만 잇는다(리핑암↔파쇄암과 같은 처방)
"""
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_Handoff import build_handoff # noqa: E402
from B08_Quantity.B08_Quantity_Engine_MaterialSummary import ( # noqa: E402
build_table as build_material,
)
from B08_Quantity.B08_Quantity_Engine_MaterialSummary import surcharge_lookup_name # noqa: E402
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402
def 단위표() -> dict:
return build_table(
[
{
"structure_id": "s1",
"type_id": "masonry_wet",
"start_m": 0.0,
"end_m": 10.0,
"options": {
"height_m": 2.5,
"length_m": 10.0,
"back_len_cm": 45,
"foundation": "기초유",
},
}
]
)
def 자재(method: str | None = None) -> dict:
return {
row["name"]: row for row in build_material(단위표(), concrete_placing_method=method)["rows"]
}
def test_콘크리트가_자재총괄에_선다() -> None:
rows = 자재()
assert rows["채움콘크리트"]["net_amount"] > 0
assert rows["버림콘크리트"]["net_amount"] > 0
def test_배합은_분해하지_않는다() -> None:
"""⚠ 시멘트·모래·자갈로 쪼개면 B09 일위대가와 두 배가 된다(㉢)."""
rows = 자재()
assert not ({"시멘트", "모래", "자갈"} & set(rows))
def test_타설_줄과_겹치지_않는다() -> None:
"""타설은 품, 자재는 재료 — 둘 다 서되 같은 것을 두 번 세지 않는다."""
rows = build_handoff(unit_quantity_table=단위표())["work_items"]
placing = [row for row in rows if row["name"] == "콘크리트 타설"]
assert len(placing) == 1
assert all(row["name"] != "채움콘크리트" for row in rows)
def test_할증은_레미콘일_때만_붙는다() -> None:
"""⚠ 비빔은 시멘트·골재가 각각 할증되는 자리라 레미콘 할증을 붙이면 틀린다."""
레미콘 = 자재("ready_mixed")["채움콘크리트"]
비빔 = 자재("machine_mixed")["채움콘크리트"]
# ⚠ 「안 정함」은 비빔이 아니다 — 타설 줄이 기본값(레디믹스트)으로 도는데 할증만
# 미확보로 두면 같은 프로젝트에서 두 값이 어긋난다(실화면에서 걸린 자리).
안정함 = 자재(None)["채움콘크리트"]
assert 안정함["surcharge_pct"] is not None
assert "기본값" in 안정함["note"]
assert 레미콘["surcharge_pct"] is not None
assert 비빔["surcharge_pct"] is None
assert "레미콘이 아니라" in 비빔["note"] or "레미콘이 아니라" in str(비빔)
def test_이형철근은_규격을_안_가린다() -> None:
"""품셈 1-3-1 「이형철근」 줄 하나가 D13·D16 을 다 받는다."""
assert surcharge_lookup_name("이형철근 D13", None)[0] == "이형철근"
assert surcharge_lookup_name("이형철근 D16", None)[0] == "이형철근"
def test_이름을_바꾸지_않는다() -> None:
"""⚠ 줄 이름이 바뀌면 타설 줄·묶음 조각이 성분을 못 찾는다 — 찾을 때만 다른 이름을 쓴다."""
rows = 자재("ready_mixed")
assert "레미콘" not in rows
assert rows["채움콘크리트"]["name"] == "채움콘크리트"
@@ -0,0 +1,291 @@
"""자재 총괄표 검사 — PLAN 8-2·8-3·8-7.
이 일감의 위험은 계산이 아니라 **할증을 두 번 붙이는 것**과 **모르는 값을 0 으로 넘기는 것**이다.
㉠ 할증은 여기 한 번뿐 — 앞 단계가 붙였으면 경고가 떠야 한다.
· 표에 없는 자재를 0 % 로 조용히 넘기면 빠뜨린 것과 구별이 안 된다.
· 관급/사급은 발주 결정이라 지어내지 않는다 — 안 정하면 「미분류」로 드러난다.
· 할증 전·후를 둘 다 남긴다 — 하나만 넘기면 B09 가 역산한다.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_MaterialSummary import ( # noqa: E402
INSTALL_BY_CONTRACTOR,
INSTALL_BY_OWNER,
NOTE_INCLUDED,
NOTE_INSTALL_BY_MISSING,
NOTE_RATE_MISSING,
SUPPLY_CONTRACTOR,
SUPPLY_OWNER,
SUPPLY_UNKNOWN,
SurchargeTable,
build_table,
load_surcharge_table,
verify_single_surcharge,
)
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table # noqa: E402
def 성분(name: str, unit: str, amount: float, destination: str = "material", **extra) -> dict:
return {"name": name, "unit": unit, "amount": amount, "destination": destination, **extra}
def 원단위표(*components: dict, surcharge_applied: bool = False) -> dict:
"""원단위 엔진이 내는 모양 그대로."""
return {
"structures": [
{
"structure_id": "s1",
"type_id": "masonry_wet",
"name": "돌쌓기(찰)",
"components": list(components),
}
],
"surcharge_applied": surcharge_applied,
}
def (table: dict, name: str) -> dict:
return next(row for row in table["rows"] if row["name"] == name)
# ── ㉠ 할증은 여기 한 번뿐 ──────────────────────────────────────────
def test_앞단계가_붙였으면_경고() -> None:
assert verify_single_surcharge({"surcharge_applied": True})
assert not verify_single_surcharge({"surcharge_applied": False})
def test_경고가_표에_실릴것() -> None:
"""검사를 만들어 두고 안 부르면 없는 것과 같다 — 표가 실제로 부르는지 본다."""
table = build_table(원단위표(성분("시멘트", "", 100.0), surcharge_applied=True))
assert table["double_count_warnings"]
def test_원단위_엔진_출력을_그대로_받음() -> None:
"""두 엔진이 실제로 맞물리는지 — 모양이 어긋나면 여기서 깨진다."""
unit = build_unit_table(
[
{
"structure_id": "s1",
"type_id": "masonry_wet",
"start_m": 35.0,
"end_m": 45.0,
"options": {"height_m": 1.5, "length_m": 10.0},
}
]
)
table = build_table(unit)
assert table["row_count"] > 0
assert not table["double_count_warnings"]
# 돌은 자재, 터파기는 토공 — 표에는 자재만 온다.
# ⚠ 종류를 안 고른 구조물의 돌 줄 이름이 「야면석」 → 「돌」로 바뀜(2026-09-09).
assert any(row["name"] == "" for row in table["rows"])
assert not any(row["name"] == "터파기" for row in table["rows"])
def test_이_표가_할증을_붙인_곳임을_못박음() -> None:
"""B09 가 다시 붙이지 않도록 깃발을 남긴다."""
assert build_table(원단위표(성분("모래", "", 1.0)))["surcharge_applied"] is True
def test_품셈에_포함된_항목은_또_붙이지_않음() -> None:
"""품셈 1-3-1 단서 — 「할증이 포함ㆍ표시된 경우 중복 적용 금지」."""
table = build_table(원단위표(성분("모래", "", 10.0, surcharge_included=True)))
row = (table, "모래")
assert row["total_amount"] == pytest.approx(10.0)
assert row["note"] == NOTE_INCLUDED
# ── destination 가르기 ──────────────────────────────────────────────
def test_material_만_모음() -> None:
table = build_table(
원단위표(
성분("야면석", "", 5.0),
성분("터파기", "", 3.0, destination="earthwork"),
성분("모르터", "", 0.1, destination="unit_price"),
)
)
assert [row["name"] for row in table["rows"]] == ["야면석"]
def test_거른_것을_버리지_않고_세어_보임() -> None:
table = build_table(
원단위표(
성분("터파기", "", 3.0, destination="earthwork"),
성분("되메우기", "", 1.0, destination="earthwork"),
성분("모르터", "", 0.1, destination="unit_price"),
)
)
assert table["skipped_by_destination"] == {"earthwork": 2, "unit_price": 1}
def test_같은_자재는_구조물을_넘어_합쳐짐() -> None:
table = build_table(
{
"structures": [
{"name": "A", "components": [성분("야면석", "", 2.0)]},
{"name": "B", "components": [성분("야면석", "", 3.0)]},
],
"surcharge_applied": False,
}
)
row = (table, "야면석")
assert row["net_amount"] == pytest.approx(5.0)
assert row["sources"] == ["A", "B"]
def test_단위가_다르면_다른_줄() -> None:
table = build_table(
원단위표(성분("", "", 100.0), 성분("", "", 50.0)),
)
assert table["row_count"] == 2
# ── 할증률은 데이터에서, 없으면 드러낸다 ────────────────────────────
def test_실제_데이터판이_읽힘() -> None:
table = load_surcharge_table()
assert table.effective_date
assert "시멘트" in table.material_names
def test_할증률이_코드에_없고_표에서_옴() -> None:
"""표를 갈아 끼우면 결과가 따라간다 — 값이 코드에 박혀 있으면 안 바뀐다."""
fake = SurchargeTable(
effective_date="9999-01-01", rates={"모래": {"material": "모래", "rate": 50}}
)
row = (build_table(원단위표(성분("모래", "", 10.0)), surcharge_table=fake), "모래")
assert row["surcharge_pct"] == 50
assert row["total_amount"] == pytest.approx(15.0)
def test_표에_없는_자재는_0퍼센트로_넘기지_않음() -> None:
"""0 % 로 조용히 넘기면 「할증 없음」과 「값을 못 찾음」이 구별되지 않는다."""
table = build_table(원단위표(성분("낯선자재", "", 10.0)))
row = (table, "낯선자재")
assert row["surcharge_pct"] is None
assert row["note"] == NOTE_RATE_MISSING
assert "낯선자재" in table["missing_rate_materials"]
# 값은 잃지 않는다 — 순수량 그대로 둔다.
assert row["total_amount"] == pytest.approx(10.0)
def test_데이터_파일이_없으면_전부_미확보로_드러남() -> None:
empty = SurchargeTable()
table = build_table(원단위표(성분("모래", "", 1.0)), surcharge_table=empty)
assert table["missing_rate_materials"] == ["모래"]
def test_출처판을_응답에_남김() -> None:
"""어느 판으로 계산했는지 표가 스스로 말한다."""
assert build_table(원단위표(성분("모래", "", 1.0)))["surcharge_dataset"]["effective_date"]
# ── 할증 전·후 둘 다 ────────────────────────────────────────────────
def test_전후_값이_둘_다_남음() -> None:
"""하나만 넘기면 B09 가 어느 쪽인지 몰라 역산하다 사고가 난다(8-2)."""
fake = SurchargeTable(rates={"자갈": {"material": "자갈", "rate": 4}})
row = (build_table(원단위표(성분("자갈", "", 100.0)), surcharge_table=fake), "자갈")
assert row["net_amount"] == pytest.approx(100.0)
assert row["total_amount"] == pytest.approx(104.0)
def test_합계는_반올림하지_않음() -> None:
"""표기 자리와 계산 자리를 가른다(PLAN 8-16) — 반올림은 화면에서만."""
fake = SurchargeTable(rates={"모래": {"material": "모래", "rate": 6}})
row = (build_table(원단위표(성분("모래", "", 1.0)), surcharge_table=fake), "모래")
assert row["total_amount"] == pytest.approx(1.06)
# ── 관급/사급 ───────────────────────────────────────────────────────
def test_안_정한_자재는_미분류로_드러남() -> None:
table = build_table(원단위표(성분("야면석", "", 5.0)))
assert (table, "야면석")["supply"] == SUPPLY_UNKNOWN
assert (table, "야면석")["supply_label"] == "미분류"
assert table["missing_supply_materials"] == ["야면석"]
def test_설정이_정한_구분을_따름() -> None:
table = build_table(
원단위표(성분("시멘트", "", 100.0), 성분("야면석", "", 5.0)),
supply_map={
"시멘트": {"supply": SUPPLY_OWNER, "install_by": INSTALL_BY_CONTRACTOR},
"야면석": SUPPLY_CONTRACTOR,
},
)
assert (table, "시멘트")["supply"] == "owner_supplied"
assert (table, "시멘트")["supply_label"] == "관급"
assert (table, "시멘트")["install_by_label"] == "도급자설치"
assert (table, "야면석")["supply_label"] == "사급"
assert table["missing_supply_materials"] == []
def test_사급_줄에는_설치주체가_안_붙음() -> None:
"""안전관리비 대상액 밖이라 비워 두는 것이 맞다 — 잘못 적힌 값은 무시한다."""
table = build_table(
원단위표(성분("야면석", "", 5.0)),
supply_map={"야면석": {"supply": SUPPLY_CONTRACTOR, "install_by": INSTALL_BY_OWNER}},
)
assert (table, "야면석")["install_by"] is None
assert table["missing_install_by_materials"] == []
def test_관급인데_설치주체를_안_정하면_드러남() -> None:
"""기본값으로 때우면 안전관리비가 조용히 틀린다 — 「도급자설치 관급금액」이 대상액이다."""
table = build_table(
원단위표(성분("시멘트", "", 100.0)),
supply_map={"시멘트": SUPPLY_OWNER},
)
row = (table, "시멘트")
assert row["install_by"] is None
assert NOTE_INSTALL_BY_MISSING in row["note"]
assert table["missing_install_by_materials"] == ["시멘트"]
def test_이름은_정확히_일치로만_찾음() -> None:
"""부분일치면 `막자갈`(뒤채움)이 `자갈` 할증을 문다 — 원단위에서 이미 겪은 자리다."""
fake = SurchargeTable(rates={"자갈": {"material": "자갈", "rate": 4}})
table = build_table(원단위표(성분("막자갈", "", 100.0)), surcharge_table=fake)
row = (table, "막자갈")
assert row["surcharge_pct"] is None
assert row["total_amount"] == pytest.approx(100.0)
# ── 구조물 밖에서 오는 자재 ────────────────────────────────────────
def test_사면_계열_자재도_받음() -> None:
"""떼·초류종자는 구조물 전개가 아니라 사면적에서 온다(8-4)."""
table = build_table(
원단위표(성분("야면석", "", 5.0)),
extra_materials=[
{"name": "", "unit": "", "amount": 200.0, "source": "성토면 떼붙임"},
],
)
row = (table, "")
assert row["net_amount"] == pytest.approx(200.0)
assert row["surcharge_pct"] == 10 # 품셈 1-3-1 「떼ㆍ초화류 10 %」
assert row["total_amount"] == pytest.approx(220.0)
def test_열_이름에_금액이_없음() -> None:
"""8-2 경계 — 금액은 B09 몫이다."""
columns = build_table(원단위표(성분("모래", "", 1.0)))["columns"]
assert not any("금액" in name or "단가" in name for name in columns)
+196
View File
@@ -0,0 +1,196 @@
"""관측 원단위표 검사 — PLAN 8-6·8-8 · 2026-09-07 3자 승인.
이 일감의 위험은 계산이 아니라 **관측값을 늘려 쓰는 것**이다.
· 관측값은 그 규격에서만 맞다 — 벽·기초는 높이에 비례하지 않는다.
· 규격이 표에 없으면 가까운 값을 갖다 쓰지 않고 「원단위 미확보」로 드러낸다.
· 두 근거(식에서 나온 값 / 관측값)가 한 표에 섞이므로 줄마다 근거를 단다.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_ObservedUnit import ( # noqa: E402
BASIS_OBSERVED,
NOTE_UNIT_MISSING,
ObservedUnitTable,
expand_observed,
load_observed_table,
scale_for,
)
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402
BASIS_DERIVED,
build_table,
expand,
)
def 옹벽(height: float = 2.0, length: float = 10.0, form: str = "반중력식") -> dict:
return {
"structure_id": "w1",
"type_id": "retaining_wall",
"start_m": 100.0,
"end_m": 100.0 + length,
"options": {"form": form, "height_m": height, "length_m": length},
}
def 성분(result, name: str):
return next(c for c in result.components if c.name == name)
# ── 규격이 맞을 때만 쓴다 ───────────────────────────────────────────
def test_규격이_맞으면_연장만큼_곱해짐() -> None:
"""`H=2.0 옹벽 10m` 는 같은 단면이 10m 이어진 것 — 개수를 세는 것이지 규격을 늘리는 게 아니다."""
result = expand(옹벽(length=10.0))
assert 성분(result, "콘크리트").amount == pytest.approx(13.5) # 1.35 ㎥/m × 10
assert 성분(result, "유로폼").amount == pytest.approx(32.0)
def test_높이가_다르면_비례로_늘리지_않고_미확보() -> None:
"""⚠ 이 시험이 이 파일의 핵심 — H=1.6 은 표에 없다. 1.35 × 0.8 로 만들지 않는다."""
result = expand(옹벽(height=1.6))
assert result.components == []
# ⚠ 사용자에게 뜨는 말로 잰다 — 키 이름이 새면 안 된다(㉑).
assert any("자료에 없습니다" in note for note in result.notes)
def test_형식이_다르면_미확보() -> None:
result = expand(옹벽(form="캔틸레버식"))
assert result.components == []
# ⚠ 사용자에게 뜨는 말로 잰다 — 키 이름이 새면 안 된다(㉑).
assert any("자료에 없습니다" in note for note in result.notes)
def test_미확보_알림에_있는_규격을_같이_알려줄것() -> None:
"""「없다」만 말하면 사용자가 무엇을 고쳐야 할지 모른다.
⚠ 규격도 **사람 말**로 보인다 — `form`·`height_m` 이 아니라 「옹벽 형식」·「높이(m)」."""
result = expand(옹벽(height=1.6))
note = next(n for n in result.notes if "자료에 있는 규격" in n)
assert "옹벽 형식 반중력식" in note and "높이(m) 2.0" in note
assert "form" not in note and "height_m" not in note
def test_규격이_비어_있으면_고르지_못함을_알림() -> None:
result = expand({"type_id": "retaining_wall", "options": {"length_m": 5.0}})
assert result.components == []
assert result.notes
# ── 근거를 줄마다 단다 ──────────────────────────────────────────────
def test_관측값에는_observed_근거가_붙음() -> None:
result = expand(옹벽())
for component in result.components:
assert component.basis_kind == BASIS_OBSERVED
assert "관측 원단위" in component.basis
assert component.source
def test_식에서_나온_값은_derived_로_남음() -> None:
"""두 근거가 한 표에 섞이므로 갈라 보여야 한다."""
stone = expand(
{
"type_id": "masonry_wet",
"start_m": 0.0,
"end_m": 10.0,
"options": {"height_m": 1.5, "length_m": 10.0},
}
)
assert all(c.basis_kind == BASIS_DERIVED for c in stone.components)
def test_표에도_근거가_실림() -> None:
"""⚠ 관측 줄에 **파생 줄 하나가 섞인다** — 기초잡석은 버림 폭에서 나오는 값이라
관측이 아니라 `derived` 다(2026-09-09 확정 3차 ②). 근거가 갈려 실리는 것이 맞다."""
table = build_table([옹벽()])
components = [c for s in table["structures"] for c in s["components"]]
kinds = {c["basis_kind"] for c in components if c["name"] != "기초잡석"}
assert kinds == {BASIS_OBSERVED}
잡석 = next(c for c in components if c["name"] == "기초잡석")
assert 잡석["basis_kind"] == BASIS_DERIVED
# ── 이중계상 규칙은 그대로 ──────────────────────────────────────────
def test_관측값에도_배합이_섞이지_않음() -> None:
"""㉢ — 콘크리트 ㎥ 까지만. 시멘트·모래로 쪼개는 것은 B09 몫이다."""
assert build_table([옹벽()])["mix_components_found"] == []
def test_터파기는_토공으로_되메우기는_토공으로() -> None:
"""관측 원단위의 터파기도 내역 줄이 아니라 토공 합산이다."""
table = load_observed_table()
entry = table.find("pipe_inlet_basin", {"inlet_basin_form": "돌집수정 ㄷ형"})
destinations = {c["name"]: c["destination"] for c in entry["components"]}
assert destinations["터파기"] == "earthwork"
assert destinations["잔토처리"] == "earthwork"
assert destinations["콘크리트"] == "unit_price"
def test_할증은_여전히_자재총괄_몫() -> None:
assert build_table([옹벽()])["surcharge_applied"] is False
# ── 표 자체 ─────────────────────────────────────────────────────────
def test_실제_데이터판이_읽힘() -> None:
table = load_observed_table()
assert table.effective_date
assert table.find("retaining_wall", {"form": "반중력식", "height_m": 2.0})
def test_숫자와_글자_규격을_같이_본다() -> None:
"""입력 폼이 `"800"` 을 문자열로 준다 — 그렇다고 `2.0` 과 `1.6` 을 같다고 보면 안 된다."""
table = load_observed_table()
assert table.find(
"pipe_inlet_basin",
{
"inlet_basin_form": "□형(기본형)",
"inlet_basin_material": "콘크리트",
"pipe_diameter_mm": 800,
},
)
assert table.find("retaining_wall", {"form": "반중력식", "height_m": 1.9999}) is None
def test_BOX암거는_미확보로_남음() -> None:
"""⚠ 두께가 저장돼 있지 않아 식도 못 세우고 관측값도 없다 — 지어내면 콘크리트·거푸집·
철근으로 번져 나간다. 사용자 확정 대기."""
table = load_observed_table()
assert table.find("box_culvert", {"body_width_m": 3.0, "body_height_m": 1.2}) is None
reasons = [item["type_id"] for item in table.not_found["items"]]
assert "box_culvert" in reasons
def test_파일이_없으면_전부_미확보() -> None:
empty = ObservedUnitTable()
components, notes = expand_observed("retaining_wall", {"form": "반중력식"}, 옹벽(), empty)
assert components == []
# 자료가 통째로 없으면 「표준 물량 자료가 없다」로 말한다.
assert "표준 물량 자료가 아직 없습니다" in notes[0]
def test_곱할_연장이_0이면_내지_않음() -> None:
result = expand(옹벽(length=0.0))
assert result.components == []
def test_개소당_원단위는_안_곱해짐() -> None:
"""집수정은 1개소가 1개소다 — 연장을 곱하면 값이 부푼다."""
table = load_observed_table()
entry = table.find("pipe_inlet_basin", {"inlet_basin_form": "돌집수정 ㄴ형"})
scale, note = scale_for(entry, {"options": {"length_m": 10.0}})
assert scale == 1.0
assert "개소" in note
+116
View File
@@ -0,0 +1,116 @@
"""저장 제원 키 ↔ 엔진이 읽는 키 대조 (2026-09-07 ⑳ 앞).
⚠ **오늘 잡은 사고 중 사용자 입력이 직접 무시되던 첫 사례**가 여기였다 —
레지스트리는 `back_len_cm` 인데 엔진은 `stone_back_length_cm` 을 읽고 있어
**뒷길이를 75 로 골라도 늘 45 계수**로 돌았다. 값이 나오므로 아무 시험도 안 잡았다.
⇒ 「이름으로 알아보는 코드는 정본과 대조하라」(사방공 `type_id` 건)의 **데이터 키 판**이다.
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
#: 레지스트리에 칸이 **아직 없는** 키. 「칸이 생기면 받는다」는 뜻으로 코드에 남겨 둔 것이고,
#: 키 이름이 어긋난 것과는 다르다. 새로 늘리려면 **왜 없는지**를 여기 적을 것.
#: 레지스트리에 아직 칸이 없는 키 — **왜 없는지**를 함께 적는다.
#: ⚠ 2026-09-09 `face_slope_ratio` 는 칸이 생겨 뺐음(`ebdf2988`). 비워 두면 품셈
#: 표준경사표로 자동 판정되고, 값이 있으면 그 값이 이긴다(확정 ⑨).
#: ⓘ 2026-09-09 오후 비었다 — `ditch_spec` 은 랩탑 보조가 칸을 만들어(`92fd9092`) 여기서 뺐다.
#: 목록이 비어 있는 것이 정상이다. **칸 없이 코드에만 있는 키가 생기면** 여기 까닭과 함께
#: 적을 것 — 적지 않으면 이 시험이 「이름을 잘못 적었다」로 잡는다.
KNOWN_ABSENT: dict[str, str] = {}
def _registry_keys() -> set[str]:
payload = json.loads(
(ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8")
)
types = payload if isinstance(payload, list) else (payload.get("types") or [])
return {option["key"] for item in types for option in item.get("options") or []}
def _engine_keys() -> dict[str, set[str]]:
found: dict[str, set[str]] = {}
for path in (ROOT / "B08_Quantity").glob("*.py"):
text = path.read_text(encoding="utf-8")
for match in re.finditer(r'options\.get\(\s*"([a-z0-9_]+)"', text):
found.setdefault(match.group(1), set()).add(path.name)
for path in (ROOT / "resources").rglob("*.json"):
try:
text = path.read_text(encoding="utf-8")
except OSError:
continue
for match in re.finditer(r'"option_key"\s*:\s*"([a-z0-9_]+)"', text):
found.setdefault(match.group(1), set()).add(path.name)
return found
def test_엔진이_읽는_키가_레지스트리에_있을것() -> None:
"""⚠ 이 시험이 `back_len_cm` 사고를 다시 못 나게 한다."""
registry = _registry_keys()
missing = {
key: sorted(where)
for key, where in _engine_keys().items()
if key not in registry and key not in KNOWN_ABSENT
}
assert not missing, f"레지스트리에 없는 옵션 키를 읽고 있다: {missing}"
def test_없는_키는_사유가_적혀_있을것() -> None:
"""「없다」를 그냥 넘기지 않는다 — 왜 없는지가 적혀 있어야 한다."""
for key, why in KNOWN_ABSENT.items():
assert why, key
# 사유 목록에 적힌 키가 정말 레지스트리에 없는지도 본다(고쳐졌는데 목록만 남는 것 방지).
registry = _registry_keys()
stale = [key for key in KNOWN_ABSENT if key in registry]
assert not stale, f"레지스트리에 칸이 생겼으니 목록에서 뺄 것: {stale}"
def test_돌쌓기_뒷길이_키가_실제로_맞을것() -> None:
"""사고가 났던 그 자리를 이름으로 직접 못 박는다."""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import BACK_LENGTH_KEYS
assert "back_len_cm" in BACK_LENGTH_KEYS
assert "back_len_cm" in _registry_keys()
def test_되돌릴_수_있어야_하는_숫자_칸은_계약에_등록될것() -> None:
"""⚠ `None` 을 통째로 버리면 「안 정함」으로 되돌릴 길이 없다 (2026-09-08 ㉘).
시공법·타설 방식은 빈 문자열로 되돌리지만 **숫자 칸은 되돌릴 값이 `None` 뿐**이다.
새 숫자 칸을 넣고 여기 등록하지 않으면 **한 번 넣은 값이 영영 남는다.**
같은 병을 두 번 만났으므로 계약처럼 다룬다.
"""
from B08_Quantity.B08_Quantity_Router_Earthwork import (
NULLABLE_SETTING_KEYS,
QuantitySettingsBody,
)
# ⚠ 좁게 본다 — `dict[str, float]`(비율표)까지 걸면 정상 칸을 잡는다(넓은 규칙 사고).
# 여기서 겨냥하는 것은 **홑 숫자 칸**뿐이다.
홑숫자 = {float | None, int | None}
숫자칸 = {
name
for name, field in QuantitySettingsBody.model_fields.items()
if field.annotation in 홑숫자
}
assert 숫자칸, "홑 숫자 칸을 하나도 못 골랐다 — 판정이 틀렸다"
빠진것 = 숫자칸 - set(NULLABLE_SETTING_KEYS)
assert not 빠진것, f"숫자 칸이 NULLABLE_SETTING_KEYS 에 없다 — 되돌릴 길이 없다: {sorted(빠진것)}"
def test_되돌리기_칸은_갈아끼우기_대상이기도_할것() -> None:
"""등록만 하고 갈아끼우기(`replace_keys`)에서 빠지면 병합돼 옛 값이 남는다."""
import inspect
from B08_Quantity import B08_Quantity_Router_Earthwork as router
본문 = inspect.getsource(router.save_quantity_settings)
assert "NULLABLE_SETTING_KEYS" in 본문
+172
View File
@@ -0,0 +1,172 @@
"""배수관 물량 — 정본 셋을 잇는 자리의 짝 시험 (2026-09-08).
⚠ 여기서 겨누는 것 넷
① `facility` 가 있는 점(세월교 등)을 **관으로 세지 않는가**
② 연장이 없으면 **0 으로 때우지 않고 사유와 함께 막히는가**
③ 관종으로 공종이 갈리는가 · 기본값으로 선 것을 **알리는가**
④ ⚠ 좁게 — 측점 맞추기가 **옆 측점 길이를 물어 오지 않는가**
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_Pipe import build_rows # noqa: E402
MAPPING = json.loads(
(
ROOT / "resources" / "data_work_item_mapping" / "work_item_mapping_2026-01-01.json"
).read_text(encoding="utf-8")
)["pipe"]
def (chainage: float, **options: object) -> dict:
return {"chainage_m": chainage, "facility": "pipe", "options": options}
def 길이(chainage: float, length: float) -> dict:
return {"chainage_m": chainage, "design": {"pipe_length_m": length}}
def test_관이_아닌_시설은_안_센다() -> None:
"""⚠ `pipe_points.json` 은 계곡 통과 시설 **전부**의 정본이다 — 세월교가 섞여 있다."""
points = [
(85.05, pipe_diameter_mm=1000),
{"chainage_m": 173.09, "facility": "ford_bridge", "options": {}},
{"chainage_m": 352.14, "facility": "ford_bridge", "options": {}},
(258.12, pipe_diameter_mm=800),
]
out = build_rows(points, [], MAPPING)
assert out["pipe_count"] == 2, "세월교를 관으로 셌다"
def test_관경이_없어도_관은_관이다() -> None:
"""⚠ `pipe_diameter_mm` 유무로 가르면 **관경 미지정 관을 놓친다**."""
out = build_rows([(100.0)], [], MAPPING)
assert out["pipe_count"] == 1
assert out["rows"][0]["variant_value"] is None
def test_연장이_없으면_0_으로_때우지_않는다() -> None:
out = build_rows([(85.05, pipe_diameter_mm=1000)], [], MAPPING)
row = out["rows"][0]
assert row["in_bill"] is False
assert row["blocked_kind"] == "input_missing"
assert "횡단설계에서 [저장]" in row["blocked_reason"]
def test_연장이_오면_값이_선다() -> None:
out = build_rows([(85.05, pipe_diameter_mm=1000)], [길이(85.05, 9.0)], MAPPING)
row = out["rows"][0]
assert (row["in_bill"], row["quantity"], row["unit"]) == (True, 9.0, "m")
assert out["length_total_m"] == 9.0
def test_관종으로_공종이_갈린다() -> None:
보기 = {"파형강관": "FP-12-11-03", "흄관": "FP-12-11-02", "VR관": "FP-12-11-01"}
for kind, code in 보기.items():
out = build_rows([(85.0, pipe_kind=kind, pipe_diameter_mm=800)], [길이(85.0, 9.0)], MAPPING)
assert out["rows"][0]["work_item_code"] == code, kind
def test_관종을_안_정하면_기본값으로_서되_알린다() -> None:
"""기본 「파형강관」은 2026-08-17 사용자 확정값이라 근거가 있다 — 다만 **조용히 쓰지 않는다**."""
out = build_rows([(85.0, pipe_diameter_mm=800)], [길이(85.0, 9.0)], MAPPING)
row = out["rows"][0]
assert row["work_item_code"] == "FP-12-11-03"
assert row["kind_from_default"] is True
assert any("기본값" in note for note in out["notes"])
def test_모르는_관종은_공종을_못_고른다() -> None:
out = build_rows([(85.0, pipe_kind="철근콘크리트관")], [길이(85.0, 9.0)], MAPPING)
row = out["rows"][0]
assert row["work_item_code"] is None
assert row["in_bill"] is False
assert "아는 관종이 아니라" in row["blocked_reason"]
def test_측점이_소수점에서_어긋나도_찾는다() -> None:
out = build_rows([(85.05, pipe_diameter_mm=800)], [길이(85.0, 9.0)], MAPPING)
assert out["rows"][0]["quantity"] == 9.0
def test_옆_측점_길이를_물어_오지_않는다() -> None:
"""⚠ 좁게 — 넓히면 다른 측점 값이 조용히 실린다."""
out = build_rows([(85.0, pipe_diameter_mm=800)], [길이(120.0, 9.0)], MAPPING)
row = out["rows"][0]
assert row["quantity"] == 0.0 and row["in_bill"] is False
def test_실제_자료로_세월교가_걸러진다() -> None:
"""정본 대조 — `5601e828` 은 11점 중 관 9 · 세월교 2 임(2026-09-08 랩탑 창 확인)."""
path = (
ROOT
/ "storage"
/ "1"
/ "3"
/ "5601e828-feea-487a-9b25-415e5199f2f5"
/ "B04_PreProcess"
/ "drainage"
/ "edits"
/ "pipe_points.json"
)
if not path.is_file():
return
points = json.loads(path.read_text(encoding="utf-8"))["points"]
assert build_rows(points, [], MAPPING)["pipe_count"] == 9
# ── 「[저장]하면 풀림」과 「눌러도 안 풀림」을 갈라 말하기 (2026-09-08 실측) ────
# 실측: `5601e828` 관 9개 중 3개(439.55·620.43·720.37)가 **횡단 행 자체가 없는** 자리였다.
# 거기에 「[저장]을 누르면 섭니다」를 띄우면 눌러 보고 안 되어 헤맨다.
def test_횡단이_있는데_길이만_없으면_저장하라고_말한다() -> None:
out = build_rows(
[(85.0, pipe_diameter_mm=800)],
[],
MAPPING,
section_chainages=[85.0],
)
assert "[저장]을 한 번 누르면" in out["rows"][0]["blocked_reason"]
def test_횡단_자체가_없으면_다르게_말한다() -> None:
out = build_rows(
[(620.43, pipe_diameter_mm=1500)],
[],
MAPPING,
section_chainages=[85.0, 258.12],
)
사유 = out["rows"][0]["blocked_reason"]
assert "횡단 자체가 없습니다" in 사유
assert "[저장]으로는 안 풀립니다" in 사유
def test_측점_목록을_안_주면_종전대로_말한다() -> None:
"""⚠ 좁게 — 목록이 없다고 「횡단 없음」으로 단정하면 거짓말이 된다."""
out = build_rows([(620.43, pipe_diameter_mm=1500)], [], MAPPING)
assert "[저장]을 한 번 누르면" in out["rows"][0]["blocked_reason"]
def test_0_5m_안의_측점은_그_관의_횡단이다() -> None:
"""⚠ **뒤집힌 시험이다** — 종전에는 「옆 측점이 있다고 횡단이 있는 것이 아니다」였다.
2026-09-09 랩탑 메인 실측이 그 전제를 깼다 — 측점은 **정수 미터 격자로 스냅**되므로
관 439.55 의 횡단은 **측점 440.0** 이고, 그 측점은 구조물 이름표까지 달고 있다.
좁게 보면 「그 측점의 횡단 자체가 없습니다」라는 **거짓 사유**가 뜬다. 스냅 최대
어긋남이 0.5m 라 길이 찾기와 허용오차가 같아졌다(`e1fed5f6` — 배수관 넷 복구).
"""
out = build_rows(
[(439.55, pipe_diameter_mm=800)],
[],
MAPPING,
section_chainages=[440.0],
)
assert "[저장]을 한 번 누르면" in out["rows"][0]["blocked_reason"]
+232
View File
@@ -0,0 +1,232 @@
"""준비공·사방공 검사 — PLAN 8-3 의 ❌ 둘 (2026-09-07 ⑪).
여기서 하는 일은 **줄을 세우고 못 서는 줄의 사유를 적는 것**이다. 빈 표를 내면
「빠뜨린 것」과 「원래 없는 것」이 구별되지 않는다.
⚠⚠ 벌목은 이미 토공집계의 「지장목제거」로 선다 — 여기서 또 세우면 같은 나무를 두 번 벤다.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_Preparation import ( # noqa: E402
EROSION_CONTROL_TYPES,
STATUS_COUNTED_ELSEWHERE,
STATUS_NOT_APPLICABLE,
STATUS_PENDING,
build_table,
erosion_rows,
)
def (table: dict, item: str) -> dict:
return next(row for row in table["rows"] if row["item"] == item)
# ── 이중계상 방어 ───────────────────────────────────────────────────
def test_벌목은_값을_내지_않고_가리키기만_함() -> None:
"""⚠ 이 파일의 핵심 — 토공집계의 「지장목제거」와 겹치면 같은 나무를 두 번 벤다."""
table = build_table({"tree_removal_fill": 1000.0, "tree_removal_cut": 500.0})
row = (table, "벌목·지장목제거")
assert row["amount"] is None # 값은 안 낸다
assert row["status"] == STATUS_COUNTED_ELSEWHERE
assert "이중계상" in row["reason"]
# 참고 면적은 보인다 — 어느 값과 겹치는지 사람이 알아야 한다.
assert row["reference_amount"] == pytest.approx(1500.0)
# ── 못 서는 줄은 사유를 적는다 ─────────────────────────────────────
def test_못_서는_줄이_목록에_남을것() -> None:
"""빈 표를 내면 「빠뜨린 것」과 「원래 없는 것」이 구별되지 않는다."""
table = build_table()
items = [row["item"] for row in table["rows"]]
assert "표토제거" in items and "제근·뿌리다듬기" in items
# 규준틀은 둘로 갈렸다 — 비탈(11-2)과 수평(11-3)의 설치 기준이 다르다.
assert "비탈 규준틀" in items and "수평 규준틀" in items
def test_사유가_비어_있지_않을것() -> None:
for row in build_table()["rows"]:
assert row["reason"], f"{row['item']} 에 사유가 없다"
def test_값이_없어도_공종코드는_알려줄것() -> None:
"""무엇으로 서게 될 줄인지 알아야 나중에 잇는다."""
assert (build_table(), "표토제거")["work_item_code"] == "FP-09-15"
# ── 사방공 ──────────────────────────────────────────────────────────
def test_사방_시설이_없으면_해당_없음() -> None:
"""있는 것처럼 0 을 적지 않는다."""
row = (build_table(structures=[{"type_id": "masonry_wet"}]), "사방 시설")
assert row["status"] == STATUS_NOT_APPLICABLE
assert row["amount"] is None
def test_사방_시설이_있으면_줄이_섬() -> None:
table = build_table(structures=[{"type_id": "erosion_check"}, {"type_id": "revetment"}])
사방 = [row for row in table["rows"] if row["group"] == "사방공"]
# 키는 `type_id` 칸으로 오가고, 화면에 뜨는 `item` 은 사람 이름이다.
assert [row["type_id"] for row in 사방] == ["erosion_check", "revetment"]
assert [row["item"] for row in 사방] == ["골막이", "기슭막이"]
assert all(row["status"] == STATUS_PENDING for row in table["rows"] if row["group"] == "사방공")
def test_사방_종류_이름이_레지스트리와_맞을것() -> None:
"""⚠ 이름을 지어내면 영영 안 걸린다 — 레지스트리의 실제 `type_id` 여야 한다."""
import json
registry = json.loads(
(ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8")
)
types = registry if isinstance(registry, list) else (registry.get("types") or [])
known = {t["type_id"] for t in types}
assert EROSION_CONTROL_TYPES <= known, EROSION_CONTROL_TYPES - known
def test_셈이_맞을것() -> None:
table = build_table()
assert table["row_count"] == len(table["rows"])
assert table["pending_count"] == sum(1 for r in table["rows"] if r["status"] == STATUS_PENDING)
# ── 화면에 안 보이던 확정 항목을 드러내기 (2026-09-07) ─────────────
#
# ⚠ 목록에만 있고 화면에 없으면 **사용자는 그것이 잠정인 줄도 모른다.**
# 「확인 필요」만 있어도 안 된다 — **지금 무슨 값으로 돌고 있는지**를 함께 적어야
# 사용자가 무엇을 정할지 안다(원단위 미확보에서 「표에 있는 규격을 함께 알린」 그 방식).
def test_지장목제거_줄에_공종_미확정_사유가_적힐것() -> None:
row = (build_table(), "벌목·지장목제거")
assert "공종 미확정" in row["reason"]
assert "수확베기" in row["reason"] # 후보가 무엇인지도 함께 보인다
# ── 규준틀 개소 — 원문이 기준을 정해 둠 (2026-09-07 ㉒) ────────────
#
# 11-2 [주]① 「비탈길이 10m 이상 20m마다 설치한다」
# 11-3 [주]① 「중심점에서 성토 높이 5m 이상에 설치한다」
# ⚠ 재료량은 [주]④ 「설계수량에 따른다」 — **개소만 내고 재료는 미확보**.
def 사면줄(*pairs: tuple[float, float]) -> list[dict]:
"""(그 측점의 사면길이, 앞 측점까지 거리) 목록."""
return [
{"lengths": {"face_dressing_fill": length}, "distance_m": dist} for length, dist in pairs
]
def test_비탈길이_10m_이상_구간만_셀것() -> None:
"""⚠ 「10m 이상」은 **비탈길이** 조건이고 「20m마다」는 **노선 거리** 간격이다.
둘을 섞으면 짧은 사면 구간까지 세어 개소가 부푼다."""
from B08_Quantity.B08_Quantity_Engine_Preparation import batter_frame_count
# 사면길이 12m 인 구간 40m + 사면길이 3m 인 구간 100m → 40m 만 센다.
count, notes = batter_frame_count(사면줄((12.0, 20.0), (12.0, 20.0), (3.0, 100.0)))
assert count == 3 # 40 ÷ 20 + 1
assert "11-2" in notes[0]
def test_긴_사면이_없으면_안_설것() -> None:
from B08_Quantity.B08_Quantity_Engine_Preparation import batter_frame_count
count, notes = batter_frame_count(사면줄((4.0, 100.0)))
assert count == 0
assert notes
def test_비탈_규준틀이_표에_값으로_설것() -> None:
# ⚠ 실제 사면표는 **첫 측점의 distance_m 이 0** 이다(앞 측점이 없다). 그 모양으로 넣는다.
table = build_table(slope_rows=사면줄((12.0, 0.0), (12.0, 20.0)))
row = (table, "비탈 규준틀")
assert row["amount"] == 2.0 # 20 ÷ 20 + 1
assert row["status"] == "값 있음"
# 재료는 여전히 미확보임을 같은 줄에 적는다.
assert "설계수량" in row["reason"]
def test_수평_규준틀은_성토고_칸이_없으면_못_셈() -> None:
"""⚠ 0 으로 때우지 않는다 — 「없음」과 「못 셈」은 다르다."""
row = (build_table(slope_rows=[{"lengths": {}, "distance_m": 20.0}]), "수평 규준틀")
assert row["amount"] is None
assert "성토고" in row["reason"] and "11-3" in row["reason"]
def test_수평_규준틀은_성토고_5m_이상_측점마다() -> None:
"""⚠ 비탈규준틀(「10m 이상 20m마다」)과 **기준이 다르다** — 이쪽은 **지점 조건**이라
간격이 없다. 두 기준을 같은 식으로 쓰면 조용히 틀린다."""
rows = [
{"fill_height_m": 6.0, "distance_m": 20.0},
{"fill_height_m": 2.0, "distance_m": 20.0},
{"fill_height_m": 5.0, "distance_m": 20.0},
]
row = (build_table(slope_rows=rows), "수평 규준틀")
assert row["amount"] == 2.0 # 6.0 · 5.0 두 곳(5.0 은 「이상」이라 든다)
assert row["status"] == "값 있음"
def test_성토고가_다_낮으면_0개소() -> None:
"""0 이 **값**인 자리다 — 「못 셈」과 달리 「없다」가 맞는 답이다."""
row = (build_table(slope_rows=[{"fill_height_m": 1.0, "distance_m": 20.0}]), "수평 규준틀")
assert row["amount"] == 0.0
assert row["status"] == "값 있음"
# ── 표토제거 두께 — 품셈이 아니라 설계가 정함 (2026-09-07 ㉓) ───────
#
# 9-15 [주]② 「Q=Q1/T … T : 표토두께(m)」 — **공식의 입력 변수**다.
# ⚠ 안 넣으면 0 으로 때우지 않고 물량을 안 낸다.
def test_두께가_없으면_물량을_안_낼것() -> None:
row = (build_table({"face_dressing_fill": 1000.0, "face_dressing_cut": 500.0}), "표토제거")
assert row["amount"] is None
# ⚠ 사유가 정정됐다(2026-09-09) — 종전에 「품셈이 정하는 값이 아닙니다」라고만 적었는데
# 품셈 9-15-2(답외구간) 원문에 **T=0.2m · L=20m 가 적용값으로 박혀 있다.** 두께를
# 자동으로 넣지는 않되 **원문에 있는 값을 없다고 말하지도 않는다.**
assert "두께는 설계가 정하는 값" in row["reason"]
assert "T=0.2m" in row["reason"] and "9-15-2" in row["reason"]
# 대상 면적은 참고로 보인다 — 무엇에 곱할지 사용자가 알아야 한다.
assert row["reference_amount"] == pytest.approx(1500.0)
def test_두께를_넣으면_물량이_설것() -> None:
table = build_table(
{"face_dressing_fill": 1000.0, "face_dressing_cut": 500.0}, topsoil_thickness_m=0.15
)
row = (table, "표토제거")
assert row["amount"] == pytest.approx(225.0) # 1,500㎡ × 0.15m
assert row["status"] == "값 있음"
def test_두께가_0이면_안_낼것() -> None:
"""0 은 「안 넣음」과 같게 본다 — 0㎥ 를 내면 「없다」로 오해된다."""
row = (build_table({"face_dressing_fill": 1000.0}, topsoil_thickness_m=0), "표토제거")
assert row["amount"] is None
def test_erosion_row_uses_registry_name_not_type_id():
"""사방공 줄 이름에 개발자 키가 새지 않는다 (2026-09-08 ㉕ 통과에서 `soil_guard` 로 뜬 자리)."""
rows = erosion_rows([{"type_id": "soil_guard"}], {"soil_guard": "흙막이"})
assert [row["item"] for row in rows] == ["흙막이"]
assert rows[0]["type_id"] == "soil_guard"
def test_erosion_row_falls_back_to_wording_table_without_registry():
"""이름표가 없어도 문구표가 받아 준다 — 없으면 키를 보이되 지어내지 않는다."""
assert erosion_rows([{"type_id": "soil_guard"}])[0]["item"] == "흙막이"
assert erosion_rows([{"type_id": "erosion_check"}])[0]["item"] == "골막이"
+115
View File
@@ -0,0 +1,115 @@
"""기초잡석 — 버림 폭에서 나온다 (2026-09-09 사용자 확정 3차 ②).
⚠ 겨누는 것 여섯
① 두께는 **0.2 m**(사용자 확정) — 품셈 12-25 는 ㎥당 품만 주고 두께를 안 정함
② 폭을 다시 세지 않는다 — **버림 폭이 곧 잡석다짐 폭**(KCS 34 50 05)이라 두께 비만 곱함
③ ⚠ 그래서 **관측 원단위로 오는 옹벽에도 값이 선다**(버림 0.15㎥/m ⇒ 잡석 0.30㎥/m)
④ 두께는 화면에서 바꿀 수 있다
⑤ ⚠ 자재총괄에 안 섞인다 — 운반·부설·다짐 품이 붙는 **공종**이다
⑥ ⚠ 묶음으로 서는 구조물은 줄을 또 세우지 않는다(옹벽 조각이 이미 셈)
"""
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_Handoff import build_handoff # noqa: E402
from B08_Quantity.B08_Quantity_Engine_MaterialSummary import ( # noqa: E402
build_table as build_material,
)
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402
RUBBLE_BASE_THICKNESS_M,
build_table,
)
def 돌쌓기(thickness: float | None = None) -> dict:
return build_table(
[
{
"structure_id": "s1",
"type_id": "masonry_wet",
"start_m": 0.0,
"end_m": 10.0,
"options": {
"height_m": 2.5,
"length_m": 10.0,
"back_len_cm": 45,
"foundation": "기초유",
},
}
],
None,
None,
None,
thickness,
)
def 옹벽() -> dict:
return build_table(
[
{
"structure_id": "w1",
"type_id": "retaining_wall",
"start_m": 100.0,
"end_m": 110.0,
"options": {"form": "반중력식", "height_m": 2.0, "length_m": 10.0},
}
],
{"retaining_wall": "옹벽"},
)
def 성분(table: dict, name: str) -> dict | None:
for structure in table["structures"]:
for component in structure["components"]:
if component["name"] == name:
return component
return None
def test_두께는_사용자_확정값() -> None:
assert RUBBLE_BASE_THICKNESS_M == 0.2
def test_버림_두께_비로_나온다() -> None:
"""버림 1.2㎥ × (0.2 ÷ 0.1) = 2.4㎥ — 폭을 다시 세지 않는다."""
table = 돌쌓기()
assert abs(성분(table, "버림콘크리트")["amount"] - 1.2) < 1e-9
assert abs(성분(table, "기초잡석")["amount"] - 2.4) < 1e-9
def test_관측_원단위_구조물에도_선다() -> None:
"""⚠ 옹벽은 관측 원단위로 오는데 그 표에 기초잡석 줄이 없다 — 버림에서 나온다."""
assert abs(성분(옹벽(), "기초잡석")["amount"] - 3.0) < 1e-9 # 0.30㎥/m × 10m
def test_두께를_바꾸면_따라간다() -> None:
assert abs(성분(돌쌓기(0.3), "기초잡석")["amount"] - 3.6) < 1e-9
def test_자재총괄에_안_섞인다() -> None:
"""⚠ 운반·부설·다짐 품이 붙는 공종이다 — 자재로 서면 같은 잡석을 두 번 센다."""
assert 성분(돌쌓기(), "기초잡석")["destination"] == "unit_price"
assert all(row["name"] != "기초잡석" for row in build_material(돌쌓기())["rows"])
def test_인계에_공종_줄로_선다() -> None:
rows = build_handoff(unit_quantity_table=돌쌓기())["work_items"]
row = next(r for r in rows if r["name"] == "기초잡석")
assert row["work_item_code"] == "FP-12-25"
assert abs(row["quantity"] - 2.4) < 1e-9 and row["in_bill"] is True
def test_묶음_구조물은_줄을_또_세우지_않는다() -> None:
"""⚠ 옹벽 묶음에 이미 FP-12-25 조각이 있다 — 여기서 또 세우면 두 번 센다."""
rows = build_handoff(unit_quantity_table=옹벽())["work_items"]
assert all(row["name"] != "기초잡석" for row in rows)
parts = rows[0]["composite_parts"]
gravel = next(part for part in parts if part["code"] == "FP-12-25")
assert gravel["quantity"] == 3.0
+260
View File
@@ -0,0 +1,260 @@
"""사면길이 유도·사면 4계열 면적 검사 — PLAN 8-4b·8-11.
사면길이는 저장분에서 **유도**하는 값이라 판정 규칙이 곧 정확성이다. 그래서
실측 설계선 모양을 그대로 넣어 사면과 원지반이 갈리는지를 못 박는다.
"""
from __future__ import annotations
import math
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_SlopeArea import ( # noqa: E402
SlopeRatios,
build_rows,
build_table,
totals,
unclosed_stations,
)
from B08_Quantity.B08_Quantity_Engine_SlopeLength import ( # noqa: E402
StationSlope,
station_slope,
)
def line(points: list[tuple[float, float]]) -> list[dict[str, float]]:
return [{"offset_m": o, "elevation_m": z} for o, z in points]
def 절토_설계선() -> dict:
"""측점 20.0 **실측 모양 그대로** — 노면·측구·2단 절토 사면·원지반 (우측).
실측 구간 기울기: 노면 n=33.3 · 측구 벽 n=1.0 · 절토(암) n=0.4 · 절토(토사) n=1.0 ·
원지반 n=1.63. 측구 바닥은 노면보다 **낮다**(깊이 0.3).
⚠ 측구 벽이 토사 절토비(1.0)와 같은 경사라, 이 모양이 곧 「측구를 사면으로 세지 않는가」
를 재는 시험이다.
"""
return {
# 실측 표고를 그대로 세운 것 — 노면(-2.00) 870.83 을 기준으로 바깥으로 쌓았다.
"design_line": line(
[
(-5.00, 873.73), # 원지반 (n=1.63)
(-4.50, 873.32), # 절토 토사 n=1.0
(-4.00, 872.82), # 절토 토사 n≈1.03
(-3.50, 872.33), # 절토 암 n=0.4
(-3.00, 871.08), # 절토 암 n=0.4
(-2.90, 870.83),
(-2.60, 870.53), # 측구 바깥 벽 n=1.0 — 사면이 아니다
(-2.30, 870.53), # 측구 바닥 (노면보다 0.3 낮다)
(-2.00, 870.83), # 노체 끝
(0.00, 870.89), # 노면
]
),
"road_edges": {"left": {"offset_m": 2.0}, "right": {"offset_m": -2.0}},
"ditch_enabled": True,
"ditch_side": "right",
"ditch": {"type": "standard", "top_width_m": 0.9, "bottom_width_m": 0.3, "depth_m": 0.3},
"cut_slope_ratio": 0.4,
"soil_cut_slope_ratio": 1.0,
"fill_slope_ratio": 1.2,
"two_stage_slope": True,
}
def 성토_설계선() -> dict:
"""성토 사면 n=1.2 가 이어지다 원지반(n=2.5)에서 끊긴다."""
return {
"design_line": line(
[
(2.0, 870.00), # 노체 끝
(4.4, 868.00), # 성토 사면 n=1.2
(6.8, 866.00),
(9.8, 864.80), # 원지반 n=2.5
]
),
"road_edges": {"left": {"offset_m": 2.0}, "right": {"offset_m": -2.0}},
"cut_slope_ratio": 0.4,
"soil_cut_slope_ratio": 1.0,
"fill_slope_ratio": 1.2,
}
# ── 사면길이 유도 ────────────────────────────────────────────────────
def test_절토_사면만_잡고_원지반은_끊음() -> None:
slope = station_slope(20.0, 절토_설계선())
# 암 두 조각(0.10/0.25 · 0.50/1.25) + 토사 두 조각(0.50/0.49 · 0.50/0.50).
# 원지반(-4.50~-5.00, n=1.22)은 경사비가 안 맞아 안 든다.
expected = (
math.hypot(0.10, 0.25)
+ math.hypot(0.50, 1.25)
+ math.hypot(0.50, 0.49)
+ math.hypot(0.50, 0.50)
)
assert slope.cut_length_m == pytest.approx(expected, rel=0.02)
def test_절토_2단이_암토사로_갈림() -> None:
"""8-11 의 암 5분류는 설계자 % 입력 몫 — 여기서는 2단 경계까지만 본다."""
slope = station_slope(20.0, 절토_설계선())
materials = {segment.material for segment in slope.segments if segment.role == "cut"}
assert materials == {"rock", "soil"}
def test_측구는_사면이_아님() -> None:
"""측구 벽도 n=1.0 이라 토사 절토비와 같다 — 노체 안쪽이므로 세면 안 된다."""
slope = station_slope(20.0, 절토_설계선())
for segment in slope.segments:
assert segment.from_offset_m <= -2.9 + 1e-6, f"측구 구간이 섞였다: {segment}"
def test_성토_사면도_유도됨() -> None:
"""원지반선 없이도 경사비 불일치로 끊긴다 — 이 길이 3번의 갈림길이었다."""
slope = station_slope(40.0, 성토_설계선())
assert slope.fill_length_m == pytest.approx(math.hypot(4.8, 4.0), rel=0.02)
assert slope.cut_length_m == 0.0
def 성토_실측_설계선() -> dict:
"""route 150 측점 20.0 **좌측 실측 좌표** — 성토 사면이 원지반을 만나 끊기는 자리.
2.00~6.95 가 n=1.2 로 일정하고 6.95 에서 n=2.0 으로 꺾인다. 그 꺾임이 곧 원지반이다.
⚠ 이 시험이 있는 까닭 — 「성토 사면적이 실무의 2.8배」라는 의심이 들어 손으로 따라간
자리다. 결과는 **엔진이 맞았고** 삼각형 근사(평지 가정)로 견준 쪽이 틀렸다.
지반이 기울어 있으면 같은 면적이라도 사면이 훨씬 길어진다.
"""
return {
"design_line": line(
[
(2.00, 870.323),
(2.50, 869.906),
(3.00, 869.490),
(3.50, 869.073),
(4.00, 868.656),
(4.50, 868.240),
(5.00, 867.823),
(5.50, 867.406),
(6.00, 866.990),
(6.50, 866.573),
(6.95, 866.202), # 여기까지가 사면 — 낙차 4.121m
(7.00, 866.177), # 원지반 n=2.0
(7.50, 865.921),
(8.00, 865.716),
]
),
"road_edges": {"left": {"offset_m": 2.0}, "right": {"offset_m": -2.0}},
"ditch_enabled": True,
"ditch_side": "right", # 좌측에는 측구가 없다
"ditch": {"top_width_m": 0.9},
"fill_slope_ratio": 1.2,
"cut_slope_ratio": 0.4,
"soil_cut_slope_ratio": 1.0,
}
def test_성토_사면이_원지반에서_정확히_끊김() -> None:
"""실측 좌표로 잰다 — 사면길이 6.44m(수평 4.95 · 낙차 4.121)."""
slope = station_slope(20.0, 성토_실측_설계선())
assert slope.fill_length_m == pytest.approx(math.hypot(4.95, 4.121), rel=0.005)
assert slope.fill_length_m == pytest.approx(6.44, abs=0.01)
# 원지반(7.00 바깥)은 한 조각도 안 든다.
assert max(s.to_offset_m for s in slope.segments) == pytest.approx(6.95)
def test_잘린_측점은_표시가_따라옴() -> None:
design = dict(성토_설계선(), slope_unclosed=True)
assert station_slope(40.0, design).unclosed is True
def test_설계선이_없으면_0() -> None:
assert station_slope(0.0, {}).cut_length_m == 0.0
# ── 사면 4계열 면적 ──────────────────────────────────────────────────
def 사면들() -> list[StationSlope]:
return [
StationSlope(chainage_m=0.0, cut_length_m=0.0, fill_length_m=0.0),
StationSlope(chainage_m=20.0, cut_length_m=4.0, fill_length_m=6.0),
StationSlope(chainage_m=40.0, cut_length_m=6.0, fill_length_m=2.0),
]
def test_면적은_평균단면적법() -> None:
"""토적표와 같은 식이다 — 체적 대신 면적이 나올 뿐 계산을 두 벌로 짜지 않는다."""
rows = build_rows(사면들())
assert rows[1].areas["face_dressing_cut"] == pytest.approx((0.0 + 4.0) / 2 * 20)
assert rows[2].areas["face_dressing_cut"] == pytest.approx((4.0 + 6.0) / 2 * 20)
assert rows[2].areas["face_dressing_fill"] == pytest.approx((6.0 + 2.0) / 2 * 20)
def test_첫_측점은_면적이_없음() -> None:
assert all(value == 0.0 for value in build_rows(사면들())[0].areas.values())
def test_층따기는_성토면만() -> None:
rows = build_rows(사면들())
assert "bench_cut_fill" in rows[1].areas
assert "bench_cut_cut" not in rows[1].areas
def test_법면보호공은_면고르기와_같은_값() -> None:
"""기본은 참조다 — 실무 시트가 그러했고 오솔길 산출에는 보호공이 비어 있었다."""
row = build_rows(사면들())[2]
assert row.areas["slope_protection_cut"] == pytest.approx(row.areas["face_dressing_cut"])
assert row.areas["slope_protection_fill"] == pytest.approx(row.areas["face_dressing_fill"])
def test_반영률_기본은_100퍼센트() -> None:
"""실무 관측 80/50/80 은 참고일 뿐 기본값이 아니다 — 법대로 방침(8-10 ★)."""
ratios = SlopeRatios()
assert (ratios.bench_cut, ratios.face_dressing, ratios.slope_protection, ratios.tree_removal) == (
1.0,
1.0,
1.0,
1.0,
)
def test_반영률을_주면_곱해짐() -> None:
plain = build_rows(사면들())[2].areas["face_dressing_cut"]
scaled = build_rows(사면들(), SlopeRatios(face_dressing=0.8))[2].areas["face_dressing_cut"]
assert scaled == pytest.approx(plain * 0.8)
def test_잘린_측점은_목록으로_드러남() -> None:
"""조용히 적게 내면 안 된다 — 화면이 이 목록을 그대로 보인다."""
slopes = 사면들()
slopes[1].unclosed = True
rows = build_rows(slopes)
assert unclosed_stations(rows) == [20.0]
def test_합계() -> None:
rows = build_rows(사면들())
total = totals(rows)
assert total["face_dressing_cut"] == pytest.approx(
sum(row.areas["face_dressing_cut"] for row in rows)
)
def test_표_모양() -> None:
table = build_table(사면들())
assert table["method"] == "average_end_area"
assert table["protection_source"] == "face_dressing"
assert table["station_count"] == 3
assert set(table["ratios"]) == {
"bench_cut",
"face_dressing",
"slope_protection",
"tree_removal",
}
assert len(table["rows"]) == 3
@@ -0,0 +1,48 @@
"""사토장 성토가 노체 성토로 새지 않는가 (2026-09-09).
⚠⚠ **둘은 다른 이야기다.** 사토장은 **없애는 곳이 아니라 목적지**다 —
그 흙은 여전히 실어 내야 하고 여전히 사토다. 바뀌는 것은 **양이 아니라 처리처와 거리**.
그리고 그 자리에 쌓인 흙은 **도로를 떠받치는 성토가 아니다** — 노체 성토로 세면
「흙이 모자라 사 와야 하는 양」이 실제보다 적어 보이는 착시가 생긴다.
⇒ B06 이 `spoil_fill_area_m2` 로 **갈라서** 내고 본 면적(`fill_area_m2`)에서 뺐다.
이 시험은 **우리 토적표가 그 갈래를 지키는지**를 본다.
"""
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_EarthworkTable import ( # noqa: E402
StationArea,
build_table,
)
def 측점(chainage: float, fill: float, spoil: float) -> StationArea:
return StationArea.from_design(
chainage,
{
"fill_area_m2": fill,
"spoil_fill_area_m2": spoil,
"cut_soil_area_m2": 0.0,
"cut_rock_area_m2": 0.0,
},
)
def test_사토장_성토가_노체_성토에_안_섞인다() -> None:
"""⚠ 섞이면 성토 물량이 갑자기 뛴다 — 사토장을 놓은 것만으로 흙 수요가 늘 리 없다."""
없을때 = build_table([측점(0.0, 10.0, 0.0), 측점(20.0, 10.0, 0.0)])
있을때 = build_table([측점(0.0, 10.0, 5.0), 측점(20.0, 10.0, 5.0)])
assert 있을때["totals"]["fill_volume_m3"] == 없을때["totals"]["fill_volume_m3"]
def test_본_면적만_본다() -> None:
"""토적표가 읽는 칸은 `fill_area_m2` 하나다 — 사토장 칸은 B06·인계가 따로 쓴다."""
table = build_table([측점(0.0, 10.0, 5.0), 측점(20.0, 12.0, 5.0)])
assert table["totals"]["fill_volume_m3"] == (10.0 + 12.0) / 2 * 20.0
+173
View File
@@ -0,0 +1,173 @@
"""사토를 실어 내는 줄 (2026-09-08).
⚠⚠ **유토곡선이 사토를 내는데 아무도 실어 내지 않고 있었다.** 운반 줄은 띠(`bands`)와
이동(`transfers`)에서만 만들어지고 사토는 잔량(`residuals`)으로 남아 어느 쪽에도 없었다 —
그래서 구조물 잔토 126.63㎥ 를 사토에 얹어도 **덤프가 하나도 안 늘었고**, 채집석 공제도
사토를 줄이는 값이라 **끝까지 금액에 안 나타났다**.
⚠ 겨누는 것 다섯
① 사토가 있으면 줄이 선다
② 거리를 안 정했으면 **막고 사유** — 임의 거리는 그대로 금액이 된다
③ 거리를 정하면 덤프 코드가 붙고 금액이 선다
④ ⚠ 자연방토는 실어 내지 않으므로 **뺀다**
⑤ 사토가 없으면 줄도 없다
"""
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_Handoff import build_handoff, load_mapping # noqa: E402
from B08_Quantity.B08_Quantity_Router_Earthwork import _spoil_of # noqa: E402
def (**spoil: object) -> dict | None:
table = {"rows": [], "spoil": spoil}
rows = build_handoff(haul_table=table)["work_items"]
found = [row for row in rows if row["name"] == "사토 운반"]
return found[0] if found else None
def test_사토가_있으면_줄이_선다() -> None:
row = (volume_m3=126.63, distance_m=None)
assert row is not None and row["quantity"] == 126.63
def test_거리를_안_정했으면_막고_사유() -> None:
"""⚠ 품셈이 정하는 값이 아니다 — 임의 거리를 넣으면 그대로 금액이 된다."""
row = (volume_m3=126.63, distance_m=None)
assert row["blocked_kind"] == "input_missing"
assert row["in_bill"] is False
# ⚠ 문구가 **바뀐 자리** — 2026-09-09 사용자 확정으로 사토장이 **이미 있는 측점 위에만**
# 놓이게 되어 거리가 저절로 나온다. 그래서 「거리가 없다」가 아니라 「사토장을 안 놓았다」다.
assert "사토장을 아직 안 놓았고" in row["blocked_reason"]
def test_거리를_정하면_금액이_선다() -> None:
row = (volume_m3=126.63, distance_m=1200.0)
assert row["work_item_code"] == load_mapping().for_haul("dump_truck")["work_item_code"]
assert row["haul_distance_m"] == 1200.0
assert row["in_bill"] is True
assert row["blocked_kind"] is None
def test_자연방토는_뺀다() -> None:
"""⚠ 자연방토는 실어 내지 않는 몫이다 — 함께 세면 운반이 부푼다."""
got = _spoil_of({"haul_plan": {"spoil_m3": 200.0, "natural_spoil_m3": 50.0}}, {})
assert got["volume_m3"] == 150.0
assert got["distance_m"] is None
assert "자연방토" in got["note"]
def test_공제와_가산이_끝난_값을_그대로_쓴다() -> None:
"""⚠ 여기서 또 빼거나 더하면 두 번 셈이다 — 근거 문구에만 적는다."""
got = _spoil_of(
{
"haul_plan": {
"spoil_m3": 126.63,
"natural_spoil_m3": 0.0,
"structure_spoil_added_m3": 126.63,
"collected_stone_deducted_m3": 0.0,
}
},
{"spoil_site_distance_m": 800.0},
)
assert got["volume_m3"] == 126.63
assert got["distance_m"] == 800.0
assert "구조물 잔토" in got["note"]
def test_사토가_없으면_줄도_없다() -> None:
assert (volume_m3=0.0, distance_m=500.0) is None
def 줄들(**spoil: object) -> list[dict]:
rows = build_handoff(haul_table={"rows": [], "spoil": spoil})["work_items"]
return [row for row in rows if row["name"] == "사토 운반"]
def test_갈래마다_한_줄로_선다() -> None:
"""⚠ 덤프 단가가 토사·암으로 갈린다 — 합쳐 세우면 한쪽 단가로 다 물린다."""
rows = 줄들(
volume_m3=100.0,
distance_m=800.0,
by_ground_m3={"ea_m3": 40.0, "rr_m3": 60.0},
ground_unknown_m3=0.0,
)
assert {(row["ground_class"], row["quantity"]) for row in rows} == {
("리핑암", 60.0),
("토사", 40.0),
}
assert all(row["in_bill"] for row in rows)
def test_갈래를_못_붙인_몫은_따로_서고_막힌다() -> None:
"""⚠ 토사로 눅이면 임의 단가가 된다 — 거리가 있어도 이 줄은 막는다."""
rows = 줄들(
volume_m3=100.0,
distance_m=800.0,
by_ground_m3={"ea_m3": 70.0},
ground_unknown_m3=30.0,
)
unknown = next(row for row in rows if row["ground_class"] is None)
assert unknown["quantity"] == 30.0
assert unknown["in_bill"] is False
assert "지반 갈래를 못 붙인" in unknown["blocked_reason"]
def test_갈래가_안_오면_종전처럼_한_줄() -> None:
"""옛 저장분(갈래 없는 유토곡선 결과)에서도 줄이 사라지지 않는다."""
rows = 줄들(volume_m3=61.88, distance_m=None)
assert len(rows) == 1 and rows[0]["quantity"] == 61.88
def test_사토장_거리가_오면_그것이_이긴다() -> None:
"""⚠ 정확한 값이 이긴다 — 설정의 대체 거리보다 **사토장 측점까지 누가거리**가 앞선다."""
rows = 줄들(
volume_m3=100.0,
distance_m=800.0,
by_ground_m3={"ea_m3": 40.0, "rr_m3": 60.0},
distance_by_ground_m={"ea_m3": 250.0, "rr_m3": 410.0},
ground_unknown_m3=0.0,
)
got = {row["ground_class"]: row for row in rows}
assert got["토사"]["haul_distance_m"] == 250.0
assert got["리핑암"]["haul_distance_m"] == 410.0
assert all(row["in_bill"] for row in rows)
assert "사토장 측점까지" in got["토사"]["spec_detail"]
def test_사토장_거리가_없는_갈래는_대체값으로_서고_그_사실이_적힌다() -> None:
rows = 줄들(
volume_m3=100.0,
distance_m=800.0,
by_ground_m3={"ea_m3": 40.0, "rr_m3": 60.0},
distance_by_ground_m={"ea_m3": 250.0},
ground_unknown_m3=0.0,
)
got = {row["ground_class"]: row for row in rows}
assert got["리핑암"]["haul_distance_m"] == 800.0
assert "대체 거리" in got["리핑암"]["spec_detail"]
def test_사토도_자연상태로_선다() -> None:
"""⚠ 내역 수량은 자연상태 — 되돌린 값이 오면 그 값으로 선다(config 5-4-3)."""
rows = 줄들(
volume_m3=61.88,
distance_m=800.0,
by_ground_m3={"rr_m3": 61.88},
natural_m3_by_ground={"rr_m3": 53.809},
ground_unknown_m3=0.0,
)
assert rows[0]["quantity"] == 53.809
assert "자연상태" in rows[0]["spec_detail"]
def test_되돌린_값이_없으면_다짐_그대로_두고_사유() -> None:
rows = 줄들(volume_m3=61.88, distance_m=800.0, by_ground_m3={"rr_m3": 61.88})
assert rows[0]["quantity"] == 61.88
assert "다짐상태 그대로" in rows[0]["spec_detail"]
+136
View File
@@ -0,0 +1,136 @@
"""돌쌓기 표준경사 — 직고·메찰·성절토로 갈린다 (2026-09-09 사용자 확정 ⑨).
⚠ 겨누는 것 여섯
① 표대로 갈린다 — 우리 종전 0.3 은 「메쌓기·성토·1.5m 이하」 한 칸이었다
② ⚠ 경계값은 **앞 칸** — 원문이 `∼3` 이라 직고 3.0m 은 `~3` 칸이다
③ 7m 초과라야 마지막 칸이다
④ 사용자가 정했으면 **그 값이 이긴다**
⑤ 근거 문구에 **어느 칸에서 왔는지**와 **성토 열로 잠정임**이 적힌다
⑥ ⚠ 큰돌쌓기는 이 표의 대상이 아니다(교본 「1:0.3 이상」)
"""
from __future__ import annotations
import math
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402
boulder_masonry,
face_slope_ratio,
stone_masonry,
)
def 기울기(wet: bool, height_m: float, face: str | None = "성토", **options: object) -> float:
"""⚠ 성토/절토는 **판정 한 벌**(`common_util_structure_face_role`)이 준다 —
시험에서는 그 자리를 명시해 표의 어느 열을 보는지 분명히 한다."""
return face_slope_ratio(dict(options), wet=wet, height_m=height_m, face=face)[0]
def test_표대로_갈린다() -> None:
# 메쌓기 성토
assert 기울기(False, 1.2) == 0.30
assert 기울기(False, 2.0) == 0.35
assert 기울기(False, 4.0) == 0.40
assert 기울기(False, 6.0) == 0.45
# 찰쌓기 성토
assert 기울기(True, 1.2) == 0.25
assert 기울기(True, 2.5) == 0.30
assert 기울기(True, 4.0) == 0.35
def test_경계값은_앞_칸이다() -> None:
"""⚠ 원문 표기가 `∼1.5 · ∼3` 이라 **이하**로 읽는다 — 한 칸 밀리면 값이 갈린다."""
assert 기울기(True, 1.5) == 0.25 # `~1.5` 칸
assert 기울기(True, 1.51) == 0.30 # 다음 칸
assert 기울기(False, 3.0) == 0.35 # `~3` 칸
assert 기울기(False, 3.01) == 0.40
def test_7m_초과라야_마지막_칸() -> None:
assert 기울기(False, 7.0) == 0.45
assert 기울기(False, 7.01) == 0.50
def test_사용자가_정하면_그_값이_이긴다() -> None:
ratio, basis = face_slope_ratio({"face_slope_ratio": 0.5}, wet=True, height_m=2.5, face="성토")
assert ratio == 0.5
assert "사용자 지정" in basis
def test_근거에_칸과_잠정임이_적힌다() -> None:
_, basis = face_slope_ratio(
{}, wet=False, height_m=2.0, face="성토", face_reason="right_cut · 좌측 → 성토면"
)
assert "13-4-4" in basis and "메쌓기" in basis
# ⚠ 어느 단면유형에서 왔는지가 근거에 실린다 — 값만 있고 까닭이 없으면 못 되짚는다.
assert "right_cut" in basis
def test_면적이_그_기울기로_선다() -> None:
"""메쌓기 H=2.0 은 1:0.35 — 종전 0.3 보다 **면적이 커진다**."""
components, _ = stone_masonry(
2.0, 10.0, {"height_m": 2.0, "length_m": 10.0, "back_len_cm": 35}, False, "성토"
)
area = next(c for c in components if c.name == "돌쌓기")
assert area.amount == 20.0 * math.hypot(1.0, 0.35)
assert "표준경사" in area.basis
def test_큰돌쌓기는_이_표의_대상이_아니다() -> None:
"""교본이 「1:0.3 **이상**」으로만 두었다 — 직고로 갈리지 않는다."""
components, _ = boulder_masonry(
5.0, 10.0, {"height_m": 5.0, "length_m": 10.0, "stone_cm": "60~80"}
)
area = next(c for c in components if c.name == "큰돌쌓기")
assert area.amount == 50.0 * math.hypot(1.0, 0.3)
def test_표_밖_값도_그대로_쓴다() -> None:
"""⚠ 실무 도면에 **S0.7·0.8** 이 실재한다(품셈 표는 0.20~0.50).
표 밖이라고 막거나 가까운 칸으로 **접지 않는다** — 접으면 사용자가 넣은 값이 조용히
다른 값으로 돌아간다(뒷길이에서 이미 겪은 자리). 「사용자 지정」으로 그대로 쓴다.
"""
for value in (0.7, 0.8, 1.2):
ratio, basis = face_slope_ratio(
{"face_slope_ratio": value}, wet=True, height_m=2.5, face="성토"
)
assert ratio == value
assert "사용자 지정" in basis
components, _ = stone_masonry(
2.5,
10.0,
{"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45, "face_slope_ratio": 0.7},
wet=True,
)
area = next(c for c in components if c.name == "돌쌓기")
assert area.amount == 25.0 * math.hypot(1.0, 0.7)
def test_성절토를_못_가르면_눅이지_않는다() -> None:
"""⚠ `both_cut` + 「자동」처럼 **가를 근거가 없는 자리**가 있다(판정 한 벌이 `None` 을 줌).
성토로 눅여 표를 고르면 **임의값이 금액으로 굳는다.** 종전값으로 서되 **왜 못 갈랐는지**를
근거에 적어 사용자가 무엇이 없는지 보게 한다.
"""
ratio, basis = face_slope_ratio(
{}, wet=True, height_m=2.5, face=None, face_reason="both_cut · 자동 — 세울 성토면이 없음"
)
assert ratio == 0.3
assert "both_cut" in basis and "종전값" in basis
def test_판정_한_벌을_그대로_쓴다() -> None:
"""⚠ 판정을 두 벌로 짜면 예외에서 갈린다 — 공용 함수 결과가 그대로 흘러야 한다."""
from common_util.common_util_structure_face_role import structure_face_role
role, reason = structure_face_role("right_cut", "")
assert role == "성토"
_, basis = face_slope_ratio({}, wet=True, height_m=2.5, face=role, face_reason=reason)
assert reason in basis
+122
View File
@@ -0,0 +1,122 @@
"""돌 종류 축 — 계수 셋이 그 축으로 갈리는지 (2026-09-08).
⚠ 겨누는 것 다섯
① 안 고르면 **종전 값 그대로**인가 (저장된 프로젝트가 안 흔들리게)
② 고르면 산림품셈 계수로 바뀌는가
③ ⚠ 뒤채움 몫을 **뒤집어 쓰지 않는가** — 표는 「뒤채움」, 우리 식은 「빼는 몫」
④ 표에 없는 칸(「-」)을 **지어내지 않는가**
⑤ 모르는 종류를 조용히 넘기지 않는가
"""
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_UnitQuantity import ( # noqa: E402
load_stone_kind_table,
stone_masonry,
)
#: 고치기 전 값 — 이 셋이 바뀌면 이미 저장된 프로젝트의 수량이 흔들린 것이다.
#: ⚠ 막자갈이 두 번 갈렸다 — 15.130 → 12.630(2026-09-08 확정 ② 벽 두께 실무 구조물도 식)
#: → **9.375**(2026-09-09 확정 5차 작은 것 3, 랩탑 보조). 지금은 「입적 − 몸통」이 아니라
#: **뒷채움 사다리꼴**(정본 상 0.30 · 하 0.45)이라 **돌 종류·뒷길이를 아예 안 본다.**
#: 고임돌·채움콘크리트는 여전히 비탈면적 × 종류별 계수다.
BEFORE = {"고임돌": 3.915, "채움콘크리트": 5.220, "막자갈": 9.375}
def 성분(options: dict) -> dict:
components, notes = stone_masonry(
2.5, 10.0, {"height_m": 2.5, "length_m": 10.0, **options}, wet=True
)
return {c.name: round(c.amount, 3) for c in components}, notes
def test_안_고르면_종전_값_그대로() -> None:
"""⚠ 값을 못 낸다고 멈추면 이미 저장된 프로젝트가 통째로 빈다."""
got, notes = 성분({"back_len_cm": 45})
for name, before in BEFORE.items():
assert abs(got[name] - before) < 0.002, f"{name}{before}{got[name]} 로 바뀌었다"
assert any("돌 종류를 안 정해" in n for n in notes), "기본값으로 선 사실을 안 알린다"
def test_야면석을_고르면_계수가_바뀐다() -> None:
"""품셈 13-4-3 야면석 0.11 · 13-4-4 [주]① 야면석 0.15 (뒷길이 45)."""
got, notes = 성분({"back_len_cm": 45, "stone_kind": "야면석·호박돌"})
assert abs(got["고임돌"] - 26.101 * 0.11) < 0.01
assert abs(got["채움콘크리트"] - 26.101 * 0.15) < 0.01
# ⚠ 사유는 여러 갈래가 함께 뜬다(기초 유/무 등) — **돌 종류 사유만** 본다.
assert all("돌 종류를 안 정해" not in n for n in notes)
def test_깬돌은_종전과_같은_계수() -> None:
"""⚠ 종전 값이 「깬돌」 줄이었다 — 그것이 확인되면 지금까지 값의 성격이 밝혀진다."""
got, _ = 성분({"back_len_cm": 45, "stone_kind": "깬돌"})
assert abs(got["고임돌"] - BEFORE["고임돌"]) < 0.002
assert abs(got["채움콘크리트"] - BEFORE["채움콘크리트"]) < 0.002
def test_막자갈은_이제_돌_종류_축이_아니다() -> None:
"""⚠ 뒤집힌 시험이다 — 종전에는 「깬돌 막자갈 > 야면석 막자갈」을 겨눴다.
그 시험은 막자갈이 「입적 − 몸통」이던 시절 **뒤채움 몫을 뒤집어 쓰는 것**을 막으려던
것이었다(표는 뒤채움 몫, 우리 식은 빼는 몫 — 반대라 19.045 가 나온 적 있다).
2026-09-09 확정 5차로 막자갈이 **뒷채움 사다리꼴**로 갈리면서 그 축이 사라졌다 —
이제 뒤집어 쓸 몫 자체가 없다. 대신 **종류로 안 갈린다는 것**을 못 박는다.
"""
야면석, _ = 성분({"back_len_cm": 45, "stone_kind": "야면석·호박돌"})
깬돌, _ = 성분({"back_len_cm": 45, "stone_kind": "깬돌"})
assert 깬돌["막자갈"] == 야면석["막자갈"] == BEFORE["막자갈"]
# ⚠ 갈리는 줄은 그대로 갈려야 한다 — 축이 통째로 죽은 것이 아니다.
assert 깬돌["고임돌"] > 야면석["고임돌"]
def test_표에_없는_칸은_지어내지_않는다() -> None:
"""⚠ 견치돌은 뒷길이 25·30 이 원문에서 「-」다 — 그 규격에 그 돌을 안 쓴다는 뜻."""
table = load_stone_kind_table()
assert table["wedge_stone_m3_per_m2"]["견치돌"]["25"] is None
assert table["wedge_stone_m3_per_m2"]["야면석·호박돌"]["75"] is None
def test_모르는_종류는_조용히_넘어가지_않는다() -> None:
got, notes = 성분({"back_len_cm": 45, "stone_kind": "화강석"})
assert any("아는 돌 종류가 아니라" in n for n in notes)
# 값은 종전대로 서되 사유가 남는다 — 0 으로 때우지 않는다.
assert got["고임돌"] > 0
def test_산출_근거에_돌_종류가_적힌다() -> None:
"""⚠ 값만 바뀌고 근거가 그대로면 화면에서 왜 달라졌는지 못 짚는다."""
components, _ = stone_masonry(
2.5,
10.0,
{"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45, "stone_kind": "깬돌"},
wet=True,
)
# ⚠ 막자갈은 이제 종류를 안 본다 — 계수가 종류로 갈리는 **두 줄**을 본다.
# 채움콘크리트는 값이 갈리는데 근거에 종류가 안 적히던 자리다(2026-09-09 맞춤).
for name in ("고임돌", "채움콘크리트"):
got = next(c for c in components if c.name == name)
assert "깬돌" in got.basis, name
def test_고른_종류의_빈_칸은_줄을_안_만든다() -> None:
"""⚠ 원문 「-」인 칸에 값이 서던 자리(만들다 잡음).
야면석 75㎝ 는 13-4-3 에 「-」다 — `None` 이라고 안 덮으면 종전 값(깬돌 0.25)이 남아
**원문에 없는 값이 조용히 선다.** 0 줄로 만들지도 않는다 — 0 은 「없음」과 구별이 안 된다.
"""
got, notes = 성분({"back_len_cm": 75, "stone_kind": "야면석·호박돌"})
assert "고임돌" not in got
assert any("「-」" in n or "칸이 비어" in n for n in notes)
def test_뒷길이_일곱_규격이_다_선다() -> None:
"""⚠ 앞서 네 칸만 들고 25·30·75 를 접고 있었다."""
for back in (25, 30, 35, 45, 55, 60, 75):
got, _ = 성분({"back_len_cm": back, "stone_kind": "깬잡석"})
assert got.get("채움콘크리트", 0) > 0, back
@@ -0,0 +1,245 @@
"""구조물 터파기·되메우기·잔토가 인계 축에 오르나 (2026-09-08).
⚠⚠ **빠뜨렸던 자리다.** 성분으로만 있고 아무도 안 받아 **내역서에 한 줄도 안 나갔다** —
두께 식·기초 몫을 아무리 맞춰도 금액이 0원이었다(B09 매김에서 드러남).
⚠ 겨누는 것 여섯
① 터파기가 **공종 줄로 선다**
② 심도로 갈린다 — 품셈 9-13 이 18구분(토질 3 × 육상/용수 × 심도 3)이라서
③ ⚠ 토질·용수를 **지어내지 않는다** — 상위 코드 + 사유
④ 되메우기는 코드가 붙고 **금액이 선다**
⑤ ⚠ 잔토는 **사토로 갈 몫**이라 내역에 안 서되 값과 사유가 실린다
⑥ 구조물이 없으면 줄도 없다
"""
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_Handoff import build_handoff # noqa: E402
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402
def 구조물(**options: object) -> dict:
return {
"structure_id": "s1",
"type_id": "masonry_wet",
"start_m": 0.0,
"end_m": 10.0,
"options": {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45, **options},
}
def (**options: object) -> dict:
unit = build_table([구조물(**options)])
rows = build_handoff(unit_quantity_table=unit)["work_items"]
return {row["name"]: row for row in rows}
def test_터파기가_공종_줄로_선다() -> None:
row = (foundation="기초유")["구조물터파기"]
assert row["unit"] == ""
assert row["quantity"] > 0
assert row["work_item_code"] == "FP-09-13"
def test_심도로_갈린다() -> None:
"""직고 2.5 + 기초 0.5 = 3.0m → `2~3m` 칸. 기초를 안 고르면 2.5m 라 같은 칸이다."""
assert (foundation="기초유")["구조물터파기"]["spec"] == "심도 2~3m"
깊은 = build_table(
[
{
"structure_id": "s9",
"type_id": "masonry_wet",
"start_m": 0.0,
"end_m": 10.0,
"options": {
"height_m": 3.5,
"length_m": 10.0,
"back_len_cm": 45,
"foundation": "기초유",
},
}
]
)
rows = build_handoff(unit_quantity_table=깊은)["work_items"]
assert next(r for r in rows if r["name"] == "구조물터파기")["spec"] == "심도 3m 초과"
def test_토질을_못_가르면_그렇게_적는다() -> None:
"""⚠ 지반 갈래 표가 없으면 「토질을 못 가름」이다 — 토사로 눅이지 않는다."""
row = (foundation="기초유")["구조물터파기"]
assert row["blocked_kind"] == "input_missing"
assert "토질을 못 가름" in row["blocked_reason"]
assert row["ground_class"] is None
assert row["in_bill"] is False
def test_토질은_측점_설계값에서_온다() -> None:
"""⚠ 새 칸을 만들지 않는다 — 이미 저장되는 `design.ground_type` 을 읽는다."""
unit = build_table(
[구조물(foundation="기초유")],
None,
None,
{0.0: "ripping_rock", 5.0: "ripping_rock", 10.0: "ripping_rock"},
)
row = next(
r
for r in build_handoff(unit_quantity_table=unit)["work_items"]
if r["name"] == "구조물터파기"
)
assert row["ground_class"] == "암절취"
assert row["spec"] == "암절취 · 심도 2~3m"
# 남은 축은 용수 하나다 — 그것만 사유에 남는다.
assert "용수" in row["blocked_reason"] and "토질" not in row["blocked_reason"]
def test_걸친_측점이_섞이면_안_고른다() -> None:
"""⚠ 다수결로 고르면 임의값이 금액으로 굳는다 — 암 단가가 몇 배다."""
unit = build_table(
[구조물(foundation="기초유")],
None,
None,
{0.0: "soil", 5.0: "soil", 10.0: "blasting_rock"},
)
row = next(
r
for r in build_handoff(unit_quantity_table=unit)["work_items"]
if r["name"] == "구조물터파기"
)
assert row["ground_class"] is None
assert "섞여" in row["blocked_reason"]
assert "토사 2곳" in row["blocked_reason"] and "발파암 1곳" in row["blocked_reason"]
def test_되메우기는_금액이_선다() -> None:
row = (foundation="기초유")["되메우기"]
assert row["work_item_code"] == "FP-09-14-01"
assert row["in_bill"] is True
assert abs(row["quantity"] - (0.5 + 2.5) * 0.2 * 10.0) < 1e-9
def test_잔토는_사토로_갈_몫이라_내역에_안_선다() -> None:
"""⚠ 유토곡선이 세야 겹치지 않는다 — 다만 그 통로가 아직 없다는 사실을 사유로 남긴다."""
row = (foundation="기초유")["잔토처리"]
assert row["in_bill"] is False
# ⚠ 「통로가 없어 안 실림」이던 문구가 **바뀐 자리** — 2026-09-09 유토곡선이 그 값을
# 받아 사토에 얹고 「사토 운반」 줄로 세우게 됐다. 사유도 그 사실로 옮겼다.
assert "사토" in row["in_bill_reason"] and "사토 운반" in row["in_bill_reason"]
assert row["quantity"] > 0
def test_구조물이_없으면_줄도_없다() -> None:
rows = build_handoff(unit_quantity_table={"structures": []})["work_items"]
assert all(row["name"] not in {"구조물터파기", "되메우기", "잔토처리"} for row in rows)
def test_같은_토질_같은_심도는_한_줄이다() -> None:
"""⚠ 근거 문구가 측점마다 달라 **두 줄로 갈리던 자리**(2026-09-08 실화면).
사람이 보는 문구가 다르다는 이유로 내역 줄이 쪼개지면 같은 공종이 두 번 선다.
"""
unit = build_table(
[
{
"structure_id": "a",
"type_id": "masonry_wet",
"start_m": 0.0,
"end_m": 10.0,
"options": {
"height_m": 2.5,
"length_m": 10.0,
"back_len_cm": 45,
"foundation": "기초유",
},
},
{
"structure_id": "b",
"type_id": "masonry_wet",
"start_m": 100.0,
"end_m": 110.0,
"options": {
"height_m": 2.5,
"length_m": 10.0,
"back_len_cm": 45,
"foundation": "기초유",
},
},
],
None,
None,
{0.0: "soil", 10.0: "soil", 105.0: "soil"},
)
rows = [
r
for r in build_handoff(unit_quantity_table=unit)["work_items"]
if r["name"] == "구조물터파기"
]
assert len(rows) == 1
assert rows[0]["ground_class"] == "토사"
# ── 용수 축 · 18구분 (2026-09-09 사용자 확정 3차 ④) ──────────────────
def test_18구분_차례가_원문_그대로다() -> None:
"""⚠ 한 칸 밀리면 **다른 지반·다른 심도 단가**가 붙는다."""
from B08_Quantity.B08_Quantity_Engine_Handoff_Trench import trench_child_code
assert trench_child_code("FP-09-13", "soil", "육상", "0~1m") == "FP-09-13-01"
assert trench_child_code("FP-09-13", "soil", "용수", "0~1m") == "FP-09-13-04"
assert trench_child_code("FP-09-13", "ripping_rock", "육상", "0~1m") == "FP-09-13-07"
assert trench_child_code("FP-09-13", "ripping_rock", "용수", "2~3m") == "FP-09-13-12"
assert trench_child_code("FP-09-13", "blasting_rock", "용수", "2~3m") == "FP-09-13-18"
def test_축이_없으면_상위_코드로_둔다() -> None:
"""⚠ 3m 를 넘는 칸은 **원문에 없다** — 지어내지 않는다."""
from B08_Quantity.B08_Quantity_Engine_Handoff_Trench import trench_child_code
assert trench_child_code("FP-09-13", "soil", "", "0~1m") is None
assert trench_child_code("FP-09-13", None, "육상", "0~1m") is None
assert trench_child_code("FP-09-13", "soil", "육상", "3m 초과") is None
def 물줄(water: str | None, ground: str = "ripping_rock") -> dict:
unit = build_table(
[구조물(foundation="기초유")],
None,
None,
{0.0: ground, 10.0: ground},
)
rows = build_handoff(unit_quantity_table=unit, structure_trench_water=water)["work_items"]
return next(row for row in rows if row["name"] == "구조물터파기")
def test_용수를_정하면_자식_코드로_내려가_금액이_선다() -> None:
row = 물줄("육상")
assert row["work_item_code"] == "FP-09-13-09" # 육상 암절취 2~3m
assert row["in_bill"] is True and row["blocked_kind"] is None
def test_통상값이라는_사실이_줄에_적힌다() -> None:
"""⚠ 사용자 확정이 아니라 **통상값**이다 — 안 적으면 뒤집을 근거가 사라진다."""
assert "통상값" in 물줄("육상")["spec_detail"]
def test_용수로_뒤집으면_코드가_옮겨_간다() -> None:
assert 물줄("용수")["work_item_code"] == "FP-09-13-12"
def test_안_정하면_상위_코드로_막힌다() -> None:
row = 물줄(None)
assert row["work_item_code"] == "FP-09-13"
assert row["in_bill"] is False and "용수" in row["blocked_reason"]
def test_상태_사실이_값_옆에_적힌다() -> None:
"""⚠ 코드 주석에만 두면 화면에서 안 보인다 — 되메우기·잔토 줄에 함께 실린다."""
rows = (foundation="기초유")
for name in ("되메우기", "잔토처리"):
assert "다짐부피 ÷ C" in rows[name]["spec_detail"], name
+192
View File
@@ -0,0 +1,192 @@
"""표토 운반·적치 — 법이 요구하는데 제거만 세고 있던 줄 (2026-09-09).
⚠⚠ 시행규칙 별표2 .2.차.(6)·Ⅰ.3.카.(6):
「노면·절토대상지에 있는 입목…과 그 뿌리, **표토는 전량 제거한 후** 강우 시 유실되거나
경관에 저해되지 않도록 **최고 홍수위보다 높은 장소로 운반하고 쌓아두어야 한다**」
⇒ 제거(9-15)만 세면 **운반이 빠진다.**
⚠ 겨누는 것 다섯
① 줄이 **선다** — 값이 없어도 목록에 남는다
② 물량은 **제거 물량 그대로** — 다시 세지 않는다
③ 거리는 **설계 입력** — 「최고 홍수위보다 높은 장소」는 현장이 정한다. 지어내지 않는다
④ ⚠ 제거가 안 서면(두께 미입력) 운반도 안 선다 — 밑수가 그 줄이다
⑤ 법 문구가 **사유에 적힌다** — 왜 세는지 화면에서 보여야 한다
"""
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_Preparation import ( # noqa: E402
STATUS_PENDING,
STATUS_READY,
preparation_rows,
)
사면 = {"face_dressing_fill": 600.0, "face_dressing_cut": 400.0}
def (thickness: float | None, distance: float | None) -> dict:
rows = preparation_rows(사면, [], thickness, distance)
return {row["item"]: row for row in rows}
def test_줄이_선다() -> None:
assert "표토 운반·적치" in (None, None)
def test_물량은_제거_물량_그대로() -> None:
"""⚠ 다시 세면 두 값이 갈린다 — 제거 줄이 밑수다."""
rows = (0.2, 300.0)
assert rows["표토제거"]["amount"] == 1000.0 * 0.2
assert rows["표토 운반·적치"]["amount"] == rows["표토제거"]["amount"]
assert rows["표토 운반·적치"]["status"] == STATUS_READY
assert rows["표토 운반·적치"]["work_item_code"] == "FP-10-12"
def test_거리를_안_넣으면_막고_사유() -> None:
"""⚠ 「최고 홍수위보다 높은 장소」는 현장이 정한다 — 품셈이 거리를 주지 않는다."""
row = (0.2, None)["표토 운반·적치"]
assert row["amount"] is None and row["status"] == STATUS_PENDING
assert "운반거리가 아직 입력되지 않았습니다" in row["reason"]
# 값을 버리지 않는다 — 운반할 물량은 참고로 보인다.
assert row["reference_amount"] == 200.0
def test_제거가_안_서면_운반도_안_선다() -> None:
row = (None, 300.0)["표토 운반·적치"]
assert row["amount"] is None
assert "두께 먼저" in row["reason"]
def test_법_문구가_사유에_적힌다() -> None:
for thickness, distance in ((None, None), (0.2, None), (0.2, 300.0)):
reason = (thickness, distance)["표토 운반·적치"]["reason"]
assert "별표2" in reason and "운반하고 쌓아두어야" in reason
# ── 뿌리 적재·운반 — 품셈이 네 단계로 둔 자리 (2026-09-09) ──────────────
def test_뿌리_뒤_단계_두_줄이_선다() -> None:
"""⚠⚠ 「벌개·제근 → 뿌리다듬기 → **적재** → **운반**」인데 우리는 한 줄만 세고 있었다.
표토와 같은 병이다 — 법·품셈이 두 동작을 묶어 두는데 앞 동작만 세던 자리.
"""
rows = (0.2, 300.0)
assert "뿌리 적재" in rows and "뿌리 운반" in rows
def test_적재는_9_20_2_운반은_덤프() -> None:
"""⭐ 2026-09-09 확정 5차 4번 — 「뿌리 운반은 **덤프**」로 답이 왔다.
⚠ 그 전에는 「9-20 장에 운반 공종이 없어 코드를 못 붙임」이었다. 지어낸 것이 아니라
**사용자가 고른 것**이라 코드가 붙는다.
⚠ 다만 물량은 여전히 안 선다 — 운반 밑수는 **부피(㎥)** 인데 뿌리 부피를 든 곳이 없다.
"""
rows = (0.2, 300.0)
assert rows["뿌리 적재"]["work_item_code"] == "FP-09-20-02"
assert rows["뿌리 운반"]["work_item_code"] == "FP-10-12"
assert rows["뿌리 운반"]["amount"] is None
assert "부피" in rows["뿌리 운반"]["reason"]
def test_적재는_면적으로_서고_운반은_못_선다() -> None:
"""⭐ 확정 5차 6번으로 **면적 축**이 정해져 적재는 선다(제근과 같은 밑수).
⚠ 운반만 못 선다 — 축이 **부피**라서다. 「같은 확정인데 한 줄만 서는」 자리라 갈라 적는다.
⚠ 대상 면적은 **벌개제근 연동**이라 `tree_removal_*` 에서 온다(표토 계열 `face_dressing_*`
과 **다른 면적**이다 — 2026-09-09 실측: 17,873.8 ↔ 15,726.9).
"""
사면 = {**{"tree_removal_fill": 600.0, "tree_removal_cut": 400.0}, **{}}
rows = {row["item"]: row for row in preparation_rows(사면, [], 0.2, 300.0)}
assert rows["뿌리 적재"]["amount"] == 1000.0
assert rows["제근·뿌리다듬기"]["amount"] == 1000.0 # 같은 밑수로 함께 섬
assert rows["뿌리 운반"]["amount"] is None
assert rows["뿌리 운반"]["reference_amount"] == 1000.0
# ── 2026-09-09 준비공 축 감사에서 잡은 둘 ──────────────────────────
def test_면적이_0이면_물량을_안_낸다() -> None:
"""⚠ 0 은 **「없음」과 구별이 안 된다** — 받는 쪽이 「표토가 없는 노선」으로 읽는다.
절·성토 사면적이 0 인 임도는 성립하지 않으므로, 이 자리는 사실상 **사면표가 아직
안 선 것**이다. 두께 미입력은 이미 막고 있었는데 면적 0 은 그냥 통과하고 있었다.
"""
from B08_Quantity.B08_Quantity_Engine_Preparation import build_table
rows = {r["item"]: r for r in build_table(slope_totals={}, topsoil_thickness_m=0.2)["rows"]}
제거 = rows["표토제거"]
assert 제거["amount"] is None
assert "사면표" in 제거["reason"]
# 운반은 제거를 밑수로 삼으므로 함께 막힌다 — 0 ㎥ 를 실어 나르는 줄이 안 생긴다.
assert rows["표토 운반·적치"]["amount"] is None
def test_대상_면적이_법_문언과_다르다는_사실이_적힌다() -> None:
"""⚠ 값을 고치는 것이 아니라 **드러내는** 시험이다(임의 확정 금지).
별표2 Ⅰ.2.차.(6) 은 대상을 「**노면·절토대상지**」로 못박고, 성토대상지는 (7) 에서
「표토 등은 **제거·정리**한다」로 따로 두어 운반·적치 의무를 안 건다. 우리는
절토·성토 **사면적을 다 더하고 노면은 안 센다** — 두 방향으로 어긋난다.
실무가 어느 쪽인지는 사용자·실무자 확인 사항이라 **사유만** 낸다.
"""
from B08_Quantity.B08_Quantity_Engine_Preparation import build_table
rows = {
r["item"]: r
for r in build_table(
slope_totals={"face_dressing_cut": 9000.0, "face_dressing_fill": 6726.9},
topsoil_thickness_m=0.2,
)["rows"]
}
reason = rows["표토제거"]["reason"]
assert rows["표토제거"]["amount"] is not None # 값은 그대로 선다
assert "노면·절토대상지" in reason and "노면은 안 셈" in reason
def test_운반이_갈래를_실어_보낸다() -> None:
"""⚠ `FP-10-12` 는 **부모**다 — 품이 붙은 것은 잎(10-12-1 토사 · -2 암절취 · -3 발파암)뿐.
갈래를 안 보내면 B09 가 「이 공종엔 일위대가가 없고 한 층 아래에 있습니다 — 후보 3건」
에서 멈추고 **금액이 안 선다.** 표토가 토사인 것은 다툼이 없으므로 갈래를 실어 보낸다.
"""
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff
from B08_Quantity.B08_Quantity_Engine_Preparation import build_table
table = build_table(
slope_totals={"face_dressing_cut": 9000.0, "face_dressing_fill": 6726.9},
topsoil_thickness_m=0.2,
topsoil_haul_distance_m=500.0,
)
handed = {r["name"]: r for r in build_handoff(preparation_table=table)["work_items"]}
운반 = handed["표토 운반·적치"]
assert 운반["in_bill"] is True
assert (운반["variant_axis"], 운반["variant_value"]) == ("ground_class", "토사")
def test_제거_품에_20m_압토가_들어_있다는_사실이_적힌다() -> None:
"""⚠ 품셈 9-15-2 원문이 `L(운반거리) 20m` 를 적용값으로 박아 뒀다.
실무 내역서(영월 2024)도 「표토제거 답외구간 / M2 · 도자 19Ton · D=20」 **한 줄뿐이고
운반 줄이 따로 없다.** 우리 운반 줄은 그래서 **20m 를 넘는 몫**일 때만 새 줄인데 그
가름을 아직 안 했다 — 값을 지어내지 않고 **그 사실을 사유에 남긴다**(이중계상 후보).
"""
from B08_Quantity.B08_Quantity_Engine_Preparation import build_table
rows = {
r["item"]: r
for r in build_table(
slope_totals={"face_dressing_cut": 9000.0, "face_dressing_fill": 6726.9},
topsoil_thickness_m=0.2,
topsoil_haul_distance_m=500.0,
)["rows"]
}
reason = rows["표토 운반·적치"]["reason"]
assert "L=20m 압토가 들어 있음" in reason and "20m 를 넘는" in reason
@@ -0,0 +1,77 @@
"""지장목제거를 두 줄로 가름 (2026-09-09 사용자 확정 5차 2번).
실무(영월 설계내역서 1.9)는 **한 면적에 두 작업**을 얹는다 —
1.9.1 뿌리뽑기(장비+인력) 11,035㎡ @475
1.9.2 잡관목제거 벌목(5m미만) 11,035㎡ @882 ← **같은 11,035㎡**
⚠ 겨누는 것 다섯
① 두 줄이 선다
② ⚠ **같은 면적**이 두 줄에 그대로 들어간다 — **이중계상이 아니다**(다른 작업이 얹히는 것)
③ 그 사실이 사유에 적힌다
④ ⚠ 작업 갈래(뿌리뽑기·잡관목제거)를 **지반 갈래로 읽지 않는다** — 읽으면
「시공법 미지정으로 공종을 못 고름」이라는 **틀린 사유**가 붙는다
⑤ 잡관목제거는 **품셈에 이름이 없음**이 사유에 남는다(공종 보류 — 확정 5차 3번)
"""
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_EarthworkSummary import ( # noqa: E402
SummaryInput,
build_table,
)
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402
사면 = {"tree_removal_fill": 9000.0, "tree_removal_cut": 8873.8}
def 집계() -> dict:
return build_table(
SummaryInput(
earthwork_totals={},
slope_totals=사면,
haul_rows=[],
rock_classes=["토사"],
rock_ratios_pct={},
application_ratios={},
)
)
def 줄들() -> list[dict]:
return [row for row in build_handoff(summary_table=집계())["work_items"] if row["name"] == "지장목제거"]
def test_두_줄이_선다() -> None:
assert [row["spec"] for row in 줄들()] == ["뿌리뽑기", "잡관목제거"]
def test_같은_면적이_두_줄에_들어간다() -> None:
"""⚠ 이중계상이 아니다 — 한 면적에 **다른 두 작업**이 얹히는 실무 서식이다."""
amounts = {row["quantity"] for row in 줄들()}
assert amounts == {17873.8}
def test_이중계상이_아님이_사유에_적힌다() -> None:
for row in 줄들():
assert "이중계상 아님" in row["in_bill_reason"] or "이중계상 아님" in str(row["spec_detail"])
def test_작업_갈래를_지반_갈래로_읽지_않는다() -> None:
"""⚠ 읽으면 「시공법 미지정으로 공종을 못 고름」이라는 틀린 사유가 붙는다."""
for row in 줄들():
assert row["ground_class"] is None
unmatched = build_handoff(summary_table=집계())["unmatched_work_items"]
assert "지장목제거" in unmatched
assert not any("지장목제거(" in item for item in unmatched)
def test_잡관목제거는_품셈에_이름이_없음이_남는다() -> None:
잡관목 = next(row for row in 줄들() if row["spec"] == "잡관목제거")
사유 = str(잡관목["spec_detail"]) + str(잡관목["in_bill_reason"])
assert "품셈에 그 이름이 없어" in 사유
+410
View File
@@ -0,0 +1,410 @@
"""구조물 원단위 전개식 검사 — PLAN 8-6·8-8·8-15.
이 일감의 진짜 위험은 계산이 아니라 **이중계상**이다. 그래서 시험도 거기에 무게를 둔다.
㉢ 배합(시멘트·모래)을 여기서 쪼개면 B09 일위대가와 겹쳐 두 배가 된다.
㉠ 할증을 여기서 붙이면 자재총괄과 겹친다.
· 터파기·되메우기는 토공으로 합산되는 값이라 내역 줄과 구분돼야 한다.
실무 관측값(울진 13종)은 **검산 정답지**이지 맞춰야 할 목표가 아니다 — 크게 벌어지면
전개식을 의심하되, 맞추려고 식을 비틀지는 않는다.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402
MIX_COMPONENTS,
STONE_BACK_LENGTH_TABLE,
Component,
StructureQuantity,
build_table,
expand,
stone_masonry,
verify_no_mix_components,
)
def 돌쌓기찰(height: float = 1.5, length: float = 1.0, **options) -> dict:
"""저장된 제원 모양 그대로 — `structures.json` 의 한 항목."""
return {
"structure_id": "s1",
"type_id": "masonry_wet",
"start_m": 35.0,
"end_m": 35.0 + length,
"options": {"height_m": height, "length_m": length, **options},
}
def 성분(result: StructureQuantity, name: str) -> Component:
return next(c for c in result.components if c.name == name)
# ── ㉢ 배합 분해 금지 — 이 일감 최대 위험 ────────────────────────────
def test_배합_성분이_산출물에_없을것() -> None:
"""모르터·콘크리트까지만 낸다 — 시멘트·모래는 B09 일위대가 몫이다.
⚠ **정확히 같은 이름**으로 본다. 부분문자열로 재면 `막자갈`(뒤채움 재료)이 배합
`자갈` 로 오탐된다 — 개발 중 실제로 걸렸던 자리라 시험도 같은 규칙으로 둔다.
"""
result = expand(돌쌓기찰())
names = {c.name for c in result.components}
assert not (names & MIX_COMPONENTS), f"배합 성분이 섞였다: {names & MIX_COMPONENTS}"
assert "막자갈" in names # 뒤채움 재료는 배합이 아니라 남아 있어야 한다
def test_모르터까지만_내고_멈춤() -> None:
result = expand(돌쌓기찰())
assert 성분(result, "모르터").unit == ""
assert 성분(result, "채움콘크리트").unit == ""
def test_배합이_섞이면_검사가_잡을것() -> None:
"""실무 라이브러리를 베끼다 딸려 들어오기 쉬운 자리라 코드로 막는다."""
tainted = StructureQuantity(
structure_id="x",
type_id="masonry_wet",
name="돌쌓기(찰)",
components=[Component("시멘트", "", 2.6, "material")],
)
assert verify_no_mix_components([tainted])
assert not verify_no_mix_components([expand(돌쌓기찰())])
def test_표에도_위반이_드러남() -> None:
table = build_table([돌쌓기찰()])
assert table["mix_components_found"] == []
# ── ㉠ 할증 금지 ────────────────────────────────────────────────────
def test_할증_전_값임을_못박음() -> None:
"""할증은 자재총괄 한 곳뿐이다(PLAN 8-7 ㉠)."""
assert build_table([돌쌓기찰()])["surcharge_applied"] is False
# ── 터파기·되메우기는 토공으로 합산 ─────────────────────────────────
def test_성분마다_갈_곳이_표시됨() -> None:
"""내역 줄의 실체는 작업 공종이고, 터파기는 토공으로 합쳐진다(울진 D12~D14 실증)."""
result = expand(돌쌓기찰())
assert 성분(result, "터파기").destination == "earthwork"
assert 성분(result, "되메우기").destination == "earthwork"
assert 성분(result, "잔토처리").destination == "earthwork"
assert 성분(result, "돌쌓기").destination == "unit_price"
# ⚠ 이름이 「야면석」 → 「돌」로 바뀜(2026-09-09 확정 5차 큰 것 7) — 종류를 안 고르면
# 관측표가 아니라 **계산식**으로 서고 줄 이름도 정본 계산표대로 「돌」이다.
assert 성분(result, "").destination == "material"
def test_잔토는_터파기_빼기_되메우기() -> None:
result = expand(돌쌓기찰())
assert 성분(result, "잔토처리").amount == pytest.approx(
성분(result, "터파기").amount - 성분(result, "되메우기").amount
)
# ── 전개식 — 실무 시트 값과 대조 ────────────────────────────────────
def test_실무_시트_m당_값_재현() -> None:
"""`기슭막이(찰쌓기, H=1.5, 기초무)` 시트 — 터파기 1.55 · 되메우기 0.30 · 잔토 1.25.
⚠ 우리 값은 1.5375 로 실무 1.55 와 0.012 차이가 난다. **우리 쪽이 맞다** —
평균두께가 0.825 인데 실무 시트는 **표기값 0.83 으로 다시 계산**해서 1.545 가 됐다.
품셈 1-2-2 의 소수 자리는 표기 규칙이고 계산은 전정밀로 둔다(PLAN 8-16).
맞추려고 식을 비틀지 않는다 — 허용오차를 그 차이만큼 둔다.
"""
result = expand(돌쌓기찰(height=1.5, length=1.0))
assert result.height_m == 1.5
assert result.length_m == 1.0
assert 성분(result, "터파기").amount == pytest.approx(1.55, abs=0.02)
assert 성분(result, "되메우기").amount == pytest.approx(0.30, abs=0.01)
assert 성분(result, "잔토처리").amount == pytest.approx(1.25, abs=0.02)
def test_평균두께는_전정밀로() -> None:
"""실무가 0.83 으로 반올림해 재계산한 자리 — 우리는 0.825 를 그대로 쓴다."""
result = expand(돌쌓기찰(height=1.5, length=1.0))
# 터파기 = 높이 × (평균두께 + 0.2) × 연장 = 1.5 × 1.025 = 1.5375
assert 성분(result, "터파기").amount == pytest.approx(1.5375)
def test_비탈면적은_기울기만큼_길어짐() -> None:
"""1:0.3 이면 정면적 1.5 → 돌쌓기 1.566 (실무 표기 1.57).
⚠ 2026-09-08 ㉘ 정정 — 이 시험이 **기울기 몫을 두 번 곱하는 것**(× 1.566 × 1.04)을
계약으로 못 박고 있었다. 실무 시트의 「돌쌓기 = 정면적 × 1.04」에서 그 1.04 가
**곧 기울기 몫**이다(1:0.3 → 1.0440). 값이 나오고 자원도 맞아 아무도 안 봤다.
"""
import math
# ⚠ 2026-09-09 — 기울기가 **품셈 표준경사표로 자동 판정**되면서 찰 H=1.5 는 1:0.25 가
# 되었다(확정 ⑨). 이 시험이 보는 것은 「기울기 몫을 한 번만 먹는가」이므로
# **기울기를 못 박고** 본다.
components, _ = stone_masonry(1.5, 1.0, {"face_slope_ratio": 0.3}, wet=True)
masonry = next(c for c in components if c.name == "돌쌓기")
assert masonry.amount == pytest.approx(1.5 * math.hypot(1.0, 0.3))
def test_돌쌓기_면적은_기울기_몫을_한_번만_먹는다_거울시험() -> None:
"""⚠ 거울 시험 — `정면적 × hypot` 과 어긋나면 깨진다. 기울기를 바꿔도 따라와야 한다."""
import math
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import boulder_masonry
for ratio in (0.3, 0.5, 1.0):
options = {"face_slope_ratio": ratio}
components, _ = stone_masonry(2.5, 10.0, options, wet=True)
돌쌓기 = next(c for c in components if c.name == "돌쌓기")
assert 돌쌓기.amount == pytest.approx(25.0 * math.hypot(1.0, ratio))
options["stone_cm"] = "60~80"
components, _ = boulder_masonry(2.5, 10.0, options)
큰돌 = next(c for c in components if c.name == "큰돌쌓기")
assert 큰돌.amount == pytest.approx(25.0 * math.hypot(1.0, ratio))
def test_1대0점3_에서_실무_시트값과_맞는다() -> None:
"""실무 돌골막이 시트 — 정면적 18.87 ㎡ → 돌쌓기 19.62 ㎡. 오차 0.5 % 안."""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import STONE_MASONRY
# ⚠ 실무 시트는 1:0.3 으로 그린 것이라 **그 기울기를 못 박고** 대조한다(2026-09-09).
# 자동 판정에 맡기면 직고 18.87m 로 읽혀 7m 초과 칸(1:0.45)이 되어 대조가 깨진다.
components, _ = stone_masonry(18.87, 1.0, {"face_slope_ratio": 0.3}, wet=True)
masonry = next(c for c in components if c.name == "돌쌓기")
assert masonry.amount == pytest.approx(19.62, rel=0.005)
# 시트가 반올림해 적은 1.04 가 곧 기울기 몫임을 함께 박는다.
assert STONE_MASONRY["sheet_check_factor_at_0_3"] == pytest.approx(
masonry.amount / 18.87, rel=0.005
)
def test_길이에_비례() -> None:
one = expand(돌쌓기찰(length=1.0))
ten = expand(돌쌓기찰(length=10.0))
assert 성분(ten, "터파기").amount == pytest.approx(성분(one, "터파기").amount * 10)
def test_메쌓기는_콘크리트_모르터가_없음() -> None:
result = expand({**돌쌓기찰(), "type_id": "masonry_dry"})
names = [c.name for c in result.components]
assert "채움콘크리트" not in names
assert "모르터" not in names
assert "돌쌓기" in names
# ── 계수표 — 식에 박지 않는다 ───────────────────────────────────────
def test_뒷길이를_바꾸면_계수가_따라감() -> None:
"""실무 방식의 약점을 고친 자리 — 뒷길이가 바뀌어도 식을 안 고친다(PLAN 8-8 ㉮)."""
a = expand(돌쌓기찰(stone_back_length_cm=45))
b = expand(돌쌓기찰(stone_back_length_cm=55))
ratio = STONE_BACK_LENGTH_TABLE[55]["fill_concrete_m3_per_m2"] / (
STONE_BACK_LENGTH_TABLE[45]["fill_concrete_m3_per_m2"] or 1
)
assert 성분(b, "채움콘크리트").amount == pytest.approx(성분(a, "채움콘크리트").amount * ratio)
def test_원본에_없는_칸은_지어내지_않음() -> None:
"""뒷길이 60㎝ **야면석**의 돌중량은 관측표가 비어 있다 — 값을 만들지 않고 알린다.
⚠ 2026-09-09 확정 5차 큰 것 7 로 **계산식이 기본**이 되면서, 종류를 안 고른 자리는
이제 표가 비어도 계산식으로 선다. 관측표를 보는 것은 **야면석 계열뿐**이라 그 조건으로
옮긴다 — 「빈 칸을 안 지어낸다」는 못이 사라진 것이 아니라 **자리가 좁아진 것**이다.
"""
result = expand(돌쌓기찰(stone_back_length_cm=60, stone_kind="야면석·호박돌"))
assert not any(c.unit == "ton" for c in result.components)
assert any("돌중량" in note for note in result.notes)
# ── 모르는 종류·빈 값 ────────────────────────────────────────────────
def test_전개식이_없는_종류는_물량을_내지_않음() -> None:
"""옹벽은 이제 **관측 원단위표**로 간다(치수가 저장돼 있지 않아 식을 못 세움).
형식(반중력식…)을 안 고르면 규격이 안 맞아 **미확보**로 드러난다 — 값을 지어내지 않는다."""
result = expand({"type_id": "retaining_wall", "options": {"height_m": 2.0, "length_m": 5.0}})
assert result.components == []
assert any("자료에 없습니다" in note for note in result.notes)
def test_모르는_종류는_전개식도_원단위도_없음() -> None:
result = expand({"type_id": "듣도보도못한구조물", "options": {"height_m": 2.0}})
assert result.components == []
assert any("수량 산출식이 아직 없습니다" in note for note in result.notes)
def test_높이가_없으면_전개하지_않음() -> None:
result = expand(돌쌓기찰(height=0.0))
assert result.components == []
assert result.notes
def test_표_모양() -> None:
table = build_table([돌쌓기찰(length=10.0), {**돌쌓기찰(), "type_id": "masonry_dry"}])
assert table["structure_count"] == 2
assert table["totals"]
assert all({"name", "unit", "amount", "destination"} <= set(entry) for entry in table["totals"])
def test_물구멍_잠정값이_근거에_적힐것() -> None:
"""⚠ 「미확정」만 적으면 무엇을 정해야 하는지 모른다 — 지금 값과 법 범위를 함께 적는다."""
# ⚠ 이름이 「물구멍」 → 「물구멍관」 으로 바뀜(2026-09-08) — 자재 카탈로그가 이름으로
# 줄을 찾고, 그 규약이 「공백 없는 한 낱말」이다(B09 확인).
basis = 성분(expand(돌쌓기찰()), "물구멍관").basis
# ⚠ 문구가 「잠정」 → 「안 정함」으로 바뀜(2026-09-09) — **무엇을 안 정했는지**가
# 낱말에 붙어 화면에서 바로 읽힌다.
assert "안 정함" in basis
assert "Ø50" in basis # 실무 관측값
assert "2~3㎡" in basis # 법이 정한 범위
def test_큰돌쌓기를_돌쌓기_식으로_돌리지_않을것() -> None:
"""⚠ 2026-09-07 발견 — 큰돌쌓기는 품셈 **13-6**, 돌쌓기는 **13-4** 로 **규격 축이 다르다**.
돌쌓기는 뒷길이(35·45·55·60㎝), 큰돌쌓기는 직경(40~60·60~80·80~100㎝).
앞서 `stone_masonry(dry)` 로 전개해 직경 60~80㎝ 짜리가 「뒷길이 45㎝」 계수로 돌고 있었다.
**값이 나오기는 해서 어떤 시험도 안 잡던 자리** — 「값이 있기는 하니 안 보이는」 그것이다.
⚠ 지금은 13-6 축 전개식이 섰다. 그래서 **뒷길이 계수에서 나오던 성분이 없는지**로 잰다 —
「전개가 안 된다」가 아니라 「**틀린 축으로 안 돈다**」가 지켜야 할 것이다.
"""
result = expand(
{
"type_id": "boulder_masonry",
"start_m": 10.0,
"end_m": 20.0,
"options": {"height_m": 2.0, "length_m": 10.0, "stone_cm": "60~80"},
}
)
bases = " ".join(component.basis for component in result.components)
assert "뒷길이" not in bases # 13-4 계수표가 안 걸렸다
assert "13-6" in bases
names = {component.name for component in result.components}
# ⚠ 버림 콘크리트가 2026-09-09 확정 ⑭ 로 들어왔다 — 기초 바닥에 따로 치는 줄이다.
assert names == {"큰돌쌓기", "버림콘크리트", "터파기", "되메우기", "잔토처리"}
# ── 큰돌쌓기(품셈 13-6) — 규격 축이 직경이다 (2026-09-07 ⑲) ────────
def 큰돌(diameter: str = "60~80", height: float = 2.0, length: float = 10.0) -> dict:
return {
"structure_id": "b1",
"type_id": "boulder_masonry",
"start_m": 10.0,
"end_m": 10.0 + length,
"options": {"height_m": height, "length_m": length, "stone_cm": diameter},
}
def test_큰돌쌓기가_직경_축으로_설것() -> None:
"""⚠ 앞서 돌쌓기(13-4) 뒷길이 계수로 돌던 자리 — 이제 13-6 축으로 선다."""
result = expand(큰돌())
area = 성분(result, "큰돌쌓기")
assert area.unit == ""
assert "직경 60~80㎝" in area.basis and "13-6" in area.basis
# 돌쌓기 계열 성분(뒷길이 계수로 나오던 것)은 안 나온다.
names = {c.name for c in result.components}
assert "고임돌" not in names and "야면석" not in names and "막자갈" not in names
def test_표에_없는_직경은_지어내지_않을것() -> None:
result = expand(큰돌(diameter="120~150"))
assert result.components == []
assert any("돌 직경이 아직 입력되지 않았습니다" in note for note in result.notes)
def test_직경이_비어_있으면_전개하지_않음() -> None:
result = expand({"type_id": "boulder_masonry", "options": {"height_m": 2.0, "length_m": 5.0}})
assert result.components == []
def test_품에_포함된_것을_따로_세우지_않을것() -> None:
"""13-6 [주]① 「고임돌 및 채움콘크리트 품은 포함되어 있다」 — 세우면 이중계상."""
result = expand(큰돌())
assert any("품에 포함" in note for note in result.notes)
def test_재료_원단위가_없음을_알릴것() -> None:
"""13-6 [주]⑦ 「재료량은 설계수량을 적용한다」 — 뒷길이별 돌중량 표에 해당하는 것이 없다."""
result = expand(큰돌())
assert any("재료(큰돌) 원단위 미확보" in note for note in result.notes)
def test_메찰_구분이_없음을_알릴것() -> None:
result = expand(큰돌())
assert any("메/찰" in note for note in result.notes)
def test_터파기는_직경_위끝을_벽두께로_볼것() -> None:
"""품셈에 큰돌쌓기 터파기 폭 규정이 없어 돌쌓기 방식을 준용한다 — 근거에 그 사실을 적는다."""
result = expand(큰돌(diameter="60~80", height=2.0, length=10.0))
dig = 성분(result, "터파기")
assert dig.amount == pytest.approx(2.0 * (0.8 + 0.2) * 10.0)
assert "준용" in dig.basis
# ── 돌쌓기 뒷길이 — 저장 칸 이름이 달랐다 (2026-09-07 발견) ─────────
def test_저장_칸_이름으로_뒷길이를_읽을것() -> None:
"""⚠ 레지스트리는 `back_len_cm` 인데 엔진이 `stone_back_length_cm` 을 읽고 있었다.
저장값이 영영 안 닿아 **뒷길이를 75 로 골라도 45 계수**가 붙던 자리다."""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import _back_length
assert _back_length({"back_len_cm": "55"}) == 55
assert _back_length({"stone_back_length_cm": 35}) == 35 # 옛 이름도 본다
assert _back_length({}) == 45 # 없으면 기본
def test_뒷길이를_접지_않는다() -> None:
"""⚠ 2026-09-08 정정 — 앞서 「덮는 위 칸으로 접는다」를 계약으로 못 박고 있었다.
품셈 13-4-3·13-4-4 [주]① 이 25·30·35·45·55·60·75 **일곱 규격**을 다 주므로 접을
까닭이 없었다. 접으면 **40㎝ 가 45㎝ 계수로 조용히** 돌고 999㎝ 도 60 으로 접혔다.
"""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import _back_length
assert _back_length({"back_len_cm": "25"}) == 25
assert _back_length({"back_len_cm": "75"}) == 75
assert _back_length({"back_len_cm": "40"}) == 40, "표에 없는 값도 그대로 — 접지 않는다"
def test_표에_없는_뒷길이는_물량을_안_낸다() -> None:
"""⚠ 다른 규격 계수가 조용히 도는 것보다 「없다」가 낫다."""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import stone_masonry
components, notes = stone_masonry(
2.5, 10.0, {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 40}, wet=True
)
assert components == []
assert "품셈 표(25·30·35·45·55·60·75㎝)에 없어" in notes[0]
def test_큰돌쌓기_메찰을_고르면_그_사유가_사라진다() -> None:
"""⚠ 사유가 거짓이면 **사유 칸 전체를 못 믿게 된다**(2026-09-09).
`bond` 는 레지스트리에 있는 칸인데 조건 없이 「못 고름」을 붙이고 있었다.
⚠ 얻는 것은 노무 품 갈래뿐 — 재료 원단위는 13-6 [주]⑦ 때문에 여전히 안 선다.
"""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import boulder_masonry
base = {"height_m": 2.5, "length_m": 10.0, "stone_cm": "60~80"}
_, without = boulder_masonry(2.5, 10.0, dict(base))
_, chosen = boulder_masonry(2.5, 10.0, {**base, "bond": "메쌓기"})
assert any("메/찰" in note for note in without)
assert not any("메/찰" in note for note in chosen)
# 재료 미확보 사유는 **그대로 남아야 한다** — 그건 아직 참이다.
assert any("재료(큰돌) 원단위 미확보" in note for note in chosen)
+128
View File
@@ -0,0 +1,128 @@
"""돌쌓기 벽 두께 — 실무 구조물도 식 (2026-09-08 사용자 확정 2차 ②).
⚠ 겨누는 것 여섯
① **뒷길이가 두께를 움직인다** — 옛 식(0.45+0.10H / 0.45+0.40H)에는 뒷길이가 없어
35 로 바꿔도 같은 값이 섰다
② 상부 = 뒷길이 + 0.30
③ 하부 = 상부 + 0.30 × (H 1.0)
④ ⚠ H ≤ 1.0 에서 하부가 상부보다 얇아지지 않는다(음수 몫을 0 으로 눅임)
⑤ 두께가 **돌 입적·터파기의 밑수**다 — 둘이 같이 움직인다
⚠ 2026-09-09 랩탑 보조가 확정 5차로 **막자갈을 이 축에서 떼어 냈다** — 뒷채움 폭은
정본 여섯 탭 공통값(상 0.30 · 하 0.45)이지 벽 두께가 아니었다. 시험이 겨누던
자리를 **입적**으로 옮긴다(같은 식을 근거에 싣는 줄).
⑥ 근거 문구에 그 식이 적힌다
"""
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_UnitQuantity import stone_masonry # noqa: E402
def 성분(height_m: float, back_cm: int) -> dict:
components, _ = stone_masonry(
height_m,
10.0,
{"height_m": height_m, "length_m": 10.0, "back_len_cm": back_cm},
wet=True,
face="성토",
)
return {c.name: c for c in components}
def 평균두께(height_m: float, back_cm: int) -> float:
top = back_cm / 100.0 + 0.30
bottom = top + 0.30 * max(height_m - 1.0, 0.0)
return (top + bottom) / 2.0
def test_뒷길이가_두께를_움직인다() -> None:
"""⚠ 옛 식에는 뒷길이가 없었다 — 45 든 35 든 터파기가 같은 값으로 섰다."""
굵은 = 성분(2.0, 45)["터파기"].amount
가는 = 성분(2.0, 35)["터파기"].amount
assert 굵은 > 가는
def test_상부는_뒷길이에_0_30을_더한_값() -> None:
basis = 성분(2.5, 45)["입적"].basis
assert "상부 0.75" in basis # 0.45 + 0.30
def test_하부는_높이_1m를_넘는_만큼_두꺼워진다() -> None:
basis = 성분(2.5, 45)["입적"].basis
assert "하부 1.20" in basis # 0.75 + 0.30 × (2.5 1.0)
def test_1m_이하에서는_상하부가_같다() -> None:
"""⚠ (H−1) 을 그대로 곱하면 낮은 벽에서 하부가 상부보다 **얇아진다**."""
basis = 성분(0.8, 35)["입적"].basis
assert "상부 0.65" in basis and "하부 0.65" in basis
def test_터파기가_평균두께에_매인다() -> None:
got = 성분(2.5, 45)["터파기"].amount
assert abs(got - 2.5 * (평균두께(2.5, 45) + 0.2) * 10.0) < 1e-6
def test_뒷길이가_커져도_막자갈은_안_는다() -> None:
"""⚠ 2026-09-09 확정 5차로 막자갈이 **뒷채움 정본 폭**(상 0.30 · 하 0.45)으로 갈렸다.
옛 식에서는 「입적 − 몸통」이라 뒷길이를 따라 아주 조금 움직였는데(12.653 → 12.630 →
12.607), 지금은 **뒷길이를 아예 안 본다** — 값이 셋 다 같다. 두께가 실제로 움직이는
것은 **입적·터파기·잔토**에서 드러난다.
"""
막자갈 = [성분(2.5, b)["막자갈"].amount for b in (35, 45, 55)]
assert len(set(막자갈)) == 1
터파기 = [성분(2.5, b)["터파기"].amount for b in (35, 45, 55)]
assert 터파기 == sorted(터파기) and 터파기[2] - 터파기[0] > 4.0
def test_근거에_식이_적힌다() -> None:
"""⚠ 값만 바뀌고 근거가 그대로면 왜 달라졌는지 화면에서 못 짚는다."""
for name in ("입적", "터파기"):
basis = 성분(2.5, 45)[name].basis
assert "실무 구조물도 식" in basis
def test_입적이_줄로_선다() -> None:
"""정본 계산표 좌측 열 이름 그대로다 — 「체적」으로 바꾸지 않는다 (확정 ⑦)."""
got = 성분(2.5, 45)["입적"]
assert got.unit == ""
assert abs(got.amount - 2.5 * 10.0 * 평균두께(2.5, 45)) < 1e-9
def test_석적은_정본에_없다는_사유를_달고_선다() -> None:
"""⚠ 뒤집힌 시험이다 — 종전에는 「안 낸다」였다.
2026-09-09 확정 5차 작은 것 4 로 **세우기로 정했다.** 다만 정본 여섯 탭에는 없고
소광리 시트에만 있는 줄이라 **그 사실이 근거에 적혀야** 한다(값만 서고 출처가 없으면
나중에 어디서 왔는지 못 짚는다).
"""
got = 성분(2.5, 45)["석적"]
assert got.unit == ""
assert "정본에 없는 줄" in got.basis and "소광리" in got.basis
def test_입적은_자재총괄에_안_섞인다() -> None:
"""⚠ 보여 주기만 하는 줄이다 — 자재로 서면 돌을 두 번 센다."""
from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table as 자재
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as 원단위
unit = 원단위(
[
{
"structure_id": "s1",
"type_id": "masonry_wet",
"start_m": 0.0,
"end_m": 10.0,
"options": {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45},
}
]
)
assert 성분(2.5, 45)["입적"].destination == "reference"
assert all(row["name"] != "입적" for row in 자재(unit)["rows"])
+83
View File
@@ -0,0 +1,83 @@
"""벽 터파기 — 공용 단면 함수 한 벌을 쓴다 (2026-09-08).
⚠ 겨누는 것 여섯
① 식을 여기서 다시 짜지 않는다 — `common_util_excavation.wall_trench_area_m2` 값 그대로
② 「기초유」면 기초분 0.45㎥/m 가 붙는다
③ 「기초버림」이면 0.07㎥/m 로 준다 — 기초를 **안 두는 것이 아니라 얕게 두는 것**
④ ⚠ 안 고른 프로젝트는 **비탈분만** 서고 사유가 뜬다 — 한쪽으로 찍으면 0.45 가 임의로 굳는다
⑤ 되메우기 = (기초깊이 + H) × 0.2
⑥ 큰돌쌓기도 같은 함수를 탄다
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from common_util.common_util_excavation import wall_trench_area_m2 # noqa: E402
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402
boulder_masonry,
stone_masonry,
)
H = 2.5
L = 10.0
BACK_CM = 45
두께 = ((BACK_CM / 100 + 0.30) + (BACK_CM / 100 + 0.30 + 0.30 * (H - 1.0))) / 2 # 0.975
def 성분(**options: object) -> tuple[dict, list[str]]:
components, notes = stone_masonry(
H, L, {"height_m": H, "length_m": L, "back_len_cm": BACK_CM, **options}, wet=True
)
return {c.name: c for c in components}, notes
def test_공용_함수_값을_그대로_쓴다() -> None:
"""⚠ 화면(B06 횡단도)이 그 함수로 그린다 — 여기서 다시 짜면 그림과 물량이 갈린다."""
got, _ = 성분(foundation="기초유")
expected = wall_trench_area_m2(H, 두께, has_foundation=True) * L
assert abs(got["터파기"].amount - expected) < 1e-9
def test_기초유는_기초분_0_45가_붙는다() -> None:
, _ = 성분(foundation="기초유")
미지정, _ = 성분()
assert abs((["터파기"].amount - 미지정["터파기"].amount) - 0.45 * L) < 1e-9
def test_기초버림은_0_07로_준다() -> None:
"""⚠ 「기초무」가 아니라 「기초버림」이다 — 얕게 두는 것이라 몫이 남는다."""
버림, _ = 성분(foundation="기초버림")
미지정, _ = 성분()
assert abs((버림["터파기"].amount - 미지정["터파기"].amount) - 0.07 * L) < 1e-9
def test_안_고르면_비탈분만_서고_사유가_뜬다() -> None:
got, notes = 성분()
assert abs(got["터파기"].amount - H * (두께 + 0.2) * L) < 1e-9
assert any("기초 유/무" in n for n in notes)
def test_되메우기는_기초깊이를_함께_센다() -> None:
, _ = 성분(foundation="기초유")
assert abs(["되메우기"].amount - (0.5 + H) * 0.2 * L) < 1e-9
미지정, _ = 성분()
assert abs(미지정["되메우기"].amount - H * 0.2 * L) < 1e-9
def test_잔토는_터파기_빼기_되메우기() -> None:
got, _ = 성분(foundation="기초유")
assert abs(got["잔토처리"].amount - (got["터파기"].amount - got["되메우기"].amount)) < 1e-9
def test_큰돌쌓기도_같은_함수를_탄다() -> None:
components, _ = boulder_masonry(
H, L, {"height_m": H, "length_m": L, "stone_cm": "60~80", "foundation": "기초유"}
)
got = {c.name: c for c in components}
expected = wall_trench_area_m2(H, 0.80, has_foundation=True) * L
assert abs(got["터파기"].amount - expected) < 1e-9
@@ -0,0 +1,60 @@
"""임목파쇄 — 켤 수 있는 칸 (2026-09-09 사용자 확정 5차 5번).
「근주이식·임목파쇄는 **기본 안 셈**」이되 「⚠ 현장에 따라 **파쇄가 적용될 필요 있음**」이라
**임목파쇄만** 켤 수 있게 둔다. **근주이식은 칸도 안 만든다.**
⚠ 겨누는 것 다섯
① 기본은 **줄이 아예 없다** — 빈 칸이 「세야 함」으로 읽히면 안 된다
(부대시설과 다른 자리 — 그쪽은 **법정 의무**라 줄이 늘 선다)
② 켜면 줄이 선다
③ ⚠ 켜도 **부피는 지어내지 않는다** — 넣어야 값이 선다
④ 부피를 넣으면 값이 서고 코드가 붙는다(FP-08-11 이동식 임목 파쇄)
⑤ 근주이식은 **어느 경우에도 줄이 없다**
"""
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_Preparation import ( # noqa: E402
STATUS_PENDING,
STATUS_READY,
build_table,
)
def (enabled: object = False, volume: object = None) -> dict:
table = build_table({}, [], [], None, None, {}, None, None, enabled, volume)
return {row["item"]: row for row in table["rows"]}
def test_기본은_줄이_없다() -> None:
"""⚠ 켤 자리가 없으면 물을 일도 없다 — 안 켠 프로젝트에 빈 줄을 세우지 않는다."""
assert "임목파쇄" not in ()
def test_켜면_줄이_선다() -> None:
assert "임목파쇄" in (True)
def test_켜도_부피는_지어내지_않는다() -> None:
row = (True)["임목파쇄"]
assert row["amount"] is None and row["status"] == STATUS_PENDING
assert "부피" in row["reason"]
def test_부피를_넣으면_값이_선다() -> None:
row = (True, 78.0)["임목파쇄"]
assert row["amount"] == 78.0 and row["status"] == STATUS_READY
assert row["work_item_code"] == "FP-08-11"
assert row["unit"] == ""
def test_근주이식은_어느_경우에도_없다() -> None:
"""확정 5차 5번 — 근주이식(FP-14-02)은 **칸도 안 만든다**."""
for enabled, volume in ((False, None), (True, None), (True, 78.0)):
assert "근주이식" not in (enabled, volume)
+107
View File
@@ -0,0 +1,107 @@
"""사용자에게 뜨는 문구 검사 — **키 이름이 화면에 새지 않는다** (B08 ㉑).
⚠ 「`back_len_cm` 가 저장돼 있지 않아 갈래를 못 고름」처럼 개발자 키가 그대로 뜨던 자리가
있었다. 사용자는 그 이름을 모르고 무엇을 해야 하는지도 알 수 없다.
⚠ 「없다」만 말하지 않는다 — **어디서 채우면 풀리는지**까지 있어야 그 말이 쓸모 있다.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff, masonry_class # noqa: E402
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table, expand # noqa: E402
from B08_Quantity.B08_Quantity_Wording import ( # noqa: E402
OPTION_LABELS,
option_missing,
spec_missing,
type_label,
)
#: 화면 문구에 나오면 안 되는 것 — 저장 제원 키·구조물 `type_id` 같은 개발자 이름.
LEAK_PATTERN = re.compile(
r"back_len_cm|stone_cm|height_m|length_m|face_slope_ratio"
r"|masonry_wet|masonry_dry|boulder_masonry|retaining_wall|soil_guard|pipe_inlet_basin"
)
def _notes(structures: list[dict]) -> list[str]:
table = build_table(structures)
return [note for item in table["structures"] for note in item["notes"]]
def test_돌쌓기_뒷길이_안내가_사람_말일것() -> None:
kind, why = masonry_class({})
assert kind is None
assert "뒷길이" in why
assert not LEAK_PATTERN.search(why), why
# 어디서 채우면 되는지가 있어야 한다.
assert "입력" in why
def test_큰돌쌓기_직경_안내가_사람_말일것() -> None:
notes = _notes([{"type_id": "boulder_masonry", "options": {"height_m": 2.0, "length_m": 10.0}}])
assert notes and "돌 직경" in notes[0]
assert not LEAK_PATTERN.search(notes[0]), notes[0]
def test_산출식_없는_종류_안내가_사람_말일것() -> None:
notes = _notes([{"type_id": "soil_guard", "options": {"height_m": 2.0, "length_m": 10.0}}])
assert any("흙막이" in note for note in notes)
assert not any(LEAK_PATTERN.search(note) for note in notes), notes
def test_규격_미확보_안내가_사람_말일것() -> None:
result = expand(
{
"type_id": "retaining_wall",
"options": {"height_m": 1.6, "length_m": 10.0, "form": "반중력식"},
}
)
note = next(n for n in result.notes if "규격" in n)
assert "옹벽" in note and "옹벽 형식" in note
assert not LEAK_PATTERN.search(note), note
def test_인계_못이은_줄도_사람_말일것() -> None:
unit = build_table(
[
{
"type_id": "soil_guard",
"start_m": 0.0,
"end_m": 10.0,
"options": {"height_m": 2.0, "length_m": 10.0},
}
]
)
unmatched = build_handoff(unit_quantity_table=unit)["unmatched_work_items"]
assert unmatched and "흙막이" in unmatched[0]
assert not any(LEAK_PATTERN.search(item) for item in unmatched), unmatched
def test_모르는_키는_지어내지_않을것() -> None:
"""⚠ 잘못된 안내가 없는 안내보다 나쁘다 — 모르면 키를 그대로 보인다."""
text = option_missing("듣도보도못한키")
assert "듣도보도못한키" in text
def test_라벨표에_어디서_채우는지가_있을것() -> None:
for key, (label, where) in OPTION_LABELS.items():
assert label and label != key, key
assert isinstance(where, str)
def test_이름표에_없으면_원래_값을_보일것() -> None:
assert type_label("듣도보도못한종류") == "듣도보도못한종류"
assert type_label("masonry_wet") == "돌쌓기(찰)"
# 레지스트리 이름이 있으면 그것이 먼저다.
assert type_label("masonry_wet", {"masonry_wet": "돌쌓기(찰쌓기)"}) == "돌쌓기(찰쌓기)"
def test_자료가_통째로_없으면_그렇게_말할것() -> None:
assert "표준 물량 자료" in spec_missing("box_culvert", [])
@@ -0,0 +1,752 @@
"""B08 공종 마스터 정규화 검사 — PLAN 8-6·8-7.
제일 중요한 것은 `pum_form` 이다. 생산량형(1÷값)과 소요량형(값÷밑수)은 환산 방향이
반대라, 뒤집히면 값이 오류 없이 조용히 20배쯤 틀린다. 그래서 실물 표를 집어
방향이 맞는지 못 박아 둔다.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import ( # noqa: E402
OUT_DIR,
detect_form,
parse_toc,
section_number,
)
MASTER = OUT_DIR / "work_item_master_2026-01-01.json"
UNDET = OUT_DIR / "form_undetermined_2026-01-01.json"
@pytest.fixture(scope="module")
def master() -> dict:
if not MASTER.exists():
pytest.skip("공종 마스터 미생성 — 빌드 스크립트를 먼저 실행할 것")
return json.loads(MASTER.read_text(encoding="utf-8"))
@pytest.fixture(scope="module")
def tables() -> list[dict]:
src = ROOT / "resources" / "data_cost_input_value" / "pum_forest_2026.json"
return json.loads(src.read_text(encoding="utf-8"))["variables"]["pum"]["tables"]
def find(tables: list[dict], table_id: str) -> dict:
return next(t for t in tables if t["table_id"] == table_id)
# ── pum_form — 뒤집히면 값이 반대가 되는 자리 ──────────────────────────
def test_생산량형_작업능력표(tables: list[dict]) -> None:
"""`9-4-1 암파쇄` 는 「㎥/hr」 작업능력표다 — 품 = 1 ÷ 값."""
form, why = detect_form(find(tables, "F0241"), "9")
assert form == "productivity", why
def test_생산량형이_비고의_직종에_뒤집히지_않을것(tables: list[dict]) -> None:
"""`9-7-1` 은 「작업능력」 표인데 비고에 「보통인부 1인/일」이 붙어 있다.
직종을 먼저 보면 소요량형으로 뒤집힌다 — 그 사고를 막는 순서를 고정한다.
"""
table = find(tables, "F0247")
assert "보통인부" in json.dumps(table["rows"], ensure_ascii=False)
form, why = detect_form(table, "9")
assert form == "productivity", why
def test_소요량형_직종표(tables: list[dict]) -> None:
"""`9-3-1 인력` 은 「보통인부(인) 0.16」 — ㎥당 품이다."""
form, why = detect_form(find(tables, "F0239"), "9")
assert form == "requirement", why
def test_소요량형_직종이_둘째열에_있어도_잡을것(tables: list[dict]) -> None:
"""`9-12-1 토사` 는 첫 열이 「인력(10%)」, 직종은 둘째 열에 있다."""
form, why = detect_form(find(tables, "F0258"), "9")
assert form == "requirement", why
def test_계수표는_공종이_아님(tables: list[dict]) -> None:
"""`9-3-2 기계` 는 시공능력 공식의 K·f·E 파라미터다."""
form, why = detect_form(find(tables, "F0240"), "9")
assert form == "coefficient", why
def test_1장은_기준표(tables: list[dict]) -> None:
"""품셈 제1장은 적용기준이라 공종으로 세우지 않는다."""
form, why = detect_form(find(tables, "F0008"), "1")
assert form == "reference", why
def test_참조지시는_기준표(tables: list[dict]) -> None:
"""`10-3-1 모래` 는 값이 아니라 「별도계상」 이라는 지시뿐이다."""
form, why = detect_form(find(tables, "F0298"), "10")
assert form == "reference", why
# ── 계층·코드 ────────────────────────────────────────────────────────
def test_절번호_파싱() -> None:
assert section_number("9-3-1. 인력") == "9-3-1"
assert section_number("12-2 표면 마무리를 따른다.") == "12-2"
assert section_number("부록1") is None
def test_목차_계층() -> None:
rows = [["제9장", "토공", "50"], ["9-3", "흙깎기", "51"], ["9-3-1", "인력", "51"]]
nodes = parse_toc(rows)
assert [n["work_item_code"] for n in nodes] == ["FP-09", "FP-09-03", "FP-09-03-01"]
assert [n["level"] for n in nodes] == [1, 2, 3]
assert nodes[2]["parent_code"] == "FP-09-03"
# STmate 관례대로 256 간격 — 중간 삽입 여유를 둔다.
assert [n["sort_order"] for n in nodes] == [256, 512, 768]
# ── 산출물 계약 (B09 가 읽는 부분) ─────────────────────────────────────
def test_원문셀을_버리지_않을것(master: dict) -> None:
"""`raw_row` 가 없으면 B09 가 476표를 다시 열어야 한다 — PLAN 8-7."""
got = [t for n in master["work_items"] for t in n["tables"]]
assert got, "표가 하나도 안 붙었다"
assert all("raw_row" in t for t in got)
assert any(t["raw_row"] for t in got)
def test_자원축은_비워둘것(master: dict) -> None:
"""자원 축(`resource_kind` 등)은 B09 몫이다 — 여기서 채우면 경계 위반."""
forbidden = {"resource_kind", "resource_code", "resource_spec", "amount", "amount_unit"}
for node in master["work_items"]:
for table in node["tables"]:
assert not (forbidden & set(table)), f"{table['pum_table_id']} 에 자원 축이 섞였다"
def test_dataset_version_세쪽_기록(master: dict) -> None:
"""파일명만으로는 같은 날짜 재생성본과 구분이 안 된다 — PLAN 8-6."""
dv = master["dataset_version"]
assert dv["dataset_id"] and dv["effective_date"]
assert len(dv["sha256"]) == 64
def test_미판정은_빈칸이_아니라_목록으로(master: dict) -> None:
"""판정 못 한 표를 조용히 비워 두면 뒤집힌 값이 들어간다 — 반드시 목록으로 낸다."""
if not UNDET.exists():
pytest.skip("미판정 목록 미생성")
listed = {i["pum_table_id"] for i in json.loads(UNDET.read_text(encoding="utf-8"))["items"]}
flagged = {
t["pum_table_id"]
for n in master["work_items"]
for t in n["tables"]
if t["pum_form"] == "undetermined"
}
assert flagged <= listed, "마스터에서 미판정인데 목록에 없는 표가 있다"
def test_귀속률(master: dict) -> None:
"""표가 목차 계층에 붙어야 공종으로 선다. 미귀속이 늘면 목차 파싱이 깨진 것."""
s = master["stats"]
assert s["tables_attached"] / s["tables_total"] > 0.9
# ── 첫 칸이 분류 딱지인 표 (2026-09-07 교차 확인에서 나옴) ──────────
#
# 서브 창이 자기 파싱에서 「첫 칸이 갈래 딱지이고 이름이 둘째 칸」인 표를 놓쳐 자재·장비가
# 통째로 빠지던 것을 잡았고, 같은 병이 여기 형태 판정에도 있었다 — 12장 임도 구조물 표
# 8건이 `undetermined` 로 빠져 있었다. 다시 걸리지 않게 못 박는다.
def test_딱지형_표가_소요량형으로_판정될것() -> None:
"""`['자재', 'PVC 지수판', 'm', '1.04']` — 첫 칸이 이름이 아니라 갈래다."""
table = {
"headers": ["구 분", "단위", "적 용", "비 고", ""],
"rows": [
["자재", "PVC 지수판(200×5)", "m", "1.04", "4% 할증"],
["용접봉", "kg", "0.042", "", ""],
["철선(#8)", "kg", "0.21", "", ""],
["인력(설치비)", "특별인부", "", "0.151", ""],
],
}
form, basis = detect_form(table, "12")
assert form == "requirement"
assert "딱지" in basis
def test_직종이_넷째_행에_있어도_잡힐것() -> None:
"""첫 세 행만 보던 탓에 놓쳤던 모양 — 강관동바리의 형틀목공은 넷째 행이다."""
table = {
"headers": ["구 분", "단위", "적 용", "비 고", "", ""],
"rows": [
["자재", "강관 동바리", "내관(48.6mm×2.4mm)", "", "0.38", ""],
["외관(60.6mm×2.3mm)", "", "0.38", "", "", ""],
["잡재료비(재료비의)", "%", "5", "", "", ""],
["인력", "형틀목공", "", "0.07", "", ""],
],
}
assert detect_form(table, "12")[0] == "requirement"
def test_비율_지시_한_줄이_소요량표를_참조로_넘기지_않을것() -> None:
"""⚠ 만들다 실제로 걸린 자리 — `잡재료비(재료비의) 5 %` 한 줄 때문에 강관동바리가
참조로 넘어갔다. **소요량을 먼저 보고** 비율 지시는 그 뒤에 본다."""
ratio_only = {
"headers": ["구 분", "적 용", "비 고"],
"rows": [["재료비", "", ""], ["설치비", "재료비의 5%", ""]],
}
assert detect_form(ratio_only, "12")[0] == "reference"
def test_수치가_없으면_지어내지_않고_미판정으로_둘것() -> None:
"""`['재료비', 'JOINT FILLER', '']` — 이름만 있고 소요량이 없다."""
table = {"headers": ["구 분", "적 용", "비 고"], "rows": [["재료비", "JOINT FILLER", ""]]}
assert detect_form(table, "12")[0] == "undetermined"
def test_생산량형이_딱지_규칙에_안_밀릴것() -> None:
"""딱지 규칙은 **맨 마지막**에 본다 — 앞의 판정을 흔들면 안 된다."""
table = {
"headers": ["작업능력(㎥/hr)", "비고"],
"rows": [["자재", "보통인부 1인/일", "12.5"]],
}
assert detect_form(table, "9")[0] == "productivity"
# ── 딱지가 비율을 달고 오는 표 (2026-09-07 서브 창 제보로 확인) ─────
#
# `인력(10%)` · `장비(90%)` — 소요량형이면서 **장비 몫은 시공능력 공식**이라 값이 아니다.
# 형태 한 낱말로만 적으면 받는 쪽이 그 단가를 전량에 곱해 **내역서가 9할 싸게** 선다.
def test_몫이_적힌_표는_몫을_실을것() -> None:
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import resource_shares
table = {
"headers": ["구 분", "적 용", "비 고"],
"rows": [
["인력(10%)", "보통인부(인)", "0.23"],
["장비 (90%)", "유압식백호우 (무한궤도,0.7㎥)", "k", "0.9"],
],
}
assert resource_shares(table) == {"인력": 10.0, "장비": 90.0}
def test_몫이_없는_표는_빈칸() -> None:
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import resource_shares
assert resource_shares({"rows": [["보통인부(인)", "0.16"]]}) == {}
def test_몫이_있으면_부분값으로_표시될것() -> None:
"""⚠ 공식 기호가 **첫 표에만** 있고 이어지는 표는 물려받는다 — 기호가 있는 표만 세면
9-13-2 같은 절반을 놓친다. 넓게 잡되 **값을 지우지 않고 표시만** 한다."""
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import (
capacity_formula_pending,
resource_shares,
)
inherited = {
"headers": ["구 분", "적 용", "비 고"],
"rows": [
["인력 (10%)", "보통인부(인)", "0.26"],
["장비 (90%)", "유압식백호우 (무한궤도,0.7㎥)"],
],
}
assert resource_shares(inherited) # 몫은 있고
assert not capacity_formula_pending(inherited) # 공식 기호는 이 표에 없다
def test_형태_판정은_그대로_소요량형() -> None:
"""몫 표시를 더해도 앞의 판정을 흔들지 않는다."""
table = {
"headers": ["구 분", "적 용", "비 고"],
"rows": [["인력(10%)", "보통인부(인)", "0.23"], ["장비(90%)", "유압식백호우"]],
}
assert detect_form(table, "9")[0] == "requirement"
# ── 밑수는 표 안이 아니라 **표 바로 위 본문**에 있다 (2026-09-07) ────
#
# 서브 창이 「기준 단위 없음으로 도는 단가가 125 중 122」를 보고했고 파 보니 뿌리가 여기였다.
# 「10㎡당」 표를 1㎡당으로 알면 **곱셈이 10배 틀린다.**
# ⚠ 앞서 확인한 「밑수가 **밀렸나**」와는 다른 물음이다 — 이번은 「**아예 안 적혔나**」다.
def _basis(text_lines: list[str]) -> tuple[float | None, str | None]:
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import basis_from_source
# 마지막 줄이 표라고 보고, 그 줄 번호(1-기반)를 넘긴다.
return basis_from_source(text_lines, len(text_lines))
def test_본문에서_밑수를_읽을것() -> None:
assert _basis(["### 12-2. 표면 마무리", "", "(단위: ㎡당)", "", "| 구 분 |"]) == (1.0, "")
assert _basis(["### 5-1-4. 떼채취", "", "(100㎡당)", "", "| 구 분 |"]) == (100.0, "")
def test_분모가_밑수인_꼴() -> None:
"""`(단위: 인/㎡당)` — 값의 단위(인)를 밑수로 읽으면 뜻이 뒤집힌다."""
assert _basis(["### 13-4-1. 메쌓기(인력)", "", "(단위: 인/㎡당)", "", "| 뒷길이 |"]) == (
1.0,
"",
)
def test_당도_단위도_없으면_밑수가_아님() -> None:
"""⚠ 만들다 실제로 걸린 자리 — `(무한궤도,0.7㎥)` 를 「0.7㎥당」으로 읽어 5건이 잘못 잡혔다."""
assert _basis(["| 장비 | 유압식백호우 (무한궤도,0.7㎥) |", "", "| 구 분 |"]) == (None, None)
def test_앞_표의_밑수를_물어오지_않을것() -> None:
"""위로 거슬러 보되 다른 표에 닿으면 멈춘다 — 남의 밑수를 물어 오면 조용히 틀린다."""
lines = ["(100㎡당)", "| 앞 표 |", "| 값 |", "", "### 9-21. 제근", "", "| 종 류 |"]
assert _basis(lines) == (None, None)
def test_묶음_기준_판정() -> None:
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import basis_quantity_is_grouped
assert basis_quantity_is_grouped(10.0) and basis_quantity_is_grouped(100.0)
assert not basis_quantity_is_grouped(1.0)
assert not basis_quantity_is_grouped(None)
def test_산출물에_밑수_미확보_목록이_나올것() -> None:
"""빈칸으로 두면 「1단위당」으로 오해된다 — 곱하면 안 되는 줄을 받는 쪽이 가릴 수 있게 낸다."""
import json
path = ROOT / "resources" / "data_work_item_master" / "basis_missing_2026-01-01.json"
payload = json.loads(path.read_text(encoding="utf-8"))
assert payload["items"]
# 곱할 값이 아닌 형태(참조·계수)는 목록에 안 담는다 — 잡음이 되면 안 본다.
assert {item["pum_form"] for item in payload["items"]} <= {"requirement", "productivity"}
def test_실물_묶음_기준이_실제로_실릴것() -> None:
"""13-6-2 찰쌓기는 원문이 `(단위: 10㎡당)` 이다 — 1 로 두면 10배 틀린다."""
import json
path = ROOT / "resources" / "data_work_item_master" / "work_item_master_2026-01-01.json"
master = json.loads(path.read_text(encoding="utf-8"))
tables = {t["pum_table_id"]: t for i in master["work_items"] for t in (i.get("tables") or [])}
assert tables["F0420"]["basis_quantity"] == 10.0
assert tables["F0420"]["basis_unit"] == ""
assert tables["F0420"]["basis_source"] == "본문"
# ── 값 자리에 식이 적힌 칸 (2026-09-07 서브 창 제보) ────────────────
#
# `0.2 × 30%`(기초잡석 소할)처럼 계산이 그대로 적혀 있으면 **값이 숫자로 안 읽혀
# 그 성분이 조용히 빠진다.** 형태 판정은 통과하므로 어떤 형태 검사에도 안 걸린다.
# ⚠ 식을 **계산하지 않는다** — 뜻을 잘못 읽으면 조용히 틀리므로 드러내기만 한다.
def test_식이_적힌_칸을_찾을것() -> None:
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import expression_cells
table = {
"headers": ["구 분", "적 용", "비 고"],
"rows": [
["부설다짐", "할석공(인)", "0.6"],
["소할(30%)", "할석공(인)", "0.2 × 30%"],
],
}
assert expression_cells(table) == ["0.2 × 30%"]
def test_평범한_숫자는_식이_아님() -> None:
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import expression_cells
assert expression_cells({"rows": [["보통인부(인)", "0.16"], ["", "1,234.5"]]}) == []
def test_긴_설명문은_식이_아님() -> None:
"""⚠ 좁게 잡는다 — 비고의 설명문까지 걸면 목록이 잡음이 되어 결국 안 본다."""
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import expression_cells
rows = [["비고", "- 지주목을 세우지 않을 때는 인력품의 10%를 감한다"]]
assert expression_cells({"rows": rows}) == []
def test_기초잡석이_실제로_잡힐것() -> None:
"""서브가 실물에서 부딪힌 그 표 — 부설다짐 0.6인만 서고 소할 몫이 빠져 있었다."""
import json
path = ROOT / "resources" / "data_work_item_master" / "work_item_master_2026-01-01.json"
master = json.loads(path.read_text(encoding="utf-8"))
tables = {t["pum_table_id"]: t for i in master["work_items"] for t in (i.get("tables") or [])}
assert tables["F0373"]["expression_cells"] == ["0.2 × 30%"]
# 배분율 딱지가 없어 `partial_ratio` 로는 안 걸리는 자리다 — 그래서 깃발이 따로 필요했다.
assert tables["F0373"]["partial_ratio"] is False
# ── 작업조 표 · 「단 위」 표지 제거 (2026-09-07 서브 창 제보) ────────
#
# ⚠ 「형틀목공 4인 + 보통인부 1인 / 시공량 35㎡」의 **「4」는 소요량이 아니라 인원**이다.
# 행-자원으로 그냥 읽으면 **35배 부푼다**. 값을 바꾸지 않고 표시만 한다.
def test_작업조_표를_알아볼것() -> None:
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import crew_table
table = {
"headers": ["구 분", "단 위", "수 량", "시 공 량 (㎡)"],
"rows": [
["복 잡", "보 통", "간 단"],
["형틀목공 보통인부", "인 인", "4 1", "25", "35", "40"],
],
}
assert crew_table(table) is True
def test_평범한_소요량표는_작업조가_아님() -> None:
"""⚠ 짝 시험 — 넓게 잡으면 멀쩡한 소요량표가 다 걸린다."""
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import crew_table
assert (
crew_table({"headers": ["구 분", "단 위", "수 량"], "rows": [["미 장 공", "", "0.34"]]})
is False
)
def test_헤더_단위_만으로_참조가_되지_않을것() -> None:
"""⚠ 이 시험이 이번 건의 핵심 — 「단위 열이 있다」는 소요량표의 흔한 모양이지
참조표의 표지가 아니다. 그 표지 하나로 **41건이 버려지고 있었고** 그 안에
**돌쌓기(장비) 13-4-5**(우리 매핑이 실제로 쓰는 코드)가 있었다."""
table = {
"headers": ["구 분", "단 위", "수 량"],
"rows": [["돌 쌓 기 공", "", "0.32"], ["보통인부", "", "0.28"]],
}
assert detect_form(table, "13")[0] == "requirement"
def test_돌쌓기_장비가_실제로_공종으로_설것() -> None:
"""매핑이 `masonry_wet → FP-13-04-05` 로 쓰는 표다 — 참조로 버려지면 단가가 안 선다."""
import json
path = ROOT / "resources" / "data_work_item_master" / "work_item_master_2026-01-01.json"
master = json.loads(path.read_text(encoding="utf-8"))
tables = {t["pum_table_id"]: t for i in master["work_items"] for t in (i.get("tables") or [])}
assert tables["F0414"]["pum_form"] == "requirement" # 13-4-5 찰쌓기(장비)
assert tables["F0407"]["pum_form"] == "requirement" # 13-4-2 메쌓기(장비)
def test_작업조_깃발이_산출물에_실릴것() -> None:
import json
path = ROOT / "resources" / "data_work_item_master" / "work_item_master_2026-01-01.json"
master = json.loads(path.read_text(encoding="utf-8"))
tables = {t["pum_table_id"]: t for i in master["work_items"] for t in (i.get("tables") or [])}
assert tables["F0395"]["crew_table"] is True # 유로폼 설치·해체
assert tables["F0132"]["crew_table"] is True # 평떼 시비 — 서브가 실물에서 부딪힌 표
assert tables["F0334"]["crew_table"] is False # 표면 마무리는 평범한 소요량표
# ── 이름 표기 갈림 · 공식 기호 줄 (2026-09-07 서브 창 제보) ─────────
#
# ⚠ 같은 표 묶음 안에서도 표기가 갈린다 — `13-6-1` 은 「굴 삭 기 (무한궤도)」,
# 바로 옆 `13-6-2` 는 「굴착기 (무한궤도)」. 받는 쪽이 이름으로 자원을 찾으므로
# 표기가 갈리면 **그 줄이 통째로 빠진다.**
# ⚠⚠ **여기서 이름을 고치지 않는다.** 정규화는 값을 살리지만 **잘못된 줄도 함께 살린다** —
# 서브 실례: 이름 정규화 직후 버킷계수 `K` 를 소요량으로 읽어 사용료가 이중이 됐다.
def test_자간_공백_자원_이름을_찾을것() -> None:
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import spaced_names
table = {
"rows": [["굴 삭 기 (무한궤도)", "0.8㎥", "h", "3.84"], ["작업반장", "", "", "0.83"]]
}
assert spaced_names(table) == ["굴 삭 기 (무한궤도)"]
def test_머리글_재료명은_안_걸릴것() -> None:
"""⚠ 짝 시험 — 「단 위」·「모 래」까지 걸면 101건이 되어 목록이 잡음이 된다.
**단위 칸과 수치가 함께 있는 자원 줄**만 본다."""
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import spaced_names
assert spaced_names({"rows": [["단 위", ""], ["모 래", "6"]]}) == []
def test_공식_기호_줄을_찾을것() -> None:
"""`K`·`f`·`E` 는 시공능력 공식 파라미터다 — 자원으로 세면 이중계상."""
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import formula_rows
table = {"rows": [["k", "0.9"], ["f", "1/1.3"], ["보통인부", "", "0.23"]]}
assert set(formula_rows(table)) == {"k", "f"}
def test_서브가_부딪힌_표가_실제로_잡힐것() -> None:
import json
path = ROOT / "resources" / "data_work_item_master" / "work_item_master_2026-01-01.json"
master = json.loads(path.read_text(encoding="utf-8"))
tables = {t["pum_table_id"]: t for i in master["work_items"] for t in (i.get("tables") or [])}
assert tables["F0419"]["spaced_names"] == ["굴 삭 기 (무한궤도)"] # 13-6-1 메쌓기
assert tables["F0423"]["spaced_names"] == ["굴 삭 기 (무한궤도)"] # 13-7-2 찰붙이기
# 바로 옆 13-6-2 찰쌓기는 공백이 없다 — 그래서 표기가 갈린다.
assert tables["F0420"]["spaced_names"] == []
assert "k" in tables["F0258"]["formula_rows"] # 9-12-1 측구터파기 토사
# ── 규격 표기 특수문자 (2026-09-07 갈래 계약) ──────────────────────
#
# ⚠ 원문이 한 종류로 안 쓴다 — 물결표만 셋(`∼` 662 · `` 459 · `~` 2), 곱셈표 둘.
# **두 창이 각자 갈래 키를 조립하면 글자 하나로 영영 안 맞는다.**
# 그래서 인계는 키를 조립하지 않고 **저장 원본값만** 보낸다. 여기서는 표시만 한다.
def test_특수문자_종류를_찾을것() -> None:
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import special_glyphs
table = {"rows": [["직경 40㎝이상∼60㎝미만", "600 x 1,200mm"]]}
found = special_glyphs(table)
assert "(U+223C)" in found and "x(U+0078)" in found
def test_평범한_글자는_안_걸릴것() -> None:
"""⚠ 짝 시험 — 넓게 잡으면 목록이 잡음이 된다."""
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import special_glyphs
assert special_glyphs({"rows": [["보통인부", "", "0.16"]]}) == []
def test_물결표가_두_종류_섞인_표가_실제로_있을것() -> None:
"""⚠ 이것이 계약을 바꾼 근거다 — 같은 절 안에서도 표기가 갈린다."""
import json
path = ROOT / "resources" / "data_work_item_master" / "work_item_master_2026-01-01.json"
master = json.loads(path.read_text(encoding="utf-8"))
tables = {t["pum_table_id"]: t for i in master["work_items"] for t in (i.get("tables") or [])}
mixed = [
tid
for tid, t in tables.items()
if sum(1 for g in t.get("special_glyphs") or [] if "U+223C" in g or "U+FF5E" in g) >= 2
]
assert mixed, "물결표가 섞인 표를 못 찾았다 — 계약 근거가 사라졌는지 확인할 것"
# ── 참조 표지 — 41건을 지웠던 그 규칙의 짝 시험 (2026-09-08 ㉘) ────────────────
# 넓히면 정상 공종이 사라지고, 좁히면 기준표가 공종으로 선다. 양쪽을 다 박는다.
def _표(headers: list[str], rows: list[list[str]] | None = None) -> dict:
return {"headers": headers, "rows": rows or [["보통인부", "", "1.0"]]}
def test_참조_표지가_기준표를_잡을것() -> None:
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import REFERENCE_MARKS, detect_form
for mark in REFERENCE_MARKS:
form, why = detect_form(_표([mark, "구분", "비율"]), "13")
assert form == "reference", f"'{mark}' 를 못 잡는다 — {why}"
def test_단위_열이_있다고_공종을_버리지_말것() -> None:
"""⚠ 2026-09-07 사고 — 헤더 「단 위」 하나로 **41건**이 참조로 버려졌다.
그 안에 돌쌓기(장비) `13-4-5`·`13-4-2` 처럼 **우리 매핑이 실제로 쓰는 공종**이 있었다.
「단위 열이 있다」는 소요량표의 흔한 모양이지 참조표의 표지가 아니다.
"""
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import REFERENCE_MARKS, detect_form
assert "단위" not in [m.replace(" ", "") for m in REFERENCE_MARKS]
form, _why = detect_form(_표(["명칭", "규격", "단 위", "수량"]), "13")
assert form != "reference"
def test_참조_표지를_부분일치로_넓히지_말것() -> None:
"""정상 공종 이름에 표지 글자가 스쳐도 걸리면 안 된다."""
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import detect_form
for headers in (["명칭", "규격", "단위", "수량"], ["직종", "단위", "수량"]):
form, _why = detect_form(_표(headers), "9")
assert form != "reference", headers
# ── 배수관 유입부 집수정 — 부속 줄 규칙의 짝 시험 (2026-09-08 ㉘) ──────────────
def test_부속은_그_칸이_있을_때만_줄이_선다() -> None:
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import attachments_of
= {"structure_id": "s1", "type_id": "pipe", "options": {}}
assert attachments_of() == [], "집수정 형식을 안 골랐는데 줄이 섰다"
["options"] = {"inlet_basin_form": "돌집수정 ㄷ형"}
붙은것 = attachments_of()
assert [r["type_id"] for r in 붙은것] == ["pipe_inlet_basin"]
assert 붙은것[0]["attachment_of"] == "s1"
assert 붙은것[0]["structure_id"] != "s1", "부모와 같은 id 면 두 줄이 한 줄로 뭉친다"
def test_부속이_없는_종류에는_아무것도_안_붙는다() -> None:
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import attachments_of
옹벽 = {"structure_id": "s2", "type_id": "retaining_wall", "options": {"inlet_basin_form": "ㄷ형"}}
assert attachments_of(옹벽) == []
# ── 빈 단가산출서 서식 (2026-09-08 ㉙, 서브가 찾아 준 것) ─────────────────────
def test_빈_단가산출서_서식은_공종이_아님() -> None:
"""품셈이 실어 둔 **채워 넣으라고 둔 양식** — 값이 아예 없다.
이것을 공종으로 세우면 「값이 있는데 안 서는 자리」로 오해된다.
실측: 이 규칙 하나로 견적 쪽 못 맞춘 자원이 **882 → 497** 로 줄었다.
"""
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import detect_form
= {
"headers": ["ha당 참나무시들음병방제 단가산출서(예시)", "", ""],
"rows": [["위치 및 임․소반", "1-0-1-0 또는 00임반 00소반", "비고"]],
}
form, why = detect_form(, "4")
assert form == "reference", why
def test_머리글이_없어도_서식_모양으로_가름() -> None:
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import detect_form
= {
"headers": [],
"rows": [
["위치 및 면적", "00임반 00소반", "비 고"],
["구분", "작업량", "단위품 (인원,수량,요율)", "소요품", "단가(원)"],
],
}
assert detect_form(, "3")[0] == "reference"
def test_서식_규칙이_정상_표를_지우지_말것() -> None:
"""⚠ 「구분」·「단가」는 정상 표에도 흔하다 — 하나만 보면 정상 표가 사라진다."""
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import detect_form
소요량표 = {"headers": ["명칭", "규격", "단위", "수량"], "rows": [["보통인부", "", "", "1.0"]]}
assert detect_form(소요량표, "13")[0] == "requirement"
구분표 = {
"headers": [],
"rows": [["구 분", "소요인력", "인력구성"], ["작업로 예정선 선정", "1.0", "초급기술자"]],
}
assert detect_form(구분표, "3")[0] != "reference"
def test_마스터에_빈_서식이_공종으로_남아있지_않을것() -> None:
"""정본 대조 — 다시 만든 마스터에 그 표가 소요량표로 서 있으면 깨진다."""
import json
from pathlib import Path
master = json.loads(
(
Path(__file__).resolve().parents[2]
/ "resources"
/ "data_work_item_master"
/ "work_item_master_2026-01-01.json"
).read_text(encoding="utf-8")
)
남은것: list[str] = []
def walk(node: object) -> None:
if isinstance(node, dict):
for table in node.get("tables") or []:
head = "".join(
str(c) for row in (table.get("raw_row") or [])[:1] for c in row
).replace(" ", "")
if "위치및" in head and table.get("pum_form") != "reference":
남은것.append(str(table.get("pum_table_id")))
for value in node.values():
walk(value)
elif isinstance(node, list):
for value in node:
walk(value)
walk(master)
assert not 남은것, f"빈 서식이 아직 공종으로 서 있다: {sorted(set(남은것))}"
# ── 밑수가 표 아래 [주] 에만 있는 자리 (2026-09-08, B09 제보) ──────────────────
def test_규준틀_밑수를_표_아래_주에서_읽는다() -> None:
"""⚠ 「원문에 있었음」 아홉 번째 — 이번엔 표 **아래** [주] 였다.
11-2·11-3 은 표에도 표 위 본문에도 밑수가 없고, [주]③ 「목재의 손율은
**1개소 사용당** 50%로 한다」에만 「개소」가 나온다. 뜻으로도 개소가 맞다 —
[주]② 가 「본 품은 …한 비탈규준틀의 제작·도색·가설·철거를 포함한 것」이다.
"""
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import basis_from_source
lines = [
"### 11-2. 토공의 비탈 규준틀",
"| 종 류 | 단 위 | 수 량 |",
"|---|---|---|",
"| 건축목공 | 인 | 0.16 |",
"[주] ① 비탈길이 10m 이상 20m마다 설치한다.",
"- ③ 목재의 손율은 1개소 사용당 50%로 한다.",
]
assert basis_from_source(lines, 2) == (1.0, "개소")
def test_개소당_평균면적_같은_흔한_말에는_안_걸린다() -> None:
"""⚠ 넓히면 조림 2장이 통째로 걸린다 — 실측 19건. 「사용」이 있어야 잡는다."""
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import basis_from_source
lines = [
"### 2-1. 풀베기",
"| o 집단화 정도(1-4-3) | 개소당 평균면적이 1~3ha 미만 | 5% |",
]
assert basis_from_source(lines, 2) == (None, None)
def test_아래_주를_보되_다음_절까지_넘어가지_말것() -> None:
"""남의 [주] 를 물어 오면 조용히 틀린다."""
from B08_Quantity.B08_Quantity_Build_WorkItemMaster import basis_from_source
lines = [
"### 11-9. 밑수가 없는 표",
"| 종류 | 단위 | 수량 |",
"|---|---|---|",
"### 11-2. 토공의 비탈 규준틀",
"- ③ 목재의 손율은 1개소 사용당 50%로 한다.",
]
assert basis_from_source(lines, 2) == (None, None)
def test_마스터의_규준틀_둘에_밑수가_실려있을것() -> None:
"""정본 대조 — 다시 만든 마스터에 값이 실제로 들어갔는가."""
import json
from pathlib import Path
master = json.loads(
(
Path(__file__).resolve().parents[2]
/ "resources"
/ "data_work_item_master"
/ "work_item_master_2026-01-01.json"
).read_text(encoding="utf-8")
)
찾음: dict[str, tuple] = {}
def walk(node: object) -> None:
if isinstance(node, dict):
code = node.get("work_item_code")
if code in ("FP-11-02", "FP-11-03"):
for table in node.get("tables") or []:
찾음[str(code)] = (table.get("basis_quantity"), table.get("basis_unit"))
for value in node.values():
walk(value)
elif isinstance(node, list):
for value in node:
walk(value)
walk(master)
assert 찾음 == {"FP-11-02": (1.0, "개소"), "FP-11-03": (1.0, "개소")}, 찾음
@@ -0,0 +1,53 @@
"""산출 요약(최소·중앙·최대) 검사 — 2026-09-07 조율 창 권고.
이것은 **검사가 아니라 눈에 띄게 하는 장치**다. 기준을 정해 걸러 내지 않는다 —
임도 물량은 ㎥·㎡·m·ton 이 섞여 「얼마 이하면 이상하다」를 한 벌로 못 정한다.
그래서 시험도 「단위를 안 섞는가」·「없는 값을 0 으로 만들지 않는가」를 본다.
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from common_util.common_util_quantity_spread import spread, spread_by_unit # noqa: E402
def test_값이_없으면_0이_아니라_없음() -> None:
"""0 으로 만들면 「값이 0 인 것」과 구별이 안 된다."""
assert spread([]) is None
assert spread_by_unit([], value_key="amount") == {}
def test_단위를_안_섞음() -> None:
"""㎥ 와 ton 을 한 통에 넣으면 최솟값이 뜻을 잃는다."""
rows = [
{"unit": "", "amount": 2526.99},
{"unit": "", "amount": 90.51},
{"unit": "ton", "amount": 23.89},
]
result = spread_by_unit(rows, value_key="amount")
assert result[""]["min"] == 90.51
assert result[""]["max"] == 2526.99
assert result["ton"]["count"] == 1
def test_자릿수가_어긋난_값이_최솟값에서_드러남() -> None:
"""서브가 겪은 「합계 68.8원」 같은 것 — 값이 있기는 하니 시험은 안 잡는다.
최솟값이 나머지와 자릿수가 다르면 사람이 본다."""
rows = [{"unit": "", "amount": v} for v in (13518.6, 18952.3, 68.8)]
result = spread_by_unit(rows, value_key="amount")
assert result[""]["min"] == 68.8
assert result[""]["max"] == 18952.3
def test_숫자가_아닌_값은_건너뜀() -> None:
rows = [{"unit": "", "amount": None}, {"unit": "", "amount": 5.0}]
assert spread_by_unit(rows, value_key="amount")[""]["count"] == 1
def test_단위가_없으면_물음표로_모음() -> None:
assert "?" in spread_by_unit([{"amount": 1.0}], value_key="amount")
@@ -0,0 +1,132 @@
"""절단 여유 30m → 3m 로 낮췄을 때 끝단 지반고가 튀지 않는지 (2026-09-04 사용자 지시).
용화_LAS 프로젝트의 확정 지표면으로 실측한다. 판정은 「3m 로 남긴 양 끝 20m 구간의
지반고 변화율이 노선 안쪽 구간과 같은 수준인가」 — 가장자리 점 밀도가 떨어져 값이
못 미더우면 여기서 눈에 띄게 튄다.
"""
import asyncio
import math
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.append(str(ROOT))
PROJECT_ID = "5cff3920-a181-4a3d-bec0-e0ac4082b75d" # 용화_LAS
def _project_root_and_surface():
import aiomysql
from config.config_db import DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_USER
from common_util.common_util_storage import resolve_stored_project_path
async def run():
conn = await aiomysql.connect(
host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASSWORD,
db=DB_NAME, charset="utf8mb4",
)
try:
async with conn.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"SELECT storage_path FROM projects WHERE id = %s", (PROJECT_ID,)
)
row = await cursor.fetchone()
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
params = await get_surface_confirmation_params(conn, PROJECT_ID)
return row, params
finally:
conn.close()
row, params = asyncio.run(run())
if not row:
pytest.skip("용화_LAS 프로젝트가 없습니다.")
return Path(resolve_stored_project_path(row["storage_path"])), params
def _sample_z(sampler, points):
import numpy as np
z, valid = sampler.sample_xy(np.asarray(points, dtype=np.float64))
return np.asarray(z, dtype=float), np.asarray(valid, dtype=bool)
def _slopes(sampler, points):
"""이웃 정점 사이 지반고 변화율(m/m) 목록."""
z, valid = _sample_z(sampler, points)
out = []
for i in range(1, len(points)):
if not (valid[i] and valid[i - 1]):
continue
d = math.dist(points[i - 1], points[i])
if d > 0.5:
out.append(abs(z[i] - z[i - 1]) / d)
return out
def _head_tail_within(points, metres):
"""양 끝에서 `metres` 안에 드는 정점만."""
acc = [0.0]
for i in range(1, len(points)):
acc.append(acc[-1] + math.dist(points[i - 1], points[i]))
total = acc[-1]
head = [p for p, a in zip(points, acc) if a <= metres]
tail = [p for p, a in zip(points, acc) if total - a <= metres]
return head, tail, total
def test_edge_trim_3m_ends_are_not_spiky():
from common_util.common_util_route_geometry import (
find_planned_route_file,
read_planned_route,
trim_route_to_surface,
)
from common_util.common_util_surface_sampler import build_surface_sampler
from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj
project_root, params = _project_root_and_surface()
route_file = find_planned_route_file(project_root / "B03_FileInput" / "input")
if route_file is None:
pytest.skip("계획노선 파일이 없습니다.")
planned = read_planned_route(route_file)
points = [(float(v.x), float(v.y)) for v in planned.vertices]
target_crs = project_epsg_from_prj(project_root)
source_crs = planned.crs_input or target_crs
if source_crs.upper() != target_crs.upper():
from pyproj import Transformer
transformer = Transformer.from_crs(source_crs, target_crs, always_xy=True)
points = [transformer.transform(x, y) for x, y in points]
sampler = build_surface_sampler(
project_root / "B04_PreProcess" / "models",
str(params["source_filter"]),
str(params["method"]),
bool(params["smooth"]),
)
trimmed_30 = trim_route_to_surface(points, sampler, 30.0)
trimmed_3 = trim_route_to_surface(points, sampler, 3.0)
assert len(trimmed_3) >= 2
head, tail, total_3 = _head_tail_within(trimmed_3, 20.0)
_, _, total_30 = _head_tail_within(trimmed_30, 20.0)
print(f"\n연장: 30m 트림 {total_30:.1f}m / 3m 트림 {total_3:.1f}m (차이 {total_3 - total_30:.1f}m)")
inner = _slopes(sampler, trimmed_3[len(trimmed_3) // 4 : 3 * len(trimmed_3) // 4])
ends = _slopes(sampler, head) + _slopes(sampler, tail)
assert inner and ends
inner_max = max(inner)
ends_max = max(ends)
print(f"지반고 변화율 최대: 안쪽 {inner_max:.3f} / 양 끝 20m {ends_max:.3f}")
# 끝단이 안쪽보다 크게 튀지 않아야 한다(가장자리 밀도 저하 확인).
assert ends_max <= max(inner_max * 1.5, inner_max + 0.05)
def test_config_default_is_3m():
from config.config_system_terrain import SURFACE_ROUTE_EDGE_TRIM_M
assert SURFACE_ROUTE_EDGE_TRIM_M == 3.0
@@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
"""초기 설계 실패 안내 메일 — 완료 메일 대신 나가는지, 문구에 관리자 문의가 있는지.
실제 SMTP는 타지 않는다. 확인 대상은 발송 여부 판정과 본문 규약이다.
"""
import asyncio
import pytest
import B03_FileInput.B03_FileInput_Email as email_mod
@pytest.fixture
def sent(monkeypatch):
captured = {}
async def _fake_send_email(*, to_email, subject, html):
captured["to"] = to_email
captured["subject"] = subject
captured["html"] = html
return True
monkeypatch.setattr(email_mod, "send_email", _fake_send_email)
return captured
def test_failed_email_carries_reason_and_admin_contact(sent, monkeypatch):
monkeypatch.setattr(email_mod, "ADMIN_EMAIL", "admin@example.com")
ok = asyncio.run(
email_mod.send_initial_design_failed_email(
project_id="325f57d9-0617-49b9-a793-983f24781e1e",
project_name="시제 프로젝트",
to_email="user@example.com",
reason="B06 초기 횡단 확정 실패 (status=404).",
)
)
assert ok is True
assert sent["to"] == "user@example.com"
assert "초기 설계 실패 알림" in sent["subject"]
assert "B06 초기 횡단 확정 실패 (status=404)." in sent["html"]
assert "관리자에게 문의" in sent["html"]
assert "admin@example.com" in sent["html"]
# 되돌릴 초기값이 없다는 사실을 본문에서 알려야 한다.
assert "초기값" in sent["html"]
def test_failed_email_without_admin_address_still_guides(sent, monkeypatch):
monkeypatch.setattr(email_mod, "ADMIN_EMAIL", "")
asyncio.run(
email_mod.send_initial_design_failed_email(
project_id="p1",
project_name="주소 없는 환경",
to_email="user@example.com",
reason="계획노선 CSV가 없어 초기 노선을 세울 수 없습니다.",
)
)
assert "관리자에게 문의" in sent["html"]
assert "mailto:" not in sent["html"]
+75
View File
@@ -0,0 +1,75 @@
"""카드 미리보기 재료(라이다 점 그림·GeoTIFF 썸네일) 생성 검증 (2026-09-04 사용자 지시)."""
import asyncio
import sys
import time
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.append(str(ROOT))
PROJECT_ID = "5cff3920-a181-4a3d-bec0-e0ac4082b75d" # 용화_LAS
def _project_root() -> Path:
import aiomysql
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_USER
async def run():
conn = await aiomysql.connect(
host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASSWORD,
db=DB_NAME, charset="utf8mb4",
)
try:
async with conn.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"SELECT storage_path FROM projects WHERE id = %s", (PROJECT_ID,)
)
return await cursor.fetchone()
finally:
conn.close()
row = asyncio.run(run())
if not row:
pytest.skip("용화_LAS 프로젝트가 없습니다.")
return Path(resolve_stored_project_path(row["storage_path"]))
def test_las_preview_points():
from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_las_metadata
root = _project_root()
files = sorted((root / "B03_FileInput" / "input" / "las").glob("*.la*"))
if not files:
pytest.skip("LAS 파일이 없습니다.")
started = time.perf_counter()
meta = analyze_las_metadata(files[0])
elapsed = time.perf_counter() - started
points = meta.get("preview_points") or []
print(f"\n{files[0].name}: 점 {meta['point_count']:,}개, 미리보기 점 {len(points)}개, {elapsed:.1f}s")
assert 0 < len(points) <= 5000
bounds = meta["bounds"]
for x, y in points[:200]:
assert bounds["x"][0] - 1 <= x <= bounds["x"][1] + 1
assert bounds["y"][0] - 1 <= y <= bounds["y"][1] + 1
def test_geotiff_thumbnail():
from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_tif_metadata
root = _project_root()
files = sorted((root / "B03_FileInput" / "input" / "tif").glob("*.tif"))
if not files:
pytest.skip("GeoTIFF 파일이 없습니다.")
started = time.perf_counter()
meta = analyze_tif_metadata(files[0])
elapsed = time.perf_counter() - started
thumb = meta.get("preview_thumbnail")
print(f"{files[0].name}: 썸네일 {'있음 %d바이트' % len(thumb) if thumb else '없음(오버뷰 없음)'}, {elapsed:.1f}s")
if thumb:
assert thumb.startswith("data:image/png;base64,")
assert elapsed < 5.0
+50
View File
@@ -0,0 +1,50 @@
"""미리보기 점 수집이 업로드 분석 시간을 늘리지 않는지 (2026-09-04 사용자 제약)."""
import sys
import time
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.append(str(ROOT))
def _las_file() -> Path:
from tmp.tests.test_preview_assets import _project_root # noqa: PLC0415
root = _project_root()
files = sorted((root / "B03_FileInput" / "input" / "las").glob("*.la*"))
if not files:
pytest.skip("LAS 파일이 없습니다.")
return files[0]
def test_collect_cost_is_small():
import laspy
import numpy as np
path = _las_file()
# ① 분류 통계만 (종전 동작)
started = time.perf_counter()
with laspy.open(path) as las_file:
counts: dict[int, int] = {}
for chunk in las_file.chunk_iterator(500_000):
values, chunk_counts = np.unique(
np.asarray(chunk.classification, dtype=np.uint8), return_counts=True
)
for value, count in zip(values.tolist(), chunk_counts.tolist(), strict=True):
counts[value] = counts.get(value, 0) + count
baseline = time.perf_counter() - started
# ② 지금 동작 (같은 길에 XY 를 성기게 주움)
from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_las_metadata
started = time.perf_counter()
meta = analyze_las_metadata(path)
current = time.perf_counter() - started
print(f"\n분류 통계만 {baseline:.2f}s / 점 수집 포함 {current:.2f}s "
f"(차이 {current - baseline:+.2f}s, 점 {len(meta['preview_points'])}개)")
# 파일을 다시 읽지 않으므로 한 번 훑는 시간과 크게 다르지 않아야 한다.
assert current <= baseline * 1.5 + 1.0
+49
View File
@@ -0,0 +1,49 @@
"""계획노선 사용 범위 절단 (2026-09-04 사용자 지시) 단위 검증."""
import math
import sys
from pathlib import Path
sys.path.append(str(Path(__file__).resolve().parents[2]))
from common_util.common_util_route_geometry import clip_route_by_chainage
def _length(points):
return sum(math.dist(points[i - 1], points[i]) for i in range(1, len(points)))
LINE = [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)] # 총 200m
def test_no_range_keeps_all():
assert clip_route_by_chainage(LINE, None, None) == LINE
def test_start_only():
out = clip_route_by_chainage(LINE, 50.0, None)
assert out[0] == (50.0, 0.0)
assert out[-1] == (100.0, 100.0)
assert abs(_length(out) - 150.0) < 1e-6
def test_end_only():
out = clip_route_by_chainage(LINE, None, 150.0)
assert out[0] == (0.0, 0.0)
assert abs(out[-1][0] - 100.0) < 1e-6 and abs(out[-1][1] - 50.0) < 1e-6
assert abs(_length(out) - 150.0) < 1e-6
def test_both_ends_and_interpolated_vertex():
out = clip_route_by_chainage(LINE, 50.0, 150.0)
assert abs(_length(out) - 100.0) < 1e-6
# 꺾임점(100,0)이 구간 안이면 그대로 남는다.
assert (100.0, 0.0) in [(round(x, 6), round(y, 6)) for x, y in out]
def test_range_outside_data_falls_back_to_full():
assert clip_route_by_chainage(LINE, 500.0, 900.0) == LINE
def test_reversed_range_falls_back_to_full():
assert clip_route_by_chainage(LINE, 150.0, 50.0) == LINE
+75
View File
@@ -0,0 +1,75 @@
"""실제 노선(용화_LAS)에 사용 범위를 걸면 그 구간만 남는지 (2026-09-04 사용자 지시)."""
import asyncio
import math
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.append(str(ROOT))
PROJECT_ID = "5cff3920-a181-4a3d-bec0-e0ac4082b75d" # 용화_LAS
def _project_root() -> Path:
import aiomysql
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_USER
async def run():
conn = await aiomysql.connect(
host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASSWORD,
db=DB_NAME, charset="utf8mb4",
)
try:
async with conn.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"SELECT storage_path FROM projects WHERE id = %s", (PROJECT_ID,)
)
return await cursor.fetchone()
finally:
conn.close()
row = asyncio.run(run())
if not row:
pytest.skip("용화_LAS 프로젝트가 없습니다.")
return Path(resolve_stored_project_path(row["storage_path"]))
def test_range_clips_real_route():
from common_util.common_util_route_geometry import load_design_route
project_root = _project_root()
# surface_params 없이 = 정본 CSV 지름길을 타지 않는 원본 판독 경로.
full = load_design_route(project_root)
if full is None:
pytest.skip("계획노선을 읽지 못했습니다.")
total = full.vertices[-1].chainage_m
clipped = load_design_route(project_root, None, (100.0, 400.0))
assert clipped is not None
length = clipped.vertices[-1].chainage_m
print(f"\n전 구간 {total:.1f}m → 범위 100~400m 적용 {length:.1f}m")
assert abs(length - 300.0) < 1.0
# 시점이 원 노선의 100m 자리와 같은 좌표인지.
walked = 0.0
target = None
for index in range(1, len(full.vertices)):
a = full.vertices[index - 1]
b = full.vertices[index]
step = math.dist((a.x, a.y), (b.x, b.y))
if walked + step >= 100.0:
ratio = (100.0 - walked) / (step or 1)
target = (a.x + (b.x - a.x) * ratio, a.y + (b.y - a.y) * ratio)
break
walked += step
assert target is not None
start = clipped.vertices[0]
assert math.dist((start.x, start.y), target) < 0.5
# 비우면 전 구간 그대로.
same = load_design_route(project_root, None, (None, None))
assert same is not None
assert abs(same.vertices[-1].chainage_m - total) < 1e-6