Merge remote-tracking branch 'origin/sub_desktop_1' into main_desktop_1
This commit is contained in:
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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})
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user