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

This commit is contained in:
2026-09-08 00:41:49 +09:00
11 changed files with 2906 additions and 99 deletions
@@ -0,0 +1,505 @@
"""B09 원가계산 — ④ 예산내역서 조판 (PLAN 9-5 「B08 출력을 붙이는 자리」).
**하는 일** — B08 인계 응답(공종 수량 + 자재)을 받아 **계층이 선 내역서 한 장**으로
접는다. 수량은 B08 것을 그대로 쓰고(다시 세지 않는다), 단가는 ③ 일위대가에서 가져오며,
금액은 이 자리에서 `수량 × 단가` 로 만든다.
**계층은 코드에 안 박는다** (PLAN 9-3 · STmate `BOQ11` 해부 결과). 공종 마스터가 이미
`parent_code` · `level` · `sort_order`(256 간격)를 들고 있으므로, 쓰인 공종의 **조상만
남긴 가지치기 나무**를 세우고 거기서 ITEM NO. 를 매긴다. 깊이를 코드 글자수로 세지 않는다 —
`FP-09-03-02` 가 3층이라는 보장이 없다.
**빈칸을 지어내지 않는다** — 단가가 없는 줄, 관급/사급이 안 갈린 자재는 금액을 0 으로
때우지 않고 `missing` 에 이름째 남긴다. 화면이 그것을 그대로 보인다.
⚠ **`in_bill=false` 줄에는 단가를 붙이지 않는다** (PLAN 8-7 ㉡ 와 같은 성격).
보정량계·무대 같은 검산용 줄이라 금액을 매기면 같은 것을 두 번 세게 된다. 주석으로
막지 않고 `check_excluded_rows_not_priced()` 가 수치로 멈춘다.
"""
from __future__ import annotations
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_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
_ZERO = Decimal(0)
#: 자재 공급 구분이 안 갈린 값. B08 이 실제로 이 값을 보낸다(2026-09-08 실물 확인).
#: **관급자재대에도, 도급 재료비에도 넣지 않는다** — 어느 쪽에 넣어도 총액이 틀린다.
SUPPLY_UNKNOWN = "unknown"
class BillError(ValueError):
"""내역서를 세울 수 없는 경우. 빈 표를 돌려주지 않고 멈춘다."""
@dataclass(frozen=True)
class HandoffWorkItem:
"""B08 인계 공종 한 줄. **수량은 B08 것이 정본이다** — 여기서 다시 세지 않는다."""
work_item_code: str | None
name: str
spec: str
unit: str
quantity: Decimal
in_bill: bool
in_bill_reason: str = ""
origin: str = ""
ground_class: str = ""
#: 반영률(%) — B08 이 이미 곱했으면 산출근거에만 적고 **여기서 또 곱하지 않는다**.
application_ratio_pct: Decimal | None = None
@property
def display_name(self) -> str:
return f"{self.name} {self.spec}".strip()
@dataclass(frozen=True)
class HandoffMaterial:
"""B08 인계 자재 한 줄. `work_item_code` 칸이 **아예 없는** 별도 벌이다(계약 확정)."""
material_name: str
spec: str
unit: str
net_amount: Decimal
total_amount: Decimal
supply_type: str
surcharge_pct: Decimal | None = None
surcharge_note: str = ""
install_by: str | None = None
source_structure: tuple[str, ...] = ()
@property
def display_name(self) -> str:
return f"{self.material_name} {self.spec}".strip()
@dataclass
class BillRow:
"""내역서 한 줄. 머리(그룹)줄은 `is_group=True` 이고 수량·단가가 없다."""
item_no: str
level: int
code: str | None
name: str
spec: str = ""
unit: str = ""
quantity: Decimal | None = None
unit_price_krw: Decimal | None = None
amount_krw: Decimal | None = None
#: 3분할 — ⑤ 로 넘길 때 **뭉치지 않고** 성분 그대로 간다(PLAN 8-9 규칙 2).
material_krw: Decimal = _ZERO
labor_krw: Decimal = _ZERO
expense_krw: Decimal = _ZERO
is_group: bool = False
in_bill: bool = True
note: str = ""
def as_dict(self) -> dict[str, Any]:
def money(value: Decimal | None) -> str | None:
return None if value is None else str(value)
return {
"item_no": self.item_no,
"level": self.level,
"code": self.code,
"name": self.name,
"spec": self.spec,
"unit": self.unit,
"quantity": money(self.quantity),
"unit_price_krw": money(self.unit_price_krw),
"amount_krw": money(self.amount_krw),
"material_krw": str(self.material_krw),
"labor_krw": str(self.labor_krw),
"expense_krw": str(self.expense_krw),
"is_group": self.is_group,
"in_bill": self.in_bill,
"note": self.note,
}
@dataclass
class BillResult:
"""④ 예산내역서 한 장."""
rows: list[BillRow] = field(default_factory=list)
#: 금액을 못 세운 줄 — **0 으로 안 때우고 이름째 남긴다**.
missing: list[dict[str, str]] = field(default_factory=list)
#: `in_bill=false` 라 금액을 안 매긴 줄(보정량계 등). 수량은 보이되 합계에 안 든다.
excluded: list[BillRow] = field(default_factory=list)
#: 자재 벌 — 공급 구분이 갈린 것만 금액이 선다.
material_rows: list[BillRow] = field(default_factory=list)
notes: list[str] = field(default_factory=list)
@property
def direct_material_krw(self) -> Decimal:
return sum((r.material_krw for r in self.rows if not r.is_group), _ZERO)
@property
def direct_labor_krw(self) -> Decimal:
return sum((r.labor_krw for r in self.rows if not r.is_group), _ZERO)
@property
def direct_expense_krw(self) -> Decimal:
return sum((r.expense_krw for r in self.rows if not r.is_group), _ZERO)
@property
def body_total_krw(self) -> Decimal:
"""내역서 **본체** 합계 — 줄마다 절사한 금액의 합.
⚠ 집계표(반올림) 합계와 원 단위로 어긋나는 것이 정상이다
(`B09_Estimation_Rounding.SUMMARY_MISMATCH_NOTE`).
"""
return sum((r.amount_krw or _ZERO for r in self.rows if not r.is_group), _ZERO)
def _decimal(value: Any, default: Decimal | None = _ZERO) -> Decimal | None:
if value is None or value == "":
return default
return Decimal(str(value))
def parse_handoff(payload: dict[str, Any]) -> tuple[list[HandoffWorkItem], list[HandoffMaterial]]:
"""인계 응답을 우리 자료형으로 옮긴다. **모르는 칸을 채우지 않는다.**"""
if "work_items" not in payload or "materials" not in payload:
raise BillError("인계 응답에 `work_items`·`materials` 두 벌이 다 있어야 합니다.")
work_items = [
HandoffWorkItem(
work_item_code=row.get("work_item_code"),
name=row.get("name", ""),
spec=row.get("spec") or "",
unit=row.get("unit") or "",
quantity=_decimal(row.get("quantity")) or _ZERO,
in_bill=bool(row.get("in_bill", True)),
in_bill_reason=row.get("in_bill_reason") or "",
origin=row.get("origin") or "",
ground_class=row.get("ground_class") or "",
# ⚠ 있으면 **적기만** 한다 — 곱하기는 B08 한 곳에서만(2026-09-08 이견 ①).
application_ratio_pct=_decimal(row.get("application_ratio_pct"), None),
)
for row in payload["work_items"]
]
materials = [
HandoffMaterial(
material_name=row.get("material_name", ""),
spec=row.get("spec") or "",
unit=row.get("unit") or "",
net_amount=_decimal(row.get("net_amount")) or _ZERO,
total_amount=_decimal(row.get("total_amount")) or _ZERO,
supply_type=row.get("supply_type") or SUPPLY_UNKNOWN,
surcharge_pct=_decimal(row.get("surcharge_pct"), None),
surcharge_note=row.get("surcharge_note") or "",
install_by=row.get("install_by"),
source_structure=tuple(row.get("source_structure") or ()),
)
for row in payload["materials"]
]
return work_items, materials
@dataclass(frozen=True)
class _MasterNode:
code: str
name: str
level: int
parent_code: str | None
sort_order: int
def _master_index(master: dict[str, Any]) -> dict[str, _MasterNode]:
return {
node["work_item_code"]: _MasterNode(
code=node["work_item_code"],
name=node.get("name", ""),
level=int(node.get("level", 1)),
parent_code=node.get("parent_code"),
sort_order=int(node.get("sort_order", 0)),
)
for node in master.get("work_items", [])
if node.get("work_item_code")
}
def _ancestor_chain(code: str, index: dict[str, _MasterNode]) -> list[_MasterNode]:
"""뿌리 → 자기 순서의 조상 사슬. **코드 글자수로 깊이를 세지 않는다.**"""
chain: list[_MasterNode] = []
seen: set[str] = set()
cursor: str | None = code
while cursor and cursor in index and cursor not in seen:
seen.add(cursor)
node = index[cursor]
chain.append(node)
cursor = node.parent_code
chain.reverse()
return chain
def _number_of(path: tuple[int, ...]) -> str:
"""ITEM NO. — 자리마다 1 부터. 「1」·「1-2」·「1-2-3」 모양."""
return "-".join(str(n) for n in path)
def build_bill(
payload: dict[str, Any],
*,
build: UnitPriceBuild | None = None,
master: dict[str, Any] | None = None,
) -> BillResult:
"""인계 응답 한 벌을 ④ 예산내역서 한 장으로 접는다."""
work_items, materials = parse_handoff(payload)
unit_prices = build or cached_build()
index = _master_index(master or load_work_item_master())
result = BillResult()
# ── 1) 쓰인 공종의 조상만 남긴 가지치기 나무 ────────────────────────────────
# 정렬은 마스터의 `sort_order`(256 간격)를 그대로 따른다 — 우리가 다시 매기지 않는다.
used: list[tuple[tuple[int, ...], HandoffWorkItem, list[_MasterNode]]] = []
orphans: list[HandoffWorkItem] = []
for item in work_items:
if not item.in_bill:
# ⚠ 코드 유무보다 **먼저** 가른다. 보정량계는 공종코드가 없어서가 아니라
# **검산용 줄이라서** 금액이 없는 것이다 — `missing` 으로 새면 「단가를 구해야 할
# 줄」로 잘못 읽힌다.
result.excluded.append(_excluded_row(item))
continue
if not item.work_item_code or item.work_item_code not in index:
orphans.append(item)
continue
used.append(((), item, _ancestor_chain(item.work_item_code, index)))
def sort_key(entry: tuple[tuple[int, ...], HandoffWorkItem, list[_MasterNode]]) -> tuple:
return tuple(node.sort_order for node in entry[2])
used.sort(key=sort_key)
emitted: dict[str, str] = {} # 코드 → ITEM NO.
counters: dict[str, int] = {} # 부모 ITEM NO. → 마지막 번호
def next_number(parent_no: str) -> str:
counters[parent_no] = counters.get(parent_no, 0) + 1
return f"{parent_no}-{counters[parent_no]}" if parent_no else str(counters[parent_no])
for _, item, chain in used:
parent_no = ""
# 조상 줄(머리글)을 먼저 세운다 — 이미 선 것은 다시 안 세운다.
for node in chain[:-1]:
if node.code in emitted:
parent_no = emitted[node.code]
continue
parent_no = next_number(parent_no)
emitted[node.code] = parent_no
result.rows.append(
BillRow(
item_no=parent_no,
level=node.level,
code=node.code,
name=node.name,
is_group=True,
)
)
leaf = chain[-1]
item_no = emitted.get(leaf.code) or next_number(parent_no)
emitted[leaf.code] = item_no
result.rows.append(_leaf_row(item_no, leaf, item, unit_prices, result))
# ── 2) 공종을 못 고른 줄 — 이름째 남긴다 ────────────────────────────────────
for item in orphans:
result.missing.append(
{
"name": item.display_name,
"unit": item.unit,
"quantity": str(item.quantity),
"reason": "공종을 못 골랐습니다 — B08 인계에 공종코드가 없습니다.",
}
)
# ── 3) 자재 벌 ────────────────────────────────────────────────────────────
for material in materials:
result.material_rows.append(_material_row(material, result))
# ── 4) 검사 — `in_bill=false` 줄에 금액이 붙지 않았는가 ──────────────────────
check_excluded_rows_not_priced(rows=[r.as_dict() for r in result.excluded])
if any(m.surcharge_pct is None for m in materials):
result.notes.append(
"자재 할증률이 아직 없습니다 — 할증 전 값으로 섰습니다. "
"할증은 자재총괄에서 한 번만 붙습니다 (PLAN 8-7 ㉠)."
)
return result
def _excluded_row(item: HandoffWorkItem) -> BillRow:
"""검산용 줄(`in_bill=false`). **수량만 보이고 단가·금액을 안 붙인다.**"""
return BillRow(
item_no="",
level=1,
code=item.work_item_code,
name=item.name,
spec=item.spec,
unit=item.unit,
quantity=item.quantity,
in_bill=False,
note=item.in_bill_reason or "합계 검산용 줄 — 금액을 매기지 않습니다.",
)
def _leaf_row(
item_no: str,
node: _MasterNode,
item: HandoffWorkItem,
unit_prices: UnitPriceBuild,
result: BillResult,
) -> BillRow:
"""세부 공종 한 줄. 단가가 없으면 **금액을 비우고** `missing` 에 남긴다."""
row = BillRow(
item_no=item_no,
level=node.level,
code=node.code,
name=item.name or node.name,
spec=item.spec,
unit=item.unit,
quantity=item.quantity,
in_bill=item.in_bill,
)
if item.application_ratio_pct is not None:
# ⚠ 곱하지 않는다 — B08 이 이미 곱한 값이다. 산출근거로만 적는다.
row.note = f"반영률 {item.application_ratio_pct}% 적용 후 수량"
if not item.in_bill:
# 검산용 줄 — 수량은 보이되 **단가를 안 붙인다**(PLAN 8-7 ㉡ 와 같은 성격).
row.quantity = item.quantity
row.note = item.in_bill_reason or "합계 검산용 줄 — 금액을 매기지 않습니다."
result.excluded.append(row)
return row
price_code = f"B-{node.code}"
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 children:
names = ", ".join(f"{c[2:]} {unit_prices.book.title(c).name}" for c in children)
row.note = f"이 공종엔 일위대가가 없고 한 층 아래에 있습니다 — 후보: {names}"
reason = f"일위대가가 하위 공종에 있음(후보 {len(children)}건)"
else:
row.note = "일위대가가 아직 없습니다 — 금액을 0 으로 때우지 않습니다."
reason = "일위대가 없음"
result.missing.append(
{
"name": row.name,
"code": node.code,
"unit": row.unit,
"quantity": str(item.quantity),
"reason": reason,
"candidates": ", ".join(children),
}
)
return row
covered = unit_prices.partial_ratio.get(node.code)
if covered is not None:
# ⚠ **일부 몫만 선 단가는 안 붙인다.** 「인력(10%)·장비(90%)」 표에서 인력만
# 붙은 값을 전량에 곱하면 내역서가 조용히 틀린다 — 0 으로 때우는 것과 같은 사고다.
row.note = f"단가가 일부만 섰습니다 — 붙은 몫 {covered}% (나머지는 시공능력 공식 몫)."
result.missing.append(
{
"name": row.name,
"code": node.code,
"unit": row.unit,
"quantity": str(item.quantity),
"reason": f"단가 일부만 섬(붙은 몫 {covered}%)",
}
)
return row
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)
# 내역서 **본체** 행은 절사다 — 집계표(반올림)와 어긋나는 것이 정상.
row.amount_krw = round_at(line.total, OutputPlace.BOQ_ROW)
# 3분할은 전정밀로 들고 간다 — ⑤ 밑수가 비목마다 갈리므로 여기서 자르면 안 된다.
row.material_krw = line.material
row.labor_krw = line.labor
row.expense_krw = line.expense
return row
def _material_row(material: HandoffMaterial, result: BillResult) -> BillRow:
"""자재 한 줄. 공급 구분이 안 갈렸으면 **어느 쪽에도 안 넣는다**."""
row = BillRow(
item_no="",
level=1,
code=None,
name=material.material_name,
spec=material.spec,
unit=material.unit,
quantity=material.total_amount,
note=material.surcharge_note,
)
if material.supply_type == SUPPLY_UNKNOWN:
row.note = "관급·사급이 안 갈렸습니다 — 관급자재대에도, 도급 재료비에도 넣지 않습니다."
result.missing.append(
{
"name": material.display_name,
"unit": material.unit,
"quantity": str(material.total_amount),
"reason": "공급 구분 미정(unknown)",
}
)
return row
# 사급 자재 단가는 아직 원천이 없다(미결 No.18) — 여기서도 지어내지 않는다.
row.note = row.note or "사급 자재 단가 미확보 — 6번 슬롯(적용 단가) 수동 입력 대기."
result.missing.append(
{
"name": material.display_name,
"unit": material.unit,
"quantity": str(material.total_amount),
"reason": "자재 단가 없음(미결 No.18)",
}
)
return row
def bill_summary(result: BillResult) -> dict[str, Any]:
"""화면에 낼 요약 — **무엇이 비었는지**를 함께 낸다."""
return {
"rows": len(result.rows),
"detail_rows": sum(1 for r in result.rows if not r.is_group),
"group_rows": sum(1 for r in result.rows if r.is_group),
"excluded_rows": len(result.excluded),
"material_rows": len(result.material_rows),
"missing": result.missing,
"body_total_krw": str(result.body_total_krw),
"direct_material_krw": str(result.direct_material_krw),
"direct_labor_krw": str(result.direct_labor_krw),
"direct_expense_krw": str(result.direct_expense_krw),
"notes": result.notes,
}
def cost_input_from_bill(result: BillResult, **cost_input_kwargs):
"""④ 내역서 합계를 ⑤ 원가계산서 입력으로 접어 넣는다.
**뭉치지 않는다** — 재료·노무·경비 성분이 그대로 간다(PLAN 8-9 규칙 2).
⑤ 표에 찍히는 자리이므로 **자원 집계표 규칙(반올림)** 으로 자른다.
"""
from B09_Estimation.B09_Estimation_Engine_Cost import CostInput
summary = OutputPlace.RESOURCE_SUMMARY
return CostInput(
direct_material_krw=round_at(result.direct_material_krw, summary),
direct_labor_krw=round_at(result.direct_labor_krw, summary),
direct_expense_krw=round_at(result.direct_expense_krw, summary),
**cost_input_kwargs,
)
+52
View File
@@ -153,3 +153,55 @@ def check_operator_hours_basis(
f"{daily_wage:,.0f} × {person_days} ÷ {hours_per_day}h = {expected:,.2f} 와 다릅니다 — "
"나눗수를 줄이면 작업효율을 사용료에 넣은 것이 됩니다 (PLAN 9-6 ㉣)."
)
def check_column_sums(
*,
rows: list[dict],
totals: dict[str, Decimal],
columns: tuple[str, ...] = ("material", "labor", "expense", "total"),
label: str = "본표",
) -> None:
"""㉤ **열 방향** 검사 — 표시된 합계가 상세 줄의 열별 합과 같은가.
`TC = NC + GC + JC` 는 **행 방향** 검사라 「같은 성분을 두 층에서 세는」 어긋남을
못 잡는다(행마다는 다 맞는데 열 합만 갈리는 모양). 그래서 방향을 하나 더 둔다.
예 — 기계 줄 안에 든 조종원 노무가 별도 노무 줄로도 서면 노무 열만 부풀고
행 검사는 전부 통과한다.
"""
for column in columns:
column_sum = sum((Decimal(str(row[column])) for row in rows), Decimal(0))
shown = Decimal(str(totals[column]))
if abs(column_sum - shown) > _TOLERANCE:
raise DoubleCountError(
f"{label}: `{column}` 열 합계가 어긋납니다 — 줄 합 {column_sum:,.2f} vs "
f"표시 {shown:,.2f}. 같은 성분을 두 층에서 셌을 수 있습니다 "
"(행 방향 `TC=NC+GC+JC` 검사로는 안 잡힘)."
)
def check_excluded_rows_not_priced(
*,
rows: list[dict],
amount_field: str = "amount_krw",
unit_price_field: str = "unit_price_krw",
label: str = "내역서",
) -> None:
"""㉡ 확장 — `in_bill=false` 줄(보정량계·무대 등)에 금액이 붙지 않았는가.
B08 은 검산용 줄도 **수량을 그대로 실어 보낸다**(계약 확정). 수량이 있으니
조판이 무심코 단가를 붙이면 같은 것을 두 번 세게 된다 — 합계 줄과 그 아래
상세 줄이 함께 더해지는 모양이라 **행 검사로는 안 잡힌다**.
"""
for row in rows:
for field_name in (amount_field, unit_price_field):
value = row.get(field_name)
if value in (None, ""):
continue
if Decimal(str(value)) != 0:
raise DoubleCountError(
f"{label}: 합계·검산용 줄(`in_bill=false`)에 {field_name} "
f"{Decimal(str(value)):,.0f} 이 붙었습니다 — 그 줄은 수량만 보이고 "
"금액을 매기지 않습니다 (PLAN 8-7 ㉡ 와 같은 성격)."
)
@@ -0,0 +1,157 @@
"""B09 원가계산 — 자재 카탈로그 (PLAN 9-3 · 9-4).
**관급과 사급을 처음부터 가른다.** 섞어 두면 나중에 못 가른다 — 관급은
**총원가 밖 별도 표기 + 조달수수료**라 계산 자리가 아예 다르다(PLAN 8-2 인계 6필드).
구분 이름은 두 창이 맞춘 것을 쓴다 (2026-09-07 확정):
- `supply_type` = `owner_supplied`(관급) / `contractor_supplied`(사급)
- `owner_supplied_install_by` = `contractor`(도급자설치) / `owner` / `None`
⚠ **모르면 `None` 으로 두고 「설치 주체 미지정」으로 드러낸다.** 안전관리비 대상액이
**관급 전액이 아니라 도급자설치분**을 쓰므로(PLAN 8-10), 잘못 찍으면 금액이 조용히
틀린다.
원천
- 관급 = `mat_price_public_2026-08-14.json` — 나라장터 **6,999건**.
`vat_basis: "부가가치세별도"` 라 **부가세 제외 단가**이고 원가에 그대로 쓴다.
⚠ 철근·레미콘·아스콘은 그 파일의 `excluded_named_groups` 로 **빠져 있다**.
- 사급 = **없다.** 유료 물가지 미결(No.18). **값을 지어내지 않고 공백으로 드러낸다.**
⚠ **자재 단가는 할증 전 값이다** (PLAN 8-7 ㉠). 할증은 자재총괄 한 곳에서만 붙인다.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Any
_CATALOG_SUBPATH = ("resources", "data_cost_input_value")
#: 두 창이 맞춘 구분 이름 — 값을 바꾸면 B08 자재총괄과 안 맞는다.
SUPPLY_OWNER = "owner_supplied"
SUPPLY_CONTRACTOR = "contractor_supplied"
INSTALL_BY_CONTRACTOR = "contractor"
INSTALL_BY_OWNER = "owner"
class MaterialCatalogError(LookupError):
"""자재 단가를 못 세운 경우. 0 으로 때우지 않는다."""
def _project_root() -> str:
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def _read_json(file_name: str) -> dict[str, Any]:
with open(os.path.join(_project_root(), *_CATALOG_SUBPATH, file_name), encoding="utf-8") as h:
return json.load(h)
@dataclass(frozen=True)
class MaterialItem:
"""자재 한 줄. **단가는 할증 전·부가세 제외 값**이다."""
item_code: str
name: str
specification: str
unit: str
price_krw: Decimal
supply_type: str
#: 관급일 때만 뜻이 있다. `None` = **설치 주체 미지정**(안전관리비 대상액에 못 넣음).
owner_supplied_install_by: str | None = None
vat_excluded: bool = True
notice_date: str = ""
@property
def display_name(self) -> str:
return f"{self.name} {self.specification}".strip()
@dataclass
class MaterialCatalog:
"""자재 목록. **이름만으로는 못 고른다** — 같은 품명에 규격이 여럿이다."""
items: dict[str, MaterialItem] = field(default_factory=dict)
#: 채우지 못한 것 — 사급 미결·제외 품목. **빈칸이 아니라 목록으로 든다.**
gaps: list[str] = field(default_factory=list)
def by_name(self, name: str) -> list[MaterialItem]:
return [m for m in self.items.values() if m.name == name]
def resolve(self, name: str, specification: str) -> MaterialItem | None:
"""품명 + 규격으로 한 줄을 고른다. 규격이 없으면 **고르지 않는다**.
6,999건 중 같은 품명이 수십 개인 것이 흔하다 — 이름만 맞추면 엉뚱한 규격의
단가가 조용히 붙는다.
"""
found = self.by_name(name)
if not found:
return None
if len(found) == 1 and not specification:
return found[0]
narrowed = [m for m in found if m.specification == specification]
return narrowed[0] if len(narrowed) == 1 else None
def get(self, item_code: str) -> MaterialItem:
try:
return self.items[item_code]
except KeyError as exc:
raise MaterialCatalogError(f"자재 카탈로그에 없는 코드입니다: {item_code}") from exc
def count_by_supply(self) -> dict[str, int]:
counts: dict[str, int] = {}
for item in self.items.values():
counts[item.supply_type] = counts.get(item.supply_type, 0) + 1
return counts
def load_material_catalog(
public_file: str = "mat_price_public_2026-08-14.json",
) -> MaterialCatalog:
"""관급 자재를 읽고, 사급은 **없다는 사실을 목록으로** 남긴다."""
payload = _read_json(public_file)
catalog = MaterialCatalog()
for row in payload["variables"]["mat_price"]["records"]:
code = str(row["item_code"])
catalog.items[code] = MaterialItem(
item_code=code,
name=row.get("classification_name", ""),
specification=row.get("specification", ""),
unit=row.get("unit", ""),
price_krw=Decimal(str(row.get("price_krw", 0))),
supply_type=SUPPLY_OWNER,
# ⚠ 나라장터 자료에 설치 주체가 없다 — 지어내지 않고 미지정으로 둔다.
owner_supplied_install_by=None,
vat_excluded=row.get("vat_basis", "") == "부가가치세별도",
notice_date=str(row.get("notice_datetime", ""))[:10],
)
# 사급 — 원천이 아직 없다. **값을 지어내지 않는다.**
catalog.gaps.append(
"사급 자재 단가 없음 — 유료 물가지 미결(No.18). 6번 슬롯(적용 단가) 수동 입력으로 채웁니다."
)
for group in payload.get("excluded_named_groups", []):
catalog.gaps.append(f"관급 제외 품목: {group.get('group', '')}{group.get('reason', '')}")
return catalog
def catalog_summary(catalog: MaterialCatalog) -> dict[str, Any]:
"""화면에 낼 요약 — **무엇이 없는지**를 함께 낸다."""
unspecified = [
m
for m in catalog.items.values()
if m.supply_type == SUPPLY_OWNER and m.owner_supplied_install_by is None
]
return {
"items": len(catalog.items),
"by_supply": catalog.count_by_supply(),
"owner_supplied_install_unspecified": len(unspecified),
"gaps": list(catalog.gaps),
"notes": [
"자재 단가는 할증 전·부가세 제외 값입니다 — 할증은 자재총괄에서 한 번만 붙습니다.",
"관급 자재의 설치 주체가 미지정이라 안전관리비 대상액에 자동으로 넣지 않습니다.",
],
}
+132 -10
View File
@@ -20,6 +20,7 @@
from __future__ import annotations
import hashlib
import json
import os
import re
@@ -41,8 +42,19 @@ _CATALOG_SUBPATH = ("resources", "data_cost_input_value")
_RE_SPEC = re.compile(r"[(]([^)]+)[)]|(\d+(?:\.\d+)?\s*(?:톤|ton|㎥|m3|㎡|㎜|mm|HP|kW))")
_RE_NUMBER = re.compile(r"^-?\d+(?:,\d{3})*(?:\.\d+)?$")
#: 표 머리글·소계 행의 첫 칸에 흔히 오는 말. 자원이 아니므로 `unmatched` 로도 안 올린다.
#: 첫 칸이 **분류 딱지**이고 이름이 둘째 칸에 오는 표가 있다.
#: 예 — `['자재', '종 자', '', 'kg', '0.025']` · `['장비', '종자살포기', …]`.
#: 이 표를 첫 칸만 보고 읽으면 **자재·장비가 통째로 빠진다**(2026-09-07 실측 —
#: 씨앗뿜어붙이기에서 종자·비료·피복제·침식안정제·색소·장비 3종이 다 빠지고
#: 보통인부 한 줄만 남았다).
_GROUP_LABELS = ("자재", "장비", "인력", "노무", "재료", "기계")
#: 표 머리글·소계 행의 첫 칸에 오는 말. 자원이 아니므로 `unmatched` 로도 안 올린다.
#: 이것을 안 거르면 못 맞춘 목록이 머리글로 가득 차 **쓸 수 없는 목록**이 된다.
#: ⚠ **부분일치로 보면 안 된다.** 「계」를 부분일치로 잡으면 `건설기계운전사`·`비계공`·
#: `계장공` 이, 「작업」을 잡으면 `작업반장` 이, 「인력」을 잡으면 `인력운반공` 이
#: 통째로 사라진다(2026-09-07 실측 — 정상 자원 **70/745** 가 걸리고 있었음).
#: 그래서 **셀 전체가 그 말과 같을 때만** 머리글로 본다.
_NON_RESOURCE_WORDS = (
"구분",
"합계",
@@ -106,10 +118,16 @@ class ResourceCatalog:
entries: list[CatalogEntry] = field(default_factory=list)
aliases: dict[str, str] = field(default_factory=dict)
#: 이름 → 항목 색인. 자재까지 붙으면 7,700건이 넘어 매번 훑으면 느리다.
_index: dict[str, list[CatalogEntry]] | None = None
def by_name(self, name: str) -> list[CatalogEntry]:
cleaned = _normalize(name)
return [e for e in self.entries if _normalize(e.name) == cleaned]
if self._index is None:
index: dict[str, list[CatalogEntry]] = {}
for entry in self.entries:
index.setdefault(_normalize(entry.name), []).append(entry)
self._index = index
return self._index.get(_normalize(name), [])
def resolve(self, name: str, spec: str) -> CatalogEntry | None:
"""이름(+규격)으로 한 줄을 고른다. 못 고르면 None — 0 으로 안 때운다."""
@@ -149,7 +167,24 @@ def is_non_resource_label(cell: str) -> bool:
# 자원 이름은 한글 두 자 이상이다. 기호(`f`·`E`)·숫자·단위만 있는 칸은 자원이 아니다.
if len(_RE_HANGUL.findall(text)) < 2:
return True
return any(word in text for word in _NON_RESOURCE_WORDS)
# ⚠ **정확 일치만** — 부분일치는 정상 자원을 통째로 지운다(위 주석).
if text in _NON_RESOURCE_WORDS:
return True
# 머리글 조각이 이어 붙은 칸(「단위작업별」·「위치및면적」)도 머리글이다.
return _is_header_composite(text)
def _is_header_composite(text: str) -> bool:
"""머리글 낱말만으로 이루어진 칸인가 — 「단위작업별」·「위치및면적」 같은 것.
낱말을 차례로 벗겨 아무것도 안 남으면 머리글로 본다. 자원 이름은 낱말을 벗기면
반드시 무언가 남는다(`건설기계운전사` → `건설`·`운전사`).
"""
rest = text
for word in sorted(_NON_RESOURCE_WORDS, key=len, reverse=True):
rest = rest.replace(word, "")
rest = rest.replace("", "").replace("", "").strip()
return rest == ""
def _normalize(text: str) -> str:
@@ -184,11 +219,32 @@ def load_machine_catalog_entries(file_name: str = "mach_base_2026.json") -> list
]
def load_material_catalog_entries(
file_name: str = "mat_price_public_2026-08-14.json",
) -> list[CatalogEntry]:
"""관급 자재 6,999건을 매칭용 항목으로 편다.
⚠ 같은 품명에 규격이 수백 개인 것이 있다(「연돌」 410 · 「육각볼트」 323).
**규격이 매칭의 일부**이므로 `spec` 을 반드시 싣는다.
"""
from B09_Estimation.B09_Estimation_MaterialCatalog import load_material_catalog
catalog = load_material_catalog(file_name)
return [
CatalogEntry(code=m.item_code, name=m.name, kind="material", spec=m.specification)
for m in catalog.items.values()
]
def load_combined_catalog() -> ResourceCatalog:
"""노임 + 기종 한 벌로. 자재는 카탈로그가 아직 없다."""
"""노임 + 기종 + **관급 자재** 를 한 벌로. 사급 자재는 아직 원천이 없다."""
labor = load_labor_catalog()
return ResourceCatalog(
entries=[*labor.entries, *load_machine_catalog_entries()],
entries=[
*labor.entries,
*load_machine_catalog_entries(),
*load_material_catalog_entries(),
],
aliases=labor.aliases,
)
@@ -310,6 +366,10 @@ class ResourceRow:
amount: Decimal
amount_unit: str
raw_row_index: int
#: 분류 딱지가 달고 온 배분율 — 「인력(10%)」이면 `10`. 없으면 `None`.
#: ⚠ **이 값을 안 보면 단가가 조용히 틀린다** — 인력 몫 원단위를 전량에 곱하게 된다
#: (2026-09-08 실측: 측구터파기 39,575.6원/㎥ 이 인력 10 % 몫만이었다).
group_ratio_pct: Decimal | None = None
def as_dict(self) -> dict[str, Any]:
return {
@@ -323,6 +383,7 @@ class ResourceRow:
"amount": str(self.amount),
"amount_unit": self.amount_unit,
"raw_row_index": self.raw_row_index,
"group_ratio_pct": None if self.group_ratio_pct is None else str(self.group_ratio_pct),
}
@@ -399,19 +460,29 @@ def match_table(
cells = [str(c) for c in row]
if not cells:
continue
# 첫 칸이 분류 딱지(「자재」·「장비」)면 **이름은 둘째 칸**이다.
name_cell = cells[0]
if is_non_resource_label(name_cell):
continue
value_cells = cells[1:]
group_ratio = None
if _group_label_of(name_cell) is not None and len(cells) > 1:
group_ratio = _group_ratio_of(name_cell)
name_cell = cells[1]
value_cells = cells[2:]
# 숫자 셀이 없는 행은 자원 줄이 아니다(제목·설명 행) — 목록에 안 올린다.
amount_cell = next(
(parse_amount(c) for c in cells[1:] if parse_amount(c) is not None), None
(parse_amount(c) for c in value_cells if parse_amount(c) is not None), None
)
if amount_cell is None:
continue
entry = _resolve_cell(catalog, name_cell, cells)
# ⚠ **카탈로그 조회를 먼저 한다.** 이름 필터를 앞에 두면 필터가 넓을 때
# 정상 자원이 조용히 사라진다(2026-09-07 실측 — 부분일치 필터가 매칭 14건을
# 지우고 있었음). 카탈로그에 있는 이름은 **정의상 자원**이다.
entry = _resolve_cell(catalog, name_cell, [name_cell, *value_cells])
if entry is None:
if is_non_resource_label(name_cell):
continue # 머리글·소계 — 못 맞춘 목록에도 안 올린다
name, spec = split_name_and_spec(name_cell)
found = catalog.by_name(name)
if len(found) > 1:
@@ -445,10 +516,41 @@ def match_table(
amount=amount,
amount_unit=unit,
raw_row_index=index,
group_ratio_pct=group_ratio,
)
)
#: 분류 딱지가 **비율을 달고 오는** 모양 — 「인력(10%)」·「장비(90%)」.
#: 2026-09-08 실측: 측구터파기(FP-09-12-01) 표가 이 모양이라 딱지 판정이 빗나가
#: **보통인부 0.23인이 통째로 빠지고 있었다**(그 공종의 자원 줄이 0 개였다).
#: 괄호 안이 **숫자·%·소수점뿐일 때만** 떼어 낸다 — 「보통인부(인)」 같은 단위 표기는
#: 떼면 안 되므로 넓게 잡지 않는다.
_RE_RATIO_SUFFIX = re.compile(r"[(][\d.\s]*%?[)]$")
def _group_label_of(cell: str) -> str | None:
"""첫 칸이 분류 딱지면 그 딱지를, 아니면 `None` 을 돌려준다.
⚠ 딱지 목록은 **정확 일치**를 유지한다(부분일치가 정상 자원을 지운 전례 —
`_NON_RESOURCE_WORDS` 주석). 비율 꼬리표만 떼고 다시 정확 일치로 본다.
"""
text = _normalize(cell)
if text in _GROUP_LABELS:
return text
stripped = _normalize(_RE_RATIO_SUFFIX.sub("", text))
return stripped if stripped in _GROUP_LABELS else None
def _group_ratio_of(cell: str) -> Decimal | None:
"""분류 딱지에 붙은 배분율. 「인력(10%)」 → `10`, 「자재」 → `None`."""
found = _RE_RATIO_SUFFIX.search(_normalize(cell))
if found is None:
return None
digits = found.group(0).strip("()()%").strip()
return Decimal(digits) if digits else None
def build_resource_axis(master: dict[str, Any], catalog: ResourceCatalog) -> AxisResult:
"""공종 축 전체를 훑어 자원 축을 만든다."""
result = AxisResult()
@@ -463,6 +565,23 @@ def build_resource_axis(master: dict[str, Any], catalog: ResourceCatalog) -> Axi
OUTPUT_SUBPATH = ("resources", "data_cost_resource_axis")
def _master_file_fingerprint(master: dict[str, Any]) -> dict[str, str]:
"""공종 마스터 **파일 자체**의 지문. 낡은 파생물을 드러내는 유일한 근거다.
`dataset_version`(품셈 원판 지문)은 마스터가 다시 생성돼도 그대로라, 그것만
적어 두면 「내 자원 축이 옛 마스터에서 나왔다」는 사실이 안 보인다.
"""
effective_date = master.get("effective_date", "")
file_name = f"work_item_master_{effective_date}.json"
path = os.path.join(_project_root(), *_MASTER_SUBPATH, file_name)
try:
with open(path, "rb") as handle:
digest = hashlib.sha256(handle.read()).hexdigest()
except OSError:
return {"file": file_name, "sha256": ""}
return {"file": file_name, "sha256": digest}
def write_resource_axis(
result: AxisResult,
master: dict[str, Any],
@@ -483,6 +602,9 @@ def write_resource_axis(
"effective_date": effective_date,
# 어느 공종 축 판에 붙인 것인지 — 세 쪽을 그대로 옮겨 적는다(PLAN 9-2).
"source_dataset_version": master.get("dataset_version", {}),
# ⚠ 위 지문은 **품셈 원판**의 것이라 B08 이 마스터를 다시 생성해도 안 움직인다.
# 낡음을 실제로 드러내려면 **마스터 파일 자체의 지문**이 있어야 한다.
"source_master_file": _master_file_fingerprint(master),
"policy": {
"axis": "resource_only",
"work_item_axis_owner": "B08",
@@ -36,6 +36,8 @@ class OutputPlace(str, Enum):
RESOURCE_SUMMARY = "resource_summary"
#: 관급자재대 총액 — **천원 올림**
OWNER_MATERIAL_TOTAL = "owner_material_total"
#: 일위대가표 금액란 — **0.1원 미만 버림** (품셈 1-2-2 「일위대가 금액란 0.1원 미만 버림」)
UNIT_PRICE_ROW = "unit_price_row"
def round_at(value: Decimal, place: OutputPlace) -> Decimal:
@@ -48,6 +50,8 @@ def round_at(value: Decimal, place: OutputPlace) -> Decimal:
return value.quantize(_ONE, rounding=ROUND_FLOOR)
if place is OutputPlace.RESOURCE_SUMMARY:
return value.quantize(_ONE, rounding=ROUND_HALF_UP)
if place is OutputPlace.UNIT_PRICE_ROW:
return value.quantize(Decimal("0.1"), rounding=ROUND_FLOOR)
if place is OutputPlace.OWNER_MATERIAL_TOTAL:
return (value / _THOUSAND).quantize(_ONE, rounding=ROUND_CEILING) * _THOUSAND
raise ValueError(f"단수 처리 자리를 모릅니다: {place}")
+76 -1
View File
@@ -11,6 +11,7 @@
from __future__ import annotations
import logging
from dataclasses import replace as dataclass_replace
from decimal import Decimal
from typing import Any
from uuid import UUID
@@ -25,8 +26,17 @@ from B09_Estimation.B09_Estimation_Engine_Cost import (
calculate_cost,
proposed_profit_adjustment,
)
from B09_Estimation.B09_Estimation_PriceBook import PriceBookError
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
from B09_Estimation.B09_Estimation_UnitPrice import (
build_summary,
cached_build,
detail_of,
direct_cost_from_quantities,
list_unit_prices,
)
from common_util.common_util_workflow_state import complete_stage
from config.config_db import get_db_pool
@@ -63,6 +73,11 @@ class CostRequest(BaseModel):
rate_file_name: str = "rates_2026.json"
#: 공종별 수량 `{공종코드: 수량}`. 주면 **직접비 3분할을 여기서 만들어** 쓴다.
#: ⚠ 일위대가 합계를 뭉쳐 넣지 않는다 — 밑수가 항목마다 갈린다(PLAN 8-9 규칙 2).
#: 지금 원천은 **손입력**이고, B08 인계(9번)가 나오면 **원천만 바꿔 끼운다**.
quantities: dict[str, Decimal] | None = None
#: 목표 도급공사비 — 주면 「필요한 이윤 조정액」을 **보여만 준다**.
#: ★ 법대로(PLAN 8-10) — 프로그램이 스스로 이윤을 깎지 않는다.
target_contract_amount_krw: Decimal | None = None
@@ -118,8 +133,25 @@ def _serialize(result: CostResult) -> dict[str, Any]:
@router.post("/{project_id}/estimation/cost")
async def compute_cost(project_id: UUID, payload: CostRequest) -> JSONResponse:
"""공사원가계산서 한 장을 계산해 돌려준다 (저장 없음)."""
direct_source = "manual"
missing_unit_prices: list[str] = []
try:
result = calculate_cost(payload.to_engine_input())
data = payload.to_engine_input()
if payload.quantities:
# 수량이 오면 **일위대가에서 직접비 3분할을 만들어** 갈아 끼운다.
breakdown = direct_cost_from_quantities(payload.quantities)
# ⑤ 표에 찍히는 자리라 **자원 집계표 규칙(반올림)** 으로 자른다 —
# 안 자르면 원가계산서에 소수점이 그대로 흘러나온다.
summary = OutputPlace.RESOURCE_SUMMARY
data = dataclass_replace(
data,
direct_material_krw=round_at(breakdown.material, summary),
direct_labor_krw=round_at(breakdown.labor, summary),
direct_expense_krw=round_at(breakdown.expense, summary),
)
direct_source = "quantities"
missing_unit_prices = breakdown.missing
result = calculate_cost(data)
except RateLookupError as error:
# 요율 구간을 못 고른 경우 — 기본값으로 때우지 않고 그대로 알린다.
logger.warning("B09 원가계산 요율 조회 실패: project_id=%s, %s", project_id, error)
@@ -132,6 +164,10 @@ async def compute_cost(project_id: UUID, payload: CostRequest) -> JSONResponse:
)
body = _serialize(result)
# 어느 값으로 계산했는지 화면이 알아야 한다 — 안 보이면 나중에 못 가른다.
body["direct_cost_source"] = direct_source
# 수량은 있는데 단가가 없는 공종 — **화면에 반드시 보인다**.
body["missing_unit_prices"] = missing_unit_prices
if payload.target_contract_amount_krw is not None:
# 필요액을 **보여만 준다**. 적용은 설계자가 `profit_adjustment_krw` 로 명시해야 한다.
body["suggested_profit_adjustment_krw"] = str(
@@ -154,6 +190,45 @@ async def list_items(project_id: UUID) -> JSONResponse:
)
@router.get("/{project_id}/estimation/unit-prices")
async def list_unit_price_titles(project_id: UUID) -> JSONResponse:
"""일위대가 **목록표** — 「무엇이 있나」 한 줄씩 + 산출 요약.
요약을 같이 보내는 까닭은 사용자가 **「무엇이 안 선 상태인가」를 화면에서**
알아야 하기 때문이다(자재 카탈로그 미확보로 구조물 계열이 안 섬).
"""
try:
build = cached_build()
return JSONResponse(
content={
"status": "success",
"summary": build_summary(build),
"rows": list_unit_prices(build),
}
)
except Exception:
logger.exception("B09 일위대가 목록 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "일위대가 목록을 못 만들었습니다."},
)
@router.get("/{project_id}/estimation/unit-prices/{code}")
async def get_unit_price_detail(project_id: UUID, code: str) -> JSONResponse:
"""일위대가 **본표** — 「무엇으로 이루어졌나」. 줄마다 원천·파고들기 표시가 붙는다."""
try:
return JSONResponse(content={"status": "success", **detail_of(cached_build(), code)})
except PriceBookError as error:
return JSONResponse(status_code=404, content={"status": "error", "message": str(error)})
except Exception:
logger.exception("B09 일위대가 본표 실패: project_id=%s, code=%s", project_id, code)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "일위대가 본표를 못 만들었습니다."},
)
@router.post("/{project_id}/estimation/confirm")
async def confirm_estimation(project_id: UUID) -> JSONResponse:
"""원가계산 단계 확정 — 워크플로 stage 6(ESTIMATION)을 COMPLETE 로 전이한다."""
+411 -16
View File
@@ -14,11 +14,18 @@
* ========================================================================== */
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];
@@ -42,6 +49,8 @@ interface CostLineDto {
interface CostSheetDto {
status: string;
direct_cost_source: "manual" | "quantities";
missing_unit_prices: string[];
lines: CostLineDto[];
totals: Record<string, string>;
rate_version: { dataset_id: string; effective_date: string; sha256: string };
@@ -49,6 +58,53 @@ interface CostSheetDto {
suggested_profit_adjustment_krw?: string;
}
interface UnitPriceRow {
code: string;
name: string;
spec: string;
unit: string;
material: string;
labor: string;
expense: string;
total: string;
}
interface UnitPriceListDto {
status: string;
summary: {
titles: number;
unit_prices: number;
machine_hourly: number;
notes: string[];
};
rows: UnitPriceRow[];
}
interface UnitPriceDetailRow extends UnitPriceRow {
ref_code: string;
source_label: string;
source_index: number;
drillable: boolean;
quantity: string;
unit_total: string;
note: string;
}
interface UnitPriceDetailDto {
status: string;
precise_total: string;
code: string;
name: string;
spec: string;
unit: string;
material: string;
labor: string;
expense: string;
total: string;
sum_matches: boolean;
rows: UnitPriceDetailRow[];
}
/** 좌측 입력 상태 — 화면이 들고 있는 값. 저장은 [확정] 때만. */
interface CostFormState {
direct_material_krw: string;
@@ -59,6 +115,8 @@ interface CostFormState {
procurement_fee_krw: string;
profit_adjustment_krw: string;
target_contract_amount_krw: string;
/** 「공종코드=수량」 한 줄씩. 비어 있으면 위 직접비 3칸을 그대로 쓴다. */
quantities_text: string;
}
const INITIAL_FORM: CostFormState = {
@@ -70,6 +128,7 @@ const INITIAL_FORM: CostFormState = {
procurement_fee_krw: "0",
profit_adjustment_krw: "0",
target_contract_amount_krw: "",
quantities_text: "",
};
/** 총계 성격의 줄 — 표에서 굵게 띄운다. */
@@ -128,6 +187,11 @@ function injectStyles(): void {
.b09-sheet tr.is-total td { font-weight: 600; background: var(--color-surface); }
.b09-sheet tr.is-adopted td { background: var(--color-surface); }
.b09-sheet tr.is-dropped td { color: var(--color-text-secondary); text-decoration: line-through; }
.b09-qty { min-height: 64px; font-family: monospace; font-size: var(--font-size-xs, 12px); }
.b09-clickable { cursor: pointer; }
.b09-clickable:hover td { background: var(--color-surface); }
.b09-up-list { max-height: 45%; }
.b09-up-detail { border-top: 2px solid var(--color-border); padding-top: 6px; }
.b09-empty { padding: var(--space-lg, 16px); color: var(--color-text-secondary); font-size: var(--font-size-sm, 13px); }
`;
document.head.append(style);
@@ -170,8 +234,10 @@ 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";
@@ -181,7 +247,8 @@ 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";
@@ -199,6 +266,172 @@ function buildCostSheetTable(sheet: CostSheetDto): HTMLElement {
return wrap;
}
/** 일위대가 **목록표** — 「무엇이 있나」. 고르면 아래에 본표가 뜬다(9-3 제목+상세). */
function buildUnitPriceList(
list: UnitPriceListDto,
selected: string | null,
onPick: (code: string) => void,
): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b09-sheet b09-up-list";
const caption = document.createElement("div");
caption.className = "b09-hint";
caption.textContent = `${L("B09_Estimation_UP_List")} · ${list.summary.unit_prices}`;
wrap.append(caption);
const table = document.createElement("table");
const head = document.createElement("tr");
for (const [key, left] of [
["B09_Estimation_Col_Name", true],
["B09_Estimation_Col_Unit", true],
["B09_Estimation_Col_Material", false],
["B09_Estimation_Col_Labor", false],
["B09_Estimation_Col_Expense", false],
["B09_Estimation_Col_Total", false],
] as Array<[keyof typeof ui_locales, boolean]>) {
const th = document.createElement("th");
th.textContent = L(key);
if (left) th.className = "b09-left";
head.append(th);
}
const thead = document.createElement("thead");
thead.append(head);
table.append(thead);
const body = document.createElement("tbody");
for (const row of list.rows) {
const tr = document.createElement("tr");
tr.className = "b09-clickable";
if (row.code === selected) tr.classList.add("is-adopted");
tr.addEventListener("click", () => onPick(row.code));
const name = document.createElement("td");
name.className = "b09-left";
name.textContent = row.name;
const unit = document.createElement("td");
unit.className = "b09-left";
unit.textContent = row.unit;
tr.append(name, unit);
for (const value of [row.material, row.labor, row.expense, row.total]) {
const cell = document.createElement("td");
cell.textContent = formatWon(value);
tr.append(cell);
}
body.append(tr);
}
table.append(body);
wrap.append(table);
return wrap;
}
/** 일위대가 **본표** — 「무엇으로 이루어졌나」. 줄마다 원천과 파고들기가 붙는다. */
function buildUnitPriceDetail(
detail: UnitPriceDetailDto,
onDrill: (code: string) => void,
): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b09-sheet b09-up-detail";
const caption = document.createElement("div");
caption.className = "b09-hint";
caption.textContent =
`${L("B09_Estimation_UP_Detail")} · ${detail.name}` +
(detail.spec ? ` (${detail.spec})` : "") +
` · ${formatWon(detail.total)}` +
` · ${detail.sum_matches ? L("B09_Estimation_UP_SumOk") : L("B09_Estimation_UP_SumBad")}`;
wrap.append(caption);
// 행별로 0.1원 미만을 버리므로 전정밀 합과 끝자리가 어긋난다 — **정상이다.**
// 숨기면 나중에 「합계가 안 맞는다」며 계산을 고치려 든다.
if (detail.precise_total !== detail.total) {
const gap = document.createElement("div");
gap.className = "b09-hint";
gap.textContent = `${L("B09_Estimation_UP_RoundGap")} ${formatWon(detail.precise_total)}`;
wrap.append(gap);
}
const table = document.createElement("table");
const head = document.createElement("tr");
for (const [key, left] of [
["B09_Estimation_Col_Name", true],
["B09_Estimation_Col_Spec", true],
["B09_Estimation_Col_Source", true],
["B09_Estimation_Col_Unit", true],
["B09_Estimation_Col_Qty", false],
["B09_Estimation_Col_Material", false],
["B09_Estimation_Col_Labor", false],
["B09_Estimation_Col_Expense", false],
["B09_Estimation_Col_Total", false],
] as Array<[keyof typeof ui_locales, boolean]>) {
const th = document.createElement("th");
th.textContent = L(key);
if (left) th.className = "b09-left";
head.append(th);
}
const thead = document.createElement("thead");
thead.append(head);
table.append(thead);
const body = document.createElement("tbody");
for (const row of detail.rows) {
const tr = document.createElement("tr");
if (row.drillable) {
tr.className = "b09-clickable";
tr.title = L("B09_Estimation_UP_Drill");
tr.addEventListener("click", () => onDrill(row.ref_code));
}
const name = document.createElement("td");
name.className = "b09-left";
name.textContent = row.drillable ? `${row.name}` : row.name;
const spec = document.createElement("td");
spec.className = "b09-left";
spec.textContent = row.spec;
const source = document.createElement("td");
source.className = "b09-left";
source.textContent = `${row.source_label} (${row.source_index})`;
const unit = document.createElement("td");
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,
]) {
const cell = document.createElement("td");
cell.textContent = formatWon(value);
tr.append(cell);
}
body.append(tr);
}
const sum = document.createElement("tr");
sum.className = "is-total";
const label = document.createElement("td");
label.className = "b09-left";
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,
]) {
const cell = document.createElement("td");
cell.textContent = formatWon(value);
sum.append(cell);
}
body.append(sum);
table.append(body);
wrap.append(table);
return wrap;
}
/* -----------------------------------------------------------------------------
* 좌측 패널
* -------------------------------------------------------------------------- */
@@ -269,6 +502,25 @@ function buildSidePanel(
["target_contract_amount_krw", "B09_Estimation_Field_TargetContract"],
]);
// 수량 — 여러 줄이라 텍스트 영역으로. 비어 있으면 위 직접비 3칸을 그대로 쓴다.
const quantityGroup = document.createElement("div");
quantityGroup.className = "b09-panel__group";
const quantityLegend = document.createElement("span");
quantityLegend.className = "b09-panel__legend";
quantityLegend.textContent = L("B09_Estimation_Group_Quantity");
const quantityLabel = document.createElement("label");
quantityLabel.className = "ui-field__label";
quantityLabel.textContent = L("B09_Estimation_Field_Quantities");
const quantityInput = document.createElement("textarea");
quantityInput.className = "ui-input b09-qty";
quantityInput.rows = 4;
quantityInput.placeholder = "FP-09-21=500";
quantityInput.addEventListener("input", () => {
form.quantities_text = quantityInput.value;
});
quantityGroup.append(quantityLegend, quantityLabel, quantityInput);
root.append(quantityGroup);
const hintBox = document.createElement("div");
hintBox.className = "b09-hint";
root.append(hintBox);
@@ -276,8 +528,15 @@ function buildSidePanel(
const actions = document.createElement("div");
actions.className = "b09-panel__actions";
actions.append(
createButton({ label: L("B09_Estimation_Btn_Recalc"), variant: "filled", onClick: onRecalc }),
createButton({ label: L("B09_Estimation_Btn_Confirm"), onClick: onConfirm }),
createButton({
label: L("B09_Estimation_Btn_Recalc"),
variant: "filled",
onClick: onRecalc,
}),
createButton({
label: L("B09_Estimation_Btn_Confirm"),
onClick: onConfirm,
}),
);
root.append(actions);
@@ -289,7 +548,12 @@ 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");
@@ -310,7 +574,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],
["unit_price", "B09_Estimation_Tab_UnitPrice", false],
["unit_price", "B09_Estimation_Tab_UnitPrice", true],
["price_basis", "B09_Estimation_Tab_PriceBasis", false],
["machine", "B09_Estimation_Tab_Machine", false],
["duration", "B09_Estimation_Tab_Duration", false],
@@ -318,7 +582,10 @@ 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) {
@@ -340,8 +607,24 @@ function buildTabs(active: string, onSelect: (key: string) => void): HTMLElement
* API
* -------------------------------------------------------------------------- */
/** 「공종코드=수량」 여러 줄을 객체로. 형식이 아닌 줄은 조용히 버리지 않고 건너뛴다. */
function parseQuantities(text: string): Record<string, string> {
const out: Record<string, string> = {};
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) continue;
const [code, value] = trimmed.split(/[=\t,]/);
if (!code || !value) continue;
const qty = value.trim();
if (!/^\d+(\.\d+)?$/.test(qty)) continue;
out[code.trim()] = qty;
}
return out;
}
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),
@@ -354,10 +637,15 @@ function toRequestBody(form: CostFormState): Record<string, unknown> {
if (form.target_contract_amount_krw.trim() !== "") {
body.target_contract_amount_krw = form.target_contract_amount_krw.trim();
}
const quantities = parseQuantities(form.quantities_text);
if (Object.keys(quantities).length > 0) body.quantities = quantities;
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`,
{
@@ -367,16 +655,43 @@ async function fetchCostSheet(projectId: string, form: CostFormState): Promise<C
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> {
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}`);
return (await response.json()) as UnitPriceListDto;
}
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}`);
return (await response.json()) as UnitPriceDetailDto;
}
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}`);
}
/* -----------------------------------------------------------------------------
@@ -389,6 +704,9 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
const form: CostFormState = { ...INITIAL_FORM };
let activeTab = "cost_sheet";
let sheet: CostSheetDto | null = null;
let unitPriceList: UnitPriceListDto | null = null;
let unitPriceDetail: UnitPriceDetailDto | null = null;
let selectedUnitPrice: string | null = null;
const main = document.createElement("div");
main.className = "b09-main";
@@ -398,8 +716,58 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
body.style.display = "flex";
body.style.flexDirection = "column";
/** 일위대가 본표를 불러 다시 그린다 — 기계 줄을 누르면 그 층으로 파고든다. */
const openUnitPrice = async (code: string): Promise<void> => {
if (!projectId) return;
try {
unitPriceDetail = await fetchUnitPriceDetail(projectId, code);
selectedUnitPrice = code;
drawBody();
} catch {
showToast(L("B09_Estimation_UP_Load_Failed"), "error");
}
};
const drawUnitPriceTab = (): void => {
if (!unitPriceList) {
const empty = document.createElement("div");
empty.className = "b09-empty";
empty.textContent = L("B09_Estimation_Tab_Pending");
body.append(empty);
return;
}
// 산출 요약을 **화면에도** 낸다 — 무엇이 안 선 상태인지 사용자가 알아야 한다.
for (const note of unitPriceList.summary.notes) {
const line = document.createElement("div");
line.className = "b09-hint";
line.textContent = note;
body.append(line);
}
body.append(
buildUnitPriceList(unitPriceList, selectedUnitPrice, (code) => {
void openUnitPrice(code);
}),
);
if (unitPriceDetail) {
body.append(
buildUnitPriceDetail(unitPriceDetail, (code) => {
void openUnitPrice(code);
}),
);
} else {
const hint = document.createElement("div");
hint.className = "b09-empty";
hint.textContent = L("B09_Estimation_UP_Pick");
body.append(hint);
}
};
const drawBody = (): void => {
body.replaceChildren();
if (activeTab === "unit_price") {
drawUnitPriceTab();
return;
}
if (activeTab !== "cost_sheet") {
const empty = document.createElement("div");
empty.className = "b09-empty";
@@ -414,6 +782,23 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
body.append(empty);
return;
}
// 어느 값으로 계산했는지 화면에 남긴다 — 안 보이면 나중에 못 가른다.
const source = document.createElement("div");
source.className = "b09-hint";
source.textContent =
sheet.direct_cost_source === "quantities"
? L("B09_Estimation_Src_Quantities")
: L("B09_Estimation_Src_Manual");
body.append(source);
// 수량은 있는데 단가가 없는 공종 — 총액에서 빠졌으므로 **반드시 보인다**.
if (sheet.missing_unit_prices.length > 0) {
const missing = document.createElement("div");
missing.className = "b09-hint";
missing.textContent = `${L("B09_Estimation_Missing_UP")} ${sheet.missing_unit_prices.join(", ")}`;
body.append(missing);
}
body.append(buildCostSheetTable(sheet));
for (const note of sheet.notes) {
const line = document.createElement("div");
@@ -428,6 +813,14 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
activeTab = key;
drawTabs();
drawBody();
if (key === "unit_price" && !unitPriceList && projectId) {
void fetchUnitPriceList(projectId)
.then((data) => {
unitPriceList = data;
drawBody();
})
.catch(() => showToast(L("B09_Estimation_UP_Load_Failed"), "error"));
}
});
const old = main.querySelector(".b09-tabs");
if (old) old.replaceWith(bar);
@@ -442,7 +835,8 @@ 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();
@@ -474,7 +868,8 @@ 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);
+342 -11
View File
@@ -17,10 +17,12 @@
from __future__ import annotations
import re
from dataclasses import dataclass, field
from decimal import Decimal
from functools import lru_cache
from B09_Estimation.B09_Estimation_Guards import check_surcharge_once
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_MachineOperating import (
load_fuel_price,
@@ -40,10 +42,14 @@ from B09_Estimation.B09_Estimation_ResourceAxis import (
load_labor_catalog,
load_work_item_master,
)
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
_RE_EMPHASIS = re.compile(r"\*\*(.+?)\*\*")
_ZERO = Decimal(0)
#: 연료는 자재 카탈로그에 없어 합성 코드로 세운다 — 코드가 있어야 조인이 성립한다.
FUEL_CODE_PREFIX = "M-FUEL-"
#: 일위대가 총액이 이보다 작으면 **성분이 빠졌을 가능성**이 크다 — 값이 있어도 경고한다.
SUSPICIOUSLY_LOW_KRW = Decimal(100)
def _slots(value: Decimal) -> list[Decimal | None]:
@@ -60,6 +66,8 @@ class UnitPriceBuild:
skipped: list[str] = field(default_factory=list)
#: 기계 성분이 비어 있는 채로 쓰인 기종 (연료·조종원 미확보).
incomplete_machines: list[str] = field(default_factory=list)
#: 배분율 표인데 일부 몫만 붙은 공종 — 「단가가 일부만 섬」. 값은 붙은 몫(%).
partial_ratio: dict[str, Decimal] = field(default_factory=dict)
def _add_labor_titles(book: PriceBook, wages: dict[str, Decimal]) -> None:
@@ -177,8 +185,11 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
공종 하나에 붙은 자원 줄들을 그 공종의 상세로 삼는다. 자원이 하나도 안 붙은
공종은 **빈 줄로 세우지 않고 건너뛴다** — 0 원 일위대가가 내역에 서면 안 된다.
"""
master = load_work_item_master()
if axis is None:
axis = build_resource_axis(load_work_item_master(), load_combined_catalog())
axis = build_resource_axis(master, load_combined_catalog())
# 일위대가 이름은 **공종명**이어야 한다 — 코드만 보이면 사람이 못 읽는다.
names = {w["work_item_code"]: w.get("name", "") for w in master.get("work_items", [])}
build = UnitPriceBuild()
wages = load_operator_wages()
@@ -195,22 +206,63 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
title_code = f"B-{work_item_code}"
if title_code in build.book.titles:
continue
# ⚠ **붙을 상세를 먼저 모으고, 하나도 없으면 제목도 안 세운다.**
# 제목만 세워 두면 「상세 줄이 없어 단가를 못 조립」하는 빈 일위대가가 남는다
# (기계 층이 안 선 기종만 참조하는 공종에서 실제로 생겼음).
attachable = [
(row, row.resource_code if row.resource_kind == "labor" else f"X-{row.resource_code}")
for row in rows
]
attachable = [(row, ref) for row, ref in attachable if ref in build.book.titles]
if not attachable:
build.skipped.append(work_item_code)
continue
unit = next((r.amount_unit for r in rows if r.amount_unit), "")
build.book.add_title(
PriceTitle(code=title_code, kind=PriceKind.UNIT_PRICE, name=work_item_code, unit=unit)
PriceTitle(
code=title_code,
kind=PriceKind.UNIT_PRICE,
name=names.get(work_item_code) or work_item_code,
spec=work_item_code,
unit=unit,
)
added = 0
for row in rows:
ref = row.resource_code if row.resource_kind == "labor" else f"X-{row.resource_code}"
if ref not in build.book.titles:
continue
)
for row, ref in attachable:
build.book.add_detail(PriceDetail(title_code, ref, row.amount))
added += 1
if added == 0:
build.skipped.append(work_item_code)
# ⚠ **배분율이 있는 표는 「몇 %가 실제로 붙었나」를 세어 둔다.**
# 「인력(10%)·장비(90%)」 표에서 인력만 붙으면 단가가 10 % 몫만인데, 그 값이
# 조용히 서면 내역서가 틀린 줄 모른다(2026-09-08 실측: 측구터파기 39,575.6원/㎥
# 이 인력 10 % 몫만이었다). 0 으로 때우는 것과 같은 종류의 사고다.
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
return build
def _covered_ratio_pct(
rows: list, attached_refs: set[str], build: UnitPriceBuild
) -> Decimal | None:
"""배분율 표에서 **실제로 붙은 몫**의 합계(%). 배분율이 없는 표면 `None`."""
ratios = {
row.group_ratio_pct for row in rows if getattr(row, "group_ratio_pct", None) is not None
}
if not ratios:
return None
covered = Decimal(0)
seen: set[Decimal] = set()
for row in rows:
ratio = getattr(row, "group_ratio_pct", None)
if ratio is None or ratio in seen:
continue
ref = row.resource_code if row.resource_kind == "labor" else f"X-{row.resource_code}"
if ref in attached_refs:
seen.add(ratio)
covered += ratio
return covered
def material_total_before_surcharge(build: UnitPriceBuild, code: str) -> Decimal:
"""일위대가 한 줄의 **할증 전** 재료비 합계 — ㉠ 가드에 넘길 값."""
return build.book.resolve(code).material
@@ -230,3 +282,282 @@ def verify_surcharge_once(
surcharge_rate_percent=surcharge_rate_percent,
label=code,
)
#: 상세 줄이 **어느 층에서 왔는지** 보이는 표시 (PLAN 9-3, ESTX `LinkIndex` 와 같은 축).
SOURCE_INDEX: dict[PriceKind, int] = {
PriceKind.MATERIAL: 5,
PriceKind.LABOR: 6,
PriceKind.MACHINE_BASE: 105,
PriceKind.MACHINE_HOURLY: 105,
PriceKind.UNIT_PRICE: 103,
PriceKind.PRICE_BASIS: 104,
PriceKind.LUMPSUM: 0,
}
SOURCE_LABEL: dict[PriceKind, str] = {
PriceKind.MATERIAL: "자재",
PriceKind.LABOR: "노임",
PriceKind.MACHINE_BASE: "기계경비",
PriceKind.MACHINE_HOURLY: "기계경비",
PriceKind.UNIT_PRICE: "일위대가",
PriceKind.PRICE_BASIS: "단가산출",
PriceKind.LUMPSUM: "일식·견적",
}
#: 상세를 파고들 수 있는 층 — 이 종류의 줄을 누르면 그 본표가 열린다.
DRILLABLE_KINDS = frozenset({PriceKind.MACHINE_HOURLY, PriceKind.UNIT_PRICE, PriceKind.PRICE_BASIS})
@lru_cache(maxsize=1)
def cached_build() -> UnitPriceBuild:
"""조립 결과를 한 번만 만든다 — 품셈 3 MB 를 요청마다 다시 읽지 않는다."""
return build_unit_prices()
def _plain(text: str) -> str:
"""화면용 평문 — 마크다운 강조 표시를 벗긴다."""
return _RE_EMPHASIS.sub(lambda match: match.group(1), text)
def _status_notes() -> list[str]:
"""화면에 낼 「지금 무엇이 안 선 상태인가」.
자재 카탈로그를 붙인 뒤 실측한 사실을 그대로 적는다 — 관급 목록에 임도 자재가
거의 없다는 것이 이 자리의 진짜 공백이다.
"""
from B09_Estimation.B09_Estimation_MaterialCatalog import (
catalog_summary,
load_material_catalog,
)
summary = catalog_summary(load_material_catalog())
# 화면은 평문이라 마크다운 강조가 그대로 보인다 — 내보내기 직전에 벗긴다.
return [
_plain(note)
for note in [
f"관급 자재 {summary['items']:,}건을 붙였으나 **임도 자재는 거의 없습니다** — "
"나라장터 목록이 건축·설비 자재 중심이고, 시멘트·모래·자갈은 애초에 사급이며 "
"철근·레미콘·아스콘은 원천에서 빠져 있습니다.",
"**사급 자재 단가는 설계자가 직접 넣습니다**(6번 슬롯 「적용 단가」) — "
"유료 물가지 미구독. 값을 지어내지 않으므로, 넣기 전까지 구조물 계열 "
"일위대가는 서지 않습니다. (잠정 — 물가지를 구독하면 1~5번 슬롯에 꽂습니다.)",
(
f"관급 자재 **설치 주체가 미지정**"
f"({summary['owner_supplied_install_unspecified']:,}건)이라 "
"안전관리비 대상액에 자동으로 넣지 않습니다."
),
]
]
def build_summary(build: UnitPriceBuild) -> dict:
"""산출 요약 — **화면에도 낸다.** 사용자가 「무엇이 안 선 상태인가」를 알아야 한다."""
kinds: dict[str, int] = {}
for title in build.book.titles.values():
kinds[title.kind.value] = kinds.get(title.kind.value, 0) + 1
# ⚠ **크기가 말이 되나**를 볼 수 있게 분포를 낸다.
# 「0 이 아님」만 보면 씨앗뿜어붙이기가 **합계 68.8원**이던 것을 못 잡는다
# (자재·장비가 통째로 빠지고 노무 한 줄만 남았던 자리, 2026-09-07).
totals = sorted(
build.book.resolve(code).total
for code, title in build.book.titles.items()
if title.kind is PriceKind.UNIT_PRICE
)
stats: dict[str, str] = {}
low: list[dict[str, str]] = []
if totals:
stats = {
"min": _money_text(totals[0]),
"median": _money_text(totals[len(totals) // 2]),
"max": _money_text(totals[-1]),
}
low = [
{"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 (money := build.book.resolve(code).total) < SUSPICIOUSLY_LOW_KRW
]
return {
"titles": len(build.book.titles),
"unit_price_totals": stats,
# 값이 서기는 했는데 **크기가 이상한** 것 — 성분이 빠졌을 가능성이 크다.
"suspiciously_low": low,
"unit_prices": kinds.get(PriceKind.UNIT_PRICE.value, 0),
"machine_hourly": kinds.get(PriceKind.MACHINE_HOURLY.value, 0),
"skipped_work_items": len(build.skipped),
"incomplete_machines": len(build.incomplete_machines),
"kinds": kinds,
# ⚠ 지금 상태를 화면에 그대로 알린다 (PLAN 9-6 미결).
"notes": _status_notes(),
}
def _money_text(value: Decimal) -> str:
"""화면에 낼 금액 — 일위대가 금액란은 0.1원 미만 버림(품셈 1-2-2).
계산은 전정밀로 두고 **표를 그리는 자리에서만** 자른다
(`B09_Estimation_Rounding` — 단수는 출력 위치에 붙는다).
"""
return str(round_at(value, OutputPlace.UNIT_PRICE_ROW))
def list_unit_prices(build: UnitPriceBuild) -> list[dict]:
"""목록표 — 「무엇이 있나」 한 줄씩."""
rows: list[dict] = []
for code, title in sorted(build.book.titles.items()):
if title.kind is not PriceKind.UNIT_PRICE:
continue
money = build.book.resolve(code)
rows.append(
{
"code": code,
"name": title.name,
"spec": title.spec,
"unit": title.unit,
"material": _money_text(money.material),
"labor": _money_text(money.labor),
"expense": _money_text(money.expense),
"total": _money_text(money.total),
}
)
return rows
def detail_of(build: UnitPriceBuild, code: str) -> dict:
"""본표 — 「그것이 무엇으로 이루어졌나」. 줄마다 원천과 파고들기 여부를 함께 낸다."""
title = build.book.title(code)
money = build.book.resolve(code)
rows: list[dict] = []
for detail in build.book.details.get(code, []):
child = build.book.title(detail.ref_code)
unit_money = build.book.resolve(detail.ref_code)
line = unit_money.scaled(detail.quantity)
rows.append(
{
"ref_code": detail.ref_code,
"name": child.name,
"spec": child.spec,
"unit": child.unit,
"source_index": SOURCE_INDEX.get(child.kind, 0),
"source_label": SOURCE_LABEL.get(child.kind, ""),
"drillable": child.kind in DRILLABLE_KINDS,
"quantity": str(detail.quantity),
"unit_material": _money_text(unit_money.material),
"unit_labor": _money_text(unit_money.labor),
"unit_expense": _money_text(unit_money.expense),
"unit_total": _money_text(unit_money.total),
"material": _money_text(line.material),
"labor": _money_text(line.labor),
"expense": _money_text(line.expense),
# 행 합계는 **자른 성분 셋의 합** — 그래야 표에서 `TC = NC+GC+JC` 가 선다.
# 전정밀 합을 따로 자르면 성분과 합계가 1원 단위로 어긋나 보인다.
"total": str(
round_at(line.material, OutputPlace.UNIT_PRICE_ROW)
+ round_at(line.labor, OutputPlace.UNIT_PRICE_ROW)
+ round_at(line.expense, OutputPlace.UNIT_PRICE_ROW)
),
"note": detail.note,
}
)
# 합계는 **행별로 자른 값을 더한다** — 「행별 처리(합계 후 아님)」
# (`단수처리_규칙.md` §2). 전정밀 합을 나중에 자르면 실무 표와 끝자리가 어긋난다.
summed = {
key: sum((Decimal(r[key]) for r in rows), Decimal(0))
for key in ("material", "labor", "expense", "total")
}
# ㉤ 열 방향 검사 — 같은 성분을 두 층에서 세면 여기서 멈춘다.
# 행 방향(`TC=NC+GC+JC`)만으로는 안 잡히는 어긋남이다.
check_column_sums(rows=rows, totals=summed, label=f"{title.name} 본표")
return {
"code": code,
"name": title.name,
"spec": title.spec,
"unit": title.unit,
"kind": title.kind.value,
"material": str(summed["material"]),
"labor": str(summed["labor"]),
"expense": str(summed["expense"]),
"total": str(summed["total"]),
# TC = NC + GC + JC 가 성립하는지 화면이 스스로 보이게 한다.
"sum_matches": summed["total"] == summed["material"] + summed["labor"] + summed["expense"],
# 전정밀 합과의 차이 — 행별 절사 탓에 끝자리가 어긋나는 것은 **정상**이다.
"precise_total": _money_text(money.total),
"rows": rows,
}
@dataclass
class DirectCostBreakdown:
"""⑤ 공사원가계산서가 받는 **직접비 3분할**.
⚠ **일위대가 합계를 순공사비로 뭉쳐 넣으면 안 된다.** ⑤ 의 밑수는 항목마다 갈리고
(산재·고용 = 노무비 / 건강·연금 = 직접노무비 / 기타경비 = 재료비+노무비 …),
뭉쳐 넣으면 그 밑수가 전부 틀린다(PLAN 8-9 규칙 2). 일위대가는 3분할을 이미
들고 있으니 **성분별로 접어 넣는다.**
"""
material: Decimal = _ZERO
labor: Decimal = _ZERO
expense: Decimal = _ZERO
#: 값을 못 세운 공종 — 수량이 있는데 단가가 없으면 여기 남는다(0 으로 안 때운다).
missing: list[str] = field(default_factory=list)
@property
def total(self) -> Decimal:
return self.material + self.labor + self.expense
def direct_cost_from_quantities(
quantities: dict[str, Decimal],
build: UnitPriceBuild | None = None,
) -> DirectCostBreakdown:
"""공종별 수량을 일위대가에 곱해 **직접비 3분할**을 만든다.
`quantities` = `{공종코드: 수량}`. 공종코드는 `FP-09-21` 처럼 마스터 코드를 쓰거나
`B-FP-09-21` 처럼 일위대가 코드를 그대로 써도 된다.
단가가 없는 공종은 **0 으로 안 때우고** `missing` 에 남긴다 — 수량이 있는데 단가가
없으면 그 공종이 총액에서 조용히 빠진다.
"""
book = (build or cached_build()).book
result = DirectCostBreakdown()
for raw_code, quantity in quantities.items():
code = raw_code if raw_code.startswith("B-") else f"B-{raw_code}"
if code not in book.titles:
result.missing.append(raw_code)
continue
unit_money = book.resolve(code)
line = unit_money.scaled(Decimal(str(quantity)))
result.material += line.material
result.labor += line.labor
result.expense += line.expense
return result
def cost_input_from_quantities(
quantities: dict[str, Decimal],
build: UnitPriceBuild | None = None,
**cost_input_kwargs,
):
"""직접비 3분할을 ⑤ 엔진 입력으로 접어 넣는다.
성분이 그대로 `direct_material_krw`·`direct_labor_krw`·`direct_expense_krw` 로 간다 —
**뭉치지 않는다.**
"""
from B09_Estimation.B09_Estimation_Engine_Cost import CostInput
breakdown = direct_cost_from_quantities(quantities, build)
# ⑤ 표에 들어가는 자리이므로 여기서 자른다 — 자원 집계표는 **반올림**이다
# (`B09_Estimation_Rounding` 참조). `breakdown` 자체는 전정밀 값으로 남긴다.
summary = OutputPlace.RESOURCE_SUMMARY
return (
CostInput(
direct_material_krw=round_at(breakdown.material, summary),
direct_labor_krw=round_at(breakdown.labor, summary),
direct_expense_krw=round_at(breakdown.expense, summary),
**cost_input_kwargs,
),
breakdown,
)
@@ -211,6 +211,18 @@
"resource_spec": "",
"work_item_code": "FP-05-23-03"
},
{
"amount": "0.0007",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0135",
"raw_row_index": 8,
"resource_code": "1038",
"resource_kind": "labor",
"resource_name": "조경공",
"resource_spec": "",
"work_item_code": "FP-05-24-01"
},
{
"amount": "0.0004",
"amount_unit": "",
@@ -223,6 +235,18 @@
"resource_spec": "",
"work_item_code": "FP-05-24-01"
},
{
"amount": "0.0007",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0136",
"raw_row_index": 8,
"resource_code": "1038",
"resource_kind": "labor",
"resource_name": "조경공",
"resource_spec": "",
"work_item_code": "FP-05-24-02"
},
{
"amount": "0.0004",
"amount_unit": "",
@@ -235,6 +259,18 @@
"resource_spec": "",
"work_item_code": "FP-05-24-02"
},
{
"amount": "0.002",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0139",
"raw_row_index": 1,
"resource_code": "1038",
"resource_kind": "labor",
"resource_name": "조경공",
"resource_spec": "",
"work_item_code": "FP-05-25"
},
{
"amount": "0.0007",
"amount_unit": "",
@@ -307,6 +343,18 @@
"resource_spec": "",
"work_item_code": "FP-06-05"
},
{
"amount": "2.00",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0163",
"raw_row_index": 2,
"resource_code": "1002",
"resource_kind": "labor",
"resource_name": "보통인부",
"resource_spec": "",
"work_item_code": "FP-06-05"
},
{
"amount": "0.29",
"amount_unit": "",
@@ -355,6 +403,18 @@
"resource_spec": "",
"work_item_code": "FP-06-07-02"
},
{
"amount": "0.75",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0236",
"raw_row_index": 1,
"resource_code": "7930-0001",
"resource_kind": "machine",
"resource_name": "",
"resource_spec": "0.75",
"work_item_code": "FP-08-10"
},
{
"amount": "0.16",
"amount_unit": "",
@@ -391,6 +451,18 @@
"resource_spec": "",
"work_item_code": "FP-09-05-01"
},
{
"amount": "0.02",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0251",
"raw_row_index": 0,
"resource_code": "1012",
"resource_kind": "labor",
"resource_name": "용접공",
"resource_spec": "",
"work_item_code": "FP-09-08-01"
},
{
"amount": "0.08",
"amount_unit": "",
@@ -403,6 +475,30 @@
"resource_spec": "",
"work_item_code": "FP-09-08-01"
},
{
"amount": "0.23",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0258",
"raw_row_index": 0,
"resource_code": "1002",
"resource_kind": "labor",
"resource_name": "보통인부",
"resource_spec": "",
"work_item_code": "FP-09-12-01"
},
{
"amount": "1.6",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0259",
"raw_row_index": 0,
"resource_code": "1017",
"resource_kind": "labor",
"resource_name": "할석공",
"resource_spec": "",
"work_item_code": "FP-09-12-02"
},
{
"amount": "0.8",
"amount_unit": "",
@@ -415,6 +511,18 @@
"resource_spec": "",
"work_item_code": "FP-09-12-02"
},
{
"amount": "2.8",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0260",
"raw_row_index": 0,
"resource_code": "1017",
"resource_kind": "labor",
"resource_name": "할석공",
"resource_spec": "",
"work_item_code": "FP-09-12-03"
},
{
"amount": "1.266",
"amount_unit": "",
@@ -427,6 +535,90 @@
"resource_spec": "",
"work_item_code": "FP-09-12-03"
},
{
"amount": "0.23",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0261",
"raw_row_index": 0,
"resource_code": "1002",
"resource_kind": "labor",
"resource_name": "보통인부",
"resource_spec": "",
"work_item_code": "FP-09-13-01"
},
{
"amount": "0.31",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0262",
"raw_row_index": 0,
"resource_code": "1002",
"resource_kind": "labor",
"resource_name": "보통인부",
"resource_spec": "",
"work_item_code": "FP-09-13-02"
},
{
"amount": "0.39",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0263",
"raw_row_index": 0,
"resource_code": "1002",
"resource_kind": "labor",
"resource_name": "보통인부",
"resource_spec": "",
"work_item_code": "FP-09-13-03"
},
{
"amount": "0.345",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0264",
"raw_row_index": 0,
"resource_code": "1002",
"resource_kind": "labor",
"resource_name": "보통인부",
"resource_spec": "",
"work_item_code": "FP-09-13-04"
},
{
"amount": "0.465",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0265",
"raw_row_index": 0,
"resource_code": "1002",
"resource_kind": "labor",
"resource_name": "보통인부",
"resource_spec": "",
"work_item_code": "FP-09-13-05"
},
{
"amount": "0.585",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0266",
"raw_row_index": 0,
"resource_code": "1002",
"resource_kind": "labor",
"resource_name": "보통인부",
"resource_spec": "",
"work_item_code": "FP-09-13-06"
},
{
"amount": "1.6",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0267",
"raw_row_index": 0,
"resource_code": "1017",
"resource_kind": "labor",
"resource_name": "할석공",
"resource_spec": "",
"work_item_code": "FP-09-13-07"
},
{
"amount": "0.8",
"amount_unit": "",
@@ -439,6 +631,18 @@
"resource_spec": "",
"work_item_code": "FP-09-13-07"
},
{
"amount": "1.8",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0268",
"raw_row_index": 0,
"resource_code": "1017",
"resource_kind": "labor",
"resource_name": "할석공",
"resource_spec": "",
"work_item_code": "FP-09-13-08"
},
{
"amount": "0.9",
"amount_unit": "",
@@ -451,6 +655,18 @@
"resource_spec": "",
"work_item_code": "FP-09-13-08"
},
{
"amount": "2.0",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0269",
"raw_row_index": 0,
"resource_code": "1017",
"resource_kind": "labor",
"resource_name": "할석공",
"resource_spec": "",
"work_item_code": "FP-09-13-09"
},
{
"amount": "1.0",
"amount_unit": "",
@@ -463,6 +679,54 @@
"resource_spec": "",
"work_item_code": "FP-09-13-09"
},
{
"amount": "1.2",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0270",
"raw_row_index": 0,
"resource_code": "1002",
"resource_kind": "labor",
"resource_name": "보통인부",
"resource_spec": "",
"work_item_code": "FP-09-13-10"
},
{
"amount": "1.35",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0271",
"raw_row_index": 0,
"resource_code": "1002",
"resource_kind": "labor",
"resource_name": "보통인부",
"resource_spec": "",
"work_item_code": "FP-09-13-11"
},
{
"amount": "1.5",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0272",
"raw_row_index": 0,
"resource_code": "1002",
"resource_kind": "labor",
"resource_name": "보통인부",
"resource_spec": "",
"work_item_code": "FP-09-13-12"
},
{
"amount": "2.8",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0273",
"raw_row_index": 0,
"resource_code": "1017",
"resource_kind": "labor",
"resource_name": "할석공",
"resource_spec": "",
"work_item_code": "FP-09-13-13"
},
{
"amount": "1.266",
"amount_unit": "",
@@ -475,6 +739,18 @@
"resource_spec": "",
"work_item_code": "FP-09-13-13"
},
{
"amount": "3.5",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0274",
"raw_row_index": 0,
"resource_code": "1017",
"resource_kind": "labor",
"resource_name": "할석공",
"resource_spec": "",
"work_item_code": "FP-09-13-14"
},
{
"amount": "1.566",
"amount_unit": "",
@@ -487,6 +763,18 @@
"resource_spec": "",
"work_item_code": "FP-09-13-14"
},
{
"amount": "4.2",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0275",
"raw_row_index": 0,
"resource_code": "1017",
"resource_kind": "labor",
"resource_name": "할석공",
"resource_spec": "",
"work_item_code": "FP-09-13-15"
},
{
"amount": "1.866",
"amount_unit": "",
@@ -499,6 +787,18 @@
"resource_spec": "",
"work_item_code": "FP-09-13-15"
},
{
"amount": "4.20",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0276",
"raw_row_index": 0,
"resource_code": "1017",
"resource_kind": "labor",
"resource_name": "할석공",
"resource_spec": "",
"work_item_code": "FP-09-13-16"
},
{
"amount": "1.899",
"amount_unit": "",
@@ -511,6 +811,18 @@
"resource_spec": "",
"work_item_code": "FP-09-13-16"
},
{
"amount": "5.25",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0277",
"raw_row_index": 0,
"resource_code": "1017",
"resource_kind": "labor",
"resource_name": "할석공",
"resource_spec": "",
"work_item_code": "FP-09-13-17"
},
{
"amount": "2.349",
"amount_unit": "",
@@ -523,6 +835,18 @@
"resource_spec": "",
"work_item_code": "FP-09-13-17"
},
{
"amount": "6.3",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0278",
"raw_row_index": 0,
"resource_code": "1017",
"resource_kind": "labor",
"resource_name": "할석공",
"resource_spec": "",
"work_item_code": "FP-09-13-18"
},
{
"amount": "2.799",
"amount_unit": "",
@@ -535,6 +859,42 @@
"resource_spec": "",
"work_item_code": "FP-09-13-18"
},
{
"amount": "0.10",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0279",
"raw_row_index": 0,
"resource_code": "1002",
"resource_kind": "labor",
"resource_name": "보통인부",
"resource_spec": "",
"work_item_code": "FP-09-14-01"
},
{
"amount": "0.019",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0290",
"raw_row_index": 0,
"resource_code": "1002",
"resource_kind": "labor",
"resource_name": "보통인부",
"resource_spec": "",
"work_item_code": "FP-09-19-02"
},
{
"amount": "0.0328",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0291",
"raw_row_index": 0,
"resource_code": "1002",
"resource_kind": "labor",
"resource_name": "보통인부",
"resource_spec": "",
"work_item_code": "FP-09-19-03"
},
{
"amount": "0.80",
"amount_unit": "",
@@ -595,6 +955,18 @@
"resource_spec": "",
"work_item_code": "FP-10-06-02"
},
{
"amount": "0.35",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0305",
"raw_row_index": 0,
"resource_code": "1001",
"resource_kind": "labor",
"resource_name": "작업반장",
"resource_spec": "",
"work_item_code": "FP-10-07-01"
},
{
"amount": "0.35",
"amount_unit": "",
@@ -607,6 +979,18 @@
"resource_spec": "",
"work_item_code": "FP-10-07-01"
},
{
"amount": "2.0",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0306",
"raw_row_index": 0,
"resource_code": "1001",
"resource_kind": "labor",
"resource_name": "작업반장",
"resource_spec": "",
"work_item_code": "FP-10-07-02"
},
{
"amount": "2.0",
"amount_unit": "",
@@ -631,6 +1015,18 @@
"resource_spec": "",
"work_item_code": "FP-10-07-02"
},
{
"amount": "1.0",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0307",
"raw_row_index": 0,
"resource_code": "1001",
"resource_kind": "labor",
"resource_name": "작업반장",
"resource_spec": "",
"work_item_code": "FP-10-07-03"
},
{
"amount": "1.0",
"amount_unit": "",
@@ -667,6 +1063,30 @@
"resource_spec": "",
"work_item_code": "FP-10-07-04"
},
{
"amount": "2.0",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0317",
"raw_row_index": 0,
"resource_code": "1001",
"resource_kind": "labor",
"resource_name": "작업반장",
"resource_spec": "",
"work_item_code": "FP-10-10-01"
},
{
"amount": "2.0",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0318",
"raw_row_index": 0,
"resource_code": "1001",
"resource_kind": "labor",
"resource_name": "작업반장",
"resource_spec": "",
"work_item_code": "FP-10-10-02"
},
{
"amount": "0.22",
"amount_unit": "",
@@ -799,6 +1219,18 @@
"resource_spec": "",
"work_item_code": "FP-12-10"
},
{
"amount": "0.12",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0348",
"raw_row_index": 1,
"resource_code": "1050",
"resource_kind": "labor",
"resource_name": "일반기계운전사",
"resource_spec": "",
"work_item_code": "FP-12-11-02"
},
{
"amount": "1.23",
"amount_unit": "",
@@ -847,6 +1279,42 @@
"resource_spec": "",
"work_item_code": "FP-12-18"
},
{
"amount": "0.07",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0367",
"raw_row_index": 3,
"resource_code": "1007",
"resource_kind": "labor",
"resource_name": "형틀목공",
"resource_spec": "",
"work_item_code": "FP-12-20"
},
{
"amount": "0.05",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0367",
"raw_row_index": 4,
"resource_code": "1002",
"resource_kind": "labor",
"resource_name": "보통인부",
"resource_spec": "",
"work_item_code": "FP-12-20"
},
{
"amount": "0.034",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0368",
"raw_row_index": 1,
"resource_code": "1026",
"resource_kind": "labor",
"resource_name": "방수공",
"resource_spec": "",
"work_item_code": "FP-12-21"
},
{
"amount": "0.04",
"amount_unit": "",
@@ -859,6 +1327,30 @@
"resource_spec": "",
"work_item_code": "FP-12-21"
},
{
"amount": "0.003",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0370",
"raw_row_index": 2,
"resource_code": "1002",
"resource_kind": "labor",
"resource_name": "보통인부",
"resource_spec": "",
"work_item_code": "FP-12-23"
},
{
"amount": "0.2",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0372",
"raw_row_index": 1,
"resource_code": "1003",
"resource_kind": "labor",
"resource_name": "특별인부",
"resource_spec": "",
"work_item_code": "FP-12-24-02"
},
{
"amount": "4.0",
"amount_unit": "",
@@ -883,6 +1375,18 @@
"resource_spec": "",
"work_item_code": "FP-12-26"
},
{
"amount": "0.2",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0371",
"raw_row_index": 1,
"resource_code": "1003",
"resource_kind": "labor",
"resource_name": "특별인부",
"resource_spec": "",
"work_item_code": "FP-12-24-01"
},
{
"amount": "4.0",
"amount_unit": "",
@@ -895,6 +1399,42 @@
"resource_spec": "",
"work_item_code": "FP-12-24-01"
},
{
"amount": "0.12",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0378",
"raw_row_index": 2,
"resource_code": "1027",
"resource_kind": "labor",
"resource_name": "미장공",
"resource_spec": "",
"work_item_code": "FP-12-28"
},
{
"amount": "0.004",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0384",
"raw_row_index": 1,
"resource_code": "1002",
"resource_kind": "labor",
"resource_name": "보통인부",
"resource_spec": "",
"work_item_code": "FP-12-33"
},
{
"amount": "3.8",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0387",
"raw_row_index": 1,
"resource_code": "1008",
"resource_kind": "labor",
"resource_name": "철근공",
"resource_spec": "",
"work_item_code": "FP-12-34-03"
},
{
"amount": "2.2",
"amount_unit": "",
@@ -943,6 +1483,30 @@
"resource_spec": "",
"work_item_code": "FP-13-02-02"
},
{
"amount": "2.6",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0399",
"raw_row_index": 0,
"resource_code": "1001",
"resource_kind": "labor",
"resource_name": "작업반장",
"resource_spec": "",
"work_item_code": "FP-13-02-03"
},
{
"amount": "0.83",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0419",
"raw_row_index": 1,
"resource_code": "1001",
"resource_kind": "labor",
"resource_name": "작업반장",
"resource_spec": "",
"work_item_code": "FP-13-06-01"
},
{
"amount": "1.04",
"amount_unit": "",
@@ -955,6 +1519,18 @@
"resource_spec": "",
"work_item_code": "FP-13-06-01"
},
{
"amount": "0.83",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0420",
"raw_row_index": 1,
"resource_code": "1001",
"resource_kind": "labor",
"resource_name": "작업반장",
"resource_spec": "",
"work_item_code": "FP-13-06-02"
},
{
"amount": "1.30",
"amount_unit": "",
@@ -979,6 +1555,18 @@
"resource_spec": "0.8",
"work_item_code": "FP-13-06-02"
},
{
"amount": "1.19",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0421",
"raw_row_index": 1,
"resource_code": "1001",
"resource_kind": "labor",
"resource_name": "작업반장",
"resource_spec": "",
"work_item_code": "FP-13-06-03"
},
{
"amount": "1.86",
"amount_unit": "",
@@ -1003,6 +1591,18 @@
"resource_spec": "0.8",
"work_item_code": "FP-13-06-03"
},
{
"amount": "0.58",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0422",
"raw_row_index": 1,
"resource_code": "1001",
"resource_kind": "labor",
"resource_name": "작업반장",
"resource_spec": "",
"work_item_code": "FP-13-07-01"
},
{
"amount": "0.58",
"amount_unit": "",
@@ -1027,6 +1627,18 @@
"resource_spec": "0.8",
"work_item_code": "FP-13-07-01"
},
{
"amount": "0.58",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0423",
"raw_row_index": 1,
"resource_code": "1001",
"resource_kind": "labor",
"resource_name": "작업반장",
"resource_spec": "",
"work_item_code": "FP-13-07-02"
},
{
"amount": "1.01",
"amount_unit": "",
@@ -1063,6 +1675,18 @@
"resource_spec": "",
"work_item_code": "FP-13-11-02"
},
{
"amount": "0.0141",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0438",
"raw_row_index": 0,
"resource_code": "1001",
"resource_kind": "labor",
"resource_name": "작업반장",
"resource_spec": "",
"work_item_code": "FP-13-12-02"
},
{
"amount": "0.0381",
"amount_unit": "",
@@ -1087,6 +1711,18 @@
"resource_spec": "",
"work_item_code": "FP-13-12-02"
},
{
"amount": "0.20",
"amount_unit": "",
"pum_form": "requirement",
"pum_table_id": "F0441",
"raw_row_index": 0,
"resource_code": "1002",
"resource_kind": "labor",
"resource_name": "보통인부",
"resource_spec": "",
"work_item_code": "FP-13-13-02"
},
{
"amount": "0.0027",
"amount_unit": "",
@@ -1107,13 +1743,17 @@
"file": "pum_forest_2026.json",
"sha256": "111208fd482dcfc3b8c8dd58004b153382fc46555d0d00985c19b45ebbc2e0dd"
},
"source_master_file": {
"file": "work_item_master_2026-01-01.json",
"sha256": "593653135d5a2871180e7a3921f9238629b275438ef47230412aa21e9bdd80c0"
},
"stats": {
"rows": 91,
"rows": 144,
"skipped_forms": {
"coefficient": 19,
"reference": 94,
"undetermined": 83
"reference": 98,
"undetermined": 76
},
"unmatched": 269
"unmatched": 330
}
}
@@ -44,6 +44,12 @@
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-02-01-05"
},
{
"cell": "친환경 비닐랩",
"pum_table_id": "F0055",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-02-01-05"
},
{
"cell": "천공기날",
"pum_table_id": "F0056",
@@ -68,6 +74,48 @@
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-02-01-06"
},
{
"cell": "경비(기계경비)",
"pum_table_id": "F0456",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-02-02"
},
{
"cell": "경비(기계경비)",
"pum_table_id": "F0472",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-03-01"
},
{
"cell": "작업로 예정선 선정 및 표식",
"pum_table_id": "F0076",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-03-02"
},
{
"cell": "경비(기계경비)",
"pum_table_id": "F0473",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-03-02"
},
{
"cell": "소작업로",
"pum_table_id": "F0077",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-03-03"
},
{
"cell": "대작업로",
"pum_table_id": "F0077",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-03-03"
},
{
"cell": "경비(기계경비)",
"pum_table_id": "F0474",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-03-03"
},
{
"cell": "모든 벌채산물 임내존치지역",
"pum_table_id": "F0079",
@@ -104,6 +152,12 @@
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-03-05"
},
{
"cell": "임업기계장비 이용 정리",
"pum_table_id": "F0079",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-03-05"
},
{
"cell": "벌채와 동시정리지역",
"pum_table_id": "F0079",
@@ -128,6 +182,18 @@
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-04-01"
},
{
"cell": "o 재료비",
"pum_table_id": "F0475",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-04-01"
},
{
"cell": "o 경비(기계경비)",
"pum_table_id": "F0475",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-04-01"
},
{
"cell": "단목",
"pum_table_id": "F0083",
@@ -152,6 +218,24 @@
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-04-02"
},
{
"cell": "o 재료비",
"pum_table_id": "F0476",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-04-02"
},
{
"cell": "o 경비(기계경비)",
"pum_table_id": "F0476",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-04-02"
},
{
"cell": "작업보조",
"pum_table_id": "F0086",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-04-02-01"
},
{
"cell": "굴착기+부착용집게",
"pum_table_id": "F0087",
@@ -230,6 +314,24 @@
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-05-04"
},
{
"cell": "지면긁기작업",
"pum_table_id": "F0110",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-05-05"
},
{
"cell": "폭 80cm × 열간거리 2m (전면적의 40%)",
"pum_table_id": "F0110",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-05-05"
},
{
"cell": "맹아근주 정리작업",
"pum_table_id": "F0111",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-05-06"
},
{
"cell": "움싹본수조절",
"pum_table_id": "F0112",
@@ -315,7 +417,7 @@
"work_item_code": "FP-05-22-04"
},
{
"cell": "자",
"cell": "자",
"pum_table_id": "F0135",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-05-24-01"
@@ -344,6 +446,12 @@
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-05-24-01"
},
{
"cell": "종자살포기",
"pum_table_id": "F0135",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-05-24-01"
},
{
"cell": "트 럭",
"pum_table_id": "F0135",
@@ -357,7 +465,7 @@
"work_item_code": "FP-05-24-01"
},
{
"cell": "자",
"cell": "자",
"pum_table_id": "F0136",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-05-24-02"
@@ -386,6 +494,12 @@
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-05-24-02"
},
{
"cell": "종자살포기",
"pum_table_id": "F0136",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-05-24-02"
},
{
"cell": "트 럭",
"pum_table_id": "F0136",
@@ -399,7 +513,7 @@
"work_item_code": "FP-05-24-02"
},
{
"cell": "자재",
"cell": "거 적",
"pum_table_id": "F0139",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-05-25"
@@ -518,6 +632,12 @@
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-06-04-02"
},
{
"cell": "작업보조",
"pum_table_id": "F0159",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-06-04-02"
},
{
"cell": "소금 처리",
"pum_table_id": "F0160",
@@ -542,6 +662,12 @@
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-06-04-04"
},
{
"cell": "유령림 단계",
"pum_table_id": "F0163",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-06-05"
},
{
"cell": "병해충방제",
"pum_table_id": "F0172",
@@ -602,6 +728,12 @@
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-07-08-02"
},
{
"cell": "작업량",
"pum_table_id": "F0188",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-07-10"
},
{
"cell": "원목집재와 동시에 부산물 수집시",
"pum_table_id": "F0191",
@@ -614,6 +746,12 @@
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-07-13"
},
{
"cell": "경비(기계경비)",
"pum_table_id": "F0454",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-08-02-03"
},
{
"cell": "롤트랩 설치",
"pum_table_id": "F0223",
@@ -836,12 +974,24 @@
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-08-10"
},
{
"cell": "피복작업",
"pum_table_id": "F0236",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-08-10"
},
{
"cell": "벌목조재",
"pum_table_id": "F0236",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-08-10"
},
{
"cell": "피복작업",
"pum_table_id": "F0236",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-08-10"
},
{
"cell": "대형브레이커+ 유압식백호우 (무한궤도,0.7㎥)",
"pum_table_id": "F0241",
@@ -861,7 +1011,7 @@
"work_item_code": "FP-09-04-01"
},
{
"cell": "자재",
"cell": "폭 약",
"pum_table_id": "F0243",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-09-05-01"
@@ -878,6 +1028,18 @@
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-09-05-01"
},
{
"cell": "화 약 공",
"pum_table_id": "F0243",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-09-05-01"
},
{
"cell": "착 암 기",
"pum_table_id": "F0243",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-09-05-01"
},
{
"cell": "공기압축기",
"pum_table_id": "F0243",
@@ -909,7 +1071,7 @@
"work_item_code": "FP-09-06-01"
},
{
"cell": "자재",
"cell": "아세틸렌",
"pum_table_id": "F0251",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-09-08-01"
@@ -941,9 +1103,21 @@
{
"cell": "아스팔트",
"pum_table_id": "F0255",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"reason": "규격이 없어 같은 이름 여럿 중 고를 수 없음",
"work_item_code": "FP-09-10-02"
},
{
"cell": "유압식백호우 (무한궤도,0.7㎥)",
"pum_table_id": "F0258",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-09-12-01"
},
{
"cell": "대형브레이커(㎥/hr)",
"pum_table_id": "F0259",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-09-12-02"
},
{
"cell": "치즐소모(본/hr)",
"pum_table_id": "F0259",
@@ -956,6 +1130,12 @@
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-09-12-02"
},
{
"cell": "대형브레이커(㎥/hr)",
"pum_table_id": "F0260",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-09-12-03"
},
{
"cell": "치즐소모량(본/hr)",
"pum_table_id": "F0260",
@@ -968,6 +1148,24 @@
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-09-12-03"
},
{
"cell": "유압식백호우 (무한궤도,0.7㎥)",
"pum_table_id": "F0261",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-09-13-01"
},
{
"cell": "유압식백호우 (무한궤도,0.7㎥)",
"pum_table_id": "F0264",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-09-13-04"
},
{
"cell": "깨기",
"pum_table_id": "F0267",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-09-13-07"
},
{
"cell": "치즐소모량(본/hr)",
"pum_table_id": "F0267",
@@ -980,18 +1178,36 @@
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-09-13-07"
},
{
"cell": "깨기",
"pum_table_id": "F0268",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-09-13-08"
},
{
"cell": "치즐소모량(본/hr)",
"pum_table_id": "F0268",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-09-13-08"
},
{
"cell": "깨기",
"pum_table_id": "F0269",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-09-13-09"
},
{
"cell": "치즐소모량(본/hr)",
"pum_table_id": "F0269",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-09-13-09"
},
{
"cell": "깨기",
"pum_table_id": "F0270",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-09-13-10"
},
{
"cell": "치즐소모량(본/hr)",
"pum_table_id": "F0270",
@@ -1004,18 +1220,36 @@
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-09-13-10"
},
{
"cell": "깨기",
"pum_table_id": "F0271",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-09-13-11"
},
{
"cell": "치즐소모량(본/hr)",
"pum_table_id": "F0271",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-09-13-11"
},
{
"cell": "깨기",
"pum_table_id": "F0272",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-09-13-12"
},
{
"cell": "치즐소모량(본/hr)",
"pum_table_id": "F0272",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-09-13-12"
},
{
"cell": "깨기",
"pum_table_id": "F0273",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-09-13-13"
},
{
"cell": "치즐소모량(본/hr)",
"pum_table_id": "F0273",
@@ -1028,18 +1262,36 @@
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-09-13-13"
},
{
"cell": "깨기",
"pum_table_id": "F0274",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-09-13-14"
},
{
"cell": "치즐소모량(본/hr)",
"pum_table_id": "F0274",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-09-13-14"
},
{
"cell": "깨기",
"pum_table_id": "F0275",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-09-13-15"
},
{
"cell": "치즐소모량(본/hr)",
"pum_table_id": "F0275",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-09-13-15"
},
{
"cell": "깨기",
"pum_table_id": "F0276",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-09-13-16"
},
{
"cell": "치즐소모량(본/hr)",
"pum_table_id": "F0276",
@@ -1053,17 +1305,35 @@
"work_item_code": "FP-09-13-16"
},
{
"cell": "치즐소모량(본/hr)",
"cell": "깨기",
"pum_table_id": "F0277",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-09-13-17"
},
{
"cell": "치즐소모량(본/hr)",
"pum_table_id": "F0277",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-09-13-17"
},
{
"cell": "깨기",
"pum_table_id": "F0278",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-09-13-18"
},
{
"cell": "치즐소모량(본/hr)",
"pum_table_id": "F0278",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-09-13-18"
},
{
"cell": "유압식백호우 (무한궤도,0.7㎥)",
"pum_table_id": "F0279",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-09-14-01"
},
{
"cell": "모래ㆍ사질토ㆍ점토ㆍ점질토",
"pum_table_id": "F0288",
@@ -1100,6 +1370,18 @@
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-09-19-01"
},
{
"cell": "유압식백호우 (무한궤도,0.7㎥)",
"pum_table_id": "F0290",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-09-19-02"
},
{
"cell": "공기압축기(3.5㎥/min)",
"pum_table_id": "F0291",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-09-19-03"
},
{
"cell": "소형브레이커",
"pum_table_id": "F0291",
@@ -1223,18 +1505,24 @@
{
"cell": "각 재",
"pum_table_id": "F0336",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"reason": "규격이 없어 같은 이름 여럿 중 고를 수 없음",
"work_item_code": "FP-12-04"
},
{
"cell": "철 선",
"pum_table_id": "F0336",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"reason": "규격이 없어 같은 이름 여럿 중 고를 수 없음",
"work_item_code": "FP-12-04"
},
{
"cell": "박 리 제",
"pum_table_id": "F0336",
"reason": "규격이 없어 같은 이름 여럿 중 고를 수 없음",
"work_item_code": "FP-12-04"
},
{
"cell": "사용고재 평가기준",
"pum_table_id": "F0336",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-12-04"
},
@@ -1353,17 +1641,47 @@
"work_item_code": "FP-12-18"
},
{
"cell": "자재",
"pum_table_id": "F0368",
"cell": "강관 동바리",
"pum_table_id": "F0367",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-12-20"
},
{
"cell": "외관(60.6mm×2.3mm)",
"pum_table_id": "F0367",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-12-20"
},
{
"cell": "잡재료비(재료비의)",
"pum_table_id": "F0367",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-12-20"
},
{
"cell": "아스팔트(㏊-500)",
"pum_table_id": "F0368",
"reason": "규격이 없어 같은 이름 여럿 중 고를 수 없음",
"work_item_code": "FP-12-21"
},
{
"cell": "자재",
"cell": "부 직 포",
"pum_table_id": "F0370",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-12-23"
},
{
"cell": "잡재료비(재료비의)",
"pum_table_id": "F0370",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-12-23"
},
{
"cell": "부설",
"pum_table_id": "F0372",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-12-24-02"
},
{
"cell": "적사",
"pum_table_id": "F0373",
@@ -1383,10 +1701,10 @@
"work_item_code": "FP-12-26"
},
{
"cell": "자재",
"pum_table_id": "F0376",
"cell": "부설",
"pum_table_id": "F0371",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-12-27-02"
"work_item_code": "FP-12-24-01"
},
{
"cell": "접착제",
@@ -1395,13 +1713,37 @@
"work_item_code": "FP-12-27-02"
},
{
"cell": "재료",
"pum_table_id": "F0384",
"cell": "인력(설치비)",
"pum_table_id": "F0376",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-12-33"
"work_item_code": "FP-12-27-02"
},
{
"cell": "자재",
"cell": "실런트",
"pum_table_id": "F0377",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-12-27-03"
},
{
"cell": "인력(설치비)",
"pum_table_id": "F0377",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-12-27-03"
},
{
"cell": "에폭시 접착제",
"pum_table_id": "F0378",
"reason": "규격이 없어 같은 이름 여럿 중 고를 수 없음",
"work_item_code": "FP-12-28"
},
{
"cell": "시너",
"pum_table_id": "F0378",
"reason": "규격이 없어 같은 이름 여럿 중 고를 수 없음",
"work_item_code": "FP-12-28"
},
{
"cell": "결속선(R-0.9mm)",
"pum_table_id": "F0387",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-12-34-03"
@@ -1430,6 +1772,12 @@
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-13-02-03"
},
{
"cell": "제 잡비 비율",
"pum_table_id": "F0399",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-13-02-03"
},
{
"cell": "인 부",
"pum_table_id": "F0401",
@@ -1490,6 +1838,12 @@
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-13-11-01"
},
{
"cell": "인력(인)",
"pum_table_id": "F0431",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-13-11-01"
},
{
"cell": "돌 채 움",
"pum_table_id": "F0431",
@@ -1520,6 +1874,12 @@
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-13-11-02"
},
{
"cell": "인력(인)",
"pum_table_id": "F0433",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-13-11-02"
},
{
"cell": "돌 채 움",
"pum_table_id": "F0433",
@@ -1574,6 +1934,12 @@
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-13-13-01"
},
{
"cell": "굴착기 (0.2㎥)",
"pum_table_id": "F0441",
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
"work_item_code": "FP-13-13-02"
},
{
"cell": "간 단 구 조",
"pum_table_id": "F0443",
+198 -38
View File
@@ -37,7 +37,10 @@ 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.",
@@ -69,19 +72,34 @@ 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"],
@@ -96,7 +114,10 @@ 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암거 전환 유량 조건 */
@@ -121,7 +142,10 @@ 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.",
@@ -156,27 +180,45 @@ 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"],
@@ -189,7 +231,10 @@ 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"],
@@ -216,9 +261,18 @@ 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.",
@@ -244,9 +298,18 @@ 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. */
@@ -267,7 +330,10 @@ 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"],
@@ -276,7 +342,10 @@ 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)",
@@ -444,7 +513,10 @@ 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"],
@@ -485,8 +557,14 @@ 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.",
@@ -499,17 +577,29 @@ 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)"],
@@ -532,7 +622,10 @@ 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: [
@@ -542,7 +635,10 @@ 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.",
@@ -569,7 +665,10 @@ 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"],
@@ -584,7 +683,10 @@ 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 ). */
@@ -610,7 +712,10 @@ 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.",
@@ -650,7 +755,10 @@ 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"],
@@ -688,7 +796,10 @@ 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.",
@@ -698,6 +809,52 @@ export const ui_locales_b2 = {
"Failed to confirm the cost estimate stage.",
],
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_Drill: ["펼쳐 보기", "Open"],
B09_Estimation_Col_Name: ["명칭", "Name"],
B09_Estimation_Col_Spec: ["규격", "Spec"],
B09_Estimation_Col_Unit: ["단위", "Unit"],
B09_Estimation_Col_Qty: ["수량", "Qty"],
B09_Estimation_Col_Source: ["원천", "Source"],
B09_Estimation_Col_Material: ["재료비", "Material"],
B09_Estimation_Col_Labor: ["노무비", "Labor"],
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_RoundGap: [
"행별로 0.1원 미만을 버려 합계 끝자리가 다릅니다 (정상). 자르기 전 합계:",
"Rows are floored to 0.1 KRW, so the total's last digit differs (expected). Unrounded total:",
],
B09_Estimation_Group_Quantity: ["수량", "Quantities"],
B09_Estimation_Field_Quantities: [
"공종별 수량 (한 줄에 「공종코드=수량」)",
'Quantities (one "code=qty" per line)',
],
B09_Estimation_Src_Manual: [
"수량 원천: 손입력(직접비 직접 입력)",
"Source: manual direct costs",
],
B09_Estimation_Src_Quantities: [
"수량 원천: 손입력 공종 수량 × 일위대가",
"Source: manual quantities × unit prices",
],
B09_Estimation_Missing_UP: [
"수량은 있는데 단가가 없는 공종 — 총액에서 빠졌습니다:",
"Quantities without a unit price — excluded from the total:",
],
B09_Estimation_UP_Load_Failed: [
"일위대가를 못 불러왔습니다.",
"Failed to load unit prices.",
],
/* --- B10_Payment 결재 --- */
B10_Payment_Title: ["결재", "Payment"],
@@ -715,7 +872,10 @@ 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.",