Merge remote-tracking branch 'origin/sub_desktop_1' into main_desktop_1
This commit is contained in:
@@ -25,6 +25,7 @@ from typing import Any
|
||||
|
||||
from B09_Estimation.B09_Estimation_Guards import (
|
||||
check_excluded_rows_not_priced,
|
||||
check_included_materials_not_listed,
|
||||
check_free_haul_not_priced,
|
||||
check_haul_volume_within_cut,
|
||||
)
|
||||
@@ -61,6 +62,16 @@ class HandoffWorkItem:
|
||||
ground_class: str = ""
|
||||
#: 반영률(%) — B08 이 이미 곱했으면 산출근거에만 적고 **여기서 또 곱하지 않는다**.
|
||||
application_ratio_pct: Decimal | None = None
|
||||
#: 반영률 적용 **전** 수량. 산출근거에만 쓴다.
|
||||
quantity_gross: Decimal | None = None
|
||||
#: 성·절토처럼 율이 갈리는 경우의 몫별 율·수량 — 문장 파싱 없이 그대로 그린다.
|
||||
application_ratio_breakdown: dict | None = None
|
||||
quantity_breakdown: dict | None = None
|
||||
#: 묶음 줄(옹벽처럼 품셈에 그 공종이 없는 것) — 무엇으로 이루어지는지.
|
||||
composite_parts: tuple = ()
|
||||
#: 묶음인데 아직 못 채운 조각 — 「단가 없음」과 「물량 없음」을 갈라 적는다.
|
||||
composite_not_ready: tuple = ()
|
||||
structure_kind: str = ""
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
@@ -192,6 +203,12 @@ def parse_handoff(payload: dict[str, Any]) -> tuple[list[HandoffWorkItem], list[
|
||||
haul_equipment=row.get("haul_equipment"),
|
||||
# ⚠ 있으면 **적기만** 한다 — 곱하기는 B08 한 곳에서만(2026-09-08 이견 ①).
|
||||
application_ratio_pct=_decimal(row.get("application_ratio_pct"), None),
|
||||
quantity_gross=_decimal(row.get("quantity_gross"), None),
|
||||
application_ratio_breakdown=row.get("application_ratio_breakdown"),
|
||||
quantity_breakdown=row.get("quantity_breakdown"),
|
||||
composite_parts=tuple(row.get("composite_parts") or ()),
|
||||
composite_not_ready=tuple(row.get("composite_not_ready") or ()),
|
||||
structure_kind=row.get("structure_kind") or "",
|
||||
)
|
||||
for row in payload["work_items"]
|
||||
]
|
||||
@@ -271,6 +288,7 @@ def build_bill(
|
||||
# 정렬은 마스터의 `sort_order`(256 간격)를 그대로 따른다 — 우리가 다시 매기지 않는다.
|
||||
used: list[tuple[tuple[int, ...], HandoffWorkItem, list[_MasterNode]]] = []
|
||||
orphans: list[HandoffWorkItem] = []
|
||||
composites: list[HandoffWorkItem] = []
|
||||
for item in work_items:
|
||||
if not item.in_bill:
|
||||
# ⚠ 코드 유무보다 **먼저** 가른다. 보정량계는 공종코드가 없어서가 아니라
|
||||
@@ -278,6 +296,11 @@ def build_bill(
|
||||
# 줄」로 잘못 읽힌다.
|
||||
result.excluded.append(_excluded_row(item))
|
||||
continue
|
||||
if (item.composite_parts or item.composite_not_ready) and not item.work_item_code:
|
||||
# 묶음 줄 — 품셈에 그 공종이 없어 **조각을 합쳐** 한 줄로 세운다
|
||||
# (옹벽 = 타설 + 거푸집 + 철근 + 잡석). 「코드 없음」으로 세면 안 된다.
|
||||
composites.append(item)
|
||||
continue
|
||||
if not item.work_item_code or item.work_item_code not in index:
|
||||
orphans.append(item)
|
||||
continue
|
||||
@@ -321,14 +344,26 @@ def build_bill(
|
||||
emitted.setdefault(leaf.code, item_no)
|
||||
result.rows.append(_leaf_row(item_no, leaf, item, unit_prices, result))
|
||||
|
||||
# ── 1-2) 묶음 줄 ──────────────────────────────────────────────────────────
|
||||
for item in composites:
|
||||
counters[""] = counters.get("", 0) + 1
|
||||
result.rows.append(_composite_row(str(counters[""]), item, unit_prices, result))
|
||||
|
||||
# ── 2) 공종을 못 고른 줄 — 이름째 남긴다 ────────────────────────────────────
|
||||
for item in orphans:
|
||||
# 구조물 줄은 사유가 다르다 — 품셈에 그 공종이 없어 **전개식(원단위)** 이 있어야
|
||||
# 조각으로 설 수 있다. 「코드가 없다」로만 적으면 무엇을 해야 하는지 안 보인다.
|
||||
reason = (
|
||||
"구조물 전개식(원단위)이 없어 조각을 못 세웠습니다 — B08 원단위 필요."
|
||||
if item.origin == "structure"
|
||||
else "공종을 못 골랐습니다 — B08 인계에 공종코드가 없습니다."
|
||||
)
|
||||
result.missing.append(
|
||||
{
|
||||
"name": item.display_name,
|
||||
"unit": item.unit,
|
||||
"quantity": str(item.quantity),
|
||||
"reason": "공종을 못 골랐습니다 — B08 인계에 공종코드가 없습니다.",
|
||||
"reason": reason,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -339,6 +374,15 @@ def build_bill(
|
||||
# ── 4) 검사 — `in_bill=false` 줄에 금액이 붙지 않았는가 ──────────────────────
|
||||
check_excluded_rows_not_priced(rows=[r.as_dict() for r in result.excluded])
|
||||
|
||||
# ㉦ 큰돌쌓기 품에 포함된 자재(고임돌·채움콘크리트)를 따로 세지 않았는가.
|
||||
check_included_materials_not_listed(
|
||||
work_item_codes=[row.code or "" for row in result.rows],
|
||||
materials=[
|
||||
{"material_name": m.material_name, "source_structure": list(m.source_structure)}
|
||||
for m in materials
|
||||
],
|
||||
)
|
||||
|
||||
# ㉡ **무대(20 m 이내)에 단가가 붙지 않았는가** (PLAN 8-7 ㉡).
|
||||
# 줄 자체는 실무 서식대로 남기되 **금액을 매기지 않는다** — 품에 이미 들어 있다.
|
||||
# 2026-09-08: B08 이 운반을 실물로 내기 시작해 이 검사가 처음으로 실제로 돈다.
|
||||
@@ -380,6 +424,77 @@ def _haul_price_of(item: HandoffWorkItem, result: BillResult) -> Decimal:
|
||||
return _ZERO
|
||||
|
||||
|
||||
def _composite_row(
|
||||
item_no: str,
|
||||
item: HandoffWorkItem,
|
||||
unit_prices: UnitPriceBuild,
|
||||
result: BillResult,
|
||||
) -> BillRow:
|
||||
"""묶음 줄 — 조각들의 `단가 × 조각수량` 을 더해 **1단위 단가**를 만든다.
|
||||
|
||||
옹벽처럼 품셈에 그 공종이 없는 것은 **조각을 합친 것이 곧 그 줄의 단가**다
|
||||
(PLAN 9-3 「제목 + 상세」 한 쌍). 조각이 하나라도 비면 **금액을 만들지 않는다** —
|
||||
절반짜리 단가가 서는 것이 가장 위험하다.
|
||||
"""
|
||||
row = BillRow(
|
||||
item_no=item_no,
|
||||
level=1,
|
||||
code=None,
|
||||
name=item.name,
|
||||
spec=item.spec,
|
||||
unit=item.unit,
|
||||
quantity=item.quantity,
|
||||
in_bill=item.in_bill,
|
||||
)
|
||||
missing_parts: list[str] = []
|
||||
money = None
|
||||
for part in item.composite_parts:
|
||||
code = str(part.get("code") or "")
|
||||
amount = _decimal(part.get("quantity"), None)
|
||||
if not code or amount is None or f"B-{code}" not in unit_prices.book.titles:
|
||||
missing_parts.append(code or str(part.get("name") or "이름 없음"))
|
||||
continue
|
||||
scaled = unit_prices.book.resolve(f"B-{code}").scaled(amount)
|
||||
money = scaled if money is None else money + scaled
|
||||
|
||||
reasons: list[str] = []
|
||||
for pending in item.composite_not_ready:
|
||||
# 「단가 없음」과 「물량 없음」을 가른다 — 사유가 다르면 할 일도 다르다.
|
||||
if isinstance(pending, str):
|
||||
missing_parts.append(pending)
|
||||
continue
|
||||
label = str(pending.get("code") or pending.get("name") or "이름 없음")
|
||||
why = str(pending.get("reason") or pending.get("note") or "")
|
||||
missing_parts.append(label)
|
||||
if why:
|
||||
reasons.append(f"{label}: {why}")
|
||||
|
||||
if missing_parts or money is None:
|
||||
detail_text = "; ".join(reasons[:3]) or ", ".join(missing_parts[:4])
|
||||
row.note = f"묶음 조각이 덜 찼습니다 — {detail_text}"
|
||||
result.missing.append(
|
||||
{
|
||||
"name": row.name,
|
||||
"unit": row.unit,
|
||||
"quantity": str(item.quantity),
|
||||
"reason": (
|
||||
f"묶음 조각 미확보 ({len(missing_parts)}건)"
|
||||
+ (f" — {reasons[0]}" if reasons else "")
|
||||
),
|
||||
}
|
||||
)
|
||||
return row
|
||||
|
||||
line = money.scaled(item.quantity)
|
||||
row.unit_price_krw = round_at(money.total, OutputPlace.UNIT_PRICE_ROW)
|
||||
row.amount_krw = round_at(line.total, OutputPlace.BOQ_ROW)
|
||||
row.material_krw = line.material
|
||||
row.labor_krw = line.labor
|
||||
row.expense_krw = line.expense
|
||||
row.note = f"묶음 {len(item.composite_parts)}조각 합계"
|
||||
return row
|
||||
|
||||
|
||||
def _excluded_row(item: HandoffWorkItem) -> BillRow:
|
||||
"""검산용 줄(`in_bill=false`). **수량만 보이고 단가·금액을 안 붙인다.**"""
|
||||
return BillRow(
|
||||
|
||||
@@ -205,3 +205,80 @@ def check_excluded_rows_not_priced(
|
||||
f"{Decimal(str(value)):,.0f} 이 붙었습니다 — 그 줄은 수량만 보이고 "
|
||||
"금액을 매기지 않습니다 (PLAN 8-7 ㉡ 와 같은 성격)."
|
||||
)
|
||||
|
||||
|
||||
#: 제잡비 「윗단」 값을 쓴다는 뜻 — 물빼기 파이프를 **설치하는** 경우다.
|
||||
#: 품셈 13-6-2 [주]③ 「… 상단에는 물빼기 파이프 설치에 관계되는 노무비, 재료비를
|
||||
#: 포함한다」. 그러므로 윗단을 쓰면 파이프를 **따로 세면 안 된다**.
|
||||
OVERHEAD_TIER_WITH_PIPE = "with_pipe"
|
||||
OVERHEAD_TIER_WITHOUT_PIPE = "without_pipe"
|
||||
|
||||
#: 물빼기 파이프를 가리키는 자재 이름들. **정확 일치**로만 본다 — 넓게 잡으면
|
||||
#: 「물구멍 마감재」 같은 정상 자재까지 지운다.
|
||||
DRAIN_PIPE_NAMES = ("물빼기파이프", "물빼기 파이프", "물구멍", "배수공")
|
||||
|
||||
|
||||
def check_drain_pipe_not_double_counted(
|
||||
*,
|
||||
overhead_tier: str,
|
||||
material_names: list[str],
|
||||
label: str = "돌쌓기",
|
||||
) -> None:
|
||||
"""㉥ 제잡비 윗단을 쓰면서 물빼기 파이프를 또 세지 않았는가.
|
||||
|
||||
품셈 13-6-2 [주]③ 이 **윗단 값에 파이프 설치의 노무비·재료비가 포함**된다고
|
||||
적어 두었다. 그 값을 쓰면서 파이프를 자재로 또 실으면 같은 것을 두 번 센다.
|
||||
|
||||
⚠ 둘 중 하나만 골라야 한다 — 아랫단(파이프 미설치) 값을 쓰고 파이프를 따로 세거나,
|
||||
윗단 값을 쓰고 파이프를 안 세거나.
|
||||
"""
|
||||
if overhead_tier != OVERHEAD_TIER_WITH_PIPE:
|
||||
return
|
||||
tight = {"".join(str(name).split()) for name in material_names}
|
||||
hit = next(
|
||||
(name for name in DRAIN_PIPE_NAMES if "".join(name.split()) in tight),
|
||||
None,
|
||||
)
|
||||
if hit is not None:
|
||||
raise DoubleCountError(
|
||||
f"{label}: 제잡비 윗단(물빼기 파이프 설치)을 쓰면서 「{hit}」을 자재로 또 "
|
||||
"실었습니다 — 윗단 값에 파이프의 노무비·재료비가 이미 들어 있습니다 "
|
||||
"(품셈 13-6-2 [주]③)."
|
||||
)
|
||||
|
||||
|
||||
#: 큰돌쌓기(13-6) 품에 **이미 들어 있는** 자재 — 따로 세우면 두 번이다.
|
||||
#: 근거: 품셈 13-6 [주]① 「고임돌 및 채움 콘크리트 등은 품에 포함」.
|
||||
#: ⚠ **13-6 한정**이다 — 돌쌓기(13-4)·돌붙임(13-7)에는 이 [주]가 없으므로
|
||||
#: 그쪽에서 고임돌이 자재로 오는 것은 정상이다. 넓게 잡으면 정상 자재를 지운다.
|
||||
BOULDER_INCLUDED_MATERIALS = ("고임돌", "채움콘크리트", "채움 콘크리트")
|
||||
BOULDER_WORK_ITEM_PREFIX = "FP-13-06"
|
||||
|
||||
|
||||
def check_included_materials_not_listed(
|
||||
*,
|
||||
work_item_codes: list[str],
|
||||
materials: list[dict],
|
||||
name_field: str = "material_name",
|
||||
source_field: str = "source_structure",
|
||||
label: str = "큰돌쌓기",
|
||||
) -> None:
|
||||
"""㉦ 품에 포함된 자재를 따로 세지 않았는가 (품셈 13-6 [주]①).
|
||||
|
||||
큰돌쌓기 줄이 서 있는데 **그 구조물이 낳은** 고임돌·채움콘크리트가 자재로도 서면
|
||||
같은 것을 두 번 센다. 자재의 `source_structure` 로 **그 구조물에서 온 것만** 본다 —
|
||||
다른 구조물(돌쌓기 13-4)의 고임돌은 정상이다.
|
||||
"""
|
||||
if not any(str(code).startswith(BOULDER_WORK_ITEM_PREFIX) for code in work_item_codes):
|
||||
return
|
||||
for material in materials:
|
||||
name = "".join(str(material.get(name_field) or "").split())
|
||||
if name not in {"".join(x.split()) for x in BOULDER_INCLUDED_MATERIALS}:
|
||||
continue
|
||||
sources = material.get(source_field) or []
|
||||
if any(label in str(source) for source in sources):
|
||||
raise DoubleCountError(
|
||||
f"{label}: 「{material.get(name_field)}」이 자재로도 실렸습니다 — "
|
||||
"큰돌쌓기 품에 이미 들어 있습니다 (품셈 13-6 [주]① 「고임돌 및 "
|
||||
"채움 콘크리트 등은 품에 포함」)."
|
||||
)
|
||||
|
||||
@@ -145,6 +145,10 @@ class PriceDetail:
|
||||
note: str = ""
|
||||
#: 비율 행(공구손료 등) — 참조 단가의 %로 계산하는 줄.
|
||||
percent_of_parent: Decimal | None = None
|
||||
#: **노무비 합계**의 %로 붙는 경비 줄 — 제잡비(품셈 13-6-1 [주]③).
|
||||
#: ⚠ `percent_of_parent` 와 다르다: 밑수가 3분할 전체가 아니라 **노무비만**이고,
|
||||
#: 결과는 **경비(J)** 로만 들어간다. 「상한」이라 설계자가 낮출 수 있는 값이다.
|
||||
percent_of_labor: Decimal | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -191,6 +195,13 @@ class PriceBook:
|
||||
|
||||
total = Money3()
|
||||
for row in rows:
|
||||
# ⚠ 비율 줄은 **참조를 풀기 전에** 처리한다 — 자기 자신을 가리키므로
|
||||
# 먼저 풀면 순환으로 잡힌다(제잡비 줄이 그렇다).
|
||||
if row.percent_of_labor is not None:
|
||||
# 제잡비 — **노무비 합계**의 %가 **경비**로 붙는다(품셈 13-6-1 [주]③).
|
||||
total = total + Money3(expense=total.labor * row.percent_of_labor / Decimal(100))
|
||||
continue
|
||||
|
||||
child = self.resolve(row.ref_code, (*_seen, code))
|
||||
if row.percent_of_parent is not None:
|
||||
# 비율 행 — 지금까지 쌓인 값의 %로 붙는다(공구손료 등).
|
||||
|
||||
@@ -455,6 +455,9 @@ class AxisResult:
|
||||
rows: list[ResourceRow] = field(default_factory=list)
|
||||
unmatched: list[UnmatchedRow] = field(default_factory=list)
|
||||
skipped_forms: dict[str, int] = field(default_factory=dict)
|
||||
#: 제잡비 비율(%) — `{공종코드: (윗단, 아랫단)}`. 윗단은 물빼기 파이프 설치,
|
||||
#: 아랫단은 미설치 (품셈 13-6-2 [주]③). 값이 하나뿐이면 둘이 같다.
|
||||
overhead_ratio: dict = field(default_factory=dict)
|
||||
#: 자원은 알아봤는데 **값을 못 읽은** 줄이 있는 공종 — 그 단가는 「일부만 선 것」이다.
|
||||
#: 기초잡석 12-25 가 `소할(30%) | 할석공(인) | 0.2 × 30%` 를 못 읽어 부설다짐만으로
|
||||
#: 107,145 원이 서고 있었다(2026-09-08). **부분 성공이 가장 위험하다.**
|
||||
@@ -481,6 +484,19 @@ def _tidy_resource_name(cell: str) -> str:
|
||||
return tight + sep + tail
|
||||
|
||||
|
||||
#: 장비 줄의 단위 — 시간·대수로 센다. 자재는 kg·매·㎥ 로 센다.
|
||||
_MACHINE_UNITS = ("시간", "hr", "h", "대", "시 간")
|
||||
|
||||
|
||||
def _is_machine_like_row(cells: list[str]) -> bool:
|
||||
"""그 줄이 **장비 몫**인가 — 단위 칸이 시간·대수인지로 본다."""
|
||||
for cell in cells:
|
||||
text = _normalize(cell)
|
||||
if text and any(text == unit or text.replace(" ", "") == unit for unit in _MACHINE_UNITS):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_cell(catalog: ResourceCatalog, name_cell: str, cells: list[str]):
|
||||
"""셀 하나를 카탈로그 한 줄로 푼다 — 세 가지 모양을 차례로 시도한다.
|
||||
|
||||
@@ -579,6 +595,20 @@ def match_table(
|
||||
name_cell = cells[1]
|
||||
value_cells = cells[2:]
|
||||
|
||||
# 제잡비 비율 줄 — 자원이 아니라 **노무비에 붙는 경비율**이다(품셈 [주]③).
|
||||
if "제잡비" in _normalize(name_cell):
|
||||
ratios = [
|
||||
parse_amount(_normalize(token))
|
||||
for cell in value_cells
|
||||
for token in _RE_ALTERNATIVE.sub(r"", _normalize(cell)).split(" ")
|
||||
]
|
||||
ratios = [x for x in ratios if x is not None]
|
||||
if ratios:
|
||||
upper = ratios[0]
|
||||
lower = ratios[1] if len(ratios) > 1 else ratios[0]
|
||||
result.overhead_ratio[node["work_item_code"]] = (upper, lower)
|
||||
continue
|
||||
|
||||
# ⚠ **공식 계수를 단 줄은 자원 줄이 아니다.** 「유압식백호우 … | k | 0.9」 처럼
|
||||
# 같은 줄에 버킷계수가 붙어 오는데, 그 0.9 를 소요량으로 읽으면 **시간당 사용료가
|
||||
# 0.9시간분** 붙어 이중이 된다(2026-09-08: 이름 표기를 맞추자 측구터파기에
|
||||
@@ -650,6 +680,14 @@ def match_table(
|
||||
result.unmatched.append(
|
||||
UnmatchedRow(node["work_item_code"], table["pum_table_id"], name_cell, reason)
|
||||
)
|
||||
# ⚠ **시간·대수로 세는 줄은 장비 몫**이다 — 못 맞추면 그 공종 단가가
|
||||
# 노무만으로 서서 조용히 싸진다(2026-09-08: 초본류 시비가 「트럭(2.5t)
|
||||
# 2.6시간」을 빼고 33.1원/㎡ 로 섰다). 자재 줄(kg·매)은 이미 알려진
|
||||
# 미결이라 막지 않고 드러내기만 한다.
|
||||
if _is_machine_like_row(value_cells):
|
||||
result.partial_items[node["work_item_code"]] = (
|
||||
f"{_normalize(name_cell)[:20]} (장비 줄)을 못 맞췄습니다"
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
|
||||
@@ -319,6 +319,11 @@ def match_packed_rows(
|
||||
# 자원으로 **안 풀리는** 줄이면 갈래 라벨 줄로 본다.
|
||||
if not labels:
|
||||
return False
|
||||
# ⚠ **라벨 줄에는 순수 숫자 칸이 없다.** 「35cm 이하」는 수를 품되 순수 수가 아니고,
|
||||
# 「자재 | 종 자 | | kg | 0.025」는 순수 수(0.025)가 있는 **자료 줄**이다.
|
||||
# 이 구분을 안 두면 씨앗뿜어붙이기 표를 라벨 줄로 오해해 표째 가로챈다(2026-09-08 회귀).
|
||||
if any(parse_amount(cell) is not None for cell in rows[0]):
|
||||
return False
|
||||
# ⚠ **첫 줄의 어느 칸이라도 자원이면 라벨 줄이 아니다.** 첫 칸만 보면 기초잡석
|
||||
# 12-25 처럼 「소할(30%) | 할석공(인) | 0.2 × 30%」인 표를 라벨 줄로 오해해
|
||||
# 표째 가로챈다(2026-09-08: 그 탓에 기초잡석이 다시 막혔다).
|
||||
@@ -352,9 +357,28 @@ def match_packed_rows(
|
||||
for index, cells in enumerate(rows[1:], start=1):
|
||||
if not cells or not cells[0]:
|
||||
continue
|
||||
names = _packed_names(cells[0])
|
||||
# 제잡비 비율 줄 — 자원이 아니라 **노무비에 붙는 경비율**이다(품셈 [주]③).
|
||||
# 갈래마다 같은 값이 되풀이되므로 **첫 값 칸**만 본다. 「9(9) 3(3)」 = 윗단 9 · 아랫단 3.
|
||||
if "제잡비" in _normalize_label(cells[0]).replace(" ", ""):
|
||||
for cell in cells[1:]:
|
||||
numbers = _packed_numbers(cell)
|
||||
if numbers:
|
||||
upper = numbers[0]
|
||||
lower = numbers[1] if len(numbers) > 1 else numbers[0]
|
||||
result.overhead_ratio[work_item_code] = (upper, lower)
|
||||
break
|
||||
continue
|
||||
|
||||
# 규격은 **옆 칸**에 있을 수 있다 — 「굴착기+부착용 집게 | 0.6㎥ | 시간 | …」.
|
||||
side_specs = [cell for cell in cells[1:3] if cell]
|
||||
# ⚠ **칸 전체를 한 이름으로 먼저 본다** — 「굴착기 (무한궤도)」처럼 이름 안에
|
||||
# 공백이 있으면 쪼개서 보다가 통째로 못 푼다(2026-09-08: 찰쌓기 13-6-2 의
|
||||
# 장비 몫이 그래서 빠지고 공종이 막혔다).
|
||||
whole = _resolve_packed(catalog, _normalize_label(cells[0]), side_specs)
|
||||
if whole:
|
||||
names, resolved = [_normalize_label(cells[0])], [whole]
|
||||
else:
|
||||
names = _packed_names(cells[0])
|
||||
resolved = [_resolve_packed(catalog, name, side_specs) for name in names]
|
||||
if not all(resolved):
|
||||
if _packed_numbers(" ".join(cells[1:])):
|
||||
|
||||
@@ -673,6 +673,23 @@ interface BillDto {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 수량 표시 — 소수 **2자리**. 계산은 전정밀 그대로다.
|
||||
*
|
||||
* ⚠ 표시값끼리 곱하면 금액이 몇 원 어긋난다(90.51 × 5,288.6 ≠ 화면 금액). 그것이
|
||||
* 정상임을 표 아래 문구로 밝힌다 — 밝히지 않으면 「1원 틀린다」는 지적으로 돌아온다.
|
||||
* 실무 서식이 수량을 몇 자리로 쓰는지는 기준 문서에 없어(미결) 2자리는 잠정이다.
|
||||
*/
|
||||
function formatQuantity(value: string | null): string {
|
||||
if (value === null || value === "") return "";
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return value;
|
||||
return parsed.toLocaleString("ko-KR", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchBill(projectId: string): Promise<BillDto> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/bill`,
|
||||
@@ -806,7 +823,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
|
||||
indent + row.name,
|
||||
row.spec,
|
||||
row.unit,
|
||||
row.quantity ?? "",
|
||||
formatQuantity(row.quantity),
|
||||
row.unit_price_krw ?? "",
|
||||
row.amount_krw ?? "",
|
||||
row.note,
|
||||
@@ -827,6 +844,12 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
|
||||
total.textContent = `${L("B09_Estimation_Boq_Total")}: ${bill.summary.body_total_krw}`;
|
||||
body.append(total);
|
||||
|
||||
// 표시 자릿수와 계산 자릿수가 다르다는 것을 숨기지 않는다.
|
||||
const precision = document.createElement("div");
|
||||
precision.className = "b09-hint";
|
||||
precision.textContent = L("B09_Estimation_Boq_Precision");
|
||||
body.append(precision);
|
||||
|
||||
// ⚠ 자재비가 빠진 채 선 합계임을 숨기지 않는다.
|
||||
const shortfall = document.createElement("div");
|
||||
shortfall.className = "b09-hint";
|
||||
@@ -838,7 +861,9 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
|
||||
note.className = "b09-hint";
|
||||
note.textContent =
|
||||
`${L("B09_Estimation_Boq_Excluded")}: ` +
|
||||
bill.excluded.map((row) => `${row.name} ${row.quantity ?? ""}${row.unit}`).join(", ");
|
||||
bill.excluded
|
||||
.map((row) => `${row.name} ${formatQuantity(row.quantity)}${row.unit}`)
|
||||
.join(", ");
|
||||
body.append(note);
|
||||
}
|
||||
|
||||
|
||||
@@ -295,6 +295,23 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
|
||||
share = _share_of(row)
|
||||
build.book.add_detail(PriceDetail(title_code, ref, row.amount * share))
|
||||
|
||||
# 제잡비 — **노무비 합계의 %가 경비로** 붙는다(품셈 13-6-1 [주]③).
|
||||
# ⚠ 기본은 **아랫단**(물빼기 파이프 미설치)이다. 윗단을 쓰면 파이프를 따로 세면
|
||||
# 안 되므로(㉥ 가드), 그 선택은 설계 조건이 들어올 때 한다.
|
||||
# ⚠ **「상한」이다** — 곱한 값 이하로 계상하는 값이라 산출근거에 그 사실을 적는다.
|
||||
ratio = axis.overhead_ratio.get(work_item_code)
|
||||
if ratio is not None and not variant_key.startswith("__"):
|
||||
lower = ratio[1]
|
||||
build.book.add_detail(
|
||||
PriceDetail(
|
||||
title_code,
|
||||
title_code,
|
||||
_ZERO,
|
||||
note=f"제잡비 노무비의 {lower}% (상한, 물빼기 파이프 미설치 기준)",
|
||||
percent_of_labor=lower,
|
||||
)
|
||||
)
|
||||
|
||||
# 장비 몫은 자원 수량이 아니라 **시공능력 공식**으로 온다 (품셈 8-1-4).
|
||||
machine_share = (
|
||||
_ZERO if variant else _attach_machine_share(build, master, work_item_code, title_code)
|
||||
@@ -504,10 +521,13 @@ def build_summary(build: UnitPriceBuild) -> dict:
|
||||
"median": _money_text(totals[len(totals) // 2]),
|
||||
"max": _money_text(totals[-1]),
|
||||
}
|
||||
# ⚠ **막아 둔 공종은 여기 안 센다** — 「성분이 빠져 싸다」를 이미 아는 값이라
|
||||
# 목록에 남으면 새로 살펴야 할 것과 섞인다. 막힌 것은 `partial_ratio` 로 따로 센다.
|
||||
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 code[2:].split("#")[0] not in build.partial_ratio
|
||||
and (money := build.book.resolve(code).total) < SUSPICIOUSLY_LOW_KRW
|
||||
]
|
||||
|
||||
@@ -525,6 +545,8 @@ def build_summary(build: UnitPriceBuild) -> dict:
|
||||
"unit_price_totals": stats,
|
||||
# 값이 서기는 했는데 **크기가 이상한** 것 — 성분이 빠졌을 가능성이 크다.
|
||||
"suspiciously_low": low,
|
||||
# 성분이 빠져 **금액을 안 만드는** 공종 — 화면이 사유째 보인다.
|
||||
"blocked_items": len(build.partial_ratio),
|
||||
# 기준 단위가 없는 채로 큰 값 — 값이 틀린 게 아니라 **기준을 모르는 것**이다.
|
||||
"unknown_basis_high": high,
|
||||
"unknown_basis": sum(
|
||||
@@ -579,6 +601,29 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict:
|
||||
money = build.book.resolve(code)
|
||||
rows: list[dict] = []
|
||||
for detail in build.book.details.get(code, []):
|
||||
if detail.percent_of_labor is not None:
|
||||
# 제잡비 — 지금까지 쌓인 **노무비**의 %가 경비로 붙는다. 표시 합계에도 넣어야
|
||||
# 화면 합계와 실제 단가가 어긋나지 않는다.
|
||||
labor_so_far = sum((Decimal(str(r["labor"])) for r in rows), _ZERO)
|
||||
amount = labor_so_far * detail.percent_of_labor / Decimal(100)
|
||||
rows.append(
|
||||
{
|
||||
"code": detail.ref_code,
|
||||
"name": "제잡비",
|
||||
"spec": f"노무비의 {detail.percent_of_labor}%",
|
||||
"unit": "%",
|
||||
"quantity": str(detail.percent_of_labor),
|
||||
"material": "0",
|
||||
"labor": "0",
|
||||
"expense": str(amount),
|
||||
"total": _money_text(amount),
|
||||
"source": "품셈 [주]",
|
||||
"drillable": False,
|
||||
"note": detail.note,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
child = build.book.title(detail.ref_code)
|
||||
unit_money = build.book.resolve(detail.ref_code)
|
||||
line = unit_money.scaled(detail.quantity)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -671,6 +671,10 @@ export const ui_locales_b2 = {
|
||||
B09_Estimation_Tab_CostSheet: ["공사원가계산서", "Cost Statement"],
|
||||
B09_Estimation_Tab_Boq: ["설계내역서", "Bill of Quantities"],
|
||||
B09_Estimation_Boq_Total: ["내역서 합계", "Bill total"],
|
||||
B09_Estimation_Boq_Precision: [
|
||||
"수량 표시는 소수 2자리, 계산은 전정밀 — 표시값끼리 곱하면 끝자리가 다릅니다.",
|
||||
"Quantities are shown to 2 decimals but computed at full precision — multiplying the shown values gives a slightly different last digit.",
|
||||
],
|
||||
B09_Estimation_Boq_Excluded: [
|
||||
"검산용 줄 — 수량만 보이고 금액을 매기지 않습니다",
|
||||
"Check rows — quantity only, never priced",
|
||||
|
||||
Reference in New Issue
Block a user