feat(B09): 기계 수송비를 세움 — 기계경비의 셋째 몫이 통째로 빠져 있었음

기계경비 = 기계손료 + 운전경비 + 수송비인데 수송비가 없었음. 수송비를 내는 공종
FP-10-04 중기운반은 표가 미판정이라 단가가 아예 안 섰음.

- 산림품셈 10-4 사이클 그대로 — 트레일러(20TON) t1=20·t3=20·t4=0.42 /
  트럭(10.5TON) t1=10·t3=10·t4=5 · ㎝=t1+t2+t3+t4 · N=60×0.9/㎝, 단위는 회당.
- 거리·도로 구분은 설계 입력 — 비면 줄이 안 서고 사유만 남음(임의 거리 금지).
- 원문이 「-」로 둔 칸(트레일러·고속4차선)은 지어내지 않고 사유로 냄.
- ⚠ 우리가 정한 둘을 화면 근거에 적음 — 속도를 8-1-6의 2 나 이동속도표에서 가져온 것,
  운반시간을 왕복으로 본 것.
- 곁다리: 운전경비 표가 페이지에서 잘려 앞자리를 못 이어받아 39 기종이 통째로
  버려지고 있었음(그 안에 수송 차량 2702 가 있었음). 이어받되 카탈로그에 있는
  코드일 때만 받게 함 — 운전경비 줄 92 → 158.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-09 22:23:06 +09:00
co-authored by Claude Opus 5
parent 08c0ec3134
commit fdc30ecf17
6 changed files with 473 additions and 6 deletions
@@ -106,13 +106,19 @@ def _tokens(cell: str) -> list[str]:
return [t for t in re.split(r"\s+", str(cell or "").strip()) if t]
def expand_codes(tokens: list[str]) -> list[str]:
def expand_codes(tokens: list[str], carry_prefix: str = "") -> list[str]:
"""`0201-0012 0020 0040` → `0201-0012 · 0201-0020 · 0201-0040`.
뒤 코드는 앞 코드의 **앞 네 자리를 이어받는다**. 이어받을 앞자리가 없으면 버린다.
⚠ `carry_prefix` — **앞 표의 마지막 앞자리**. 원문이 페이지에서 잘리면 이어지는 판이
앞자리를 다시 안 적는다(8-4-3 이 `0080 0100 …` 로 시작한다). 그것을 못 이어받아
**운반·하역기계 39 기종의 운전경비가 통째로 버려지고 있었다** — 그 안에 수송비를 내는
**트럭 트랙터 및 평판트레일러(2702)** 가 들어 있다(2026-09-09 밤 실측).
⚠ 이어받은 코드는 **카탈로그에 있는지 확인한 뒤에만** 쓴다(`parse_operating_tables`).
"""
codes: list[str] = []
prefix = ""
prefix = carry_prefix
for token in tokens:
full = _RE_CODE_FULL.match(token)
if full:
@@ -166,9 +172,16 @@ def _operator_code(machine_name: str, aliases: dict[str, str]) -> str:
def parse_operating_tables(
pum: dict[str, Any],
aliases: dict[str, str],
known_codes: set[str] | None = None,
) -> OperatingParseResult:
"""품셈 표 뭉치에서 8-4 운전경비 표만 골라 기종별 원단위를 만든다."""
"""품셈 표 뭉치에서 8-4 운전경비 표만 골라 기종별 원단위를 만든다.
⚠ `known_codes` — 기종 카탈로그의 코드. **앞 표에서 앞자리를 이어받을 때만** 쓰며,
이어받아 만든 코드가 하나라도 카탈로그에 없으면 **그 줄을 통째로 버린다.**
이어받기는 원문이 페이지에서 잘린 자리를 잇는 것이지 코드를 지어내는 것이 아니다.
"""
result = OperatingParseResult()
carry_prefix = ""
for table in pum.get("tables", []):
headers = table.get("headers") or []
joined = " ".join(headers)
@@ -179,6 +192,14 @@ def parse_operating_tables(
if len(row) < 6:
continue
codes = expand_codes(_tokens(row[0]))
if not codes and carry_prefix:
carried = expand_codes(_tokens(row[0]), carry_prefix)
# ⚠ 이어받은 코드가 **전부 카탈로그에 있을 때만** 받는다 — 하나라도 없으면
# 앞자리를 잘못 물어 온 것이므로 종전대로 버린다.
if carried and known_codes and all(code in known_codes for code in carried):
codes = carried
if codes:
carry_prefix = codes[-1][:4]
names = _tokens(row[1])
specs = _tokens(row[2])
fuels = _tokens(row[3])
@@ -253,8 +274,9 @@ def load_operating_records(
pum = _read_json(*_CATALOG_SUBPATH, pum_file)["variables"]["pum"]
aliases = _read_json(*_CATALOG_SUBPATH, labor_file)["variables"].get("aliases", {})
parsed = parse_operating_tables(pum, aliases)
return enrich_with_catalog(parsed, load_machine_catalog(), aliases)
catalog = load_machine_catalog()
parsed = parse_operating_tables(pum, aliases, set(catalog.machines))
return enrich_with_catalog(parsed, catalog, aliases)
def write_operating_records(