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

This commit is contained in:
2026-09-08 04:42:17 +09:00
10 changed files with 1515 additions and 972 deletions
@@ -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(
+77
View File
@@ -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,10 +357,29 @@ 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]
resolved = [_resolve_packed(catalog, name, side_specs) for name in names]
# ⚠ **칸 전체를 한 이름으로 먼저 본다** — 「굴착기 (무한궤도)」처럼 이름 안에
# 공백이 있으면 쪼개서 보다가 통째로 못 푼다(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:])):
# 수가 있는데 이름을 못 풀었다 — 그 몫이 빠진 채 서면 안 된다.
+27 -2
View File
@@ -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
+4
View File
@@ -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",