fix(b09): 운반공 줄 이름 = 거리이정표 항목명 그대로 · 행마다 개별 줄 (261-4)

- 「운반」 덧붙임 없앰 · 빈 항목명 = 거래처 · 그것도 없으면 묶음 이름 · 「운반」
- 자동 상자 둘째 행부터 `haul:{묶음}.n` 개별 줄(묶음 줄 바로 뒤 · 수량 빈칸)
- 시험 기대값 고침(253_1 · 251_1 · 145_3) · 새 시험 test_261_4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
This commit is contained in:
2026-10-11 15:19:06 +09:00
co-authored by Claude Opus 5.5
parent 6e53acfcac
commit b751f29b01
6 changed files with 116 additions and 52 deletions
+9 -7
View File
@@ -131,16 +131,18 @@ def with_haul_routes(data: dict[str, Any], groups: list[dict[str, Any]]) -> dict
def route_bill_key(route: dict[str, Any], index: int = 0) -> str:
"""거리이정표 행 → B09 운반공 줄 키(253-1) — 자동 상자 = 옛 키 · 사용자 상자 = `haul:r-{id}` ·
한 상자 둘째 행부터 `haul:r-{id}.{n}`(경로 id 에 「.」 없음 — 안 겹침)."""
한 상자 둘째 행부터 `.{n}`(261-4 자동 상자도 · 경로 id · 묶음 키에 「.」 없음 — 안 겹침)."""
tail = f".{index}" if index else ""
if route.get("haul"):
return bill_key(str(route["haul"]))
return f"haul:r-{route.get('id')}" + (f".{index}" if index else "")
return bill_key(str(route["haul"])) + tail
return f"haul:r-{route.get('id')}" + tail
def haul_rows(data: dict[str, Any], table: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""거리이정표 표 행 하나 = 운반공 줄 하나(253-1) — [{key, name, ref, user, L1, L2}] ·
"""거리이정표 표 행 하나 = 운반공 줄 하나(253-1 · 261-4 단가연결 무관) —
[{key, name, ref, user, haul, L1, L2}] · name = 행 항목명 그대로(없으면 거래처 · 그것도 없으면 빈칸) ·
L1 = 포장 계(2차 + 1차 + 포장) · L2 = 비포장(`compute_milestone` 결과) ·
user = 사용자 상자(자동 상자는 총괄자재 묶음 줄이 이미 있음 · 둘째 행은 안 씀)."""
user = 사용자 상자 · haul = 자동 상자 묶음 키(첫 행은 총괄자재 묶음 줄 이름 · 거리)."""
routes = {r.get("id"): r for r in data.get("routes") or []}
out, seen = [], set()
for row in table:
@@ -149,13 +151,13 @@ def haul_rows(data: dict[str, Any], table: list[dict[str, Any]]) -> list[dict[st
if key in seen:
continue
seen.add(key)
item = str(row.get("item") or "").strip()
out.append(
{
"key": key,
"name": f"{item}운반" if item else "운반",
"name": str(row.get("item") or "").strip() or str(route.get("name") or "").strip(),
"ref": route.get("ref"),
"user": not route.get("haul"),
"haul": route.get("haul"),
"L1": row.get("paved"),
"L2": row.get("unpaved"),
}
+33 -36
View File
@@ -111,48 +111,45 @@ def haul_items(
routes: list[dict[str, Any]] | None = None,
) -> list[dict[str, Any]]:
"""운반공 줄 — B08 운반 묶음(251-1 · 장비 둘 + 총괄자재 묶음 · 옛 키 haul:1~6 그대로) +
거리이정표 사용자 상자 행(253-1 · `B08_Quantity_Haul.haul_rows` · 수량 빈칸) ·
거리이정표 행(253-1 · `B08_Quantity_Haul.haul_rows` · 행 하나 = 줄 하나 · 둘째 행부터 수량 빈칸) ·
이름 = 거리이정표 항목명 그대로(261-4 · 「운반」 안 붙임 · 비면 거래처 · 그것도 없으면 묶음 이름 · 「운반」) ·
연결 = 거리이정표 단가연결(251-5 · `refs{줄 키: ref}` · 설계내역서 연결 덮개가 이김) ·
`dist{L1, L2}` = 그 행 거리(253-3 「운반 단가」 모달 · 없으면 None)."""
dists = {r["key"]: {"L1": r.get("L1"), "L2": r.get("L2")} for r in routes or []}
rows = routes or []
by_key = {r["key"]: r for r in rows}
extra: dict[str, list[dict[str, Any]]] = {} # 자동 상자 둘째 행부터 — 묶음 줄 바로 뒤
for r in rows:
if r.get("haul") and r["key"] != bill_key(str(r["haul"])):
extra.setdefault(str(r["haul"]), []).append(r)
def line(key: str, name: str, unit: str, qty: Any, note: str) -> dict[str, Any]:
ref = (refs or {}).get(key)
route = by_key.get(key) or {}
return {
"key": key,
"section": "3",
"name": name,
"spec": "",
"unit": unit,
"qty": qty,
"note": note,
"ref": ref,
"dist": {"L1": route["L1"], "L2": route["L2"]} if route else None,
"why": WHY_LOGIC if (ref or {}).get("tab") == "logic" else WHY_HAUL,
}
out = []
for g in haul_groups(materials):
qty = g["qty"]
ref = (refs or {}).get(bill_key(g["key"]))
logic = (ref or {}).get("tab") == "logic"
key = bill_key(g["key"])
name = (by_key.get(key) or {}).get("name") or g["name"]
out.append(
{
"key": bill_key(g["key"]),
"section": "3",
"name": f"{g['name']}운반",
"spec": "",
"unit": g["unit"],
"qty": qty,
"note": "B08 총괄자재" if qty is not None else "",
"ref": ref,
"dist": dists.get(bill_key(g["key"])),
"why": WHY_LOGIC if logic else WHY_HAUL,
}
)
for r in routes or []:
if not r.get("user"):
continue
ref = (refs or {}).get(r["key"])
logic = (ref or {}).get("tab") == "logic"
out.append(
{
"key": r["key"],
"section": "3",
"name": r["name"],
"spec": "",
"unit": "",
"qty": None,
"note": "B08 거리이정표",
"ref": ref,
"dist": dists.get(r["key"]),
"why": WHY_LOGIC if logic else WHY_HAUL,
}
line(key, name, g["unit"], g["qty"], "B08 총괄자재" if g["qty"] is not None else "")
)
for r in extra.get(g["key"], []):
out.append(line(r["key"], r.get("name") or g["name"], "", None, "B08 거리이정표"))
for r in rows:
if r.get("user"):
out.append(line(r["key"], r.get("name") or "운반", "", None, "B08 거리이정표"))
return out
+2 -2
View File
@@ -190,8 +190,8 @@ def test_항목_짜기_B08_수량():
"key": "pipe:revet|돌쌓기(찰)|2.0",
}
haul = {v["name"]: v["qty"] for k, v in items.items() if k.startswith("haul:")}
assert haul == {"중기운반": None, "기계운반": None, "모래운반": 16, "자갈운반": None,
"시멘트운반": None, "철근운반": 0.75} # fmt: skip
assert haul == {"중기": None, "기계": None, "모래": 16, "자갈": None,
"시멘트": None, "철근": 0.75} # fmt: skip 261-4 묶음 이름 그대로
def test_거울_TS_가_같은_값():
+2 -2
View File
@@ -44,8 +44,8 @@ def test_묶음_장비_옛_여섯_늘_수량_없는_새_묶음_없음():
def test_B09_운반_줄_옛_키_그대로_새_묶음_키():
items = {i["key"]: i for i in haul_items(_mats(ROWS))}
assert [bill_key(k) for k in ("equip_heavy", "sand", "rebar")] == ["haul:1", "haul:3", "haul:6"]
assert items["haul:3"]["name"] == "모래운반" and items["haul:3"]["qty"] == 16
assert items["haul:remicon"]["name"] == "레미콘운반" and items["haul:remicon"]["unit"] == "㎥"
assert items["haul:3"]["name"] == "모래" and items["haul:3"]["qty"] == 16
assert items["haul:remicon"]["name"] == "레미콘" and items["haul:remicon"]["unit"] == "㎥"
assert all(i["section"] == "3" for i in items.values())
assert {"haul:1", "haul:2", "haul:3", "haul:4", "haul:5", "haul:6"} <= set(items)
+5 -5
View File
@@ -37,7 +37,7 @@ def test_rows_one_per_table_row():
assert list(got) == ["haul:3", "haul:r-u1", "haul:r-u1.1"]
assert (got["haul:3"]["L1"], got["haul:3"]["L2"], got["haul:3"]["user"]) == (25.1, 0, False)
assert (got["haul:r-u1"]["name"], got["haul:r-u1"]["L1"], got["haul:r-u1"]["L2"]) == (
"혼합골재운반", 10, 0.8) # fmt: skip
"혼합골재", 10, 0.8) # fmt: skip 261-4 항목명 그대로
assert got["haul:r-u1.1"]["L2"] == 2.8 # 중점 = 시점 → 중점 2 km 비포장 더함
assert got["haul:r-u1"]["ref"] == LOGIC
@@ -47,14 +47,14 @@ def test_bill_items_user_rows():
extra = haul_user(rows)
items = {i["key"]: i for i in haul_items(None, extra["haul_refs"], rows)}
assert (
"haul:3" in items and items["haul:3"]["name"] == "모래운반"
) # 자동 = 총괄자재 묶음 그대로
"haul:3" in items and items["haul:3"]["name"] == "모래"
) # 자동 = 상자 항목명(261-4)
user = items["haul:r-u1"]
assert (user["section"], user["name"], user["qty"], user["ref"]) == (
"3",
"혼합골재운반",
"혼합골재",
None,
LOGIC,
)
assert items["haul:r-u1.1"]["name"] == "깬돌운반" and items["haul:r-u1.1"]["ref"] == LOGIC
assert items["haul:r-u1.1"]["name"] == "깬돌" and items["haul:r-u1.1"]["ref"] == LOGIC
assert extra["haul_dist"]["haul:r-u1"] == {"L1": 10, "L2": 0.8}
+65
View File
@@ -0,0 +1,65 @@
"""261-4 운반공 줄 이름 = 거리이정표 항목명 그대로(「운반」 안 붙임) · 행마다 개별 줄 · 단가연결 무관."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from B08_Quantity.B08_Quantity_Haul import haul_rows # noqa: E402
from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone # noqa: E402
from B09_Estimation.B09_Estimation_Bill_Items import haul_items # noqa: E402
from B09_Estimation.B09_Estimation_Router import haul_user # noqa: E402
LOGIC = {"tab": "logic", "code": "GF000134", "name": "골재운반", "given": {}}
DATA = {
"site": {"mid_station_m": 2000, "mid_road": "비포장"},
"base": {"enabled": False, "legs": []},
"routes": [
# 자동 상자 — 항목명 고침 · 둘째 행(같은 이름)
{"id": "h-sand", "name": "골재상", "haul": "sand", "via_base": False,
"legs": [{"road": "포장", "km": 25.1}],
"rows": [{"item": "강모래", "dest": "start"}, {"item": "강모래", "dest": "mid"}]},
# 자동 상자 — 항목명 빈칸 · 거래처 없음 = 묶음 이름
{"id": "h-gravel", "name": "", "haul": "gravel", "via_base": False,
"legs": [], "rows": [{"item": "", "dest": "start"}]},
# 사용자 상자 — 「운반」 이 든 항목명 · 같은 이름 두 행 · 빈 항목명(거래처) · 연결 없음
{"id": "u1", "name": "석산", "via_base": False, "legs": [{"road": "포장", "km": 10}],
"rows": [{"item": "전석운반", "dest": "start"}, {"item": "전석운반", "dest": "start"},
{"item": " ", "dest": "start"}]},
# 사용자 상자 — 항목명 · 거래처 모두 빈칸 · 연결 있음
{"id": "u2", "name": "", "ref": LOGIC, "via_base": False, "legs": [],
"rows": [{"item": "", "dest": "start"}]},
],
} # fmt: skip
def _items() -> dict[str, dict]:
rows = haul_rows(DATA, compute_milestone(DATA, None))
return {i["key"]: i for i in haul_items(None, haul_user(rows)["haul_refs"], rows)}
def test_자동_상자_이름_항목명():
items = _items()
assert items["haul:3"]["name"] == "강모래"
assert items["haul:4"]["name"] == "자갈" # 항목 · 거래처 빈칸 = 묶음 이름
assert items["haul:1"]["name"] == "중기" # 상자 없음 = 묶음 이름
def test_자동_상자_둘째_행_개별_줄_묶음_바로_뒤():
items = _items()
keys = list(items)
assert keys.index("haul:3.1") == keys.index("haul:3") + 1
extra = items["haul:3.1"]
assert (extra["name"], extra["qty"], extra["dist"]["L2"]) == ("강모래", None, 2)
def test_사용자_상자_이름_항목명_같은_이름_두_줄():
items = _items()
assert items["haul:r-u1"]["name"] == items["haul:r-u1.1"]["name"] == "전석운반"
assert items["haul:r-u1.2"]["name"] == "석산" # 빈 항목명 = 거래처
assert items["haul:r-u2"]["name"] == "운반" and items["haul:r-u2"]["ref"] == LOGIC
assert items["haul:r-u1"]["ref"] is None # 연결 없어도 줄
def test_운반운반_없음():
assert not any("운반운반" in i["name"] for i in _items().values())