Merge remote-tracking branch 'origin/sub_desktop_1' into main_laptop_1
This commit is contained in:
@@ -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,
|
||||
)
|
||||
@@ -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": [
|
||||
"자재 단가는 할증 전·부가세 제외 값입니다 — 할증은 자재총괄에서 한 번만 붙습니다.",
|
||||
"관급 자재의 설치 주체가 미지정이라 안전관리비 대상액에 자동으로 넣지 않습니다.",
|
||||
],
|
||||
}
|
||||
@@ -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}")
|
||||
|
||||
@@ -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 로 전이한다."""
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user