main_laptop_1 -> main byeonghap (4 hwangyeong 585 commits) #12

Merged
eomsangdon merged 585 commits from main_laptop_1 into main 2026-09-08 17:26:30 +09:00
7 changed files with 755 additions and 14 deletions
Showing only changes of commit f1d86e2525 - Show all commits
@@ -31,7 +31,11 @@ from B09_Estimation.B09_Estimation_Guards import (
)
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
from B09_Estimation.B09_Estimation_UnitPrice import (
UnitPriceBuild,
cached_build,
find_variant_code,
)
_ZERO = Decimal(0)
@@ -39,6 +43,16 @@ _ZERO = Decimal(0)
#: **관급자재대에도, 도급 재료비에도 넣지 않는다** — 어느 쪽에 넣어도 총액이 틀린다.
SUPPLY_UNKNOWN = "unknown"
#: 관급 — 총원가 밖 별도 표기라 사급과 **가는 자리가 다르다**(PLAN 8-2).
SUPPLY_OWNER = "owner_supplied"
#: 막힘 갈래를 사람 말로. **할 일이 다르므로 화면에서 갈라 보인다.**
_BLOCKED_LABELS = {
"input_missing": "입력이 필요합니다",
"unit_data_missing": "원단위가 없습니다(우리가 만들 것)",
"formula_missing": "전개식이 없습니다(우리가 만들 것)",
}
class BillError(ValueError):
"""내역서를 세울 수 없는 경우. 빈 표를 돌려주지 않고 멈춘다."""
@@ -72,6 +86,14 @@ class HandoffWorkItem:
#: 묶음인데 아직 못 채운 조각 — 「단가 없음」과 「물량 없음」을 갈라 적는다.
composite_not_ready: tuple = ()
structure_kind: str = ""
#: B08 이 적어 보낸 막힘 사유 — **문구는 B08 것을 그대로 쓴다**(두 벌로 짜지 않는다).
blocked_reason: str = ""
#: 막힘 갈래 — `input_missing`(사용자가 입력하면 풀림) /
#: `unit_data_missing`·`formula_missing`(우리가 만들어야 함). 할 일이 다르므로 가른다.
blocked_kind: str = ""
#: 갈래 축·원본값 — 「stone_cm」·「60~80」. **키 문자열은 우리가 만든다**(두 창 합의).
variant_axis: str = ""
variant_value: str = ""
@property
def display_name(self) -> str:
@@ -151,9 +173,15 @@ class BillResult:
missing: list[dict[str, str]] = field(default_factory=list)
#: `in_bill=false` 라 금액을 안 매긴 줄(보정량계 등). 수량은 보이되 합계에 안 든다.
excluded: list[BillRow] = field(default_factory=list)
#: 이 내역서에 쓰인 일위대가 코드 — ③ 단가산출서 번호를 매기는 차례가 된다.
used_unit_prices: list[str] = field(default_factory=list)
#: 자재 벌 — 공급 구분이 갈린 것만 금액이 선다.
material_rows: list[BillRow] = field(default_factory=list)
notes: list[str] = field(default_factory=list)
#: ③ 단가산출서 한 벌 — 조판할 때 번호가 매겨진다.
price_basis: Any = None
#: 자재대 표 — 사급·관급·미정 셋으로 갈린다(PLAN 8-7 「금액은 B09」).
material_sheet: Any = None
@property
def direct_material_krw(self) -> Decimal:
@@ -209,6 +237,10 @@ def parse_handoff(payload: dict[str, Any]) -> tuple[list[HandoffWorkItem], list[
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 "",
blocked_reason=row.get("blocked_reason") or "",
blocked_kind=row.get("blocked_kind") or "",
variant_axis=row.get("variant_axis") or "",
variant_value=str(row.get("variant_value") or ""),
)
for row in payload["work_items"]
]
@@ -408,6 +440,26 @@ def build_bill(
total_cut_volume_m3=cut_total,
)
# 자재대 — B08 수량·할증에 단가를 붙인다. 관급은 총원가 밖 별도 표기다.
from B09_Estimation.B09_Estimation_MaterialSheet import build_material_sheet
result.material_sheet = build_material_sheet(
materials,
surcharge_status=str(payload.get("surcharge_status") or "rate_unavailable"),
)
# ③ 단가산출서 번호를 줄 비고에 단다 — 실무가 내역서를 검산하는 길이다(8-13).
from B09_Estimation.B09_Estimation_PriceBasis import build_price_basis
sheet = build_price_basis(result.used_unit_prices, unit_prices)
for row in result.rows:
if row.is_group or row.code is None or row.amount_krw is None:
continue
entry = sheet.by_unit_price(f"B-{row.code}")
if entry is not None:
row.note = " / ".join(part for part in (entry.label, row.note) if part)
result.price_basis = sheet
if any(m.surcharge_pct is None for m in materials):
result.notes.append(
"자재 할증률이 아직 없습니다 — 할증 전 값으로 섰습니다. "
@@ -539,7 +591,32 @@ def _leaf_row(
result.excluded.append(row)
return row
if item.blocked_reason:
# B08 이 「왜 못 골랐는지」를 적어 보냈다 — **그 문구를 그대로** 보인다.
# 사용자가 입력하면 풀리는 것(`input_missing`)과 우리가 만들어야 하는 것을
# 가르지 않으면, 사용자가 「후보를 고르면 되나」로 잘못 읽는다.
row.note = f"{_BLOCKED_LABELS.get(item.blocked_kind, '막힘')}{item.blocked_reason}"
result.missing.append(
{
"name": row.name,
"code": node.code,
"unit": row.unit,
"quantity": str(item.quantity),
"reason": row.note,
"blocked_kind": item.blocked_kind,
}
)
return row
price_code = f"B-{node.code}"
if item.variant_value and price_code not in unit_prices.book.titles:
# B08 은 **의미**(어느 공종·어느 제원)만 보내고 갈래 키는 우리가 만든다.
# 못 맞추면 후보를 보이는 길로 내려간다 — 가까운 갈래를 임의로 고르지 않는다.
picked = find_variant_code(node.code, item.variant_value, unit_prices)
if picked is not None:
price_code = picked
row.spec = f"{row.spec} {item.variant_value}".strip()
if price_code not in unit_prices.book.titles:
# 한 층 아래에 일위대가가 있으면 **후보로 보여준다** — 임의로 고르지 않는다
# (CLAUDE.md 3장 「미결 항목 임의 확정 금지」, B08 `mapping_pending_user` 와 같은 태도).
@@ -620,6 +697,10 @@ def _leaf_row(
if part
)
# 쓰인 차례를 기억한다 — 실무 참조번호(「단산 46」)가 그 차례다.
if price_code not in result.used_unit_prices:
result.used_unit_prices.append(price_code)
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)
@@ -655,14 +736,27 @@ def _material_row(material: HandoffMaterial, result: BillResult) -> BillRow:
}
)
return row
# 사급 자재 단가는 아직 원천이 없다(미결 No.18) — 여기서도 지어내지 않는다.
row.note = row.note or "사급 자재 단가 미확보 — 6번 슬롯(적용 단가) 수동 입력 대기."
# ⚠ **관급을 「사급」이라 적으면 안 된다** (2026-09-08 메인 창 실측 — 물구멍·야면석이
# `owner_supplied` 인데 「사급 자재 단가 미확보」로 뜨고 있었다). 갈래마다 **가는 자리도
# 원천도 다르다** — 관급은 총원가 밖 관급자재대(나라장터), 사급은 도급 재료비(물가지).
if material.supply_type == SUPPLY_OWNER:
row.note = (
row.note
or "관급 자재 단가 미확보 — 나라장터 목록에 이 품목이 없습니다. "
"관급자재대(총원가 밖 별도 표기)로 갑니다."
)
reason = "관급 자재 단가 없음"
else:
row.note = row.note or "사급 자재 단가 미확보 — 6번 슬롯(적용 단가) 수동 입력 대기."
reason = "사급 자재 단가 없음(미결 No.18)"
result.missing.append(
{
"name": material.display_name,
"unit": material.unit,
"quantity": str(material.total_amount),
"reason": "자재 단가 없음(미결 No.18)",
"reason": reason,
"supply_type": material.supply_type,
}
)
return row
@@ -676,6 +770,7 @@ def bill_summary(result: BillResult) -> dict[str, Any]:
"group_rows": sum(1 for r in result.rows if r.is_group),
"excluded_rows": len(result.excluded),
"material_rows": len(result.material_rows),
"material_sheet": result.material_sheet.as_dict() if result.material_sheet else None,
"missing": result.missing,
"body_total_krw": str(result.body_total_krw),
"direct_material_krw": str(result.direct_material_krw),
@@ -0,0 +1,180 @@
"""B09 원가계산 — 자재대 표 (PLAN 8-7 「자재대·관급자재대(금액)는 B09」).
**무엇인가** — B08 자재총괄이 낸 **수량·할증**에 **단가**를 붙여 금액을 내는 표다.
수량은 B08 것이 정본이고 여기서 다시 세지 않는다.
**관급과 사급은 자리가 다르다** (PLAN 8-2 · 9-1)
- **사급** — 도급 재료비. 내역서 안에 들어간다.
- **관급** — **총원가 밖 별도 표기** + 조달수수료. ⑤ 공사원가계산서의
「관급자재대」와 같은 값이라 그쪽과 이어야 한다.
- **`unknown`** — 관급·사급이 안 갈린 것. **어느 쪽에도 안 넣는다** — 넣는 순간
총액이 틀리고, 어느 쪽으로 넣었는지 나중에 못 가린다.
⚠ **할증은 여기서 한 번만** (PLAN 8-7 ㉠). B08 이 `total_amount` 에 이미 할증을
반영해 보내면 그 값을 쓰고, 여기서 또 곱하지 않는다. `surcharge_status` 가
`rate_unavailable` 이면 **할증 전 값**임을 표에 드러낸다.
⚠ **단가가 없으면 금액을 만들지 않는다.** 사급 물가지가 미결(No.18)이라 지금은
대부분이 그 자리다 — 0 으로 때우면 자재비가 통째로 사라진 채 총액이 그럴듯해진다.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Any
from B09_Estimation.B09_Estimation_MaterialCatalog import (
SUPPLY_CONTRACTOR,
SUPPLY_OWNER,
MaterialCatalog,
load_material_catalog,
)
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
_ZERO = Decimal(0)
#: 관급·사급이 안 갈린 값. B08 이 실제로 보낸다.
SUPPLY_UNKNOWN = "unknown"
#: 할증 깃발 — B08 과 맞춘 세 갈래(2026-09-08).
SURCHARGE_APPLIED = "applied"
SURCHARGE_NOT_APPLIED = "not_applied"
SURCHARGE_RATE_UNAVAILABLE = "rate_unavailable"
@dataclass
class MaterialSheetRow:
"""자재대 한 줄. 금액이 `None` 이면 **단가를 못 세운 것**이지 0 이 아니다."""
name: str
spec: str
unit: str
net_amount: Decimal
total_amount: Decimal
supply_type: str
unit_price_krw: Decimal | None = None
amount_krw: Decimal | None = None
surcharge_pct: Decimal | None = None
source_structure: tuple[str, ...] = ()
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 {
"name": self.name,
"spec": self.spec,
"unit": self.unit,
"net_amount": str(self.net_amount),
"total_amount": str(self.total_amount),
"supply_type": self.supply_type,
"unit_price_krw": money(self.unit_price_krw),
"amount_krw": money(self.amount_krw),
"surcharge_pct": money(self.surcharge_pct),
"source_structure": list(self.source_structure),
"note": self.note,
}
@dataclass
class MaterialSheet:
"""자재대 한 벌 — 사급·관급·미정 셋으로 갈린다."""
contractor_rows: list[MaterialSheetRow] = field(default_factory=list)
owner_rows: list[MaterialSheetRow] = field(default_factory=list)
unknown_rows: list[MaterialSheetRow] = field(default_factory=list)
#: 단가를 못 세운 줄 — **0 으로 안 때우고 이름째 남긴다.**
missing: list[dict[str, str]] = field(default_factory=list)
notes: list[str] = field(default_factory=list)
@property
def contractor_total_krw(self) -> Decimal:
"""사급 자재비 합계 — 도급 재료비로 들어간다."""
return sum((row.amount_krw or _ZERO for row in self.contractor_rows), _ZERO)
@property
def owner_total_krw(self) -> Decimal:
"""관급자재대 — **총원가 밖 별도 표기**. ⑤ 의 관급자재대와 같은 값이어야 한다."""
return sum((row.amount_krw or _ZERO for row in self.owner_rows), _ZERO)
def as_dict(self) -> dict[str, Any]:
return {
"contractor": [row.as_dict() for row in self.contractor_rows],
"owner": [row.as_dict() for row in self.owner_rows],
"unknown": [row.as_dict() for row in self.unknown_rows],
"contractor_total_krw": str(self.contractor_total_krw),
# 관급자재대는 **천원 올림** 자리다(단수처리 규칙).
"owner_total_krw": str(
round_at(self.owner_total_krw, OutputPlace.OWNER_MATERIAL_TOTAL)
),
"missing": self.missing,
"notes": self.notes,
}
def build_material_sheet(
materials: list,
*,
surcharge_status: str = SURCHARGE_RATE_UNAVAILABLE,
catalog: MaterialCatalog | None = None,
) -> MaterialSheet:
"""자재 목록에 단가를 붙인다. 못 붙이면 **금액을 비우고 사유를 남긴다**."""
book = catalog or load_material_catalog()
sheet = MaterialSheet()
if surcharge_status == SURCHARGE_RATE_UNAVAILABLE:
sheet.notes.append(
"할증률이 아직 없어 **할증 전 수량**입니다 — 할증은 자재총괄에서 한 번만 "
"붙습니다 (PLAN 8-7 ㉠)."
)
for material in materials:
row = MaterialSheetRow(
name=getattr(material, "material_name", ""),
spec=getattr(material, "spec", ""),
unit=getattr(material, "unit", ""),
net_amount=getattr(material, "net_amount", _ZERO),
total_amount=getattr(material, "total_amount", _ZERO),
supply_type=getattr(material, "supply_type", SUPPLY_UNKNOWN),
surcharge_pct=getattr(material, "surcharge_pct", None),
source_structure=tuple(getattr(material, "source_structure", ()) or ()),
)
if row.supply_type == SUPPLY_UNKNOWN:
# ⚠ 어느 쪽에도 안 넣는다 — 넣으면 총액이 틀리고 나중에 못 가린다.
row.note = "관급·사급이 안 갈렸습니다 — 어느 쪽 합계에도 넣지 않습니다."
sheet.unknown_rows.append(row)
sheet.missing.append(
{"name": row.name, "unit": row.unit, "reason": "공급 구분 미정(unknown)"}
)
continue
found = book.resolve(row.name, row.spec)
if found is None:
row.note = (
"자재 단가가 없습니다 — 유료 물가지 미결(No.18). "
"6번 슬롯(적용 단가) 수동 입력 대기."
)
sheet.missing.append(
{"name": row.name, "unit": row.unit, "reason": "자재 단가 없음(미결 No.18)"}
)
else:
row.unit_price_krw = found.price_krw
# 자재대 줄도 **내역서 본체와 같은 절사** 자리다.
row.amount_krw = round_at(found.price_krw * row.total_amount, OutputPlace.BOQ_ROW)
if row.supply_type == SUPPLY_OWNER:
sheet.owner_rows.append(row)
elif row.supply_type == SUPPLY_CONTRACTOR:
sheet.contractor_rows.append(row)
else:
sheet.unknown_rows.append(row)
if sheet.owner_rows:
sheet.notes.append(
"관급자재대는 **총원가 밖 별도 표기**입니다 — ⑤ 공사원가계산서의 "
"관급자재대와 같은 값이어야 합니다."
)
return sheet
+165
View File
@@ -0,0 +1,165 @@
"""B09 원가계산 — ③ 단가산출서 `D` 층 (PLAN 9-1 · 9-3).
**무엇인가** — 내역서 한 줄의 단가가 **어떻게 나왔는지** 보이는 표다. 실무 내역서는
줄마다 비고에 「단산 46 참조」처럼 **참조번호**를 적고, 그 번호의 산출서를 펴서 검산한다
(8-13 관측). STC 실측도 `D01341 절토(토사) 굴삭기0.7㎥ m³ 1,939` 처럼 **`D` 가 `B`
(일위대가)를 참조하는 한 층 위**였다.
D 단가산출 → B 일위대가 → X 시간당 사용료 → S·M·L 카탈로그
⚠ **표를 세 벌 만들지 않는다** (PLAN 9-3). `PriceBook` 의 「제목 + 상세」 한 쌍에
`kind` 만 `PRICE_BASIS` 로 얹는다 — 일위대가와 같은 구조, 같은 화면 모양이다.
⚠ **번호는 코드에 박지 않는다.** 실무 참조번호(「단산 46」)는 **그 내역서 안에서의
차례**라 프로젝트마다 다르다. 코드(`D-FP-…`)는 공종을 가리키고, 번호는 조판할 때 매긴다.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Any
from B09_Estimation.B09_Estimation_PriceBook import PriceDetail, PriceKind, PriceTitle
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build
_ONE = Decimal(1)
@dataclass
class PriceBasisEntry:
"""단가산출서 한 장 — 참조번호 + 그 줄이 무엇을 참조하는지."""
number: int
code: str
name: str
spec: str
unit: str
unit_price_krw: Decimal
#: 이 산출서가 참조하는 일위대가 코드. 화면에서 눌러 내려가는 자리.
ref_code: str
@property
def label(self) -> str:
"""내역서 비고에 적는 문구 — 실무 서식 그대로 「단산 46 참조」."""
return f"단산 {self.number} 참조"
def as_dict(self) -> dict[str, Any]:
return {
"number": self.number,
"label": self.label,
"code": self.code,
"name": self.name,
"spec": self.spec,
"unit": self.unit,
"unit_price_krw": str(self.unit_price_krw),
"ref_code": self.ref_code,
}
@dataclass
class PriceBasisSheet:
"""그 내역서에 딸린 단가산출서 한 벌."""
entries: list[PriceBasisEntry] = field(default_factory=list)
def by_unit_price(self, ref_code: str) -> PriceBasisEntry | None:
return next((entry for entry in self.entries if entry.ref_code == ref_code), None)
def as_dict(self) -> dict[str, Any]:
return {"entries": [entry.as_dict() for entry in self.entries]}
def build_price_basis(
unit_price_codes: list[str],
build: UnitPriceBuild | None = None,
) -> PriceBasisSheet:
"""내역서에 쓰인 일위대가마다 산출서 한 장을 세운다.
번호는 **쓰인 차례**로 매긴다 — 실무 참조번호가 그 내역서 안의 차례이기 때문이다.
같은 일위대가가 두 줄에 쓰이면 **산출서는 한 장**이고 두 줄이 같은 번호를 가리킨다.
"""
prices = build or cached_build()
sheet = PriceBasisSheet()
seen: set[str] = set()
for code in unit_price_codes:
if not code or code in seen or code not in prices.book.titles:
continue
seen.add(code)
title = prices.book.title(code)
money = prices.book.resolve(code)
basis_code = f"D-{code[2:]}" if code.startswith("B-") else f"D-{code}"
if basis_code not in prices.book.titles:
prices.book.add_title(
PriceTitle(
code=basis_code,
kind=PriceKind.PRICE_BASIS,
name=title.name,
spec=title.spec,
unit=title.unit,
)
)
# ⚠ 지금은 **일위대가를 그대로 한 줄로** 참조한다. 할증·기타 비용이 붙는
# 자리가 생기면 여기에 줄이 는다 — 구조를 미리 열어 둔다.
prices.book.add_detail(PriceDetail(basis_code, code, _ONE, note="일위대가 그대로"))
sheet.entries.append(
PriceBasisEntry(
number=len(sheet.entries) + 1,
code=basis_code,
name=title.name,
spec=title.spec,
unit=title.unit,
unit_price_krw=round_at(money.total, OutputPlace.UNIT_PRICE_ROW),
ref_code=code,
)
)
return sheet
def price_basis_detail(
code: str,
build: UnitPriceBuild | None = None,
) -> dict[str, Any]:
"""산출서 한 장의 본표 — 무엇을 참조해 그 단가가 나왔는지.
일위대가 본표와 **같은 모양**이라 화면이 같은 표를 쓴다.
"""
from B09_Estimation.B09_Estimation_UnitPrice import detail_of
prices = build or cached_build()
title = prices.book.title(code)
rows: list[dict[str, Any]] = []
for detail in prices.book.details.get(code, []):
child = prices.book.title(detail.ref_code)
money = prices.book.resolve(detail.ref_code).scaled(detail.quantity)
rows.append(
{
"code": detail.ref_code,
"name": child.name,
"spec": child.spec,
"unit": child.unit,
"quantity": str(detail.quantity),
"total": str(round_at(money.total, OutputPlace.UNIT_PRICE_ROW)),
"drillable": True,
"note": detail.note,
}
)
money = prices.book.resolve(code)
return {
"code": code,
"name": title.name,
"spec": title.spec,
"unit": title.unit,
"rows": rows,
"total": str(round_at(money.total, OutputPlace.UNIT_PRICE_ROW)),
"material": str(money.material),
"labor": str(money.labor),
"expense": str(money.expense),
# 한 층 아래(일위대가) 본표를 그대로 딸려 보낸다 — 화면이 두 번 물어보지 않게.
"unit_price": detail_of(prices, rows[0]["code"]) if rows else None,
}
+17
View File
@@ -296,5 +296,22 @@ async def get_bill(project_id: UUID) -> JSONResponse:
"excluded": [row.as_dict() for row in result.excluded],
"materials": [row.as_dict() for row in result.material_rows],
"summary": bill_summary(result),
"price_basis": result.price_basis.as_dict() if result.price_basis else {"entries": []},
}
)
@router.get("/{project_id}/estimation/price-basis/{code}")
async def get_price_basis_detail(project_id: UUID, code: str) -> JSONResponse:
"""③ 단가산출서 한 장 — 그 단가가 무엇을 참조해 나왔는지."""
from B09_Estimation.B09_Estimation_PriceBasis import price_basis_detail
try:
body = price_basis_detail(code)
except Exception:
logger.exception("B09 단가산출서 조회 실패: project_id=%s, code=%s", project_id, code)
return JSONResponse(
status_code=404,
content={"status": "error", "message": "그 단가산출서를 찾지 못했습니다."},
)
return JSONResponse(content={"status": "success", **body})
+191 -9
View File
@@ -549,10 +549,10 @@ const TAB_KEYS: Array<[string, keyof typeof ui_locales, boolean]> = [
["cost_sheet", "B09_Estimation_Tab_CostSheet", true],
["boq", "B09_Estimation_Tab_Boq", true],
["unit_price", "B09_Estimation_Tab_UnitPrice", true],
["price_basis", "B09_Estimation_Tab_PriceBasis", false],
["price_basis", "B09_Estimation_Tab_PriceBasis", true],
["machine", "B09_Estimation_Tab_Machine", false],
["duration", "B09_Estimation_Tab_Duration", false],
["supply", "B09_Estimation_Tab_Supply", false],
["supply", "B09_Estimation_Tab_Supply", true],
["base_data", "B09_Estimation_Tab_BaseData", false],
];
@@ -660,6 +660,17 @@ interface BillRowDto {
note: string;
}
interface PriceBasisEntryDto {
number: number;
label: string;
code: string;
name: string;
spec: string;
unit: string;
unit_price_krw: string;
ref_code: string;
}
interface BillDto {
rows: BillRowDto[];
excluded: BillRowDto[];
@@ -668,9 +679,37 @@ interface BillDto {
rows: number;
detail_rows: number;
body_total_krw: string;
missing: Array<{ name: string; reason: string; unit?: string; quantity?: string }>;
missing: Array<{
name: string;
reason: string;
unit?: string;
quantity?: string;
blocked_kind?: string;
}>;
notes: string[];
material_sheet: MaterialSheetDto | null;
};
price_basis: { entries: PriceBasisEntryDto[] };
}
interface MaterialSheetRowDto {
name: string;
spec: string;
unit: string;
total_amount: string;
unit_price_krw: string | null;
amount_krw: string | null;
note: string;
}
interface MaterialSheetDto {
contractor: MaterialSheetRowDto[];
owner: MaterialSheetRowDto[];
unknown: MaterialSheetRowDto[];
contractor_total_krw: string;
owner_total_krw: string;
missing: Array<{ name: string; reason: string }>;
notes: string[];
}
/**
@@ -721,6 +760,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
let unitPriceDetail: UnitPriceDetailDto | null = null;
let selectedUnitPrice: string | null = null;
let bill: BillDto | null = null;
let priceBasis: string | null = null;
const main = document.createElement("div");
main.className = "b09-main";
@@ -872,13 +912,30 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
note.className = "b09-hint";
note.textContent = `${L("B09_Estimation_Boq_Missing")} (${bill.summary.missing.length})`;
body.append(note);
const list = document.createElement("ul");
for (const item of bill.summary.missing) {
const li = document.createElement("li");
li.textContent = `${item.name}${item.reason}`;
list.append(li);
// ⚠ **할 일이 다르므로 갈라 보인다** — 「사용자가 입력하면 풀리는 것」과
// 「우리가 만들어야 하는 것」. 한 목록에 섞으면 사용자가 무엇을 해야 할지 못 읽는다.
const needsInput = bill.summary.missing.filter(
(item) => item.blocked_kind === "input_missing",
);
const rest = bill.summary.missing.filter((item) => item.blocked_kind !== "input_missing");
for (const [labelKey, group] of [
["B09_Estimation_Boq_NeedsInput", needsInput],
["B09_Estimation_Boq_NeedsWork", rest],
] as Array<[keyof typeof ui_locales, typeof bill.summary.missing]>) {
if (group.length === 0) continue;
const head = document.createElement("div");
head.className = "b09-hint";
head.textContent = `${L(labelKey)} (${group.length})`;
body.append(head);
const list = document.createElement("ul");
for (const item of group) {
const li = document.createElement("li");
li.textContent = `${item.name}${item.reason}`;
list.append(li);
}
body.append(list);
}
body.append(list);
}
if (bill.materials.length > 0) {
@@ -891,6 +948,123 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
}
};
/** ③ 단가산출서 — 내역 줄의 단가가 **어떻게 나왔는지** 보이는 표(실무 「단산 46 참조」). */
const drawPriceBasisTab = (): void => {
if (!bill) {
const empty = document.createElement("div");
empty.className = "b09-empty";
empty.textContent = L("B09_Estimation_PB_Empty");
body.append(empty);
return;
}
const entries = bill.price_basis?.entries ?? [];
// 일위대가 탭과 **같은 모양**으로 — 목록 위, 본표 아래 2단(PLAN 9-3 「표를 세 벌
// 만들지 않는다」와 같은 뜻: 화면도 한 벌로 쓴다).
const split = document.createElement("div");
const list = document.createElement("table");
list.className = "b09-sheet b09-up-list";
list.innerHTML = "<thead><tr><th>번호</th><th>공종</th><th>단위</th><th>단가</th></tr></thead>";
const tbody = document.createElement("tbody");
for (const entry of entries) {
const tr = document.createElement("tr");
for (const text of [
String(entry.number),
`${entry.name} ${entry.spec}`.trim(),
entry.unit,
entry.unit_price_krw,
]) {
const td = document.createElement("td");
td.textContent = text;
tr.append(td);
}
tr.style.cursor = "pointer";
if (entry.code === priceBasis) tr.style.fontWeight = "600";
tr.addEventListener("click", () => {
priceBasis = entry.code;
drawBody();
});
tbody.append(tr);
}
list.append(tbody);
split.append(list);
const picked = entries.find((entry) => entry.code === priceBasis) ?? null;
const panel = document.createElement("div");
panel.className = "b09-up-detail";
if (picked === null) {
panel.textContent = L("B09_Estimation_PB_Pick");
} else {
const head = document.createElement("div");
head.className = "b09-hint";
head.textContent = `${picked.label}${picked.name} ${picked.spec} (${picked.unit}) ${picked.unit_price_krw}`;
const ref = document.createElement("div");
ref.className = "b09-hint";
// 한 층 아래(일위대가)를 가리킨다 — 그 표는 일위대가 탭에서 그대로 본다.
ref.textContent = `${L("B09_Estimation_PB_Ref")}: ${picked.ref_code}`;
panel.append(head, ref);
}
split.append(panel);
body.append(split);
};
/** 자재대 — B08 수량·할증에 단가를 붙인 표. 관급은 **총원가 밖 별도 표기**다. */
const drawMaterialTab = (): void => {
const sheet = bill?.summary.material_sheet ?? null;
if (!sheet) {
const empty = document.createElement("div");
empty.className = "b09-empty";
empty.textContent = L("B09_Estimation_Mat_Empty");
body.append(empty);
return;
}
for (const [labelKey, rows, total] of [
["B09_Estimation_Mat_Contractor", sheet.contractor, sheet.contractor_total_krw],
["B09_Estimation_Mat_Owner", sheet.owner, sheet.owner_total_krw],
["B09_Estimation_Mat_Unknown", sheet.unknown, null],
] as Array<[keyof typeof ui_locales, MaterialSheetRowDto[], string | null]>) {
const head = document.createElement("div");
head.className = "b09-hint";
head.textContent = `${L(labelKey)} (${rows.length})` + (total === null ? "" : `${total}`);
body.append(head);
if (rows.length === 0) continue;
const table = document.createElement("table");
table.className = "b09-sheet";
table.innerHTML =
"<thead><tr><th>자재</th><th>규격</th><th>단위</th><th>수량</th>" +
"<th>단가</th><th>금액</th><th>비고</th></tr></thead>";
const tbody = document.createElement("tbody");
for (const row of rows) {
const tr = document.createElement("tr");
for (const text of [
row.name,
row.spec,
row.unit,
formatQuantity(row.total_amount),
row.unit_price_krw ?? "",
row.amount_krw ?? "",
row.note,
]) {
const td = document.createElement("td");
td.textContent = text;
tr.append(td);
}
tbody.append(tr);
}
table.append(tbody);
body.append(table);
}
for (const note of sheet.notes) {
const line = document.createElement("div");
line.className = "b09-hint";
line.textContent = note.replace(/\*\*/g, "");
body.append(line);
}
};
const drawBody = (): void => {
body.replaceChildren();
if (activeTab === "unit_price") {
@@ -901,6 +1075,14 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
drawBoqTab();
return;
}
if (activeTab === "price_basis") {
drawPriceBasisTab();
return;
}
if (activeTab === "supply") {
drawMaterialTab();
return;
}
if (activeTab !== "cost_sheet") {
const empty = document.createElement("div");
empty.className = "b09-empty";
+76 -1
View File
@@ -224,6 +224,81 @@ def load_basis_missing(
}
#: 품셈 원문이 섞어 쓰는 물결표 — 「직경40㎝이상∼60㎝미만」(U+223C)과
#: 「직경40㎝이상~60㎝미만」(U+FF5E)이 **같은 뜻인데 키가 두 벌**이었다(2026-09-08 실측:
#: 13-6-1·2 는 , 13-6-3 은 ~). 키에서만 한 종류로 모으고 **원문 문구는 이름에 보존**한다.
#: ⚠ 규칙은 둘뿐이다 — **내부 공백 제거 + 물결표 통일.** 다른 글자는 손대지 않는다
#: (키에 쓰인 글자를 세어 보니 그 밖에는 소수점·괄호뿐이었다).
_TILDE_CHARS = "∼~〜~"
def normalize_variant_key(text: str) -> str:
"""갈래 키 정규화 — 공백을 지우고 물결표를 한 종류(`~`)로 모은다."""
tight = "".join(str(text).split())
return "".join("~" if ch in _TILDE_CHARS else ch for ch in tight)
def find_variant_code(
work_item_code: str,
variant_value: str,
build: UnitPriceBuild | None = None,
) -> str | None:
"""B08 이 보낸 **저장 제원 원본값**(「60~80」)을 내 갈래 코드로 옮긴다.
갈래 키는 품셈 원문에서 나오고 ** 원문을 읽는 쪽이 여기**(2026-09-08 합의).
맞추면 `None` **가까운 갈래를 임의로 고르지 않는다.**
"""
prices = build or cached_build()
wanted = normalize_variant_key(variant_value)
if not wanted:
return None
prefix = f"B-{work_item_code}#"
candidates = [code for code in prices.book.titles if code.startswith(prefix)]
for code in candidates:
if normalize_variant_key(code[len(prefix) :]) == wanted:
return code
# ⚠ 글자 포함으로는 안 맞는다 — 「60~80」은 「직경60㎝이상~80㎝미만」 **안에 없다**
# (사이에 「㎝이상」이 낀다). **수의 짝**으로 견준다: [60, 80] == [60, 80].
numbers = _numbers_of(wanted)
if not numbers:
return None
hits = [
code
for code in candidates
if _numbers_of(normalize_variant_key(code[len(prefix) :])) == numbers
]
if len(hits) == 1:
return hits[0]
if len(numbers) == 1:
# 저장 제원이 **한 값**으로 온다(뒷길이 45㎝). 갈래는 구간이므로 그 값을 담는
# 구간을 고른다 — 「45」 → 「55cm이하」. **가장 좁은 구간**을 고른다.
return _bracket_for(numbers[0], candidates, prefix)
return None
def _bracket_for(value: Decimal, candidates: list[str], prefix: str) -> str | None:
"""그 값을 담는 갈래 — 「N 이하」는 상한, 「A 이상~B 미만」은 범위로 본다."""
best: tuple[Decimal, str] | None = None
for code in candidates:
label = normalize_variant_key(code[len(prefix) :])
bounds = _numbers_of(label)
if len(bounds) == 1:
if "이하" in label and value <= bounds[0]:
if best is None or bounds[0] < best[0]:
best = (bounds[0], code)
elif len(bounds) == 2 and bounds[0] <= value <= bounds[1]:
width = bounds[1] - bounds[0]
if best is None or width < best[0]:
best = (width, code)
return best[1] if best else None
def _numbers_of(text: str) -> list[Decimal]:
"""그 문자열에 나오는 수들 — 「직경60㎝이상~80㎝미만」 → [60, 80]."""
return [Decimal(token) for token in re.findall(r"\d+(?:\.\d+)?", text)]
def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
"""자원 축을 일위대가(`B`)로 조립한다.
@@ -254,7 +329,7 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
# 갈래 키는 **내부 공백을 지운 것**, 화면 문구는 **원문 그대로**
# (2026-09-08 두 창 합의). 원문이 「보 통」·「보 통」으로 들쭉날쭉해
# 키에 공백을 남기면 한 칸 차이로 영영 안 맞는다. 공백 말고는 손대지 않는다.
variant_key = "".join(variant.split())
variant_key = normalize_variant_key(variant)
title_code = f"B-{work_item_code}" + (f"#{variant_key}" if variant_key else "")
if title_code in build.book.titles:
continue
+27
View File
@@ -674,6 +674,25 @@ 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_PB_Empty: [
"설계내역서를 먼저 불러오면 단가산출서가 섭니다.",
"Load the bill first and the price-basis sheets appear.",
],
B09_Estimation_PB_Pick: ["왼쪽에서 산출서를 고르세요.", "Pick a sheet on the left."],
B09_Estimation_PB_Ref: ["참조", "Refers to"],
B09_Estimation_Mat_Contractor: ["사급 자재 (도급 재료비)", "Contractor-supplied (in the bill)"],
B09_Estimation_Mat_Owner: [
"관급 자재 — 총원가 밖 별도 표기",
"Owner-supplied — listed outside the total cost",
],
B09_Estimation_Mat_Unknown: [
"관급·사급이 안 갈린 것 — 어느 합계에도 안 넣습니다",
"Supply type undecided — excluded from both totals",
],
B09_Estimation_Mat_Empty: [
"설계내역서를 먼저 불러오면 자재대가 섭니다.",
"Load the bill first and the material sheet appears.",
],
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.",
@@ -682,6 +701,14 @@ export const ui_locales_b2 = {
"검산용 줄 — 수량만 보이고 금액을 매기지 않습니다",
"Check rows — quantity only, never priced",
],
B09_Estimation_Boq_NeedsInput: [
"입력하면 풀리는 것 — 설계 화면에서 값을 고르면 금액이 섭니다",
"Waiting on input — pick the value on the design screen and the amount appears",
],
B09_Estimation_Boq_NeedsWork: [
"우리가 만들어야 하는 것 — 원단위·전개식이 아직 없습니다",
"Needs build — unit data or formula is missing",
],
B09_Estimation_Boq_Missing: [
"금액을 못 세운 줄 — 0 으로 채우지 않고 그대로 보입니다",
"Rows without an amount — shown as-is, not zero-filled",