feat(B09): 산출기초 — 줄에 달린 근거를 한 장으로 접음

별표2 (5)(가) 열셋째. 근거는 줄마다 이미 있었고 묶는 자리만 없었음.
원가계산에 「산출기초」 탭으로 세움. 넷으로 접음 —
① 어느 판으로 계산했나(데이터 기준일·지문) ② 무엇을 골랐나(고른 값만)
③ 공종마다 무엇을 근거로 했나(줄 문구 그대로) ④ 못 채운 자리(0 으로 안 때운 자리).

- 판 목록은 장부(_manifest.json)를 그대로 읽음 — 목록을 따로 적으면 한쪽만 고쳐짐.
- ⚠ 여기서 값을 다시 계산하지 않음 — 금액 칸이 아예 없음(두 벌 방지, 시험으로 못 박음).

⇒ 「설계서 구성」의 산출기초가 반쪽 → 있음. 법이 정한 13 중 9 가 서고,
  우리가 더 낼 것은 공사설명서 하나(서식·설계하중 표기는 사용자에게 받아야 함).

곁들여: 자재총괄의 「미분류」를 「미정(발주기관 결정)」으로 고침 — 발주기관이 정할
자리인데 우리가 못 만든 것처럼 읽히던 문구(V-14). 값·판정은 그대로.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-10 00:06:22 +09:00
co-authored by Claude Opus 5
parent 3934bd8d07
commit 89effeca07
11 changed files with 419 additions and 15 deletions
@@ -64,7 +64,14 @@ ACCEPTED_DESTINATION = "material"
SUPPLY_OWNER = "owner_supplied" # 관급 — 발주처 지급
SUPPLY_CONTRACTOR = "contractor_supplied" # 사급 — 도급자 구입
SUPPLY_UNKNOWN = "unknown" # 아직 안 정함 — 화면에 드러낸다
SUPPLY_LABELS = {SUPPLY_OWNER: "관급", SUPPLY_CONTRACTOR: "사급", SUPPLY_UNKNOWN: "미분류"}
#: ⚠ 「미분류」로만 적으면 **우리가 아직 못 만든 것처럼** 읽힌다 — 실제로는 **발주기관이
#: 정할 자리**라 우리가 지어내면 안 되는 값이다(2026-09-09 화면 확인에서 나옴).
#: 그래서 표기에 **누가 정하는지**를 함께 적는다. 값(`unknown`)과 판정은 그대로다.
SUPPLY_LABELS = {
SUPPLY_OWNER: "관급",
SUPPLY_CONTRACTOR: "사급",
SUPPLY_UNKNOWN: "미정(발주기관 결정)",
}
#: ⚠ 관급 안의 **설치 주체** — 안전관리비 대상액은 관급 전액이 아니라 「도급자설치 관급금액」이다
#: (PLAN 8-10 대상액 정의). 관급/사급 두 갈래로만 두면 B09 가 ⑤를 못 세운다.
+4 -3
View File
@@ -4,7 +4,7 @@
*
* 자재총괄 열은 순수량·할증률·합계 셋이고 **금액이 없다** — 금액은 B09 몫이다.
*
* ⚠ 「모르는 값」을 빈칸으로 두지 않는다. 할증률 미확보·관급구분 미분류·설치주체 미지정은
* ⚠ 「모르는 값」을 빈칸으로 두지 않는다. 할증률 미확보·관급/사급 미정·설치주체 미지정은
* 모두 화면에 **글자로** 뜬다. 0 % 나 빈칸으로 두면 「할증 없음」과 구별이 안 되고,
* 설치 주체를 못 정한 채 넘어가면 B09 안전관리비가 조용히 틀린다.
*
@@ -174,7 +174,8 @@ function warning(title: string, items: string[]): HTMLElement | null {
/** 관급/사급 고르는 칸의 보기. **값은 영문 키, 표기는 한글**(B09 와 같은 낱말). */
const SUPPLY_OPTIONS = [
{ value: "unknown", label: "미분류" },
// ⚠ 「미분류」로만 적으면 미완성으로 읽힌다 — **발주기관이 정할 자리**임을 표기에 담는다.
{ value: "unknown", label: "미정(발주기관 결정)" },
{ value: "contractor_supplied", label: "사급" },
{ value: "owner_supplied", label: "관급" },
];
@@ -259,7 +260,7 @@ export function renderMaterialGrid(
for (const notice of [
warning("⚠ 중복 할증 위험", table.double_count_warnings),
warning("할증률 미확보", table.missing_rate_materials),
warning("관급구분 미분류", table.missing_supply_materials),
warning("관급/사급 미정 — 발주기관이 정하는 자리입니다", table.missing_supply_materials),
warning("설치 주체 미지정(관급)", table.missing_install_by_materials),
]) {
if (notice) wrap.append(notice);
+164
View File
@@ -0,0 +1,164 @@
"""B09 — **산출기초** 한 장 (별표2 (5)(가) 열셋째).
**무엇인가** — 새로 세는 장이 아니다. **이미 줄마다 달려 있는 근거를 한 장으로 접는** 자리다.
법이 설계서 맨 뒤에 「산출기초」를 두라 했는데, 우리는 근거를 **줄에만** 달고 있어
설계서로 묶을 때 그 문구들을 사람이 손으로 모아야 했다.
**넷으로 접는다**
① 어느 판으로 계산했나 — 품셈·요율·노임·유가·기계·자재 데이터의 판과 기준일
② 무엇을 골랐나 — 프로젝트가 고른 값(범위 계수·장비·유가 지역·수송·품 할증 …)
③ 공종마다 무엇을 근거로 했나 — 일위대가 줄에 달린 근거 문구 그대로
④ 못 채운 자리 — 값이 안 선 사유(0 으로 때우지 않은 자리)
⚠ **여기서 값을 다시 계산하지 않는다.** 계산은 일위대가·내역서가 하고 이 장은 **모으기만**
한다 — 두 벌로 세면 설계서와 내역서가 조용히 갈린다.
"""
from __future__ import annotations
import json
import os
from typing import Any
from B09_Estimation.B09_Estimation_PriceBook import PriceKind
_MANIFEST = ("resources", "data_cost_input_value", "_manifest.json")
_MASTER_DIR = ("resources", "data_work_item_master")
SHEET_NOTE = (
"산출기초는 새로 세는 장이 아니라 **줄에 달린 근거를 한 장으로 모은 것**입니다 —"
" 값은 일위대가·내역서가 낸 그대로입니다."
).replace("**", "")
def _project_root() -> str:
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def _read_json(*parts: str) -> Any:
with open(os.path.join(_project_root(), *parts), encoding="utf-8") as handle:
return json.load(handle)
def dataset_versions() -> list[dict[str, str]]:
"""① 어느 판으로 계산했나 — 값층 데이터의 판과 기준일.
⚠ **장부(`_manifest.json`)를 그대로 읽는다** — 여기서 목록을 따로 적으면 판이 바뀔 때
한쪽만 고쳐진다.
"""
rows: list[dict[str, str]] = []
manifest = _read_json(*_MANIFEST)
for entry in manifest.get("files") or []:
rows.append(
{
"dataset_id": str(entry.get("dataset_id") or ""),
"file": str(entry.get("file") or ""),
"effective_date": str(entry.get("effective_date") or ""),
"sha256": str(entry.get("sha256") or "")[:12],
}
)
# 공종 마스터는 다른 폴더의 장부라 함께 싣는다 — 품셈 원문을 정규화한 판이다.
master_dir = os.path.join(_project_root(), *_MASTER_DIR)
if os.path.isdir(master_dir):
for name in sorted(os.listdir(master_dir)):
if name.startswith("work_item_master_") and name.endswith(".json"):
rows.append(
{
"dataset_id": "work_item_master",
"file": name,
"effective_date": name[len("work_item_master_") : -len(".json")],
"sha256": "",
}
)
return rows
def chosen_conditions(settings: dict[str, Any] | None) -> list[dict[str, str]]:
"""② 무엇을 골랐나 — **고른 것만** 싣는다(안 고른 칸은 기본값이라 줄이 안 선다)."""
picked = settings or {}
rows: list[dict[str, str]] = []
for key, label in (
("misc_material_percent", "공구손료·잡재료 (주재료비의 %)"),
("fuel_region", "유가 지역(시도코드)"),
("transport_distance_km", "기계 수송 거리(편도 ㎞)"),
("transport_road", "수송 도로 구분"),
):
value = str(picked.get(key) or "").strip()
if value:
rows.append({"item": label, "value": value})
for code, choice in sorted((picked.get("range_factor_choices") or {}).items()):
rows.append({"item": f"범위 계수 {code}", "value": str(choice)})
for code, choice in sorted((picked.get("machine_choices") or {}).items()):
rows.append({"item": f"장비 규격 {code}", "value": str(choice)})
from B09_Estimation.B09_Estimation_LaborSurcharge import parse_choices, total_percent
surcharge = parse_choices(picked.get("labor_surcharge"))
if surcharge:
percent, reasons = total_percent(surcharge)
rows.append({"item": f"품 할인·할증 합계 {percent:g}%", "value": " · ".join(reasons)})
return rows
def work_item_basis(build: Any) -> list[dict[str, Any]]:
"""③ 공종마다 무엇을 근거로 했나 — **줄에 달린 문구를 그대로** 모은다."""
rows: list[dict[str, Any]] = []
for code, title in sorted(build.book.titles.items()):
if title.kind is not PriceKind.UNIT_PRICE:
continue
notes = [
detail.note.strip()
for detail in build.book.details.get(code, [])
if detail.note and detail.note.strip()
]
work_item_code = code[2:].split("#")[0]
source = build.factor_sources.get(work_item_code)
if source and source not in notes:
notes.append(str(source))
if not notes:
continue
rows.append(
{
"code": code,
"name": title.name,
"unit": title.unit,
"notes": notes,
}
)
return rows
def open_gaps(build: Any) -> list[dict[str, str]]:
"""④ 못 채운 자리 — **0 으로 때우지 않은 자리**를 사유째 모은다."""
rows: list[dict[str, str]] = []
for code, reason in sorted(build.component_gaps.items()):
rows.append({"kind": "성분 못 채움", "code": code, "reason": str(reason)})
for code, section in sorted(build.basis_missing.items()):
rows.append({"kind": "밑수 미확보", "code": code, "reason": str(section)})
for note in build.transport_notes:
rows.append({"kind": "수송비", "code": "FP-10-04", "reason": str(note)})
for machine in build.incomplete_machines:
rows.append({"kind": "기계 층 미완성", "code": machine, "reason": "손료·운전경비 미확보"})
return rows
def basis_sheet(build: Any, settings: dict[str, Any] | None = None) -> dict[str, Any]:
"""산출기초 한 장 — 넷을 접어 낸다."""
versions = dataset_versions()
conditions = chosen_conditions(settings)
items = work_item_basis(build)
gaps = open_gaps(build)
return {
"note": SHEET_NOTE,
"dataset_versions": versions,
"chosen_conditions": conditions,
"work_items": items,
"gaps": gaps,
"summary": (
f"데이터 {len(versions)} 판 · 고른 값 {len(conditions)} · "
f"근거가 달린 공종 {len(items)} · 못 채운 자리 {len(gaps)}"
),
}
@@ -149,12 +149,13 @@ DESIGN_DOC_ITEMS: tuple[dict[str, Any], ...] = (
{
"order": 13,
"name": "산출기초",
"status": STATUS_PARTIAL,
"status": STATUS_READY,
"owner": OWNER_PROGRAM,
"where": "각 줄의 산출근거 문구",
"where": "원가계산 → 산출기초",
"note": (
"마다 어디서 온 값인지는 적혀 있으나 별도의 장으로 묶여 있지 않다 — "
"설계서로 묶으려면 그 문구들을 한 장으로 모으는 자리가 필요하다."
"에 달린 근거를 한 장으로 모은 장이다 — 어느 판으로 계산했나(데이터 기준일) · "
"무엇을 골랐나(산출 조건) · 공종마다 무엇을 근거로 했나 · 못 채운 자리. "
"여기서 값을 다시 계산하지 않는다."
),
},
)
+22
View File
@@ -319,6 +319,28 @@ async def get_price_sources(project_id: UUID) -> JSONResponse:
)
@router.get("/{project_id}/estimation/basis-sheet")
async def get_basis_sheet(project_id: UUID) -> JSONResponse:
"""**산출기초** — 줄에 달린 근거를 한 장으로 모은다(별표2 (5)(가) 열셋째).
⚠ 값을 다시 계산하지 않는다 — 일위대가·내역서가 낸 그대로를 모으기만 한다.
"""
from B09_Estimation.B09_Estimation_BasisSheet import basis_sheet
from common_util.common_util_project_settings import estimation_settings
try:
build = await _build_for(project_id)
root = await _project_root_of(project_id)
settings = estimation_settings(root) if root else {}
return JSONResponse(content={"status": "success", **basis_sheet(build, settings)})
except Exception:
logger.exception("B09 산출기초 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "산출기초를 못 만들었습니다."},
)
@router.get("/{project_id}/estimation/machine-expense")
async def get_machine_expense(project_id: UUID) -> JSONResponse:
"""**각종 중기경비계산서** — 기종마다 한 장(별표2 (5)(가) 아홉째).
@@ -357,6 +357,91 @@ export function drawMachineExpense(body: HTMLElement, data: MachineExpenseDto):
for (const sheet of data.sheets) body.append(machineExpenseSheet(sheet));
}
/* =============================================================================
* (2 (5)() ).
* .
* ========================================================================== */
export interface BasisSheetDto {
status: string;
note: string;
summary: string;
dataset_versions: Array<{
dataset_id: string;
file: string;
effective_date: string;
sha256: string;
}>;
chosen_conditions: Array<{ item: string; value: string }>;
work_items: Array<{ code: string; name: string; unit: string; notes: string[] }>;
gaps: Array<{ kind: string; code: string; reason: string }>;
}
export async function fetchBasisSheet(projectId: string): Promise<BasisSheetDto> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/basis-sheet`,
{ credentials: "include" },
);
if (!response.ok) throw new Error(`basis-sheet ${response.status}`);
return (await response.json()) as BasisSheetDto;
}
export function drawBasisSheet(body: HTMLElement, data: BasisSheetDto): void {
body.append(head("산출기초"));
body.append(note(data.summary));
body.append(note(data.note));
body.append(head(`① 어느 판으로 계산했나 (${data.dataset_versions.length})`));
body.append(
table(
["자료", "파일", "기준일", "지문(앞 12)"],
data.dataset_versions.map((row) => [
row.dataset_id,
row.file,
row.effective_date,
row.sha256 || "—",
]),
[0, 1, 2, 3],
),
);
body.append(head(`② 무엇을 골랐나 (${data.chosen_conditions.length})`));
if (data.chosen_conditions.length === 0) {
body.append(note("고른 값이 없습니다 — 전부 확정 기본값으로 돌고 있습니다."));
} else {
body.append(
table(
["항 목", "고른 값"],
data.chosen_conditions.map((row) => [row.item, row.value]),
[0, 1],
),
);
}
body.append(head(`③ 공종마다 무엇을 근거로 했나 (${data.work_items.length})`));
body.append(
table(
["코드", "공 종", "단위", "근 거"],
data.work_items.map((row) => [row.code, row.name, row.unit, row.notes.join(" · ")]),
[0, 1, 2, 3],
),
);
body.append(head(`④ 못 채운 자리 (${data.gaps.length})`));
if (data.gaps.length === 0) {
body.append(note("못 채운 자리가 없습니다."));
} else {
body.append(
table(
["갈 래", "코드", "사 유"],
data.gaps.map((row) => [row.kind, row.code, row.reason]),
[0, 1, 2],
),
);
body.append(note("⚠ 여기 있는 것은 0 으로 때우지 않고 남겨 둔 자리입니다."));
}
}
/** 두 탭이 함께 쓰는 「아직 못 불러왔습니다」 문구. */
export function drawBaseDataError(body: HTMLElement): void {
body.append(note(L("B09_Estimation_Tab_Pending")));
+27
View File
@@ -19,17 +19,20 @@ import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import {
drawBaseDataTab,
drawFactorChoices,
drawBasisSheet,
drawDesignDocTab,
drawMachineExpense,
drawMachineTab,
drawPriceSourcesPending,
drawPriceSourcesSections,
fetchBaseData,
fetchBasisSheet,
fetchDesignDocIndex,
fetchMachineExpense,
fetchFactorChoices,
fetchPriceSources,
type BaseDataDto,
type BasisSheetDto,
type DesignDocDto,
type MachineExpenseDto,
type FactorChoicesDto,
@@ -602,6 +605,7 @@ const TAB_KEYS: Array<[string, keyof typeof ui_locales, boolean]> = [
["supply", "B09_Estimation_Tab_Supply", true],
["base_data", "B09_Estimation_Tab_BaseData", true],
["design_doc", "B09_Estimation_Tab_DesignDoc", true],
["basis_sheet", "B09_Estimation_Tab_BasisSheet", true],
];
function buildTabs(active: string, onSelect: (key: string) => void): HTMLElement {
@@ -815,6 +819,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
let priceSources: PriceSourcesDto | null = null;
let factorChoices: FactorChoicesDto | null = null;
let designDoc: DesignDocDto | null = null;
let basisSheet: BasisSheetDto | null = null;
let machineExpense: MachineExpenseDto | null = null;
let sheet: CostSheetDto | null = null;
let unitPriceList: UnitPriceListDto | null = null;
@@ -1177,6 +1182,28 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
drawMaterialTab();
return;
}
if (activeTab === "basis_sheet") {
// 산출기초 — 줄에 달린 근거를 모아 오는 장이라 조립이 끝나야 뜬다.
if (basisSheet) {
drawBasisSheet(body, basisSheet);
return;
}
const waiting = document.createElement("div");
waiting.className = "b09-empty";
waiting.textContent = L("B09_Estimation_Tab_Pending");
body.append(waiting);
if (projectId) {
void fetchBasisSheet(projectId)
.then((data) => {
basisSheet = data;
drawBody();
})
.catch(() => {
/* 못 받아도 화면을 비우지 않는다. */
});
}
return;
}
if (activeTab === "design_doc") {
// 설계서 구성표 — 프로젝트 값이 아니라 **우리가 무엇을 내는가**의 표다.
if (designDoc) {
@@ -215,10 +215,12 @@ def test_합계는_반올림하지_않음() -> None:
# ── 관급/사급 ───────────────────────────────────────────────────────
def test_안_정한_자재는_미분류로_드러남() -> None:
def test_안_정한_자재는_미정으로_드러남() -> None:
"""⚠ 표기에 **누가 정하는지**가 있어야 한다 — 「미분류」로만 적으면 우리가 못 만든 것처럼
읽힌다(2026-09-09 화면 확인). (`unknown`) 판정은 그대로다."""
table = build_table(원단위표(성분("야면석", "", 5.0)))
assert (table, "야면석")["supply"] == SUPPLY_UNKNOWN
assert (table, "야면석")["supply_label"] == "분류"
assert (table, "야면석")["supply_label"] == "정(발주기관 결정)"
assert table["missing_supply_materials"] == ["야면석"]
+87
View File
@@ -0,0 +1,87 @@
"""산출기초 — 줄에 달린 근거를 한 장으로 (2026-09-09).
별표2 (5)() 열셋째. **새로 세는 장이 아니다** 이미 줄마다 달려 있는 근거를 모은다.
겨누는 다섯
어느 판으로 계산했나 **장부(`_manifest.json`) 그대로** 읽는다(목록을 따로 적음)
고른 값만 싣는다 고른 칸은 기본값이라 줄이 선다
근거는 **줄에 달린 문구 그대로** 여기서 새로 짓지 않는다
채운 자리는 **사유째** 모은다(0 으로 때운 자리가 아님)
여기서 **값을 다시 계산하지 않는다** 일위대가가 값과 벌이 되면 된다
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B09_Estimation.B09_Estimation_BasisSheet import ( # noqa: E402
basis_sheet,
chosen_conditions,
dataset_versions,
work_item_basis,
)
from B09_Estimation.B09_Estimation_UnitPrice import cached_build # noqa: E402
def test_판_목록은_장부를_그대로_읽는다() -> None:
manifest = json.loads(
(ROOT / "resources" / "data_cost_input_value" / "_manifest.json").read_text(
encoding="utf-8"
)
)
ledger = {str(row["file"]) for row in manifest["files"]}
rows = dataset_versions()
assert ledger <= {row["file"] for row in rows}
# 공종 마스터도 함께 실린다 — 품셈 원문을 정규화한 판이다.
assert any(row["dataset_id"] == "work_item_master" for row in rows)
for row in rows:
assert row["effective_date"], row
def test_고른_값만_싣는다() -> None:
assert chosen_conditions({}) == []
assert chosen_conditions({"misc_material_percent": "", "fuel_region": ""}) == []
picked = chosen_conditions({"fuel_region": "01", "transport_distance_km": "12"})
assert [row["value"] for row in picked] == ["01", "12"]
def test_품_할증은_고른_것을_풀어_적는다() -> None:
picked = chosen_conditions({"labor_surcharge": {"1-4-1": "1-4-1:0"}})
assert len(picked) == 1
assert "10%" in picked[0]["item"]
assert "작업시기" in picked[0]["value"]
def test_근거는_줄에_달린_문구_그대로다() -> None:
build = cached_build()
rows = {row["code"]: row for row in work_item_basis(build)}
assert rows, "근거가 달린 공종이 하나도 없습니다"
for code, row in rows.items():
notes = {
detail.note.strip()
for detail in build.book.details.get(code, [])
if detail.note and detail.note.strip()
}
# 줄에 없는 문구를 지어내지 않는다(공종 단위 출처 한 줄만 더 붙을 수 있다).
assert notes <= set(row["notes"])
def test_못_채운_자리는_사유째_모은다() -> None:
sheet = basis_sheet(cached_build(), {})
assert sheet["gaps"], "못 채운 자리가 하나도 없다고 나옵니다 — 셈이 이상합니다"
for row in sheet["gaps"]:
assert row["kind"] and row["code"] and row["reason"]
def test_값을_다시_계산하지_않는다() -> None:
"""⑤ 이 장에는 금액 칸이 없다 — 금액은 일위대가·내역서 몫이다."""
sheet = basis_sheet(cached_build(), {})
text = json.dumps(sheet, ensure_ascii=False)
assert "총액" not in text and "합계 금액" not in text
for row in sheet["work_items"]:
assert set(row) == {"code", "name", "unit", "notes"}
+12 -5
View File
@@ -60,13 +60,20 @@ def test_설계자_몫과_우리_몫을_가른다() -> None:
assert item["status"] == STATUS_MISSING
def test_반쪽은_반쪽이라_적는() -> None:
"""⚠ 2026-09-09 밤 — 중기경비계산서를 세우면서 **반쪽이 하나 줄었다**(기종마다 한 장).
def test_반쪽이_남지_않았() -> None:
"""⚠ 2026-09-09 밤 — 중기경비계산서·산출기초를 차례로 세우며 **반쪽이 다 없어졌다.**
남은 반쪽은 산출기초 하나 근거 문구는 줄마다 있으나 장으로 묶는 자리가 없다.
이제 우리 가운데 없음 공사설명서 하나이고, 그것은 **서식·설계하중 표기를
사용자에게 받아야** 서는 자리다.
"""
partial = {item["name"] for item in DESIGN_DOC_ITEMS if item["status"] == STATUS_PARTIAL}
assert partial == {"산출기초"}
assert partial == set()
ours_missing = {
item["name"]
for item in DESIGN_DOC_ITEMS
if item["owner"] == OWNER_PROGRAM and item["status"] != STATUS_READY
}
assert ours_missing == {"공사설명서"}
def test_서는_것은_어디서_나오는지_적혀_있다() -> None:
@@ -84,7 +91,7 @@ def test_못_내는_것에는_무엇이_필요한지_적혀_있다() -> None:
def test_요약이_셈과_맞는다() -> None:
data = design_doc_index()
assert data["counts"][STATUS_READY] == 8
assert data["counts"][STATUS_READY] == 9
assert sum(data["counts"].values()) == len(DESIGN_DOC_ITEMS)
ours_missing = [
item["name"]
+1
View File
@@ -828,6 +828,7 @@ export const ui_locales_b2 = {
B09_Estimation_Tab_Supply: ["관급·사급", "Supplied Materials"],
B09_Estimation_Tab_BaseData: ["기초자료", "Base Data"],
B09_Estimation_Tab_DesignDoc: ["설계서 구성", "Design Document Set"],
B09_Estimation_Tab_BasisSheet: ["산출기초", "Calculation Basis"],
B09_Estimation_Group_Condition: ["공사 조건", "Project Conditions"],
B09_Estimation_Group_RateVersion: ["요율 판", "Rate Edition"],
B09_Estimation_Group_Supplied: ["관급자재", "Owner-Supplied"],