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:
@@ -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,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);
|
||||
|
||||
@@ -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": (
|
||||
"줄마다 어디서 온 값인지는 적혀 있으나 별도의 장으로 묶여 있지 않다 — "
|
||||
"설계서로 묶으려면 그 문구들을 한 장으로 모으는 자리가 필요하다."
|
||||
"줄에 달린 근거를 한 장으로 모은 장이다 — 어느 판으로 계산했나(데이터 기준일) · "
|
||||
"무엇을 골랐나(산출 조건) · 공종마다 무엇을 근거로 했나 · 못 채운 자리. "
|
||||
"여기서 값을 다시 계산하지 않는다."
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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")));
|
||||
|
||||
@@ -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"] == ["야면석"]
|
||||
|
||||
|
||||
|
||||
@@ -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"}
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user