feat(B09): ④ 예산내역서 조판 + 배분율 표 오독 차단
조판 - B08 인계 응답을 계층 선 내역서로 접음. 계층·정렬은 공종 마스터의 `parent_code`·`sort_order`(256 간격)에서 옴 — 코드 글자수로 깊이 안 셈 - ITEM NO. 를 가지치기 나무에서 매김. 머리글 줄은 수량·금액 없음 - `in_bill=false`(보정량계)는 수량만 보이고 금액 안 붙임. `check_excluded_rows_not_priced()` 가 수치로 막음 (㉡ 확장) - 단가 없는 줄·공급 구분 미정 자재는 0 으로 안 때우고 `missing` 에 이름째 남김 - 잎에 일위대가가 없고 하위에 있으면 **후보만 보임** — 임의로 고르지 않음 - 반영률은 적기만 함(B08 이 이미 곱함) — 여기서 또 곱하면 두 배 배분율 표 오독 차단 (2026-09-08 실측으로 발견) - 분류 딱지가 「인력(10%)」처럼 비율을 달고 오면 판정이 빗나가 자원이 통째로 빠지고 있었음. 측구터파기(FP-09-12-01)는 자원 줄이 0 개였음 - 비율 꼬리표만 떼고 정확 일치 유지 — 「보통인부(인)」·「인력운반공」은 안 걸림 - 자원 축 119 → 144 줄, 일위대가 73 → 85. 기존 줄 변경 0·삭제 0 - ⚠ 그 표들은 인력 몫만 붙음(장비 몫은 시공능력 공식). 그대로 두면 인력 10 % 몫 단가가 전량에 곱해져 **조용히 틀림** — 25 공종을 `partial_ratio` 로 표시하고 내역서에서 금액을 안 붙임 검증: pytest 146 통과(신규 10). 가드는 일부러 어겨 멈추는 것까지 확인, 오탐 짝 시험 포함. 실물 인계자료(프로젝트 5cff3920)로 조판 실행 — 14 줄·검산줄 1·미확보 14 건이 이름째 뜸 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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,
|
||||
)
|
||||
@@ -179,3 +179,29 @@ def check_column_sums(
|
||||
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 ㉡ 와 같은 성격)."
|
||||
)
|
||||
|
||||
@@ -366,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 {
|
||||
@@ -379,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),
|
||||
}
|
||||
|
||||
|
||||
@@ -458,7 +463,9 @@ def match_table(
|
||||
# 첫 칸이 분류 딱지(「자재」·「장비」)면 **이름은 둘째 칸**이다.
|
||||
name_cell = cells[0]
|
||||
value_cells = cells[1:]
|
||||
if _normalize(name_cell) in _GROUP_LABELS and len(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:]
|
||||
|
||||
@@ -509,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()
|
||||
|
||||
@@ -66,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:
|
||||
@@ -228,9 +230,39 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
|
||||
)
|
||||
for row, ref in attachable:
|
||||
build.book.add_detail(PriceDetail(title_code, ref, row.amount))
|
||||
|
||||
# ⚠ **배분율이 있는 표는 「몇 %가 실제로 붙었나」를 세어 둔다.**
|
||||
# 「인력(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
|
||||
|
||||
@@ -475,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": "",
|
||||
@@ -487,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": "",
|
||||
@@ -499,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": "",
|
||||
@@ -511,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": "",
|
||||
@@ -523,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": "",
|
||||
@@ -535,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": "",
|
||||
@@ -547,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": "",
|
||||
@@ -559,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": "",
|
||||
@@ -571,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": "",
|
||||
@@ -583,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": "",
|
||||
@@ -595,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": "",
|
||||
@@ -607,6 +859,18 @@
|
||||
"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": "",
|
||||
@@ -1075,6 +1339,18 @@
|
||||
"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": "",
|
||||
@@ -1099,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": "",
|
||||
@@ -1423,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": "",
|
||||
@@ -1448,12 +1748,12 @@
|
||||
"sha256": "593653135d5a2871180e7a3921f9238629b275438ef47230412aa21e9bdd80c0"
|
||||
},
|
||||
"stats": {
|
||||
"rows": 119,
|
||||
"rows": 144,
|
||||
"skipped_forms": {
|
||||
"coefficient": 19,
|
||||
"reference": 98,
|
||||
"undetermined": 76
|
||||
},
|
||||
"unmatched": 355
|
||||
"unmatched": 330
|
||||
}
|
||||
}
|
||||
@@ -1107,25 +1107,13 @@
|
||||
"work_item_code": "FP-09-10-02"
|
||||
},
|
||||
{
|
||||
"cell": "인력(10%)",
|
||||
"cell": "유압식백호우 (무한궤도,0.7㎥)",
|
||||
"pum_table_id": "F0258",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-12-01"
|
||||
},
|
||||
{
|
||||
"cell": "장비(90%)",
|
||||
"pum_table_id": "F0258",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-12-01"
|
||||
},
|
||||
{
|
||||
"cell": "인력 (10%)",
|
||||
"pum_table_id": "F0259",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-12-02"
|
||||
},
|
||||
{
|
||||
"cell": "장비 (90%)",
|
||||
"cell": "대형브레이커(㎥/hr)",
|
||||
"pum_table_id": "F0259",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-12-02"
|
||||
@@ -1143,13 +1131,7 @@
|
||||
"work_item_code": "FP-09-12-02"
|
||||
},
|
||||
{
|
||||
"cell": "인력 (10%)",
|
||||
"pum_table_id": "F0260",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-12-03"
|
||||
},
|
||||
{
|
||||
"cell": "장비 (90%)",
|
||||
"cell": "대형브레이커(㎥/hr)",
|
||||
"pum_table_id": "F0260",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-12-03"
|
||||
@@ -1167,61 +1149,19 @@
|
||||
"work_item_code": "FP-09-12-03"
|
||||
},
|
||||
{
|
||||
"cell": "인력 (10%)",
|
||||
"cell": "유압식백호우 (무한궤도,0.7㎥)",
|
||||
"pum_table_id": "F0261",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-13-01"
|
||||
},
|
||||
{
|
||||
"cell": "장비 (90%)",
|
||||
"pum_table_id": "F0261",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-13-01"
|
||||
},
|
||||
{
|
||||
"cell": "인력 (10%)",
|
||||
"pum_table_id": "F0262",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-13-02"
|
||||
},
|
||||
{
|
||||
"cell": "인력 (10%)",
|
||||
"pum_table_id": "F0263",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-13-03"
|
||||
},
|
||||
{
|
||||
"cell": "인력 (10%)",
|
||||
"cell": "유압식백호우 (무한궤도,0.7㎥)",
|
||||
"pum_table_id": "F0264",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-13-04"
|
||||
},
|
||||
{
|
||||
"cell": "장비 (90%)",
|
||||
"pum_table_id": "F0264",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-13-04"
|
||||
},
|
||||
{
|
||||
"cell": "인력 (10%)",
|
||||
"pum_table_id": "F0265",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-13-05"
|
||||
},
|
||||
{
|
||||
"cell": "인력 (10%)",
|
||||
"pum_table_id": "F0266",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-13-06"
|
||||
},
|
||||
{
|
||||
"cell": "인력 (10%)",
|
||||
"pum_table_id": "F0267",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-13-07"
|
||||
},
|
||||
{
|
||||
"cell": "장비 (90%)",
|
||||
"cell": "깨기",
|
||||
"pum_table_id": "F0267",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-13-07"
|
||||
@@ -1239,13 +1179,7 @@
|
||||
"work_item_code": "FP-09-13-07"
|
||||
},
|
||||
{
|
||||
"cell": "인력 (10%)",
|
||||
"pum_table_id": "F0268",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-13-08"
|
||||
},
|
||||
{
|
||||
"cell": "장비 (90%)",
|
||||
"cell": "깨기",
|
||||
"pum_table_id": "F0268",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-13-08"
|
||||
@@ -1257,13 +1191,7 @@
|
||||
"work_item_code": "FP-09-13-08"
|
||||
},
|
||||
{
|
||||
"cell": "인력 (10%)",
|
||||
"pum_table_id": "F0269",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-13-09"
|
||||
},
|
||||
{
|
||||
"cell": "장비 (90%)",
|
||||
"cell": "깨기",
|
||||
"pum_table_id": "F0269",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-13-09"
|
||||
@@ -1275,13 +1203,7 @@
|
||||
"work_item_code": "FP-09-13-09"
|
||||
},
|
||||
{
|
||||
"cell": "인력 (10%)",
|
||||
"pum_table_id": "F0270",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-13-10"
|
||||
},
|
||||
{
|
||||
"cell": "장비 (90%)",
|
||||
"cell": "깨기",
|
||||
"pum_table_id": "F0270",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-13-10"
|
||||
@@ -1299,13 +1221,7 @@
|
||||
"work_item_code": "FP-09-13-10"
|
||||
},
|
||||
{
|
||||
"cell": "인력 (10%)",
|
||||
"pum_table_id": "F0271",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-13-11"
|
||||
},
|
||||
{
|
||||
"cell": "장비 (90%)",
|
||||
"cell": "깨기",
|
||||
"pum_table_id": "F0271",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-13-11"
|
||||
@@ -1317,13 +1233,7 @@
|
||||
"work_item_code": "FP-09-13-11"
|
||||
},
|
||||
{
|
||||
"cell": "인력 (10%)",
|
||||
"pum_table_id": "F0272",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-13-12"
|
||||
},
|
||||
{
|
||||
"cell": "장비 (90%)",
|
||||
"cell": "깨기",
|
||||
"pum_table_id": "F0272",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-13-12"
|
||||
@@ -1335,13 +1245,7 @@
|
||||
"work_item_code": "FP-09-13-12"
|
||||
},
|
||||
{
|
||||
"cell": "인력 (10%)",
|
||||
"pum_table_id": "F0273",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-13-13"
|
||||
},
|
||||
{
|
||||
"cell": "장비 (90%)",
|
||||
"cell": "깨기",
|
||||
"pum_table_id": "F0273",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-13-13"
|
||||
@@ -1359,13 +1263,7 @@
|
||||
"work_item_code": "FP-09-13-13"
|
||||
},
|
||||
{
|
||||
"cell": "인력 (10%)",
|
||||
"pum_table_id": "F0274",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-13-14"
|
||||
},
|
||||
{
|
||||
"cell": "장비 (90%)",
|
||||
"cell": "깨기",
|
||||
"pum_table_id": "F0274",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-13-14"
|
||||
@@ -1377,13 +1275,7 @@
|
||||
"work_item_code": "FP-09-13-14"
|
||||
},
|
||||
{
|
||||
"cell": "인력 (10%)",
|
||||
"pum_table_id": "F0275",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-13-15"
|
||||
},
|
||||
{
|
||||
"cell": "장비 (90%)",
|
||||
"cell": "깨기",
|
||||
"pum_table_id": "F0275",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-13-15"
|
||||
@@ -1395,13 +1287,7 @@
|
||||
"work_item_code": "FP-09-13-15"
|
||||
},
|
||||
{
|
||||
"cell": "인력 (10%)",
|
||||
"pum_table_id": "F0276",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-13-16"
|
||||
},
|
||||
{
|
||||
"cell": "장비 (90%)",
|
||||
"cell": "깨기",
|
||||
"pum_table_id": "F0276",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-13-16"
|
||||
@@ -1419,13 +1305,7 @@
|
||||
"work_item_code": "FP-09-13-16"
|
||||
},
|
||||
{
|
||||
"cell": "인력 (10%)",
|
||||
"pum_table_id": "F0277",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-13-17"
|
||||
},
|
||||
{
|
||||
"cell": "장비 (90%)",
|
||||
"cell": "깨기",
|
||||
"pum_table_id": "F0277",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-13-17"
|
||||
@@ -1437,13 +1317,7 @@
|
||||
"work_item_code": "FP-09-13-17"
|
||||
},
|
||||
{
|
||||
"cell": "인력 (10%)",
|
||||
"pum_table_id": "F0278",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-13-18"
|
||||
},
|
||||
{
|
||||
"cell": "장비 (90%)",
|
||||
"cell": "깨기",
|
||||
"pum_table_id": "F0278",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-13-18"
|
||||
@@ -1455,13 +1329,7 @@
|
||||
"work_item_code": "FP-09-13-18"
|
||||
},
|
||||
{
|
||||
"cell": "인력 (10%)",
|
||||
"pum_table_id": "F0279",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-14-01"
|
||||
},
|
||||
{
|
||||
"cell": "장비 (90%)",
|
||||
"cell": "유압식백호우 (무한궤도,0.7㎥)",
|
||||
"pum_table_id": "F0279",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-09-14-01"
|
||||
@@ -1809,13 +1677,7 @@
|
||||
"work_item_code": "FP-12-23"
|
||||
},
|
||||
{
|
||||
"cell": "인력 (10%)",
|
||||
"pum_table_id": "F0372",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-12-24-02"
|
||||
},
|
||||
{
|
||||
"cell": "장비 (90%)",
|
||||
"cell": "부설",
|
||||
"pum_table_id": "F0372",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-12-24-02"
|
||||
@@ -1839,13 +1701,7 @@
|
||||
"work_item_code": "FP-12-26"
|
||||
},
|
||||
{
|
||||
"cell": "인력 (10%)",
|
||||
"pum_table_id": "F0371",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-12-24-01"
|
||||
},
|
||||
{
|
||||
"cell": "장비 (90%)",
|
||||
"cell": "부설",
|
||||
"pum_table_id": "F0371",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-12-24-01"
|
||||
@@ -2079,13 +1935,7 @@
|
||||
"work_item_code": "FP-13-13-01"
|
||||
},
|
||||
{
|
||||
"cell": "인력(10%)",
|
||||
"pum_table_id": "F0441",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-13-13-02"
|
||||
},
|
||||
{
|
||||
"cell": "장비(90%)",
|
||||
"cell": "굴착기 (0.2㎥)",
|
||||
"pum_table_id": "F0441",
|
||||
"reason": "카탈로그에 없는 이름 (기계·자재 카탈로그 미확보 포함)",
|
||||
"work_item_code": "FP-13-13-02"
|
||||
|
||||
Reference in New Issue
Block a user