feat(B09): 설계서 구성표 — 법이 정한 목차와 우리가 내는 것을 맞대 봄
별표2 (5)(가) 가 설계서 작성 순서를 열셋으로 못 박아 두었는데, 우리가 무엇을 내고 무엇을 못 내는지가 계획서에만 있었음. 원가계산에 「설계서 구성」 탭으로 세움. - 법이 적은 차례 그대로. 줄마다 상태(있음·반쪽·없음)와 어디서 나오는지를 적음. - ⚠ 「없음」과 「우리 몫 아님」을 가름 — 일반시방서·특별시방서·예정공정표는 현장을 아는 사람이 쓰는 문서라 프로그램이 지어낼 것이 아님. 법이 목차를 정했다고 열셋을 다 우리가 만드는 것은 아님. - 못 내는 것에는 무엇을 받아야 내는지를 적음(공사설명서 = 서식·설계하중 표기). - 사방사업은 목록이 다름(별표0 은 위치도·관계지적조서·설계도면을 더 둠)을 함께 적음. ⇒ 법이 정한 13 중 7 이 서 있고, 3 은 설계자·발주청 몫, 우리가 더 낼 것은 3 (공사설명서 · 각종 중기경비계산서 · 산출기초). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
"""설계서 구성표 — **법이 정한 목차와 우리가 내는 것을 맞대 보는 표** (2026-09-09).
|
||||
|
||||
**왜 있나** — 「무엇을 내야 하나」가 법에 목록으로 박혀 있는데, 우리가 그중 무엇을 내고
|
||||
무엇을 못 내는지가 **계획서에만** 있었다. 설계서를 묶는 사람이 화면에서 바로 봐야 한다.
|
||||
|
||||
산림자원법 시행규칙 [별표2] (5)(가)
|
||||
「설계서는 **목차·공사설명서·일반시방서·특별시방서·예정공정표·예산내역서·일위대가표·
|
||||
단가산출서·각종중기경비계산서·공종별 수량계산서·각종 소요자재총괄표·토적표·산출기초**
|
||||
순으로 작성한다」
|
||||
|
||||
⚠ **법이 목차를 정했다고 그 열셋을 다 프로그램이 만들 것은 아니다.** 시방서·예정공정표는
|
||||
**설계자·발주청이 쓰는 문서**다. 그래서 줄마다 「누가 만드나」를 함께 적는다 —
|
||||
못 내는 것과 **애초에 우리 몫이 아닌 것**을 갈라야 다음에 할 일이 달라진다.
|
||||
|
||||
⚠ **사방사업은 목록이 다르다** — 사방사업법 시행규칙 [별표0] 은 **위치도·관계지적조서·
|
||||
공사원가계산서·설계도면**을 더 두고 예산내역서 대신 「설계내역서」라 적는다. 지금 이 표는
|
||||
**임도(별표2)** 기준이다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
#: 상태 — 세 갈래. 「없음」과 「우리 몫 아님」을 **갈라 적는다.**
|
||||
STATUS_READY = "있음"
|
||||
STATUS_PARTIAL = "반쪽"
|
||||
STATUS_MISSING = "없음"
|
||||
|
||||
OWNER_PROGRAM = "프로그램"
|
||||
OWNER_DESIGNER = "설계자·발주청"
|
||||
|
||||
LAW_SOURCE = (
|
||||
"산림자원의 조성 및 관리에 관한 법률 시행규칙 [별표2] 산림관리기반시설의 설계 및 시설기준"
|
||||
" (5)(가) — 설계서 작성 순서"
|
||||
)
|
||||
|
||||
#: 별표2 가 적은 **그 차례 그대로**. 순서를 바꾸지 않는다(법이 순서를 정한 목록이다).
|
||||
DESIGN_DOC_ITEMS: tuple[dict[str, Any], ...] = (
|
||||
{
|
||||
"order": 1,
|
||||
"name": "목차",
|
||||
"status": STATUS_READY,
|
||||
"owner": OWNER_PROGRAM,
|
||||
"where": "이 표(설계서 구성)",
|
||||
"note": "법이 정한 차례대로 무엇이 서고 무엇이 안 서는지를 이 표가 그대로 보인다.",
|
||||
},
|
||||
{
|
||||
"order": 2,
|
||||
"name": "공사설명서",
|
||||
"status": STATUS_MISSING,
|
||||
"owner": OWNER_PROGRAM,
|
||||
"where": "",
|
||||
"note": (
|
||||
"값은 거의 다 우리가 갖고 있다 — 공사명·총공사비(원가계산서) · 유효폭·노견·설계속도·"
|
||||
"종단구배·최소곡선반경(설계 기준). **받아야 할 것은 서식과 설계하중 표기**"
|
||||
"(실무 거창은 「DB-18(32.45T)」로 적었는데 우리 값이 아니다)."
|
||||
),
|
||||
},
|
||||
{
|
||||
"order": 3,
|
||||
"name": "일반시방서",
|
||||
"status": STATUS_MISSING,
|
||||
"owner": OWNER_DESIGNER,
|
||||
"where": "",
|
||||
"note": (
|
||||
"표준시방서를 인용하는 문서다 — **프로그램이 지어낼 것이 아니다.** "
|
||||
"원문도 아직 안 갖고 있다(실무 자료에 「임도시설공사 표준시방서」가 있으나 미보유)."
|
||||
),
|
||||
},
|
||||
{
|
||||
"order": 4,
|
||||
"name": "특별시방서",
|
||||
"status": STATUS_MISSING,
|
||||
"owner": OWNER_DESIGNER,
|
||||
"where": "",
|
||||
"note": "그 현장의 특기사항을 설계자가 쓴다 — 현장을 아는 사람만 쓸 수 있는 문서다.",
|
||||
},
|
||||
{
|
||||
"order": 5,
|
||||
"name": "예정공정표",
|
||||
"status": STATUS_MISSING,
|
||||
"owner": OWNER_DESIGNER,
|
||||
"where": "",
|
||||
"note": (
|
||||
"공기를 공종에 어떻게 나누는지는 설계 판단이다. 우리는 **공사기간(일)만** 받아"
|
||||
"요율 구간에 쓴다 — 그것으로 공정표를 지어내지 않는다."
|
||||
),
|
||||
},
|
||||
{
|
||||
"order": 6,
|
||||
"name": "예산내역서",
|
||||
"status": STATUS_READY,
|
||||
"owner": OWNER_PROGRAM,
|
||||
"where": "원가계산 → 설계내역서",
|
||||
"note": "",
|
||||
},
|
||||
{
|
||||
"order": 7,
|
||||
"name": "일위대가표",
|
||||
"status": STATUS_READY,
|
||||
"owner": OWNER_PROGRAM,
|
||||
"where": "원가계산 → 일위대가",
|
||||
"note": "",
|
||||
},
|
||||
{
|
||||
"order": 8,
|
||||
"name": "단가산출서",
|
||||
"status": STATUS_READY,
|
||||
"owner": OWNER_PROGRAM,
|
||||
"where": "원가계산 → 단가산출근거",
|
||||
"note": "",
|
||||
},
|
||||
{
|
||||
"order": 9,
|
||||
"name": "각종 중기경비계산서",
|
||||
"status": STATUS_PARTIAL,
|
||||
"owner": OWNER_PROGRAM,
|
||||
"where": "원가계산 → 중기(중기목록표)",
|
||||
"note": (
|
||||
"기종별 **시간당 사용료 3분할**은 서 있고 줄을 누르면 무엇으로 이루어졌는지 "
|
||||
"파고들 수 있다. ⚠ 다만 실무 서식의 **기종마다 한 장짜리 계산서**"
|
||||
"(취득가·손료계수·상각비·정비비·관리비 + 운전경비 + 수송비)는 아직 없다."
|
||||
),
|
||||
},
|
||||
{
|
||||
"order": 10,
|
||||
"name": "공종별 수량계산서",
|
||||
"status": STATUS_READY,
|
||||
"owner": OWNER_PROGRAM,
|
||||
"where": "수량산출 → 토공집계·구조물 원단위",
|
||||
"note": "",
|
||||
},
|
||||
{
|
||||
"order": 11,
|
||||
"name": "각종 소요자재총괄표",
|
||||
"status": STATUS_READY,
|
||||
"owner": OWNER_PROGRAM,
|
||||
"where": "수량산출 → 자재총괄",
|
||||
"note": "",
|
||||
},
|
||||
{
|
||||
"order": 12,
|
||||
"name": "토적표",
|
||||
"status": STATUS_READY,
|
||||
"owner": OWNER_PROGRAM,
|
||||
"where": "수량산출 → 토적표",
|
||||
"note": "",
|
||||
},
|
||||
{
|
||||
"order": 13,
|
||||
"name": "산출기초",
|
||||
"status": STATUS_PARTIAL,
|
||||
"owner": OWNER_PROGRAM,
|
||||
"where": "각 줄의 산출근거 문구",
|
||||
"note": (
|
||||
"줄마다 **어디서 온 값인지**는 적혀 있으나 **별도의 장으로 묶여 있지 않다** — "
|
||||
"설계서로 묶으려면 그 문구들을 한 장으로 모으는 자리가 필요하다."
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def design_doc_index() -> dict[str, Any]:
|
||||
"""설계서 구성표 한 장 — 법 순서·상태·누가 만드나·어디서 나오나."""
|
||||
counts = {STATUS_READY: 0, STATUS_PARTIAL: 0, STATUS_MISSING: 0}
|
||||
for item in DESIGN_DOC_ITEMS:
|
||||
counts[item["status"]] += 1
|
||||
ours_missing = [
|
||||
item["name"]
|
||||
for item in DESIGN_DOC_ITEMS
|
||||
if item["owner"] == OWNER_PROGRAM and item["status"] != STATUS_READY
|
||||
]
|
||||
theirs = [item["name"] for item in DESIGN_DOC_ITEMS if item["owner"] == OWNER_DESIGNER]
|
||||
return {
|
||||
"law": LAW_SOURCE,
|
||||
"items": [dict(item) for item in DESIGN_DOC_ITEMS],
|
||||
"counts": counts,
|
||||
"summary": (
|
||||
f"법이 정한 {len(DESIGN_DOC_ITEMS)} 가운데 {counts[STATUS_READY]} 이 서 있고,"
|
||||
f" {len(theirs)} 은 설계자·발주청이 쓰는 문서이며,"
|
||||
f" 우리가 더 내야 할 것은 {len(ours_missing)} 입니다"
|
||||
+ (f" — {' · '.join(ours_missing)}." if ours_missing else ".")
|
||||
),
|
||||
"notes": [
|
||||
"⚠ 법이 목차를 정했다고 열셋을 다 프로그램이 만들 것은 아닙니다 —"
|
||||
" 시방서·예정공정표는 현장을 아는 사람이 쓰는 문서입니다.",
|
||||
"⚠ 사방사업은 목록이 다릅니다 — 사방사업법 [별표0] 은 위치도·관계지적조서·"
|
||||
"공사원가계산서·설계도면을 더 둡니다. 이 표는 임도(별표2) 기준입니다.",
|
||||
],
|
||||
}
|
||||
@@ -319,6 +319,25 @@ async def get_price_sources(project_id: UUID) -> JSONResponse:
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/estimation/design-doc-index")
|
||||
async def get_design_doc_index(project_id: UUID) -> JSONResponse:
|
||||
"""**설계서 구성표** — 법이 정한 목차와 우리가 내는 것을 맞대 본다(별표2 (5)(가)).
|
||||
|
||||
⚠ 프로젝트마다 달라지는 값이 아니라 **우리가 무엇을 내는가**의 표다. 그래도 프로젝트
|
||||
화면에서 보여야 설계서를 묶는 사람이 쓴다.
|
||||
"""
|
||||
from B09_Estimation.B09_Estimation_DesignDocIndex import design_doc_index
|
||||
|
||||
try:
|
||||
return JSONResponse(content={"status": "success", **design_doc_index()})
|
||||
except Exception:
|
||||
logger.exception("B09 설계서 구성표 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "설계서 구성표를 못 만들었습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/estimation/factors")
|
||||
async def get_factor_choices(project_id: UUID) -> JSONResponse:
|
||||
"""**산출 조건** — 품셈이 범위로 준 계수와 장비 규격 (사용자 확정 ① 딸림 지시).
|
||||
|
||||
@@ -189,6 +189,57 @@ export function drawMachineTab(body: HTMLElement, data: BaseDataDto): void {
|
||||
body.append(note("잡재료(주연료의 %)는 연료 소요량에 포함되어 있습니다 — 따로 세지 않습니다."));
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* 설계서 구성표 — 법이 정한 목차와 우리가 내는 것을 맞대 본다(별표2 (5)(가)).
|
||||
* ⚠ 「없음」과 「우리 몫 아님」을 갈라 보인다 — 갈라야 다음에 할 일이 달라진다.
|
||||
* ========================================================================== */
|
||||
|
||||
export interface DesignDocDto {
|
||||
status: string;
|
||||
law: string;
|
||||
summary: string;
|
||||
counts: Record<string, number>;
|
||||
items: Array<{
|
||||
order: number;
|
||||
name: string;
|
||||
status: string;
|
||||
owner: string;
|
||||
where: string;
|
||||
note: string;
|
||||
}>;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export async function fetchDesignDocIndex(projectId: string): Promise<DesignDocDto> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/design-doc-index`,
|
||||
{ credentials: "include" },
|
||||
);
|
||||
if (!response.ok) throw new Error(`design-doc-index ${response.status}`);
|
||||
return (await response.json()) as DesignDocDto;
|
||||
}
|
||||
|
||||
export function drawDesignDocTab(body: HTMLElement, data: DesignDocDto): void {
|
||||
body.append(head(`설계서 구성 (법이 정한 ${data.items.length})`));
|
||||
body.append(note(data.summary));
|
||||
body.append(note(data.law));
|
||||
body.append(
|
||||
table(
|
||||
["차례", "이 름", "상 태", "누가 만드나", "어디서 나오나", "비 고"],
|
||||
data.items.map((row) => [
|
||||
String(row.order),
|
||||
row.name,
|
||||
row.status,
|
||||
row.owner,
|
||||
row.where || "—",
|
||||
row.note,
|
||||
]),
|
||||
[0, 1, 2, 3, 4, 5],
|
||||
),
|
||||
);
|
||||
for (const line of data.notes) body.append(note(line));
|
||||
}
|
||||
|
||||
/** 두 탭이 함께 쓰는 「아직 못 불러왔습니다」 문구. */
|
||||
export function drawBaseDataError(body: HTMLElement): void {
|
||||
body.append(note(L("B09_Estimation_Tab_Pending")));
|
||||
|
||||
@@ -19,13 +19,16 @@ import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||
import {
|
||||
drawBaseDataTab,
|
||||
drawFactorChoices,
|
||||
drawDesignDocTab,
|
||||
drawMachineTab,
|
||||
drawPriceSourcesPending,
|
||||
drawPriceSourcesSections,
|
||||
fetchBaseData,
|
||||
fetchDesignDocIndex,
|
||||
fetchFactorChoices,
|
||||
fetchPriceSources,
|
||||
type BaseDataDto,
|
||||
type DesignDocDto,
|
||||
type FactorChoicesDto,
|
||||
type PriceSourcesDto,
|
||||
} from "./B09_Estimation_UI_BaseData";
|
||||
@@ -595,6 +598,7 @@ const TAB_KEYS: Array<[string, keyof typeof ui_locales, boolean]> = [
|
||||
["duration", "B09_Estimation_Tab_Duration", false],
|
||||
["supply", "B09_Estimation_Tab_Supply", true],
|
||||
["base_data", "B09_Estimation_Tab_BaseData", true],
|
||||
["design_doc", "B09_Estimation_Tab_DesignDoc", true],
|
||||
];
|
||||
|
||||
function buildTabs(active: string, onSelect: (key: string) => void): HTMLElement {
|
||||
@@ -807,6 +811,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
|
||||
let baseData: BaseDataDto | null = null;
|
||||
let priceSources: PriceSourcesDto | null = null;
|
||||
let factorChoices: FactorChoicesDto | null = null;
|
||||
let designDoc: DesignDocDto | null = null;
|
||||
let sheet: CostSheetDto | null = null;
|
||||
let unitPriceList: UnitPriceListDto | null = null;
|
||||
let unitPriceDetail: UnitPriceDetailDto | null = null;
|
||||
@@ -1168,6 +1173,28 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
|
||||
drawMaterialTab();
|
||||
return;
|
||||
}
|
||||
if (activeTab === "design_doc") {
|
||||
// 설계서 구성표 — 프로젝트 값이 아니라 **우리가 무엇을 내는가**의 표다.
|
||||
if (designDoc) {
|
||||
drawDesignDocTab(body, designDoc);
|
||||
return;
|
||||
}
|
||||
const loading = document.createElement("div");
|
||||
loading.className = "b09-empty";
|
||||
loading.textContent = L("B09_Estimation_Tab_Pending");
|
||||
body.append(loading);
|
||||
if (projectId) {
|
||||
void fetchDesignDocIndex(projectId)
|
||||
.then((data) => {
|
||||
designDoc = data;
|
||||
drawBody();
|
||||
})
|
||||
.catch(() => {
|
||||
/* 못 받아도 화면을 비우지 않는다 — 위 문구가 그대로 남는다. */
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (activeTab === "base_data" || activeTab === "machine") {
|
||||
// 기초자료 네 표 — 없으면 한 번 받아 오고, 받은 뒤 다시 그린다.
|
||||
if (!baseData) {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""설계서 구성표 — 법이 정한 목차와 우리가 내는 것 (2026-09-09).
|
||||
|
||||
산림자원법 시행규칙 [별표2] (5)(가) 가 **설계서 작성 순서를 열셋으로 못 박아** 두었다.
|
||||
그 목록과 우리 화면을 맞대 두지 않으면 「무엇을 못 내는가」가 계획서에만 남는다.
|
||||
|
||||
⚠ 겨누는 것 넷
|
||||
① 법이 적은 **차례 그대로**다 — 순서를 우리가 바꾸지 않는다
|
||||
② **「없음」과 「우리 몫 아님」을 가른다** — 시방서·예정공정표는 설계자가 쓰는 문서다
|
||||
③ 「반쪽」은 반쪽이라 적는다 — 중기경비계산서·산출기초
|
||||
④ 못 내는 것에는 **무엇을 받아야 내는지**가 적혀 있다
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B09_Estimation.B09_Estimation_DesignDocIndex import ( # noqa: E402
|
||||
DESIGN_DOC_ITEMS,
|
||||
OWNER_DESIGNER,
|
||||
OWNER_PROGRAM,
|
||||
STATUS_MISSING,
|
||||
STATUS_PARTIAL,
|
||||
STATUS_READY,
|
||||
design_doc_index,
|
||||
)
|
||||
|
||||
#: 별표2 (5)(가) 원문 차례.
|
||||
_LAW_ORDER = (
|
||||
"목차",
|
||||
"공사설명서",
|
||||
"일반시방서",
|
||||
"특별시방서",
|
||||
"예정공정표",
|
||||
"예산내역서",
|
||||
"일위대가표",
|
||||
"단가산출서",
|
||||
"각종 중기경비계산서",
|
||||
"공종별 수량계산서",
|
||||
"각종 소요자재총괄표",
|
||||
"토적표",
|
||||
"산출기초",
|
||||
)
|
||||
|
||||
|
||||
def test_법이_적은_차례_그대로다() -> None:
|
||||
assert tuple(item["name"] for item in DESIGN_DOC_ITEMS) == _LAW_ORDER
|
||||
assert [item["order"] for item in DESIGN_DOC_ITEMS] == list(range(1, 14))
|
||||
|
||||
|
||||
def test_설계자_몫과_우리_몫을_가른다() -> None:
|
||||
"""② 「없음」 넷 가운데 셋은 **애초에 우리가 만들 것이 아니다.**"""
|
||||
theirs = {item["name"] for item in DESIGN_DOC_ITEMS if item["owner"] == OWNER_DESIGNER}
|
||||
assert theirs == {"일반시방서", "특별시방서", "예정공정표"}
|
||||
for item in DESIGN_DOC_ITEMS:
|
||||
if item["owner"] == OWNER_DESIGNER:
|
||||
assert item["status"] == STATUS_MISSING
|
||||
|
||||
|
||||
def test_반쪽은_반쪽이라_적는다() -> None:
|
||||
partial = {item["name"] for item in DESIGN_DOC_ITEMS if item["status"] == STATUS_PARTIAL}
|
||||
assert partial == {"각종 중기경비계산서", "산출기초"}
|
||||
|
||||
|
||||
def test_서는_것은_어디서_나오는지_적혀_있다() -> None:
|
||||
for item in DESIGN_DOC_ITEMS:
|
||||
if item["status"] == STATUS_READY:
|
||||
assert item["where"], f"{item['name']}: 어느 화면인지 안 적혀 있음"
|
||||
|
||||
|
||||
def test_못_내는_것에는_무엇이_필요한지_적혀_있다() -> None:
|
||||
"""④ 「없음」으로만 두면 다음 사람이 무엇부터 해야 할지 모른다."""
|
||||
for item in DESIGN_DOC_ITEMS:
|
||||
if item["status"] != STATUS_READY:
|
||||
assert item["note"].strip(), f"{item['name']}: 사유가 비어 있음"
|
||||
|
||||
|
||||
def test_요약이_셈과_맞는다() -> None:
|
||||
data = design_doc_index()
|
||||
assert data["counts"][STATUS_READY] == 7
|
||||
assert sum(data["counts"].values()) == len(DESIGN_DOC_ITEMS)
|
||||
ours_missing = [
|
||||
item["name"]
|
||||
for item in DESIGN_DOC_ITEMS
|
||||
if item["owner"] == OWNER_PROGRAM and item["status"] != STATUS_READY
|
||||
]
|
||||
for name in ours_missing:
|
||||
assert name in data["summary"]
|
||||
@@ -827,6 +827,7 @@ export const ui_locales_b2 = {
|
||||
B09_Estimation_Tab_Duration: ["공사기간", "Duration"],
|
||||
B09_Estimation_Tab_Supply: ["관급·사급", "Supplied Materials"],
|
||||
B09_Estimation_Tab_BaseData: ["기초자료", "Base Data"],
|
||||
B09_Estimation_Tab_DesignDoc: ["설계서 구성", "Design Document Set"],
|
||||
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