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