Merge remote-tracking branch 'origin/sub_desktop_1' into main_desktop_1
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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.55∼0.45」·「0.2~0.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
|
||||
@@ -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
|
||||
@@ -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),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user