Merge remote-tracking branch 'origin/sub_desktop_1' into main_desktop_1

This commit is contained in:
2026-09-08 04:12:58 +09:00
11 changed files with 10523 additions and 1323 deletions
@@ -23,7 +23,11 @@ from dataclasses import dataclass, field
from decimal import Decimal
from typing import Any
from B09_Estimation.B09_Estimation_Guards import check_excluded_rows_not_priced
from B09_Estimation.B09_Estimation_Guards import (
check_excluded_rows_not_priced,
check_free_haul_not_priced,
check_haul_volume_within_cut,
)
from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build
@@ -49,6 +53,9 @@ class HandoffWorkItem:
unit: str
quantity: Decimal
in_bill: bool
#: 운반 줄에만 있다 — 거리(m)와 수단. 무대(20 m 이내)는 `free_haul` 로 온다.
haul_distance_m: Decimal | None = None
haul_equipment: str | None = None
in_bill_reason: str = ""
origin: str = ""
ground_class: str = ""
@@ -181,6 +188,8 @@ def parse_handoff(payload: dict[str, Any]) -> tuple[list[HandoffWorkItem], list[
in_bill_reason=row.get("in_bill_reason") or "",
origin=row.get("origin") or "",
ground_class=row.get("ground_class") or "",
haul_distance_m=_decimal(row.get("haul_distance_m"), None),
haul_equipment=row.get("haul_equipment"),
# ⚠ 있으면 **적기만** 한다 — 곱하기는 B08 한 곳에서만(2026-09-08 이견 ①).
application_ratio_pct=_decimal(row.get("application_ratio_pct"), None),
)
@@ -305,8 +314,11 @@ def build_bill(
)
)
leaf = chain[-1]
item_no = emitted.get(leaf.code) or next_number(parent_no)
emitted[leaf.code] = item_no
# ⚠ **잎 줄은 번호를 재사용하지 않는다.** 같은 공종코드가 지반·규격만 달리해
# 두 번 올 수 있고(2026-09-08 실물: 도자운반 토사/리핑암 두 줄), 그때 번호를
# 물려주면 ITEM NO. 가 겹쳐 어느 줄인지 못 가린다. 머리글만 물려준다.
item_no = next_number(parent_no)
emitted.setdefault(leaf.code, item_no)
result.rows.append(_leaf_row(item_no, leaf, item, unit_prices, result))
# ── 2) 공종을 못 고른 줄 — 이름째 남긴다 ────────────────────────────────────
@@ -327,6 +339,31 @@ def build_bill(
# ── 4) 검사 — `in_bill=false` 줄에 금액이 붙지 않았는가 ──────────────────────
check_excluded_rows_not_priced(rows=[r.as_dict() for r in result.excluded])
# ㉡ **무대(20 m 이내)에 단가가 붙지 않았는가** (PLAN 8-7 ㉡).
# 줄 자체는 실무 서식대로 남기되 **금액을 매기지 않는다** — 품에 이미 들어 있다.
# 2026-09-08: B08 이 운반을 실물로 내기 시작해 이 검사가 처음으로 실제로 돈다.
haul_rows = [
{
"equipment": item.haul_equipment,
"unit_price_krw": _haul_price_of(item, result),
}
for item in work_items
if item.haul_equipment
]
check_free_haul_not_priced(haul_rows=haul_rows)
# ㉡ 보조 — 운반토량 합이 총 절취량을 넘지 않는가(같은 흙을 두 번 세지 않았는가).
cut_total = sum(
(item.quantity for item in work_items if "깎기" in item.name or "절취" in item.name),
_ZERO,
)
haul_total = sum((item.quantity for item in work_items if item.haul_equipment), _ZERO)
if cut_total > 0 and haul_total > 0:
check_haul_volume_within_cut(
haul_volume_total_m3=haul_total,
total_cut_volume_m3=cut_total,
)
if any(m.surcharge_pct is None for m in materials):
result.notes.append(
"자재 할증률이 아직 없습니다 — 할증 전 값으로 섰습니다. "
@@ -335,6 +372,14 @@ def build_bill(
return result
def _haul_price_of(item: HandoffWorkItem, result: BillResult) -> Decimal:
"""그 운반 줄에 실제로 붙은 단가. 안 붙었으면 0 — ㉡ 검사에 넘길 값이다."""
for row in result.rows:
if row.name == item.name and row.unit_price_krw is not None:
return row.unit_price_krw
return _ZERO
def _excluded_row(item: HandoffWorkItem) -> BillRow:
"""검산용 줄(`in_bill=false`). **수량만 보이고 단가·금액을 안 붙인다.**"""
return BillRow(
@@ -383,10 +428,16 @@ def _leaf_row(
if price_code not in unit_prices.book.titles:
# 한 층 아래에 일위대가가 있으면 **후보로 보여준다** — 임의로 고르지 않는다
# (CLAUDE.md 3장 「미결 항목 임의 확정 금지」, B08 `mapping_pending_user` 와 같은 태도).
# 한 층 아래 공종 + **규격 갈래**(`#무근구조물`) 둘 다 후보로 본다.
children = sorted(
code
for code in unit_prices.book.titles
if code.startswith(f"{price_code}-") and code.count("-") == price_code.count("-") + 1
if (
code.startswith(f"{price_code}-")
and code.count("-") == price_code.count("-") + 1
and "#" not in code
)
or code.startswith(f"{price_code}#")
)
if children:
names = ", ".join(f"{c[2:]} {unit_prices.book.title(c).name}" for c in children)
@@ -407,6 +458,22 @@ def _leaf_row(
)
return row
missing_basis = unit_prices.basis_missing.get(node.code)
if missing_basis:
# ⚠ 밑수를 모르는 표다 — 「10㎡당」인지 「1㎡당」인지 모른 채 곱하면 10배·100배
# 틀린다(떼채취가 실제로 100배였다). **곱하지 않고 드러낸다.**
row.note = f"밑수(기준 수량)를 못 찾은 표입니다 — 곱하지 않았습니다. 원문 {missing_basis}"
result.missing.append(
{
"name": row.name,
"code": node.code,
"unit": row.unit,
"quantity": str(item.quantity),
"reason": "밑수 미확보 — 곱하면 10배·100배 틀림",
}
)
return row
covered = unit_prices.partial_ratio.get(node.code)
if covered is not None:
# ⚠ **일부 몫만 선 단가는 안 붙인다.** 「인력(10%)·장비(90%)」 표에서 인력만
@@ -423,6 +490,21 @@ def _leaf_row(
)
return row
title = unit_prices.book.title(price_code)
if not title.unit:
# ⚠ 품셈 표가 기준 단위를 안 준 단가다 — 「10㎡당」 같은 묶음 기준일 수 있다.
# 값을 막지는 않되(막으면 대부분이 멈춘다) **모르는 채 곱했다는 사실을 적는다**.
# 비고를 **덮지 않고 잇는다** — 반영률 문구가 먼저 적혀 있을 수 있다.
row.note = " / ".join(
part
for part in (
row.note,
f"단가의 기준 단위가 표에 없습니다 — B08 수량 단위({row.unit})와 같다고 "
"보고 곱했습니다. 확인 필요.",
)
if part
)
unit_money = unit_prices.book.resolve(price_code)
line = unit_money.scaled(item.quantity)
row.unit_price_krw = round_at(unit_money.total, OutputPlace.UNIT_PRICE_ROW)
+240
View File
@@ -0,0 +1,240 @@
"""B09 원가계산 — **작업조 + 시공량** 표 읽기 (2026-09-08).
품셈에는 소요량을 직접 안 주고 **「작업조 몇 인이 하루 몇 ㎡」** 로 주는 표가 있다.
구 분 | 단 위 | 수 량 | 시 공 량 (㎡)
| | | 복잡 보통 간단
형틀목공 | 인 | 4 | 25 35 40
보통인부 | 인 | 1 |
1단위당 품 = 인원 ÷ 시공량 (유로폼 12-38-3: 형틀목공 4 ÷ 35 = 0.1143 인/㎡)
**유형은 우리가 안 고른다.** 품셈 12-38-3 [유형] 표가 「보통 : 측구, 수로, 옹벽 …」로
정해 두었으므로 **갈래(`#복잡`·`#보통`·`#간단`)로 세워 두고 고르는 것은 B08**에 맡긴다
(철근 12-3 [주]① 과 같은 모양).
⚠ **뭉쳐 온 칸을 짝지어 읽는다.** 이름·단위·인원이 한 칸에 붙어 온다
(`형틀목공 보통인부` | `인 인` | `4 1`). **개수가 안 맞으면 그 표를 통째로 버린다** —
자리를 밀어 읽으면 다른 직종의 품이 붙는다(오늘 여러 번 겪은 자리).
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from decimal import Decimal
from typing import Any
from B09_Estimation.B09_Estimation_ResourceAxis import (
AxisResult,
ResourceCatalog,
ResourceRow,
UnmatchedRow,
parse_amount,
split_name_and_spec,
)
_RE_NUMBER = re.compile(r"^\d+(?:,\d{3})*(?:\.\d+)?$")
#: 시공량 열을 알아보는 말. 「시 공 량 (㎡)」처럼 띄어쓰기가 섞여 온다.
_OUTPUT_WORDS = ("시공량", "기준시공량", "적용시공량")
@dataclass(frozen=True)
class CrewMember:
"""작업조 한 사람(또는 한 대)."""
code: str
name: str
spec: str
kind: str
count: Decimal
@dataclass(frozen=True)
class CrewTable:
"""작업조 표 한 장. `outputs` 는 유형별 시공량 — 갈래가 하나면 이름이 빈 문자열."""
members: tuple[CrewMember, ...]
outputs: tuple[tuple[str, Decimal], ...]
def amount_of(self, member: CrewMember, output: Decimal) -> Decimal:
"""1단위당 품 = 인원 ÷ 시공량."""
return member.count / output
def _normalize(text: str) -> str:
return " ".join(str(text).split())
def _is_output_header(cell: str) -> bool:
tight = "".join(str(cell).split())
return any(word in tight for word in _OUTPUT_WORDS)
def _numbers_in(cell: str) -> list[Decimal]:
"""한 칸에 뭉쳐 온 수들 — 「4 1」 → [4, 1]."""
found: list[Decimal] = []
for token in _normalize(cell).split(" "):
if _RE_NUMBER.match(token):
found.append(Decimal(token.replace(",", "")))
return found
def output_columns(table: dict[str, Any]) -> list[int]:
"""시공량 열의 번호. 없으면 빈 목록 — 작업조 표가 아니다."""
headers = table.get("condition_note") or []
return [index for index, cell in enumerate(headers) if _is_output_header(cell)]
def parse_crew_table(
table: dict[str, Any],
catalog: ResourceCatalog,
) -> CrewTable | str:
"""작업조 표를 읽는다. 못 읽으면 **사유 문자열**을 돌려준다(빈 표를 만들지 않는다)."""
if not output_columns(table):
return "시공량 열이 없습니다"
rows = [[str(c).strip() for c in row] for row in (table.get("raw_row") or [])]
if not rows:
return "빈 표입니다"
members: list[CrewMember] = []
outputs: list[Decimal] = []
labels: list[str] = []
for row in rows:
if not row or not row[0]:
continue
head = row[0]
# 첫 칸에 자원 이름이 뭉쳐 올 수 있다 — 「형틀목공 보통인부」.
# ⚠ 이름 안에 공백이 든 표가 있다 — 「조 경 공」·「비 계 공」. 공백을 구분자로만
# 보면 그런 줄이 통째로 안 풀린다. **한 이름으로 먼저 시도**하고, 안 되면 쪼갠다.
tight = "".join(_normalize(head).split(" "))
single = _resolve(catalog, tight)
if single is not None:
names, resolved = [tight], [single]
else:
names = [part for part in _normalize(head).split(" ") if part]
resolved = [_resolve(catalog, name) for name in names]
if not all(resolved):
# 자원이 아니면 유형 라벨 줄로 본다 — 「복 잡 | 보 통 | 간 단」.
if not _numbers_in(" ".join(row)):
if not members:
labels = [_normalize(cell) for cell in row if _normalize(cell)]
continue
# ⚠ **수가 있는데 이름을 못 푼 줄은 작업조의 한 몫**이다 — 조용히 건너뛰면
# 그 몫이 빠진 채 단가가 선다(2026-09-08: 평떼 시비에서 「트럭 2.5ton 1대」가
# 빠지고 노무만으로 28.6원/㎡ 이 섰다). 표를 통째로 버린다.
return f"작업조 줄 「{_normalize(head)[:20]}」을 못 풀었습니다"
counts = _counts_of(row, len(resolved))
if counts is None:
return f"인원 수가 이름 {len(resolved)} 개와 안 맞습니다"
for entry, count in zip(resolved, counts):
members.append(
CrewMember(
code=entry.code,
name=entry.name,
spec=entry.spec,
kind=entry.kind,
count=count,
)
)
# 시공량은 보통 **첫 작업조 줄**에 붙어 온다.
if not outputs:
outputs = _outputs_of(row, counts)
if not members:
return "작업조 줄을 못 찾았습니다"
if not outputs:
return "시공량 값을 못 찾았습니다"
if labels and len(labels) != len(outputs):
# 라벨과 값의 개수가 다르면 **어느 유형인지 단정할 수 없다** — 읽지 않는다.
return f"유형 {len(labels)} 개와 시공량 {len(outputs)} 개가 안 맞습니다"
named = tuple((labels[index] if labels else "", value) for index, value in enumerate(outputs))
return CrewTable(members=tuple(members), outputs=named)
def _resolve(catalog: ResourceCatalog, name_cell: str):
name, spec = split_name_and_spec(name_cell)
entry = catalog.resolve(name, spec)
if entry is not None:
return entry
if spec:
return None
found = catalog.by_name(name)
return found[0] if len(found) == 1 else None
def _counts_of(row: list[str], wanted: int) -> list[Decimal] | None:
"""이름 개수와 같은 만큼의 인원 수를 가진 칸을 찾는다. 없으면 `None`."""
for cell in row[1:]:
numbers = _numbers_in(cell)
if len(numbers) == wanted:
return numbers
return None
def _outputs_of(row: list[str], counts: list[Decimal]) -> list[Decimal]:
"""인원 칸 **뒤**에 오는 수들이 시공량이다."""
seen_counts = False
values: list[Decimal] = []
for cell in row[1:]:
numbers = _numbers_in(cell)
if not numbers:
continue
if not seen_counts and numbers == counts:
seen_counts = True
continue
if seen_counts:
values.extend(numbers)
return values
def match_crew_table(
node: dict[str, Any],
table: dict[str, Any],
catalog: ResourceCatalog,
result: AxisResult,
unit: str,
) -> bool:
"""작업조 표를 자원 축 줄로 바꾼다. 그런 표가 아니면 `False`."""
if not output_columns(table):
return False
work_item_code = node.get("work_item_code", "")
table_id = str(table.get("pum_table_id", ""))
form = str(table.get("pum_form", ""))
parsed = parse_crew_table(table, catalog)
if isinstance(parsed, str):
result.unmatched.append(
UnmatchedRow(
work_item_code=work_item_code,
pum_table_id=table_id,
cell=_normalize(" | ".join(str(c) for c in (table.get("condition_note") or []))),
reason=f"작업조 표를 못 읽었습니다 — {parsed}",
)
)
return True # 다른 길로 보내지 않는다 — 행-자원으로 읽으면 인원을 소요량으로 오해한다
for index, (label, output) in enumerate(parsed.outputs):
if output <= 0:
continue
for member in parsed.members:
result.rows.append(
ResourceRow(
work_item_code=work_item_code,
pum_table_id=table_id,
pum_form=form,
resource_kind=member.kind,
resource_code=member.code,
resource_name=member.name,
resource_spec=member.spec,
amount=parsed.amount_of(member, output),
amount_unit=unit,
raw_row_index=index,
variant=label,
)
)
return True
@@ -0,0 +1,300 @@
"""B09 원가계산 — 기계 시공능력 `Q` (품셈 8-1-4, PLAN 9-5 ③).
**왜 있는가** — 토공 주요 공종(흙깎기·측구터파기·성토)의 품셈 표는 **소요량표가 아니다.**
「인력 10 % + 장비 90 %」로 갈리고, 장비 몫은 자원 수량이 아니라 **공식의 계수**
(`K`·`f`·`E`·`Cm`)로 적혀 있다. 그래서 표를 베끼면 **인력 몫만 서고 장비 몫이 통째로
빠진다** — 2026-09-08 실측으로 측구터파기가 인력 10 % 몫(39,575.6원/㎥)만으로 서 있었다.
공식 (지식DB `05_원가정보/기계경비_산정.md` §4 — 품셈 8-1-4)
Q = n · q · K · f · E n = 3600 ÷ Cm (시간당 싸이클 수)
q 1싸이클 표준작업량 (버킷 용량 ㎥ — **기종 규격에서 온다**)
K 버킷계수 (표의 `K`·`k`)
f 체적환산계수 (표의 `f`)
E 작업효율 = 현장능력계수 × 실작업시간율 (표의 `E`)
Cm 1싸이클 소요시간(초) (표의 `㎝(sec)` — 원문 표기가 「㎝」이지 센티미터가 아니다)
수량 1단위당 기계 소요시간(hr) = 1 ÷ Q → × 시간당 사용료 = 그 공종의 기계경비
⚠ **㉣ 와 어긋나지 않는다** (PLAN 9-6). ㉣ 는 「작업효율 `E` 를 **시간당 사용료** 쪽에
넣지 말라」이고, 품셈이 `E` 를 넣으라는 자리가 **바로 여기(작업량 `Q`)** 다. 그러므로
`reject_efficiency_in_hourly_rate()` 는 이 모듈에서 **부르지 않는다** — 부르면 정상
계산이 멈추는 오탐이 된다. 진짜 위반은 **같은 `E` 를 `Q` 와 사용료에 둘 다 넣는 것**이라,
그쪽은 사용료 계산 자리(`B09_Estimation_MachineCost`)의 가드가 그대로 지킨다.
⚠ **모르는 값을 지어내지 않는다.** 표가 범위(「0.55∼0.45」)만 주고 확정값을 안 주면
계수를 못 세운 것으로 보고 `FactorGap` 으로 드러낸다 — 가운데값을 임의로 취하지 않는다
(CLAUDE.md 3장).
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from decimal import Decimal
from typing import Any
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
_ZERO = Decimal(0)
_SECONDS_PER_HOUR = Decimal(3600)
#: 표의 행 머리 — 대문자·소문자가 섞여 온다(`K` 와 `k` 가 같은 표 안에 있다).
_KEY_BUCKET = ("k",)
_KEY_VOLUME = ("f",)
_KEY_EFFICIENCY = ("e",)
_KEY_CYCLE = ("㎝(sec)", "cm(sec)", "cm", "")
#: 품셈 표의 기계 이름 → 기종 카탈로그 이름. **표기만 다르고 같은 기종**이다.
#: 「유압식백호우」는 카탈로그에 없어 그대로 두면 장비 몫이 통째로 빠진다.
#: ⚠ 넓게 잡지 않는다 — 이름 전체가 이 표의 열쇠와 같을 때만 바꾼다.
MACHINE_NAME_ALIASES = {
"유압식백호우": "굴착기",
"백호우": "굴착기",
"백호": "굴착기",
"유압식굴삭기": "굴착기",
"굴삭기": "굴착기",
}
#: 무한궤도·타이어 갈래. 카탈로그 이름이 「굴착기(무한궤도)」처럼 갈래를 품고 있다.
_TRACK_WORDS = ("무한궤도", "타이어", "습지")
_RE_PARENS = re.compile(r"[(]([^)]*)[)]")
_RE_NUMBER = re.compile(r"-?\d+(?:\.\d+)?")
_RE_FRACTION = re.compile(r"^(\d+(?:\.\d+)?)\s*/\s*(\d+(?:\.\d+)?)$")
#: 범위 표기 — 「0.550.45」·「0.20.8」. **확정값이 아니다.**
_RE_RANGE = re.compile(r"\d+(?:\.\d+)?\s*[~-]\s*\d+(?:\.\d+)?")
class ProductivityError(ValueError):
"""시공능력을 못 세운 경우. 0 이나 가운데값으로 때우지 않는다."""
def parse_measure(cell: str) -> Decimal | None:
"""계수 셀 하나를 수로 읽는다. **확정값이 아니면 `None`.**
읽는 모양 — 「0.77」 · 「1/1.30」(분수) · 「20(135°)」(괄호는 조건 설명이라 버린다).
안 읽는 모양 — 「0.55∼0.45」(범위) · 「육상과동일」(참조) · 빈 칸.
"""
text = str(cell).strip()
if not text:
return None
if _RE_RANGE.search(text):
return None # 범위는 확정값이 아니다 — 가운데를 임의로 취하지 않는다
fraction = _RE_FRACTION.match(text)
if fraction:
divisor = Decimal(fraction.group(2))
return None if divisor == 0 else Decimal(fraction.group(1)) / divisor
# 괄호 안은 조건 설명(각도 등)이므로 떼고 본다 — 「20(135°)」 → 20
outside = _RE_PARENS.sub("", text).strip()
found = _RE_NUMBER.search(outside)
return Decimal(found.group(0)) if found else None
@dataclass(frozen=True)
class CycleFactors:
"""한 표에서 뽑아낸 시공능력 계수 한 벌."""
work_item_code: str
pum_table_id: str
machine_code: str
machine_name: str
bucket_capacity_m3: Decimal # q
bucket_coefficient: Decimal # K
volume_factor: Decimal # f
efficiency: Decimal # E — **작업량 쪽에만 들어간다** (㉣)
cycle_seconds: Decimal # Cm
#: 인력 몫 배분율(%) — 「인력(10%)」이면 `10`. 없으면 `None`.
labor_ratio_pct: Decimal | None = None
machine_ratio_pct: Decimal | None = None
@property
def formula_text(self) -> str:
return (
f"Q = 3600 ÷ {self.cycle_seconds} × {self.bucket_capacity_m3} × "
f"{self.bucket_coefficient} × {self.volume_factor} × {self.efficiency}"
)
@dataclass(frozen=True)
class FactorGap:
"""계수를 못 세운 표. **빈칸으로 두지 않고 무엇이 없는지 적는다.**"""
work_item_code: str
pum_table_id: str
missing: tuple[str, ...]
note: str = ""
def hourly_output(factors: CycleFactors) -> Decimal:
"""시간당 작업량 `Q` (㎥/hr).
`Q = (3600 ÷ Cm) · q · K · f · E` — 품셈 8-1-4.
"""
if factors.cycle_seconds <= 0:
raise ProductivityError(
f"{factors.work_item_code}: 1싸이클 시간(Cm)이 {factors.cycle_seconds} 입니다."
)
cycles_per_hour = _SECONDS_PER_HOUR / factors.cycle_seconds
output = (
cycles_per_hour
* factors.bucket_capacity_m3
* factors.bucket_coefficient
* factors.volume_factor
* factors.efficiency
)
if output <= 0:
raise ProductivityError(f"{factors.work_item_code}: 시간당 작업량이 {output} 입니다.")
return output
def machine_hours_per_unit(factors: CycleFactors) -> Decimal:
"""수량 1단위당 기계 소요시간(hr). 여기에 시간당 사용료를 곱하면 기계경비가 된다."""
return Decimal(1) / hourly_output(factors)
def resolve_machine(cell: str) -> tuple[str, str] | None:
"""표의 기계 이름 셀을 기종 카탈로그 한 줄로 푼다.
「유압식백호우 (무한궤도,0.7㎥)」 → `0201-0070` 굴착기(무한궤도) 0.7.
**이름과 규격이 둘 다 맞을 때만** 고른다 — 규격이 안 맞으면 안 고른다.
"""
text = str(cell).strip()
if not text:
return None
inside = " ".join(_RE_PARENS.findall(text))
head = _RE_PARENS.sub("", text).strip()
name = MACHINE_NAME_ALIASES.get(head.replace(" ", ""), head)
capacity = parse_measure(_capacity_token(inside))
track = next((word for word in _TRACK_WORDS if word in inside), "")
if capacity is None:
return None
catalog = load_machine_catalog()
for code, machine in catalog.machines.items():
if name not in machine.name:
continue
if track and track not in machine.name:
continue
spec = parse_measure(machine.specification)
if spec is not None and spec == capacity:
return code, f"{machine.name} {machine.specification}"
return None
def _capacity_token(inside: str) -> str:
"""괄호 안에서 용량 토막만 뽑는다 — 「무한궤도,0.7㎥」 → 「0.7㎥」."""
for token in re.split(r"[,]", inside):
if any(unit in token for unit in ("", "m3", "M3", "루베")):
return token
return ""
def extract_cycle_factors(
work_item_code: str,
table: dict[str, Any],
) -> CycleFactors | FactorGap | None:
"""표 하나에서 계수를 뽑는다.
공식 계수가 하나도 없으면 `None`(이 표는 공식형이 아니다), 일부만 있으면
`FactorGap`, 다 있으면 `CycleFactors`.
"""
rows = table.get("raw_row") or []
values: dict[str, Decimal] = {}
machine: tuple[str, str] | None = None
bucket_from_machine_row: Decimal | None = None
ratios: dict[str, Decimal] = {}
saw_key = False
for row in rows:
cells = [str(c).strip() for c in row]
if not cells:
continue
head = cells[0].lower().replace(" ", "")
rest = cells[1:]
# 「장비(90%) | 유압식백호우 (무한궤도,0.7㎥) | k | 0.9」 모양
for index, cell in enumerate(cells):
found = resolve_machine(cell)
if found is not None and machine is None:
machine = found
bucket_from_machine_row = parse_measure(
_capacity_token(" ".join(_RE_PARENS.findall(cell)))
)
# 같은 줄 뒤쪽에 「k | 0.9」가 붙어 오는 표가 있다
tail = cells[index + 1 :]
for position, token in enumerate(tail):
if token.lower() in _KEY_BUCKET and position + 1 < len(tail):
parsed = parse_measure(tail[position + 1])
if parsed is not None:
values["K"] = parsed
break
ratio = _ratio_of(cells[0])
if ratio is not None:
label = "labor" if "인력" in cells[0] else "machine" if "장비" in cells[0] else ""
if label:
ratios[label] = ratio
if head in _KEY_BUCKET:
saw_key = True
values.setdefault("K", _first_measure(rest))
elif head in _KEY_VOLUME:
saw_key = True
values.setdefault("f", _first_measure(rest))
elif head in _KEY_EFFICIENCY:
saw_key = True
values.setdefault("E", _first_measure(rest))
elif head in _KEY_CYCLE:
saw_key = True
values.setdefault("Cm", _first_measure(rest))
if not saw_key and machine is None:
return None
missing = [key for key in ("K", "f", "E", "Cm") if values.get(key) is None]
capacity = bucket_from_machine_row
if machine is None:
missing.append("기계")
if capacity is None:
missing.append("q(버킷 용량)")
if missing:
return FactorGap(
work_item_code=work_item_code,
pum_table_id=str(table.get("pum_table_id", "")),
missing=tuple(missing),
note="표가 확정값 대신 범위·참조만 주었거나 기종을 못 골랐습니다.",
)
return CycleFactors(
work_item_code=work_item_code,
pum_table_id=str(table.get("pum_table_id", "")),
machine_code=machine[0],
machine_name=machine[1],
bucket_capacity_m3=capacity,
bucket_coefficient=values["K"],
volume_factor=values["f"],
efficiency=values["E"],
cycle_seconds=values["Cm"],
labor_ratio_pct=ratios.get("labor"),
machine_ratio_pct=ratios.get("machine"),
)
def _first_measure(cells: list[str]) -> Decimal | None:
"""그 행에서 **처음 읽히는 확정값**. 뒤 칸은 유도식·참조라 앞 칸이 우선이다."""
for cell in cells:
parsed = parse_measure(cell)
if parsed is not None:
return parsed
return None
_RE_RATIO = re.compile(r"[(]\s*(\d+(?:\.\d+)?)\s*%\s*[)]")
def _ratio_of(cell: str) -> Decimal | None:
found = _RE_RATIO.search(str(cell))
return Decimal(found.group(1)) if found else None
+161 -4
View File
@@ -333,6 +333,41 @@ def parse_amount(cell: str) -> Decimal | None:
return None
#: 「0.2 × 30%」 꼴 — 값과 배분율이 **한 칸에** 적힌 표기(기초잡석 12-25).
#: ⚠ 이 값을 읽었으면 **딱지의 배분율을 또 곱하면 안 된다** — 같은 30 % 가 두 번 곱해진다.
_RE_RATIO_EXPRESSION = re.compile(r"^(\d+(?:\.\d+)?)\s*[×xX*]\s*(\d+(?:\.\d+)?)\s*%$")
def parse_amount_expression(cell: str) -> Decimal | None:
"""「0.2 × 30%」를 0.06 으로 읽는다. 그 밖의 식은 **읽지 않는다**.
식을 넓게 읽으려 들면 「5인/km」처럼 **기준이 다른 값**까지 삼킨다. 여기서 보는 것은
「값 × 비율%」 한 모양뿐이다.
"""
found = _RE_RATIO_EXPRESSION.match(_normalize(cell))
if found is None:
return None
return Decimal(found.group(1)) * Decimal(found.group(2)) / Decimal(100)
#: 「1.04(1.17)」 — 괄호 밖이 기본, 괄호 안이 **조건 시공 시** 값.
#: 근거: 품셈 13-6-1 [주]② 「흡출방지재를 시공하는 경우는 ( )의 값을 적용한다」
#: (13-6-2·13-6-3·13-7-1·13-7-2 도 같은 [주]).
#: ⚠ **기본은 괄호 밖** — 방지재 시공 여부가 설계 조건에 아직 없다(사용자 확정 대기).
#: 괄호 값을 쓰려면 그 조건이 들어와야 하므로, 지금은 값과 함께 **대안값을 남겨** 둔다.
_RE_ALTERNATIVE = re.compile(r"^(\d+(?:\.\d+)?)\s*[(](\d+(?:\.\d+)?)[)]$")
def parse_amount_pair(cell: str) -> tuple[Decimal, Decimal | None] | None:
"""「1.04(1.17)」 → `(1.04, 1.17)`. 괄호가 없으면 `(값, None)`."""
text = _normalize(cell)
found = _RE_ALTERNATIVE.match(text)
if found:
return Decimal(found.group(1)), Decimal(found.group(2))
plain = parse_amount(text)
return None if plain is None else (plain, None)
def convert_amount(raw: Decimal, *, pum_form: str, basis_quantity: Decimal | None) -> Decimal:
"""표 형태에 맞춰 소요량으로 환산한다.
@@ -366,6 +401,12 @@ class ResourceRow:
amount: Decimal
amount_unit: str
raw_row_index: int
#: 조건 시공 시 쓰는 대안값 — 「1.04(1.17)」의 1.17 (품셈 13-6-1 [주]②).
#: **기본값은 `amount`(괄호 밖)** 이고 이 값은 화면에 「시공 시 다름」으로 보인다.
alternative_amount: Decimal | None = None
#: 규격 갈래 — 열이 자원인 표에서 행 이름(「무근구조물」). 없으면 빈 문자열.
#: **갈래마다 품이 다르므로 한 일위대가로 뭉치지 않는다.**
variant: str = ""
#: 분류 딱지가 달고 온 배분율 — 「인력(10%)」이면 `10`. 없으면 `None`.
#: ⚠ **이 값을 안 보면 단가가 조용히 틀린다** — 인력 몫 원단위를 전량에 곱하게 된다
#: (2026-09-08 실측: 측구터파기 39,575.6원/㎥ 이 인력 10 % 몫만이었다).
@@ -383,6 +424,10 @@ class ResourceRow:
"amount": str(self.amount),
"amount_unit": self.amount_unit,
"raw_row_index": self.raw_row_index,
"variant": self.variant,
"alternative_amount": (
None if self.alternative_amount is None else str(self.alternative_amount)
),
"group_ratio_pct": None if self.group_ratio_pct is None else str(self.group_ratio_pct),
}
@@ -410,6 +455,30 @@ class AxisResult:
rows: list[ResourceRow] = field(default_factory=list)
unmatched: list[UnmatchedRow] = field(default_factory=list)
skipped_forms: dict[str, int] = field(default_factory=dict)
#: 자원은 알아봤는데 **값을 못 읽은** 줄이 있는 공종 — 그 단가는 「일부만 선 것」이다.
#: 기초잡석 12-25 가 `소할(30%) | 할석공(인) | 0.2 × 30%` 를 못 읽어 부설다짐만으로
#: 107,145 원이 서고 있었다(2026-09-08). **부분 성공이 가장 위험하다.**
partial_items: dict[str, str] = field(default_factory=dict)
def _tidy_resource_name(cell: str) -> str:
"""이름 표기를 카탈로그 쪽으로 맞춘다 — **뜻을 바꾸지 않는 표기 차이만.**
① 이름 안 공백 제거 (「굴 삭 기 (무한궤도)」 → 「굴삭기(무한궤도)」)
② 같은 기종의 다른 이름 (「굴삭기」·「유압식백호우」 → 「굴착기」)
⚠ 규격은 안 건드린다 — 규격을 맞추려 들면 엉뚱한 기종이 붙는다.
"""
from B09_Estimation.B09_Estimation_MachineProductivity import MACHINE_NAME_ALIASES
text = str(cell)
head, sep, tail = text.partition("(")
tight = "".join(head.split())
for wrong, right in MACHINE_NAME_ALIASES.items():
if tight == wrong:
tight = right
break
return tight + sep + tail
def _resolve_cell(catalog: ResourceCatalog, name_cell: str, cells: list[str]):
@@ -419,6 +488,10 @@ def _resolve_cell(catalog: ResourceCatalog, name_cell: str, cells: list[str]):
② 기종 셀 (「굴착기(무한궤도, 0.7㎥)」 → 이름 + 규격)
③ 규격이 **옆 칸**에 있는 표 (「굴착기 (무한궤도)」 | 「0.7㎥」)
"""
# ⚠ **이름 안 공백·표기 차이를 먼저 없앤다.** 품셈은 같은 기종을 「굴 삭 기」·
# 「굴착기」·「유압식백호우」로 섞어 적는다(2026-09-08: 메쌓기 13-6-1 의
# 「굴 삭 기 (무한궤도)」가 안 붙어 그 공종 장비 몫이 통째로 빠졌다).
name_cell = _tidy_resource_name(name_cell)
machine_name, machine_spec = parse_machine_cell(name_cell)
plain_name, plain_spec = split_name_and_spec(name_cell)
@@ -447,6 +520,15 @@ def match_table(
result: AxisResult,
) -> None:
"""표 하나에 자원 축을 붙인다. 값이 안 서면 `unmatched` 로 보낸다."""
from B09_Estimation.B09_Estimation_CrewOutput import match_crew_table
# ⚠ **작업조 표는 형태 판정보다 먼저 가른다.** 「형틀목공 4인 / 시공량 35㎡」 표는
# 마스터에서 `reference` 로 찍혀 형태 필터에 먼저 걸려 버려지고 있었다(유로폼 12-38-3).
# 그 표는 모양이 스스로를 말한다 — 시공량 열 + 작업조 줄이 있으면 그것이다.
# 행-자원으로 읽으면 **인원 4를 소요량 4로** 오해해 35배 부푼다.
if match_crew_table(node, table, catalog, result, table.get("basis_unit") or ""):
return
form = table.get("pum_form", "")
if form in NON_WORK_ITEM_FORMS or form in UNUSABLE_FORMS or form not in USABLE_FORMS:
result.skipped_forms[form] = result.skipped_forms.get(form, 0) + 1
@@ -456,24 +538,98 @@ def match_table(
basis_quantity = None if basis is None else Decimal(str(basis))
unit = table.get("basis_unit") or ""
# ⚠ **자원이 열 머리에 오는 표가 따로 있다** (2026-09-08 발견, 39 표).
# 「구 분 | 콘크리트공(인) | 보통인부(인)」처럼 **열이 자원**이고 행은 규격 갈래
# (무근·철근·소형구조물)다. 행을 자원으로 읽는 길로 보내면 통째로 못 맞춘다 —
# 콘크리트 타설(12-1)이 그래서 하나도 안 서고 있었다.
from B09_Estimation.B09_Estimation_ResourceAxis_Transposed import match_transposed_table
if match_transposed_table(node, table, catalog, result, basis_quantity, unit):
return
# 「석공 보통인부 | 0.09 0.05 | …」처럼 **이름도 값도 뭉쳐 오고 열이 갈래**인 표
# (돌쌓기 13-4 계열). 행-자원으로는 첫 이름조차 안 풀린다.
from B09_Estimation.B09_Estimation_ResourceAxis_Transposed import match_packed_rows
if match_packed_rows(node, table, catalog, result, basis_quantity, unit):
return
for index, row in enumerate(table.get("raw_row", [])):
cells = [str(c) for c in row]
if not cells:
continue
# 첫 칸이 분류 딱지(「자재」·「장비」)면 **이름은 둘째 칸**이다.
#
# ⚠ 딱지 목록만으로는 모자란다 — 첫 칸이 **공정 이름**인 표가 따로 있다
# (기초잡석 12-25: `소할(30%) | 할석공(인) | 0.2 × 30%`). 목록에 없는 말이라
# 통째로 못 맞추고 있었다. 그래서 **딱지 목록에 없더라도 첫 칸이 자원으로 안 풀리고
# 둘째 칸이 풀리면** 이름을 둘째 칸에서 읽는다 — 판정을 낱말이 아니라
# **풀리는지**로 한다. 배분율 꼬리표(「(30%)」)는 어느 쪽이든 첫 칸에서 읽는다.
name_cell = cells[0]
value_cells = cells[1:]
group_ratio = None
if _group_label_of(name_cell) is not None and len(cells) > 1:
if len(cells) > 1 and (
_group_label_of(name_cell) is not None
or (
_resolve_cell(catalog, name_cell, cells) is None
and _resolve_cell(catalog, cells[1], cells[1:]) is not None
)
):
group_ratio = _group_ratio_of(name_cell)
name_cell = cells[1]
value_cells = cells[2:]
# ⚠ **공식 계수를 단 줄은 자원 줄이 아니다.** 「유압식백호우 … | k | 0.9」 처럼
# 같은 줄에 버킷계수가 붙어 오는데, 그 0.9 를 소요량으로 읽으면 **시간당 사용료가
# 0.9시간분** 붙어 이중이 된다(2026-09-08: 이름 표기를 맞추자 측구터파기에
# 「굴착기 0.81」 줄이 새로 생겨 발견). 그 줄은 시공능력 공식 쪽에서 쓴다.
if any(_normalize(c).lower() in ("k", "f", "e") for c in value_cells):
continue
# 숫자 셀이 없는 행은 자원 줄이 아니다(제목·설명 행) — 목록에 안 올린다.
amount_cell = next(
(parse_amount(c) for c in value_cells if parse_amount(c) is not None), None
)
alternative: Decimal | None = None
amount_cell = None
for cell in value_cells:
pair = parse_amount_pair(cell)
if pair is not None:
amount_cell, alternative = pair
break
if amount_cell is None:
# 「0.2 × 30%」 꼴은 값과 배분율이 한 칸에 있다 — 읽었으면 딱지 배분율은 버린다.
expression = next(
(
parse_amount_expression(c)
for c in value_cells
if parse_amount_expression(c) is not None
),
None,
)
if expression is not None:
amount_cell = expression
group_ratio = None # ⚠ 이미 값 안에 들어 있다 — 또 곱하면 두 번이다
if amount_cell is None:
# ⚠ **이름은 자원인데 값을 못 읽은 줄**은 다르다 — 그 공종 단가는 성분이
# 빠진 채 서게 된다. 조용히 넘기지 않고 「일부만 섬」으로 표시한다.
# ⚠ **숫자가 아예 없는 줄은 머리 줄**이다 — 자원 이름만 나열된 줄
# (「특별인부 | 벌목부 | 보통인부」). 그것까지 「못 읽은 값」으로 세면
# 정상 공종이 무더기로 막힌다(2026-09-08: 28건 중 대부분이 이 오탐이었다).
# 숫자가 **있는데** 못 읽은 줄만 성분 빠짐으로 본다.
has_digit = any(ch.isdigit() for cell in value_cells for ch in cell)
if (
has_digit
and _resolve_cell(catalog, name_cell, [name_cell, *value_cells]) is not None
):
result.partial_items[node.get("work_item_code", "")] = (
f"{name_cell} 줄의 값을 못 읽었습니다"
)
result.unmatched.append(
UnmatchedRow(
work_item_code=node.get("work_item_code", ""),
pum_table_id=str(table.get("pum_table_id", "")),
cell=" | ".join(cells[:3]),
reason="자원은 알아봤으나 값을 못 읽었습니다 — 단가가 일부만 섭니다.",
)
)
continue
# ⚠ **카탈로그 조회를 먼저 한다.** 이름 필터를 앞에 두면 필터가 넓을 때
@@ -517,6 +673,7 @@ def match_table(
amount_unit=unit,
raw_row_index=index,
group_ratio_pct=group_ratio,
alternative_amount=alternative,
)
)
@@ -0,0 +1,571 @@
"""B09 원가계산 — **열이 자원인 표** 읽기 (자원 축 보조, 2026-09-08).
품셈 표에는 자원이 **행**이 아니라 **열 머리**에 오는 모양이 따로 있다 (39 표).
구 분 | 콘크리트공(인) | 보통인부(인)
무근구조물 | 0.12 | 0.15
철근구조물 | 0.14 | 0.16
행은 **규격 갈래**(무근·철근·소형구조물)이고 갈래마다 품이 다르다. 이 모양을 행-자원
표로 읽으면 통째로 안 맞는다 — 콘크리트 타설(12-1)이 그래서 하나도 안 서고 있었다.
⚠ **자리 밀림을 고쳐 읽지 않는다.** 첫 칸이 병합된 표는 값이 한 칸씩 밀려 오는데
(목재틀흙막이가 「건축목공 8.760」 자리에 등급 글자를 두어 단가가 503만원으로 섰다),
밀린 행은 **버리고 `unmatched` 에 남긴다.** 어느 칸이 어느 자원인지 단정할 수 없다.
`B09_Estimation_ResourceAxis` 가 700줄 제한에 걸려 이 표 모양만 떼어 낸 파일이다.
"""
from __future__ import annotations
import re
from decimal import Decimal
from typing import Any
from B09_Estimation.B09_Estimation_ResourceAxis import (
AxisResult,
ResourceCatalog,
ResourceRow,
UnmatchedRow,
parse_amount,
split_name_and_spec,
)
#: 갈래 이름 자리에서 걸러 낼 말 — **합계 줄만**이다. 넓게 잡으면 등급이 지워진다.
_TOTAL_LABELS = ("", "합계", "소계", "총계", "구분")
def _normalize_label(text: str) -> str:
return "".join(str(text).split())
#: 「계」 열 — **가공 + 조립을 이미 더한 값**이다. 같이 읽으면 두 번 센다(㉤ 열 방향).
_SUM_GROUP_LABELS = ("", "합계", "소계", "총계")
def _sum_group_positions(headers: list, resource_count: int) -> set:
"""「계」 묶음이 차지하는 열 번호. 2단 머리에서 묶음 하나가 여러 열을 먹는다.
첫 줄이 `구조별 | 가공 | 조립 | 계` 이고 둘째 줄이 `철근공 | 보통인부` × 3 벌이면
묶음 하나가 **2열씩** 차지한다. 「계」 묶음의 열은 통째로 뺀다.
"""
groups = [str(h).strip() for h in headers[1:]]
if not groups or resource_count % len(groups) != 0:
return set()
per_group = resource_count // len(groups)
blocked = set()
for index, group in enumerate(groups):
if "".join(group.split()) in _SUM_GROUP_LABELS:
start = index * per_group
blocked.update(range(start, start + per_group))
return blocked
def second_row_columns(table: dict, catalog: ResourceCatalog):
"""**첫 자료 행이 진짜 열 머리**인 2단 표를 읽는다.
`condition_note` 가 공정(「가공·조립·계」)뿐이고 자원 이름이 그 아래 줄에 오는 표다
(철근 현장가공 및 조립 12-3). 자원을 못 찾으면 빈 목록을 돌려준다.
"""
rows = table.get("raw_row") or []
if not rows:
return [], 0
# ⚠ **첫 줄 머리가 되풀이되면 좌우 두 판짜리 표다** — 2단이라도 마찬가지다
# (뿌리돌림 05-2: `근원직경(㎝) | 수 량 | 근원직경(㎝) | 수 량`).
# 이걸 안 가르면 오른쪽 판의 **직경 100 이 「특별인부 100인」**으로 읽혀
# 단가가 2,436만원으로 선다(2026-09-08 실측). 공정 머리(가공·조립·계)는
# 되풀이가 없으므로 이 검사에 안 걸린다.
groups = ["".join(str(h).split()) for h in (table.get("condition_note") or [])[1:]]
if len(groups) != len(set(groups)):
return [], 0
header_row = [str(c).strip() for c in rows[0]]
found = []
for position, cell in enumerate(header_row):
if not cell:
continue
name, spec = split_name_and_spec(cell)
entry = catalog.resolve(name, spec)
if entry is None and not spec:
candidates = catalog.by_name(name)
entry = candidates[0] if len(candidates) == 1 else None
if entry is not None:
found.append((position, entry))
if len(found) < 2:
return [], 0
# ⚠ **머리 줄의 이름을 하나라도 못 풀면 자리를 맞출 수 없다.** 드론방제 08-6-2 는
# 「드론조종자·부조종자」가 카탈로그에 없어 넷 중 둘만 풀리는데, 그대로 두면
# 숫자 두 개짜리 행이 **엉뚱한 직종 둘**에 붙는다. 통째로 버린다.
named = [cell for cell in header_row if cell]
if len(found) != len(named):
return [], 0
# ⚠ 「계」 묶음은 뺀다 — 가공 + 조립을 이미 더한 값이라 같이 읽으면 두 번 센다.
# ⚠ **자리를 고정 보정으로 맞추지 않는다.** 라벨 칸이 하나인 표도 둘인 표도 있어
# (드론방제 08-6-2 는 `구 분 | 항 목` 둘) 「한 칸 밀림」으로 단정하면 값이 어긋난다
# — 실측에서 「특별인부 0.2352」 자리에 다른 직종 값이 붙었다.
# 대신 **자료 행의 숫자 칸을 순서대로** 맞추고, 개수가 다르면 그 행을 버린다.
blocked = _sum_group_positions(table.get("condition_note") or [], len(found))
return [(order, entry, order in blocked) for order, (_, entry) in enumerate(found)], 1
def transposed_columns(table: dict[str, Any], catalog: ResourceCatalog) -> list[tuple[int, Any]]:
"""열 머리에서 자원을 찾는다. `[(열 번호, 카탈로그 줄)]`.
첫 칸은 갈래 이름(「구 분」)이라 **1번 열부터** 본다. 카탈로그에 있는 이름만
자원으로 본다 — 필터로 거르지 않는다(넓은 필터가 정상 자원을 지운 전례).
"""
headers = table.get("condition_note") or []
found: list[tuple[int, Any]] = []
for position, header in enumerate(headers[1:], start=1):
name, spec = split_name_and_spec(str(header))
entry = catalog.resolve(name, spec)
if entry is None and not spec:
candidates = catalog.by_name(name)
entry = candidates[0] if len(candidates) == 1 else None
if entry is not None:
found.append((position, entry))
return found
def _match_two_row_table(
node: dict,
table: dict,
catalog: ResourceCatalog,
result: AxisResult,
unit: str,
ordinal: list,
skip_rows: int,
) -> bool:
"""2단 표 — **숫자 칸을 순서대로** 자원에 맞춘다.
개수가 다른 행은 **버린다.** 라벨 칸 수가 표마다 달라(하나 또는 둘) 자리를
단정할 수 없기 때문이다. 「계」 묶음에 든 자원은 맞춘 뒤 뺀다 —
가공 + 조립을 이미 더한 값이라 같이 세면 두 번이다.
"""
work_item_code = node.get("work_item_code", "")
table_id = str(table.get("pum_table_id", ""))
form = str(table.get("pum_form", ""))
rows = (table.get("raw_row") or [])[skip_rows:]
labels = [str(row[0]).strip() for row in rows if row]
repeated = {label for label in labels if label and labels.count(label) > 1}
matched = False
for index, row in enumerate(rows):
cells = [str(c).strip() for c in row]
if not cells:
continue
variant = cells[0]
if not variant or _normalize_label(variant) in _TOTAL_LABELS or variant in repeated:
continue
numbers = [parse_amount(c) for c in cells[1:]]
numbers = [value for value in numbers if value is not None]
if len(numbers) != len(ordinal):
result.unmatched.append(
UnmatchedRow(
work_item_code=work_item_code,
pum_table_id=table_id,
cell=variant,
reason=(
f"숫자 칸 {len(numbers)} 개가 자원 열 {len(ordinal)} 개와 안 맞아 "
"버렸습니다(자리 밀림 방지)."
),
)
)
continue
for (order, entry, blocked), amount in zip(ordinal, numbers):
if blocked:
continue # 「계」 묶음 — 이미 더한 값이다
result.rows.append(
ResourceRow(
work_item_code=work_item_code,
pum_table_id=table_id,
pum_form=form,
resource_kind=entry.kind,
resource_code=entry.code,
resource_name=entry.name,
resource_spec=entry.spec,
amount=amount,
amount_unit=unit,
raw_row_index=index + skip_rows,
variant=variant,
)
)
matched = True
return matched
#: 「석공 보통인부」처럼 **여러 자원이 한 칸에** 뭉쳐 오고, 값도 「0.09 0.05」로 뭉쳐 오며,
#: 열이 규격 갈래(35cm 이하 · 55cm 이하 · 75cm 이하)인 표. 돌쌓기 13-4 계열이 그 모양이다.
#:
#: ⚠ **개수가 하나라도 안 맞으면 표째 버린다** — 이름 2 개에 값 3 개면 어느 값이 누구
#: 것인지 단정할 수 없다. 오늘 여러 번 겪은 자리다.
_RE_PACKED_NUMBER = re.compile(r"\d+(?:\.\d+)?")
#: 「1.04(1.17)」의 괄호 값 — **조건 시공 시 대안값**이라 기본 수에 안 센다
#: (품셈 13-6-1 [주]②). 안 떼면 값이 하나 더 있는 것으로 보여 표가 통째로 버려진다.
_RE_ALTERNATIVE_TAIL = re.compile(r"(?<=\d)\s*[(]\s*\d+(?:\.\d+)?\s*[)]")
def _packed_numbers(cell: str) -> list:
text = _RE_ALTERNATIVE_TAIL.sub("", str(cell))
return [Decimal(t) for t in _RE_PACKED_NUMBER.findall(text)]
def _packed_names(cell: str) -> list[str]:
"""한 칸에 뭉친 이름들. 「굴착기+부착용 집게」는 **조합 기종**이라 통째로 둔다."""
text = " ".join(str(cell).split())
if "+" in text:
return [text]
return [part for part in text.split(" ") if part]
def _resolve_packed(catalog: ResourceCatalog, name: str, specs: list[str] | None = None) -> list:
"""이름 하나를 카탈로그 줄들로 푼다.
「굴착기+부착용 집게」처럼 **두 기종을 함께 쓰는 조합**은 둘 다 돌려준다 —
같은 시간을 둘이 함께 쓰므로 사용료도 둘 다 붙는다.
⚠ TODO(미결) 조합 표기의 해석은 잠정이다 — 사용자·메인 확인 대기(PLAN 9-6).
"""
parts = [part.strip() for part in str(name).split("+") if part.strip()]
found = []
for part in parts:
base, spec = split_name_and_spec(part)
entry = catalog.resolve(base, spec)
if entry is None and not spec:
candidates = catalog.by_name(base)
entry = candidates[0] if len(candidates) == 1 else None
if entry is None:
entry = _resolve_with_side_spec(catalog, base, specs or [])
if entry is None:
return []
found.append(entry)
return found
#: 갈래(무한궤도/타이어)를 안 적은 기종을 고를 때의 **잠정** 우선순위.
#: 품셈 13-4 는 「굴착기+부착용 집게 | 0.6㎥」로만 적는데 카탈로그는 갈래까지 나뉜다.
#: ⚠ TODO(미결) 임도 현장 표준이 무한궤도라 그쪽을 잠정 채택 — 사용자 확정 대기(PLAN 9-6).
_TRACK_PREFERENCE = ("무한궤도",)
_RE_SPEC_RANGE = re.compile(r"^(\d+(?:\.\d+)?)[~-](\d+(?:\.\d+)?)$")
def _spec_matches(catalog_spec: str, wanted: str) -> bool:
"""카탈로그 규격이 표의 규격을 담는가.
카탈로그가 **범위**로 적는 경우가 있다 — 「부착용 집게 0.6∼0.8」은 0.6㎥ 를 담는다.
범위 밖이면 안 고른다.
"""
if catalog_spec == wanted or wanted.startswith(catalog_spec):
return True
found = _RE_SPEC_RANGE.match(catalog_spec)
if not found:
return False
numbers = _RE_PACKED_NUMBER.findall(wanted)
if not numbers:
return False
value = Decimal(numbers[0])
return Decimal(found.group(1)) <= value <= Decimal(found.group(2))
def _resolve_with_side_spec(catalog: ResourceCatalog, name: str, specs: list[str]):
"""이름에 갈래가 없고 규격이 **옆 칸**에 있는 기종을 고른다.
이름이 카탈로그 이름의 앞머리이고 규격이 **정확히 같을 때만** 고른다 —
규격이 다르면 안 고른다(엉뚱한 기종이 붙으면 사용료가 통째로 틀린다).
"""
if not name or not specs:
return None
wanted = {"".join(str(s).split()) for s in specs if str(s).strip()}
hits = []
for entry in catalog.entries:
if not entry.name.startswith(name):
continue
spec = "".join(str(entry.spec).split())
if spec and any(_spec_matches(spec, w) for w in wanted):
hits.append(entry)
if not hits:
return None
if len(hits) == 1:
return hits[0]
for word in _TRACK_PREFERENCE:
preferred = [e for e in hits if word in e.name]
if len(preferred) == 1:
return preferred[0]
return None
def match_packed_rows(
node: dict,
table: dict,
catalog: ResourceCatalog,
result: AxisResult,
basis_quantity,
unit: str,
) -> bool:
"""뭉친 이름 + 갈래 열 표를 읽는다. 그런 표가 아니면 `False`."""
rows = [[str(c).strip() for c in row] for row in (table.get("raw_row") or [])]
if len(rows) < 2:
return False
labels = [cell for cell in rows[0] if cell]
# ⚠ 라벨에 수가 들어 있을 수 있다 — 「35cm 이하」. 수의 유무로 가르면 안 된다.
# 자원으로 **안 풀리는** 줄이면 갈래 라벨 줄로 본다.
if not labels:
return False
# ⚠ **첫 줄의 어느 칸이라도 자원이면 라벨 줄이 아니다.** 첫 칸만 보면 기초잡석
# 12-25 처럼 「소할(30%) | 할석공(인) | 0.2 × 30%」인 표를 라벨 줄로 오해해
# 표째 가로챈다(2026-09-08: 그 탓에 기초잡석이 다시 막혔다).
for cell in rows[0]:
if not cell:
continue
if any(_resolve_packed(catalog, name, []) for name in _packed_names(cell)):
return False
# ⚠ **뭉친 표에만 쓴다.** 이 길이 넓으면 행-자원 표까지 가로채 자원 축이 줄어든다
# (2026-09-08 실측: 304 → 244 줄로 떨어졌다). 이름이 둘 이상 뭉쳤거나 값이 한 칸에
# 둘 이상 뭉친 줄이 **하나라도** 있어야 이 표로 본다.
packed = False
for cells in rows[1:]:
if not cells or not cells[0]:
continue
if len(_packed_names(cells[0])) > 1:
packed = True
break
if any(len(_packed_numbers(cell)) > 1 for cell in cells[1:]):
packed = True
break
if not packed:
return False
work_item_code = node.get("work_item_code", "")
table_id = str(table.get("pum_table_id", ""))
form = str(table.get("pum_form", ""))
staged: list = []
for index, cells in enumerate(rows[1:], start=1):
if not cells or not cells[0]:
continue
names = _packed_names(cells[0])
# 규격은 **옆 칸**에 있을 수 있다 — 「굴착기+부착용 집게 | 0.6㎥ | 시간 | …」.
side_specs = [cell for cell in cells[1:3] if cell]
resolved = [_resolve_packed(catalog, name, side_specs) for name in names]
if not all(resolved):
if _packed_numbers(" ".join(cells[1:])):
# 수가 있는데 이름을 못 풀었다 — 그 몫이 빠진 채 서면 안 된다.
result.partial_items[work_item_code] = f"{cells[0][:20]} 줄을 못 풀었습니다"
result.unmatched.append(
UnmatchedRow(
work_item_code=work_item_code,
pum_table_id=table_id,
cell=cells[0],
reason="뭉친 자원 줄의 이름을 못 풀었습니다 — 단가가 일부만 섭니다.",
)
)
continue
groups = [_packed_numbers(cell) for cell in cells[1:]]
groups = [group for group in groups if group]
# 앞쪽에 규격·단위 칸이 낄 수 있다 — 「0.6㎥ | 시간 | 0.31 | 0.30 | 0.28」.
# 갈래 수만큼 **뒤에서** 잘라 쓴다.
if len(groups) > len(labels):
groups = groups[-len(labels) :]
if len(groups) != len(labels) or any(len(g) != len(names) for g in groups):
result.unmatched.append(
UnmatchedRow(
work_item_code=work_item_code,
pum_table_id=table_id,
cell=cells[0],
reason=(
f"값 묶음 {len(groups)} 개가 갈래 {len(labels)} 개와 안 맞습니다 "
"— 자리를 단정할 수 없어 버렸습니다."
),
)
)
return True
for label, group in zip(labels, groups):
for entries, amount in zip(resolved, group):
value = amount
if basis_quantity not in (None, 0, Decimal(1)):
value = value / basis_quantity
for entry in entries:
staged.append(
ResourceRow(
work_item_code=work_item_code,
pum_table_id=table_id,
pum_form=form,
resource_kind=entry.kind,
resource_code=entry.code,
resource_name=entry.name,
resource_spec=entry.spec,
amount=value,
amount_unit=unit,
raw_row_index=index,
variant=label,
)
)
if not staged:
return False
result.rows.extend(staged)
return True
def match_transposed_table(
node: dict[str, Any],
table: dict[str, Any],
catalog: ResourceCatalog,
result: AxisResult,
basis_quantity: Decimal | None,
unit: str,
) -> bool:
"""열이 자원인 표를 읽는다. 그런 표가 아니면 `False` 를 돌려 원래 길로 보낸다.
행마다 **규격 갈래 하나**가 되므로 `variant` 를 달아 둔다 — 「무근구조물」과
「철근구조물」은 품이 달라 **한 일위대가로 뭉치면 안 된다**.
"""
columns = transposed_columns(table, catalog)
skip_rows = 0
ordinal: list = []
if not columns:
# 자원 이름이 **둘째 줄**에 오는 2단 표일 수 있다.
ordinal, skip_rows = second_row_columns(table, catalog)
if ordinal:
return _match_two_row_table(node, table, catalog, result, unit, ordinal, skip_rows)
if not columns:
return False
# ⚠ **좌우로 두 판이 붙은 표는 통째로 버린다.** 열 머리가 되풀이되면
# (「거리 | 보통인부 | 거리 | 보통인부」) 오른쪽 판의 **거리값이 인원으로** 읽힌다
# — 2026-09-08 실측: 소운반이 「보통인부 60인」이 되어 단가가 1,553만원으로 섰다.
# 판 경계를 짐작해 읽지 않는다.
# ⚠ 2단 표에서는 **같은 직종이 공정마다 되풀이되는 것이 정상**이다
# (철근 12-3: 가공 철근공 + 조립 철근공 = 합쳐야 맞는 값). 되풀이 금지는
# **1단 표에만** 건다 — 거기서만 「두 판이 좌우로 붙은 표」를 뜻한다.
codes = [entry.code for _, entry in columns]
if skip_rows == 0 and len(codes) != len(set(codes)):
result.unmatched.append(
UnmatchedRow(
work_item_code=node.get("work_item_code", ""),
pum_table_id=str(table.get("pum_table_id", "")),
cell=" | ".join(str(c) for c in (table.get("condition_note") or [])),
reason="열 머리가 되풀이되는 두 판 짜리 표 — 자리를 단정할 수 없어 버렸습니다.",
)
)
return True
# ⚠ **갈래 이름이 되풀이되면 그 줄들을 버린다.** 첫 칸이 병합된 표에서 상위 등급이
# 떨어져 나가면 「상」이 두 번 나오고, 그대로 두면 서로 다른 등급의 품이 **합산**된다
# (2026-09-08 실측: 목재틀흙막이 「상」이 8.760 + 13.767 = 22.5 인이 되어 단가가
# 667만원으로 섰다). 어느 등급인지 단정할 수 없으므로 고쳐 읽지 않는다.
labels = [str(row[0]).strip() for row in (table.get("raw_row") or [])[skip_rows:] if row]
repeated = {label for label in labels if label and labels.count(label) > 1}
work_item_code = node.get("work_item_code", "")
table_id = str(table.get("pum_table_id", ""))
form = str(table.get("pum_form", ""))
matched_any = False
for index, row in enumerate(table.get("raw_row", [])):
if index < skip_rows:
continue # 그 줄은 자료가 아니라 **열 머리**다
cells = [str(c) for c in row]
if not cells:
continue
variant = cells[0].strip()
# ⚠ 첫 칸은 **갈래 이름**이지 자원 이름이 아니다 — 자원용 머리글 필터를 여기 쓰면
# 「중」·「상」 같은 정상 등급이 통째로 지워진다(2026-09-08 실측: 목재틀흙막이의
# 중·상 등급이 사라지고 상등구조만 남았다). 합계 줄만 걸러 낸다.
if not variant or _normalize_label(variant) in _TOTAL_LABELS:
continue
if variant in repeated:
result.unmatched.append(
UnmatchedRow(
work_item_code=work_item_code,
pum_table_id=table_id,
cell=variant,
reason="같은 갈래 이름이 두 번 나오는 표 — 등급을 단정할 수 없어 버렸습니다.",
)
)
continue
# ⚠ **자리 밀림 검사** — 자원 열 가운데 하나라도 수가 아니면 그 행은 밀린 것이다.
# 첫 칸이 병합된 표에서 값이 한 칸씩 밀려 들어온다(2026-09-08 실측: 목재틀흙막이가
# 「건축목공 8.760」 자리에 등급 글자를 두어 단가가 503만원으로 섰다).
# **밀린 행은 고쳐 읽지 않고 버린다** — 어느 칸이 어느 자원인지 단정할 수 없다.
# ⚠ **숫자 칸이 자원 열보다 많으면 차원이 하나 더 있는 표다.**
# 뭉기기 13-12-1 은 행이 공정, 숫자 칸이 토질 3갈래인데 열 머리엔 자원이 하나뿐이라
# 그대로 두면 **첫 토질 값만 조용히 취한다**(보통토사 0.16 만 서고 나머지가 사라짐).
numeric_count = sum(1 for cell in cells[1:] if parse_amount(cell) is not None)
if numeric_count > len(columns):
result.unmatched.append(
UnmatchedRow(
work_item_code=work_item_code,
pum_table_id=table_id,
cell=variant,
reason=(
f"숫자 칸 {numeric_count} 개가 자원 열 {len(columns)} 개보다 많습니다 — "
"갈래가 하나 더 있는 표라 자리를 단정할 수 없어 버렸습니다."
),
)
)
continue
readable = [
parse_amount(cells[position]) if position < len(cells) else None
for position, _ in columns
]
if any(value is None for value in readable) and any(
value is not None for value in readable
):
result.unmatched.append(
UnmatchedRow(
work_item_code=work_item_code,
pum_table_id=table_id,
cell=variant,
reason="자원 열의 값이 한 칸 밀린 행 — 자리를 단정할 수 없어 버렸습니다.",
)
)
continue
for position, entry in columns:
if position >= len(cells):
# 칸이 모자란 행 — **자리를 밀어 읽지 않는다**. 밀려 읽으면 다른 직종의
# 품이 붙는다(기계경비 표에서 실제로 겪은 사고).
result.unmatched.append(
UnmatchedRow(
work_item_code=work_item_code,
pum_table_id=table_id,
cell=f"{variant} / {entry.name}",
reason="칸 수가 열 머리와 안 맞아 버렸습니다(자리 밀림 방지).",
)
)
continue
amount = parse_amount(cells[position])
if amount is None:
continue
if basis_quantity not in (None, 0, Decimal(1)):
amount = amount / basis_quantity
result.rows.append(
ResourceRow(
work_item_code=work_item_code,
pum_table_id=table_id,
pum_form=form,
resource_kind=entry.kind,
resource_code=entry.code,
resource_name=entry.name,
resource_spec=entry.spec,
amount=amount,
amount_unit=unit,
raw_row_index=index,
variant=variant,
)
)
matched_any = True
return matched_any
+52
View File
@@ -10,6 +10,7 @@
from __future__ import annotations
import json
import logging
from dataclasses import replace as dataclass_replace
from decimal import Decimal
@@ -27,6 +28,8 @@ from B09_Estimation.B09_Estimation_Engine_Cost import (
proposed_profit_adjustment,
)
from B09_Estimation.B09_Estimation_PriceBook import PriceBookError
from B09_Estimation.B09_Estimation_BillOfQuantities import bill_summary, build_bill
from B09_Estimation.B09_Estimation_Guards import DoubleCountError
from B09_Estimation.B09_Estimation_Rates import RateLookupError
from B09_Estimation.B09_Estimation_Statutory import STATUTORY_ITEMS
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
@@ -246,3 +249,52 @@ async def confirm_estimation(project_id: UUID) -> JSONResponse:
content={"status": "error", "message": "원가계산 단계 확정에 실패했습니다."},
)
return JSONResponse(content={"status": "success", "project_id": str(project_id)})
@router.get("/{project_id}/estimation/bill")
async def get_bill(project_id: UUID) -> JSONResponse:
"""④ 예산내역서 한 장 — B08 인계를 그대로 받아 계층을 세워 돌려준다.
⚠ **수량을 다시 세지 않는다.** B08 인계가 정본이고 여기서는 단가를 붙여 금액만
만든다(CLAUDE.md 5장 「같은 계산을 두 벌로 짜지 않는다」).
⚠ 단가가 없거나 밑수를 모르는 줄은 **0 으로 안 때우고** `missing` 으로 드러낸다 —
화면이 그 목록을 그대로 보인다.
"""
from B08_Quantity.B08_Quantity_Router_Material import get_handoff
try:
response = await get_handoff(project_id)
payload = json.loads(bytes(response.body).decode("utf-8"))
except Exception:
logger.exception("B09 내역서 조회 실패(인계): project_id=%s", project_id)
return JSONResponse(
status_code=502,
content={"status": "error", "message": "B08 인계 자료를 받지 못했습니다."},
)
if "work_items" not in payload:
# B08 이 오류 응답을 준 경우 — 그 사유를 그대로 넘긴다(감추지 않는다).
return JSONResponse(status_code=502, content={"status": "error", **payload})
try:
result = build_bill(payload)
except DoubleCountError as error:
# 이중계상 감시에 걸린 경우 — 표를 그리지 않고 멈춘다.
logger.warning("B09 내역서 이중계상 감지: project_id=%s, %s", project_id, error)
return JSONResponse(status_code=409, content={"status": "error", "message": str(error)})
except Exception:
logger.exception("B09 내역서 조판 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "예산내역서를 세우지 못했습니다."},
)
return JSONResponse(
content={
"status": "success",
"rows": [row.as_dict() for row in result.rows],
"excluded": [row.as_dict() for row in result.excluded],
"materials": [row.as_dict() for row in result.material_rows],
"summary": bill_summary(result),
}
)
+170 -64
View File
@@ -14,18 +14,11 @@
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import {
createButton,
createInputField,
showToast,
} from "@ui/ui_template_elements";
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import {
goToWorkflowStage,
WORKFLOW_STEP_ROUTES,
} from "../A00_Common/b_workflow_nav";
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
@@ -234,10 +227,8 @@ function buildCostSheetTable(sheet: CostSheetDto): HTMLElement {
for (const line of sheet.lines) {
const tr = document.createElement("tr");
if (TOTAL_KEYS.has(line.key)) tr.classList.add("is-total");
if (line.note === L("B09_Estimation_Adopted"))
tr.classList.add("is-adopted");
if (line.note === L("B09_Estimation_NotAdopted"))
tr.classList.add("is-dropped");
if (line.note === L("B09_Estimation_Adopted")) tr.classList.add("is-adopted");
if (line.note === L("B09_Estimation_NotAdopted")) tr.classList.add("is-dropped");
const name = document.createElement("td");
name.className = "b09-left";
@@ -247,8 +238,7 @@ function buildCostSheetTable(sheet: CostSheetDto): HTMLElement {
amount.textContent = formatWon(line.amount_krw);
const rate = document.createElement("td");
rate.textContent =
line.rate_percent === null ? "" : `${line.rate_percent}%`;
rate.textContent = line.rate_percent === null ? "" : `${line.rate_percent}%`;
const basis = document.createElement("td");
basis.className = "b09-left";
@@ -394,13 +384,7 @@ function buildUnitPriceDetail(
unit.className = "b09-left";
unit.textContent = row.unit;
tr.append(name, spec, source, unit);
for (const value of [
row.quantity,
row.material,
row.labor,
row.expense,
row.total,
]) {
for (const value of [row.quantity, row.material, row.labor, row.expense, row.total]) {
const cell = document.createElement("td");
cell.textContent = formatWon(value);
tr.append(cell);
@@ -415,12 +399,7 @@ function buildUnitPriceDetail(
label.colSpan = 5;
label.textContent = L("B09_Estimation_Col_Total");
sum.append(label);
for (const value of [
detail.material,
detail.labor,
detail.expense,
detail.total,
]) {
for (const value of [detail.material, detail.labor, detail.expense, detail.total]) {
const cell = document.createElement("td");
cell.textContent = formatWon(value);
sum.append(cell);
@@ -548,12 +527,7 @@ function renderRateVersion(box: HTMLElement, sheet: CostSheetDto | null): void {
if (!sheet) return;
const rows: Array<[string, string]> = [
["적용일", sheet.rate_version.effective_date || "—"],
[
"지문",
sheet.rate_version.sha256
? `${sheet.rate_version.sha256.slice(0, 8)}`
: "—",
],
["지문", sheet.rate_version.sha256 ? `${sheet.rate_version.sha256.slice(0, 8)}` : "—"],
];
for (const [label, value] of rows) {
const row = document.createElement("div");
@@ -573,7 +547,7 @@ function renderRateVersion(box: HTMLElement, sheet: CostSheetDto | null): void {
const TAB_KEYS: Array<[string, keyof typeof ui_locales, boolean]> = [
["cost_sheet", "B09_Estimation_Tab_CostSheet", true],
["boq", "B09_Estimation_Tab_Boq", false],
["boq", "B09_Estimation_Tab_Boq", true],
["unit_price", "B09_Estimation_Tab_UnitPrice", true],
["price_basis", "B09_Estimation_Tab_PriceBasis", false],
["machine", "B09_Estimation_Tab_Machine", false],
@@ -582,10 +556,7 @@ const TAB_KEYS: Array<[string, keyof typeof ui_locales, boolean]> = [
["base_data", "B09_Estimation_Tab_BaseData", false],
];
function buildTabs(
active: string,
onSelect: (key: string) => void,
): HTMLElement {
function buildTabs(active: string, onSelect: (key: string) => void): HTMLElement {
const bar = document.createElement("div");
bar.className = "b09-tabs";
for (const [key, labelKey, enabled] of TAB_KEYS) {
@@ -623,8 +594,7 @@ function parseQuantities(text: string): Record<string, string> {
}
function toRequestBody(form: CostFormState): Record<string, unknown> {
const num = (value: string): string =>
value.trim() === "" ? "0" : value.trim();
const num = (value: string): string => (value.trim() === "" ? "0" : value.trim());
const body: Record<string, unknown> = {
direct_material_krw: num(form.direct_material_krw),
direct_labor_krw: num(form.direct_labor_krw),
@@ -642,10 +612,7 @@ function toRequestBody(form: CostFormState): Record<string, unknown> {
return body;
}
async function fetchCostSheet(
projectId: string,
form: CostFormState,
): Promise<CostSheetDto> {
async function fetchCostSheet(projectId: string, form: CostFormState): Promise<CostSheetDto> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/cost`,
{
@@ -655,43 +622,72 @@ async function fetchCostSheet(
body: JSON.stringify(toRequestBody(form)),
},
);
if (!response.ok)
throw new Error(`estimation cost failed: ${response.status}`);
if (!response.ok) throw new Error(`estimation cost failed: ${response.status}`);
return (await response.json()) as CostSheetDto;
}
async function fetchUnitPriceList(
projectId: string,
): Promise<UnitPriceListDto> {
async function fetchUnitPriceList(projectId: string): Promise<UnitPriceListDto> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/unit-prices`,
{ credentials: "include" },
);
if (!response.ok)
throw new Error(`unit price list failed: ${response.status}`);
if (!response.ok) throw new Error(`unit price list failed: ${response.status}`);
return (await response.json()) as UnitPriceListDto;
}
async function fetchUnitPriceDetail(
projectId: string,
code: string,
): Promise<UnitPriceDetailDto> {
async function fetchUnitPriceDetail(projectId: string, code: string): Promise<UnitPriceDetailDto> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/unit-prices/${encodeURIComponent(code)}`,
{ credentials: "include" },
);
if (!response.ok)
throw new Error(`unit price detail failed: ${response.status}`);
if (!response.ok) throw new Error(`unit price detail failed: ${response.status}`);
return (await response.json()) as UnitPriceDetailDto;
}
/** ④ 예산내역서 한 줄. 금액이 `null` 이면 **못 세운 것**이지 0 이 아니다. */
interface BillRowDto {
item_no: string;
level: number;
code: string | null;
name: string;
spec: string;
unit: string;
quantity: string | null;
unit_price_krw: string | null;
amount_krw: string | null;
is_group: boolean;
in_bill: boolean;
note: string;
}
interface BillDto {
rows: BillRowDto[];
excluded: BillRowDto[];
materials: BillRowDto[];
summary: {
rows: number;
detail_rows: number;
body_total_krw: string;
missing: Array<{ name: string; reason: string; unit?: string; quantity?: string }>;
notes: string[];
};
}
async function fetchBill(projectId: string): Promise<BillDto> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/bill`,
{ credentials: "include" },
);
if (!response.ok) throw new Error(`estimation bill failed: ${response.status}`);
return (await response.json()) as BillDto;
}
async function confirmEstimationStage(projectId: string): Promise<void> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/confirm`,
{ method: "POST", credentials: "include" },
);
if (!response.ok)
throw new Error(`estimation confirm failed: ${response.status}`);
if (!response.ok) throw new Error(`estimation confirm failed: ${response.status}`);
}
/* -----------------------------------------------------------------------------
@@ -707,6 +703,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
let unitPriceList: UnitPriceListDto | null = null;
let unitPriceDetail: UnitPriceDetailDto | null = null;
let selectedUnitPrice: string | null = null;
let bill: BillDto | null = null;
const main = document.createElement("div");
main.className = "b09-main";
@@ -762,12 +759,123 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
}
};
/** ④ 예산내역서 — B08 수량에 단가를 붙인 표. 못 세운 줄은 **그대로 보인다**. */
const drawBoqTab = (): void => {
if (!bill) {
if (!projectId) {
const empty = document.createElement("div");
empty.className = "b09-empty";
empty.textContent = L("B09_Estimation_Boq_Failed");
body.append(empty);
return;
}
const load = document.createElement("button");
load.type = "button";
load.className = "b09-btn";
load.textContent = L("B09_Estimation_Boq_Load");
load.addEventListener("click", () => {
void (async () => {
try {
bill = await fetchBill(projectId);
} catch {
bill = null;
window.alert(L("B09_Estimation_Boq_Failed"));
}
drawBody();
})();
});
body.append(load);
return;
}
const table = document.createElement("table");
table.className = "b09-sheet";
const head = document.createElement("thead");
head.innerHTML =
"<tr><th>No.</th><th>공종</th><th>규격</th><th>단위</th>" +
"<th>수량</th><th>단가</th><th>금액</th><th>비고</th></tr>";
const tbody = document.createElement("tbody");
for (const row of bill.rows) {
const tr = document.createElement("tr");
// 계층은 들여쓰기로 보인다 — 번호만으로는 깊이가 안 읽힌다.
const indent = " ".repeat(Math.max(0, (row.level - 1) * 2));
const cells = row.is_group
? [row.item_no, indent + row.name, "", "", "", "", "", ""]
: [
row.item_no,
indent + row.name,
row.spec,
row.unit,
row.quantity ?? "",
row.unit_price_krw ?? "",
row.amount_krw ?? "",
row.note,
];
for (const text of cells) {
const td = document.createElement("td");
td.textContent = text;
tr.append(td);
}
if (row.is_group) tr.style.fontWeight = "600";
tbody.append(tr);
}
table.append(head, tbody);
body.append(table);
const total = document.createElement("div");
total.className = "b09-hint";
total.textContent = `${L("B09_Estimation_Boq_Total")}: ${bill.summary.body_total_krw}`;
body.append(total);
// ⚠ 자재비가 빠진 채 선 합계임을 숨기지 않는다.
const shortfall = document.createElement("div");
shortfall.className = "b09-hint";
shortfall.textContent = L("B09_Estimation_Boq_NoMaterialPrice");
body.append(shortfall);
if (bill.excluded.length > 0) {
const note = document.createElement("div");
note.className = "b09-hint";
note.textContent =
`${L("B09_Estimation_Boq_Excluded")}: ` +
bill.excluded.map((row) => `${row.name} ${row.quantity ?? ""}${row.unit}`).join(", ");
body.append(note);
}
if (bill.summary.missing.length > 0) {
const note = document.createElement("div");
note.className = "b09-hint";
note.textContent = `${L("B09_Estimation_Boq_Missing")} (${bill.summary.missing.length})`;
body.append(note);
const list = document.createElement("ul");
for (const item of bill.summary.missing) {
const li = document.createElement("li");
li.textContent = `${item.name}${item.reason}`;
list.append(li);
}
body.append(list);
}
if (bill.materials.length > 0) {
const note = document.createElement("div");
note.className = "b09-hint";
note.textContent =
`${L("B09_Estimation_Boq_Materials")}: ` +
bill.materials.map((row) => `${row.name} ${row.quantity ?? ""}${row.unit}`).join(", ");
body.append(note);
}
};
const drawBody = (): void => {
body.replaceChildren();
if (activeTab === "unit_price") {
drawUnitPriceTab();
return;
}
if (activeTab === "boq") {
drawBoqTab();
return;
}
if (activeTab !== "cost_sheet") {
const empty = document.createElement("div");
empty.className = "b09-empty";
@@ -835,8 +943,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
sheet = await fetchCostSheet(projectId, form);
renderRateVersion(panel.rateVersionBox, sheet);
panel.hintBox.textContent =
sheet.suggested_profit_adjustment_krw &&
sheet.suggested_profit_adjustment_krw !== "0"
sheet.suggested_profit_adjustment_krw && sheet.suggested_profit_adjustment_krw !== "0"
? `${L("B09_Estimation_Suggest_Adjust")} ${formatWon(sheet.suggested_profit_adjustment_krw)}`
: "";
drawBody();
@@ -868,8 +975,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
mainContent: main,
routes: WORKFLOW_STEP_ROUTES,
onStepClick: (stepIndex) => {
if (projectId)
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
},
});
root.append(layout.root);
+158 -9
View File
@@ -24,6 +24,12 @@ from functools import lru_cache
from B09_Estimation.B09_Estimation_Guards import check_column_sums, check_surcharge_once
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
from B09_Estimation.B09_Estimation_MachineProductivity import (
CycleFactors,
FactorGap,
extract_cycle_factors,
machine_hours_per_unit,
)
from B09_Estimation.B09_Estimation_MachineOperating import (
load_fuel_price,
load_operating_records,
@@ -50,6 +56,11 @@ _ZERO = Decimal(0)
FUEL_CODE_PREFIX = "M-FUEL-"
#: 일위대가 총액이 이보다 작으면 **성분이 빠졌을 가능성**이 크다 — 값이 있어도 경고한다.
SUSPICIOUSLY_LOW_KRW = Decimal(100)
#: 기준 단위를 모르는 채 이 금액을 넘으면 **사람이 한 번 봐야 한다**.
#: 품셈 표가 「10㎡당」처럼 묶음 기준일 수 있어 값 자체는 맞고 기준만 모르는 경우가 많다
#: (2026-09-08: 목재틀흙막이 상등구조 = 건축목공 16.975인 → 503만원. 값은 품셈대로다).
#: **막지 않고 드러내기만 한다** — 막으면 120 중 117 이 멈춘다.
SUSPICIOUSLY_HIGH_KRW = Decimal(1_000_000)
def _slots(value: Decimal) -> list[Decimal | None]:
@@ -68,6 +79,15 @@ class UnitPriceBuild:
incomplete_machines: list[str] = field(default_factory=list)
#: 배분율 표인데 일부 몫만 붙은 공종 — 「단가가 일부만 섬」. 값은 붙은 몫(%).
partial_ratio: dict[str, Decimal] = field(default_factory=dict)
#: 시공능력 공식으로 장비 몫을 세운 공종 — 산출근거를 화면에 그대로 보인다.
cycle_factors: dict[str, CycleFactors] = field(default_factory=dict)
#: 공종 하나가 낳은 규격 갈래들 — 「무근구조물」·「철근구조물」·「소형구조물」.
variants: dict[str, list[str]] = field(default_factory=dict)
#: 밑수(「10㎡당」)를 못 찾은 표를 쓰는 공종 — **곱하면 안 되는 줄**이다.
#: 1 단위당으로 단정하면 곱셈이 10배·100배 틀린다(B08 `basis_missing` 목록).
basis_missing: dict[str, str] = field(default_factory=dict)
#: 계수를 못 세운 표 — **무엇이 없는지**를 들고 있는다.
factor_gaps: dict[str, FactorGap] = field(default_factory=dict)
def _add_labor_titles(book: PriceBook, wages: dict[str, Decimal]) -> None:
@@ -179,6 +199,31 @@ def _add_machine_layers(book: PriceBook, machine_codes: set[str]) -> list[str]:
return incomplete
@lru_cache(maxsize=1)
def load_basis_missing(
file_name: str = "basis_missing_2026-01-01.json",
) -> dict[str, str]:
"""B08 이 낸 **밑수 못 찾은 표** 목록 — `{표 번호: 절 이름}`.
10 같은 기준을 원문에서 찾은 표다. 1 단위당으로 단정하면 곱셈이
10·100 틀리므로(떼채취가 실제로 100배였다) 표를 쓰는 공종은
**금액을 만든다**.
"""
import json
import os
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
path = os.path.join(root, "resources", "data_work_item_master", file_name)
if not os.path.exists(path):
return {}
with open(path, encoding="utf-8") as handle:
payload = json.load(handle)
return {
str(item.get("pum_table_id")): str(item.get("section", ""))
for item in payload.get("items", [])
}
def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
"""자원 축을 일위대가(`B`)로 조립한다.
@@ -192,18 +237,25 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
names = {w["work_item_code"]: w.get("name", "") for w in master.get("work_items", [])}
build = UnitPriceBuild()
missing_basis = load_basis_missing()
wages = load_operator_wages()
_add_labor_titles(build.book, wages)
machine_codes = {r.resource_code for r in axis.rows if r.resource_kind == "machine"}
build.incomplete_machines = _add_machine_layers(build.book, machine_codes)
by_item: dict[str, list] = {}
# 규격 갈래(`variant`)가 있으면 **갈래마다 따로 세운다** — 무근·철근·소형구조물은
# 품이 달라 한 일위대가로 뭉치면 어느 것도 안 맞는다.
by_item: dict[tuple[str, str], list] = {}
for row in axis.rows:
by_item.setdefault(row.work_item_code, []).append(row)
by_item.setdefault((row.work_item_code, getattr(row, "variant", "")), []).append(row)
for work_item_code, rows in sorted(by_item.items()):
title_code = f"B-{work_item_code}"
for (work_item_code, variant), rows in sorted(by_item.items()):
# 갈래 키는 **내부 공백을 지운 것**, 화면 문구는 **원문 그대로**
# (2026-09-08 두 창 합의). 원문이 「보 통」·「보 통」으로 들쭉날쭉해
# 키에 공백을 남기면 한 칸 차이로 영영 안 맞는다. 공백 말고는 손대지 않는다.
variant_key = "".join(variant.split())
title_code = f"B-{work_item_code}" + (f"#{variant_key}" if variant_key else "")
if title_code in build.book.titles:
continue
# ⚠ **붙을 상세를 먼저 모으고, 하나도 없으면 제목도 안 세운다.**
@@ -218,29 +270,110 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
build.skipped.append(work_item_code)
continue
# ⚠ 밑수를 못 찾은 표를 쓰면 **곱하면 안 되는 줄**로 표시한다.
for row in rows:
section = missing_basis.get(str(row.pum_table_id))
if section:
build.basis_missing[work_item_code] = section
break
unit = next((r.amount_unit for r in rows if r.amount_unit), "")
base_name = names.get(work_item_code) or work_item_code
build.book.add_title(
PriceTitle(
code=title_code,
kind=PriceKind.UNIT_PRICE,
name=names.get(work_item_code) or work_item_code,
spec=work_item_code,
name=f"{base_name} ({variant})" if variant else base_name,
spec=variant or work_item_code,
unit=unit,
)
)
if variant_key:
build.variants.setdefault(work_item_code, []).append(variant)
# 배분율 표는 각 몫을 **그 비율만큼만** 센다 — 인력 원단위를 전량에 곱하면 틀린다.
for row, ref in attachable:
build.book.add_detail(PriceDetail(title_code, ref, row.amount))
share = _share_of(row)
build.book.add_detail(PriceDetail(title_code, ref, row.amount * share))
# 장비 몫은 자원 수량이 아니라 **시공능력 공식**으로 온다 (품셈 8-1-4).
machine_share = (
_ZERO if variant else _attach_machine_share(build, master, work_item_code, title_code)
)
# ⚠ **배분율이 있는 표는 「몇 %가 실제로 붙었나」를 세어 둔다.**
# 「인력(10%)·장비(90%)」 표에서 인력만 붙으면 단가가 10 % 몫만인데, 그 값이
# 조용히 서면 내역서가 틀린 줄 모른다(2026-09-08 실측: 측구터파기 39,575.6원/㎥
# 이 인력 10 % 몫만이었다). 0 으로 때우는 것과 같은 종류의 사고다.
# 값을 못 읽은 자원 줄이 있으면 **일부만 선 단가**다 — 금액을 만들지 않는다.
if work_item_code in axis.partial_items:
build.partial_ratio.setdefault(work_item_code, _ZERO)
covered = _covered_ratio_pct(rows, {ref for _, ref in attachable}, build)
if covered is not None and covered < Decimal(100):
build.partial_ratio[work_item_code] = covered
if covered is not None:
covered += machine_share
if covered < Decimal(100):
build.partial_ratio[work_item_code] = covered
return build
def _share_of(row) -> Decimal:
"""그 줄이 차지하는 몫(0~1). 배분율이 없으면 1 — 종전과 같다."""
ratio = getattr(row, "group_ratio_pct", None)
return Decimal(1) if ratio is None else Decimal(str(ratio)) / Decimal(100)
def _attach_machine_share(
build: UnitPriceBuild,
master: dict,
work_item_code: str,
title_code: str,
) -> Decimal:
"""시공능력 공식으로 **장비 몫**을 붙인다. 붙인 비율(%)을 돌려준다.
계수가 서면 **아무것도 붙이고 0 돌려준다** 그러면 공종은
`partial_ratio` 남아 내역서에서 금액이 붙는다(지어낸 값이 서는 것보다 낫다).
"""
node = next(
(w for w in master.get("work_items", []) if w.get("work_item_code") == work_item_code),
None,
)
if node is None:
return _ZERO
for table in node.get("tables", []):
factors = extract_cycle_factors(work_item_code, table)
if not isinstance(factors, CycleFactors):
if isinstance(factors, FactorGap):
build.factor_gaps[work_item_code] = factors
continue
hourly_code = f"X-{factors.machine_code}"
if hourly_code not in build.book.titles:
# 기계 층이 안 섰다 — 지어내지 않고 못 붙인 채로 둔다.
build.factor_gaps[work_item_code] = FactorGap(
work_item_code=work_item_code,
pum_table_id=factors.pum_table_id,
missing=("시간당 사용료",),
note=f"{factors.machine_name} 의 시간당 사용료가 아직 안 섰습니다.",
)
continue
share = (
Decimal(1)
if factors.machine_ratio_pct is None
else Decimal(str(factors.machine_ratio_pct)) / Decimal(100)
)
build.book.add_detail(
PriceDetail(
title_code,
hourly_code,
machine_hours_per_unit(factors) * share,
note=factors.formula_text,
)
)
build.cycle_factors[work_item_code] = factors
return share * Decimal(100)
return _ZERO
def _covered_ratio_pct(
rows: list, attached_refs: set[str], build: UnitPriceBuild
) -> Decimal | None:
@@ -378,11 +511,27 @@ def build_summary(build: UnitPriceBuild) -> dict:
and (money := build.book.resolve(code).total) < SUSPICIOUSLY_LOW_KRW
]
# 기준 단위를 모르는 채 큰 값 — 「10㎡당」 같은 묶음 기준일 수 있다.
high = [
{"code": code, "name": title.name, "total": _money_text(money)}
for code, title in build.book.titles.items()
if title.kind is PriceKind.UNIT_PRICE
and not title.unit
and (money := build.book.resolve(code).total) >= SUSPICIOUSLY_HIGH_KRW
]
return {
"titles": len(build.book.titles),
"unit_price_totals": stats,
# 값이 서기는 했는데 **크기가 이상한** 것 — 성분이 빠졌을 가능성이 크다.
"suspiciously_low": low,
# 기준 단위가 없는 채로 큰 값 — 값이 틀린 게 아니라 **기준을 모르는 것**이다.
"unknown_basis_high": high,
"unknown_basis": sum(
1
for code, title in build.book.titles.items()
if title.kind is PriceKind.UNIT_PRICE and not title.unit
),
"unit_prices": kinds.get(PriceKind.UNIT_PRICE.value, 0),
"machine_hourly": kinds.get(PriceKind.MACHINE_HOURLY.value, 0),
"skipped_work_items": len(build.skipped),
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+61 -168
View File
@@ -37,10 +37,7 @@ export const ui_locales_b2 = {
"B04에서 분석해 둔 배수유역을 불러와, 지금 배치된 배관을 기준으로 세부유역(관이 담당하는 구역)을 다시 나눕니다. 관이 부족한 구간은 자동으로 보충합니다. B04 분석 결과가 없으면 B04에서 먼저 실행해야 합니다.",
"Reloads the B04 drainage analysis and re-splits sub-basins around the current culverts, adding culverts where spacing requires. Run the analysis in B04 first if none exists.",
],
B05_Drainage_Btn_DeleteSelected: [
"선택한 관 삭제",
"Delete selected culvert",
],
B05_Drainage_Btn_DeleteSelected: ["선택한 관 삭제", "Delete selected culvert"],
B05_Drainage_Btn_DeleteSelected_Tip: [
"지도에서 고른 배관 한 개를 지웁니다. 관을 먼저 눌러 고른 뒤에 쓸 수 있습니다.",
"Removes the culvert selected on the map. Select a culvert marker first.",
@@ -72,34 +69,19 @@ export const ui_locales_b2 = {
"노선을 확정하면 배수유역도가 표시됩니다.",
"The drainage map appears once the route is confirmed.",
],
B05_Drainage_Status_Analyzing: [
"세부유역을 산정하는 중…",
"Computing sub-basins…",
],
B05_Drainage_Status_NoBasin: [
"산정된 배수유역이 없습니다.",
"No drainage basin was computed.",
],
B05_Drainage_Status_Analyzing: ["세부유역을 산정하는 중…", "Computing sub-basins…"],
B05_Drainage_Status_NoBasin: ["산정된 배수유역이 없습니다.", "No drainage basin was computed."],
B05_Drainage_Status_AnalyzeFailed: [
"세부유역 산정에 실패했습니다.",
"Failed to compute sub-basins.",
],
B05_Drainage_Status_LoadingBase: [
"배경도를 불러오는 중…",
"Loading the basemap…",
],
B05_Drainage_Status_LoadingSheets: [
"도엽 레이어를 불러오는 중…",
"Loading map sheet layers…",
],
B05_Drainage_Status_LoadingBase: ["배경도를 불러오는 중…", "Loading the basemap…"],
B05_Drainage_Status_LoadingSheets: ["도엽 레이어를 불러오는 중…", "Loading map sheet layers…"],
B05_Drainage_Status_NoSheets: [
"도엽 레이어가 없습니다. B04에서 임포트하세요.",
"No map sheet layer found. Import them in B04.",
],
B05_Drainage_Status_LoadFailed: [
"배경도를 불러오지 못했습니다.",
"Failed to load the basemap.",
],
B05_Drainage_Status_LoadFailed: ["배경도를 불러오지 못했습니다.", "Failed to load the basemap."],
B05_Drainage_Basin_Undecided: ["미정", "TBD"],
/* 관 최대 규격 초과 계류 유역 — 관이 아니라 세월교 대상. 유효직경은 앞머리가 적는다 */
B05_Drainage_Basin_Bridge: ["세월교 제안", "Ford bridge proposal"],
@@ -114,10 +96,7 @@ export const ui_locales_b2 = {
"Tc {tc}min · I {i}mm/hr · Qd {q}m³/s (100yr, ×2.0)",
],
/* {chainage}=측점 누가거리(m) */
B05_Drainage_Basin_Chainage: [
"측점 누가거리 {chainage}m",
"Station chainage {chainage}m",
],
B05_Drainage_Basin_Chainage: ["측점 누가거리 {chainage}m", "Station chainage {chainage}m"],
/* {d}=규격 스냅 관경(mm). 유효직경 이상인 가장 작은 레지스트리 선택지 */
B05_Drainage_Basin_RecPipe: ["Ø{d} 배관 제안", "Ø{d} pipe proposal"],
/* 유효직경 Ø1,500 초과 — 교본 BOX암거 전환 유량 조건 */
@@ -142,10 +121,7 @@ export const ui_locales_b2 = {
B05_Route_Field_Filter: ["지면 필터", "Ground filter"],
B05_Route_Field_Method: ["지표면 표현", "Surface method"],
B05_Route_Field_SurfaceId: ["지표면 모델 ID", "Surface model ID"],
B05_Route_Surface_Confirmed: [
"확정 모델 #{id} · {method}",
"Confirmed model #{id} · {method}",
],
B05_Route_Surface_Confirmed: ["확정 모델 #{id} · {method}", "Confirmed model #{id} · {method}"],
B05_Route_Surface_NotConfirmed: [
"WF1에서 지표면 모델을 확정하세요.",
"Confirm a surface model in WF1.",
@@ -180,45 +156,27 @@ export const ui_locales_b2 = {
],
B05_Route_Reset_Failed: ["초기화에 실패했습니다.", "Failed to reset."],
B05_Route_Result_Title: ["경로 탐색 결과", "Route Result"],
B05_Route_Result_Empty: [
"아직 계산된 경로가 없습니다.",
"No route computed yet.",
],
B05_Route_Result_Empty: ["아직 계산된 경로가 없습니다.", "No route computed yet."],
B05_Route_Result_Length: ["총 연장(m)", "Total length (m)"],
B05_Route_Result_MinSlope: ["최소 경사", "Min slope"],
B05_Route_Result_MaxSlope: ["최대 경사", "Max slope"],
B05_Route_Result_MeanSlope: ["평균 경사", "Mean slope"],
B05_Route_Result_Cost: ["비용 점수", "Cost score"],
B05_Route_Result_Path: ["경로 파일", "Route file"],
B05_Route_Error_Project: [
"먼저 프로젝트를 선택하세요.",
"Select a project first.",
],
B05_Route_Error_Project: ["먼저 프로젝트를 선택하세요.", "Select a project first."],
B05_Route_Error_Points: [
"시점과 종점 좌표를 모두 입력하세요.",
"Enter both begin and end coordinates.",
],
B05_Route_Error_Filter: [
"지면 필터 키를 입력하세요.",
"Enter a ground filter key.",
],
B05_Route_Error_Filter: ["지면 필터 키를 입력하세요.", "Enter a ground filter key."],
B05_Route_Solve_Success: ["경로 탐색을 완료했습니다.", "Route solved."],
B05_Route_Solve_Failed: ["경로 탐색에 실패했습니다.", "Route solve failed."],
B05_Route_Confirm_Success: ["경로를 확정했습니다.", "Route confirmed."],
B05_Route_Confirm_Failed: [
"경로 확정에 실패했습니다.",
"Route confirm failed.",
],
B05_Route_Group_SectionOptions: [
"시작 측점 및 샘플링 설정",
"Start Station & Sampling Settings",
],
B05_Route_Confirm_Failed: ["경로 확정에 실패했습니다.", "Route confirm failed."],
B05_Route_Group_SectionOptions: ["시작 측점 및 샘플링 설정", "Start Station & Sampling Settings"],
B05_Route_Field_StationInterval: ["측점 간격(m)", "Station interval (m)"],
B05_Route_Field_CrossHalfWidth: ["횡단 반폭(m)", "Cross half-width (m)"],
B05_Route_Field_CrossSample: [
"횡단 샘플 간격(m)",
"Cross sample interval (m)",
],
B05_Route_Field_CrossSample: ["횡단 샘플 간격(m)", "Cross sample interval (m)"],
B05_Route_Field_LongSample: ["종단 샘플 간격(m)", "Long sample interval (m)"],
B05_Route_Field_StationLines: ["측점 가로선", "Station cross lines"],
B05_Route_Field_StationLabels: ["측점 라벨", "Station labels"],
@@ -231,10 +189,7 @@ export const ui_locales_b2 = {
B06_Profile_Field_Method: ["지표면 표현", "Surface method"],
B06_Profile_Field_Crs: ["좌표계", "CRS"],
B06_Profile_Group_Display: ["표시 옵션", "Display Options"],
B06_Profile_Field_VerticalExaggeration: [
"높이 배율",
"Vertical exaggeration",
],
B06_Profile_Field_VerticalExaggeration: ["높이 배율", "Vertical exaggeration"],
B06_Profile_Field_Smooth: ["지표면 스무딩", "Smooth surface"],
B06_Profile_Smooth_On: ["사용", "On"],
B06_Profile_Smooth_Off: ["미사용", "Off"],
@@ -261,18 +216,9 @@ export const ui_locales_b2 = {
B06_Profile_Result_Length: ["종단 연장(m)", "Longitudinal length (m)"],
B06_Profile_Result_CrossCount: ["횡단 개수", "Cross-section count"],
B06_Profile_Result_Path: ["종단 파일", "Longitudinal file"],
B06_Profile_Error_Project: [
"먼저 프로젝트를 선택하세요.",
"Select a project first.",
],
B06_Profile_Confirm_Success: [
"종·횡단을 확정했습니다.",
"Sections confirmed.",
],
B06_Profile_Confirm_Failed: [
"종·횡단 확정에 실패했습니다.",
"Section confirm failed.",
],
B06_Profile_Error_Project: ["먼저 프로젝트를 선택하세요.", "Select a project first."],
B06_Profile_Confirm_Success: ["종·횡단을 확정했습니다.", "Sections confirmed."],
B06_Profile_Confirm_Failed: ["종·횡단 확정에 실패했습니다.", "Section confirm failed."],
B06_Profile_Detail_Failed: [
"종·횡단 도면 데이터를 불러오지 못했습니다.",
"Failed to load section drawing data.",
@@ -298,18 +244,9 @@ export const ui_locales_b2 = {
B06_Cross_Revet_Pipe: ["관 길이", "Pipe length"],
B06_Cross_Revet_Outward: ["바깥", "outward"],
B06_Cross_Revet_Inward: ["안쪽", "inward"],
B06_Cross_Revet_Left: [
"왼쪽으로 — 관 길이 1m 단위",
"Move left — 1m of pipe length",
],
B06_Cross_Revet_Right: [
"오른쪽으로 — 관 길이 1m 단위",
"Move right — 1m of pipe length",
],
B06_Cross_Revet_Reset: [
"기슭막이 자동 자리로 초기화",
"Reset revetment to solved position",
],
B06_Cross_Revet_Left: ["왼쪽으로 — 관 길이 1m 단위", "Move left — 1m of pipe length"],
B06_Cross_Revet_Right: ["오른쪽으로 — 관 길이 1m 단위", "Move right — 1m of pipe length"],
B06_Cross_Revet_Reset: ["기슭막이 자동 자리로 초기화", "Reset revetment to solved position"],
B06_Cross_Revet_Inlet: ["기슭막이(유입)", "Revetment (inlet)"],
B06_Cross_Revet_Outlet: ["기슭막이(유출)", "Revetment (outlet)"],
/* 배관과 무관한 독립 기슭막이(구조물 정본 D군) — 2026-08-28. */
@@ -330,10 +267,7 @@ export const ui_locales_b2 = {
"Cannot move further down the slope",
],
B06_Cross_Height_Label: ["높이", "Height"],
B06_Cross_Move_Label: [
"이동(좌우·사면 상하)",
"Move (lateral / along slope)",
],
B06_Cross_Move_Label: ["이동(좌우·사면 상하)", "Move (lateral / along slope)"],
B06_Cross_Lateral_Label: ["좌우", "Lateral"],
B06_Cross_Slope_Label: ["상하(사면)", "Along slope"],
B06_Cross_Height_Minus: ["높이 0.1m", "Height 0.1m"],
@@ -342,10 +276,7 @@ export const ui_locales_b2 = {
"{mat} 높이 한계 {limit}m — 더 올리려면 재질을 변경하세요",
"{mat} height limit {limit}m — change material to go higher",
],
B06_Cross_Height_Floor: [
"최소 높이라 더 낮출 수 없습니다",
"Already at the minimum height",
],
B06_Cross_Height_Floor: ["최소 높이라 더 낮출 수 없습니다", "Already at the minimum height"],
B06_Cross_Basin_Limit_Pipe: [
"여기까지입니다 — 더 옮기면 배관 길이가 달라집니다(I형은 관을 감싸는 구조)",
"Limit reached — moving further changes the pipe length (type I wraps the pipe)",
@@ -513,10 +444,7 @@ export const ui_locales_b2 = {
B06_Design_Area_Total: ["계", "Total"],
/* 단위는 값 칸마다 붙이지 않고 표 좌상단(행제목 × 열제목 교차) 칸에 한 번만 적는다. */
B06_Design_Area_Unit: ["㎡", "㎡"],
B06_Design_Area_Highlight: [
"누르면 해당 면적을 강조합니다",
"Click to highlight this area",
],
B06_Design_Area_Highlight: ["누르면 해당 면적을 강조합니다", "Click to highlight this area"],
B06_Design_Fill_Area: ["성토", "Fill"],
B06_Design_Unset: ["미지정", "Not set"],
B06_Design_DitchType_Legend: ["측구형식", "Ditch type"],
@@ -557,14 +485,8 @@ export const ui_locales_b2 = {
B06_Design_RockBoundary_Legend: ["암 경계", "Rock boundary"],
B06_Design_RockBoundary_Up: ["암 경계선 올림", "Raise rock boundary"],
B06_Design_RockBoundary_Down: ["암 경계선 내림", "Lower rock boundary"],
B06_Design_RockBoundary_Reset: [
"암 경계선 기본값 복원",
"Reset rock boundary",
],
B06_Design_Failed: [
"횡단 설계 계산에 실패했습니다.",
"Failed to compute cross-section design.",
],
B06_Design_RockBoundary_Reset: ["암 경계선 기본값 복원", "Reset rock boundary"],
B06_Design_Failed: ["횡단 설계 계산에 실패했습니다.", "Failed to compute cross-section design."],
B06_Profile_Confirm_NeedDesign: [
"지반유형이 지정되지 않은 측점이 있습니다.",
"Some stations have no ground type assigned.",
@@ -577,29 +499,17 @@ export const ui_locales_b2 = {
"표시 반폭만 바로 반영합니다(측점 설계 재계산 없음). 계산 반폭(20m)을 넘는 값만 재생성이 필요해 시간이 걸립니다.",
"Applies the display half-width only (no per-station redesign). Only values beyond the sampled 20 m need regeneration.",
],
B06_View_Apply_Success: [
"표시 반폭을 반영했습니다.",
"Display half-width applied.",
],
B06_View_Apply_Success: ["표시 반폭을 반영했습니다.", "Display half-width applied."],
/* --- B06 표준 횡단면 설정 패널 --- */
B06_Std_Title: ["표준 횡단면 설정", "Standard cross-section"],
B06_Std_Group_Soil: ["토사 구간", "Soil section"],
B06_Std_Group_Rock: [
"암 구간 (리핑/발파)",
"Rock section (ripping/blasting)",
],
B06_Std_Group_Rock: ["암 구간 (리핑/발파)", "Rock section (ripping/blasting)"],
B06_Std_Group_Paved: ["포장 구간", "Paved section"],
B06_Std_Detail_Title: ["표준횡단면 상세값", "Standard cross-section details"],
B06_Std_Section_Common: ["공통", "Common"],
B06_Std_Section_RockOnly: [
"암 구간 — 다른 값만",
"Rock section - differing values",
],
B06_Std_Section_PavedOnly: [
"포장 구간 — 다른 값만",
"Paved section - differing values",
],
B06_Std_Section_RockOnly: ["암 구간 — 다른 값만", "Rock section - differing values"],
B06_Std_Section_PavedOnly: ["포장 구간 — 다른 값만", "Paved section - differing values"],
B06_Std_Field_RoadWidth: ["노폭(m)", "Road width (m)"],
B06_Std_Field_ShoulderLeft: ["노견 좌(m)", "Shoulder L (m)"],
B06_Std_Field_ShoulderRight: ["노견 우(m)", "Shoulder R (m)"],
@@ -622,10 +532,7 @@ export const ui_locales_b2 = {
"패널 설정을 전체 측점에 반영했습니다.",
"Applied panel settings to all stations.",
],
B06_Std_Load_Title: [
"다른 프로젝트에서 불러오기",
"Load from another project",
],
B06_Std_Load_Title: ["다른 프로젝트에서 불러오기", "Load from another project"],
B06_Std_Load_Select: ["프로젝트 선택", "Select project"],
B06_Std_Load_Placeholder: ["— 프로젝트 선택 —", "— Select a project —"],
B06_Std_Load_Empty: [
@@ -635,10 +542,7 @@ export const ui_locales_b2 = {
B06_Std_Load_Loading: ["불러오는 중…", "Loading…"],
B06_Std_Load_Apply: ["현재 설정에 적용", "Apply to current settings"],
B06_Std_Load_Applied: ["적용되었습니다.", "Applied."],
B06_Std_Load_Failed: [
"설계값을 불러오지 못했습니다.",
"Failed to load design values.",
],
B06_Std_Load_Failed: ["설계값을 불러오지 못했습니다.", "Failed to load design values."],
B06_Std_Load_None: [
"선택한 프로젝트에 저장된 설계값이 없습니다.",
"The selected project has no saved design values.",
@@ -665,10 +569,7 @@ export const ui_locales_b2 = {
"Side panel will be configured after the upstream data spec is finalized.",
],
B07_Cad_Loading: ["도면을 불러오는 중...", "Loading drawing..."],
B07_Cad_Load_Failed: [
"도면을 불러오지 못했습니다.",
"Failed to load drawing.",
],
B07_Cad_Load_Failed: ["도면을 불러오지 못했습니다.", "Failed to load drawing."],
B07_Info_Ground_Title: ["지반정보", "Ground info"],
B07_Info_Plan_Title: ["계획정보", "Plan info"],
B07_Info_GroundType: ["지반유형", "Ground type"],
@@ -683,10 +584,7 @@ export const ui_locales_b2 = {
B07_Info_FillArea: ["성토 단면적", "Fill area"],
B07_Info_Provisional: ["잠정", "Provisional"],
B07_Info_Confirmed: ["확정", "Confirmed"],
B07_Info_NoDesign: [
"지반·계획 지정 데이터가 없습니다.",
"No ground/plan designation data.",
],
B07_Info_NoDesign: ["지반·계획 지정 데이터가 없습니다.", "No ground/plan designation data."],
B07_Info_Station: ["측점", "Station"],
/* ( ) ·
(2026-09-03 ). */
@@ -712,10 +610,7 @@ export const ui_locales_b2 = {
"Failed to confirm the quantity stage.",
],
B08_Quantity_Tab_Earthwork: ["토적표", "Earthwork Table"],
B08_Quantity_Grid_Loading: [
"토적표를 만드는 중입니다…",
"Building the earthwork table…",
],
B08_Quantity_Grid_Loading: ["토적표를 만드는 중입니다…", "Building the earthwork table…"],
B08_Quantity_Grid_Empty: [
"측점 단면적이 아직 없습니다. 횡단 설계를 먼저 마치세요.",
"No cross-section areas yet. Finish the cross-section design first.",
@@ -769,15 +664,31 @@ export const ui_locales_b2 = {
B08_Quantity_Unsaved: ["저장하지 않은 변경이 있습니다.", "You have unsaved changes."],
B08_Quantity_Side_Method: ["산출법", "Method"],
B08_Quantity_Side_Method_Value: ["평균단면적법", "Average end area"],
B08_Quantity_Side_Factors: [
"토량환산계수(다짐)",
"Conversion factors (compacted)",
],
B08_Quantity_Side_Factors: ["토량환산계수(다짐)", "Conversion factors (compacted)"],
/* --- B09_Estimation 원가계산 --- */
B09_Estimation_Title: ["원가계산", "Cost Estimate"],
B09_Estimation_Tab_CostSheet: ["공사원가계산서", "Cost Statement"],
B09_Estimation_Tab_Boq: ["설계내역서", "Bill of Quantities"],
B09_Estimation_Boq_Total: ["내역서 합계", "Bill total"],
B09_Estimation_Boq_Excluded: [
"검산용 줄 — 수량만 보이고 금액을 매기지 않습니다",
"Check rows — quantity only, never priced",
],
B09_Estimation_Boq_Missing: [
"금액을 못 세운 줄 — 0 으로 채우지 않고 그대로 보입니다",
"Rows without an amount — shown as-is, not zero-filled",
],
B09_Estimation_Boq_Materials: ["자재 (별도 벌)", "Materials (separate set)"],
B09_Estimation_Boq_NoMaterialPrice: [
"사급 자재 단가가 아직 없어 자재비가 빠져 있습니다 — 지금 합계는 모자란 값입니다.",
"Contractor-supplied material prices are missing, so material cost is absent — this total is short.",
],
B09_Estimation_Boq_Load: ["B08 수량 불러오기", "Load B08 quantities"],
B09_Estimation_Boq_Failed: [
"B08 인계 자료를 받지 못했습니다.",
"Could not load the B08 handoff.",
],
B09_Estimation_Tab_UnitPrice: ["일위대가", "Unit Price"],
B09_Estimation_Tab_PriceBasis: ["단가산출근거", "Price Basis"],
B09_Estimation_Tab_Machine: ["중기", "Equipment"],
@@ -810,10 +721,7 @@ export const ui_locales_b2 = {
"목표 도급공사비를 맞추려면 이윤을 이만큼 깎아야 합니다 — 적용하려면 조정액에 직접 넣으세요.",
"To hit the target contract amount, profit must be reduced by this much — enter it in Adjustment to apply.",
],
B09_Estimation_Calc_Failed: [
"원가계산에 실패했습니다.",
"Cost calculation failed.",
],
B09_Estimation_Calc_Failed: ["원가계산에 실패했습니다.", "Cost calculation failed."],
B09_Estimation_Confirm_Success: [
"원가계산 단계를 확정했습니다.",
"Cost estimate stage confirmed.",
@@ -825,10 +733,7 @@ export const ui_locales_b2 = {
B09_Estimation_Tab_Pending: ["준비 중", "Coming soon"],
B09_Estimation_UP_List: ["일위대가 목록표", "Unit Price Index"],
B09_Estimation_UP_Detail: ["일위대가표", "Unit Price Sheet"],
B09_Estimation_UP_Pick: [
"목록에서 항목을 고르세요.",
"Pick an item from the index.",
],
B09_Estimation_UP_Pick: ["목록에서 항목을 고르세요.", "Pick an item from the index."],
B09_Estimation_UP_Drill: ["펼쳐 보기", "Open"],
B09_Estimation_Col_Name: ["명칭", "Name"],
B09_Estimation_Col_Spec: ["규격", "Spec"],
@@ -840,10 +745,7 @@ export const ui_locales_b2 = {
B09_Estimation_Col_Expense: ["경비", "Expense"],
B09_Estimation_Col_Total: ["합계", "Total"],
B09_Estimation_UP_SumOk: ["합계 = 재료+노무+경비 일치", "Total = M+L+E ✓"],
B09_Estimation_UP_SumBad: [
"⚠ 합계가 재료+노무+경비와 다릅니다",
"⚠ Total ≠ M+L+E",
],
B09_Estimation_UP_SumBad: ["⚠ 합계가 재료+노무+경비와 다릅니다", "⚠ Total ≠ M+L+E"],
B09_Estimation_UP_RoundGap: [
"행별로 0.1원 미만을 버려 합계 끝자리가 다릅니다 (정상). 자르기 전 합계:",
"Rows are floored to 0.1 KRW, so the total's last digit differs (expected). Unrounded total:",
@@ -853,10 +755,7 @@ export const ui_locales_b2 = {
"공종별 수량 (한 줄에 「공종코드=수량」)",
'Quantities (one "code=qty" per line)',
],
B09_Estimation_Src_Manual: [
"수량 원천: 손입력(직접비 직접 입력)",
"Source: manual direct costs",
],
B09_Estimation_Src_Manual: ["수량 원천: 손입력(직접비 직접 입력)", "Source: manual direct costs"],
B09_Estimation_Src_Quantities: [
"수량 원천: 손입력 공종 수량 × 일위대가",
"Source: manual quantities × unit prices",
@@ -865,10 +764,7 @@ export const ui_locales_b2 = {
"수량은 있는데 단가가 없는 공종 — 총액에서 빠졌습니다:",
"Quantities without a unit price — excluded from the total:",
],
B09_Estimation_UP_Load_Failed: [
"일위대가를 못 불러왔습니다.",
"Failed to load unit prices.",
],
B09_Estimation_UP_Load_Failed: ["일위대가를 못 불러왔습니다.", "Failed to load unit prices."],
/* --- B10_Payment 결재 --- */
B10_Payment_Title: ["결재", "Payment"],
@@ -886,10 +782,7 @@ export const ui_locales_b2 = {
B10_Payment_Deposit_Title: ["계좌 입금 안내", "Bank Transfer Guide"],
B10_Payment_Deposit_Account: ["입금 계좌", "Deposit Account"],
B10_Payment_Deposit_Amount: ["입금 금액", "Deposit Amount"],
B10_Payment_Deposit_Pending: [
"견적 확정 후 표시",
"Shown after estimate confirmation",
],
B10_Payment_Deposit_Pending: ["견적 확정 후 표시", "Shown after estimate confirmation"],
B10_Payment_Deposit_Note: [
"입금 확인 후 설계문서와 DWG 다운로드가 허용됩니다.",
"Design documents and DWG downloads are enabled after the deposit is confirmed.",