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:
2026-09-09 23:16:19 +09:00
co-authored by Claude Opus 5
parent af10565e34
commit e38b832353
6 changed files with 379 additions and 0 deletions
@@ -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) 기준입니다.",
],
}
+19
View File
@@ -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") @router.get("/{project_id}/estimation/factors")
async def get_factor_choices(project_id: UUID) -> JSONResponse: async def get_factor_choices(project_id: UUID) -> JSONResponse:
"""**산출 조건** — 품셈이 범위로 준 계수와 장비 규격 (사용자 확정 ① 딸림 지시). """**산출 조건** — 품셈이 범위로 준 계수와 장비 규격 (사용자 확정 ① 딸림 지시).
@@ -189,6 +189,57 @@ export function drawMachineTab(body: HTMLElement, data: BaseDataDto): void {
body.append(note("잡재료(주연료의 %)는 연료 소요량에 포함되어 있습니다 — 따로 세지 않습니다.")); 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 { export function drawBaseDataError(body: HTMLElement): void {
body.append(note(L("B09_Estimation_Tab_Pending"))); body.append(note(L("B09_Estimation_Tab_Pending")));
+27
View File
@@ -19,13 +19,16 @@ import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { import {
drawBaseDataTab, drawBaseDataTab,
drawFactorChoices, drawFactorChoices,
drawDesignDocTab,
drawMachineTab, drawMachineTab,
drawPriceSourcesPending, drawPriceSourcesPending,
drawPriceSourcesSections, drawPriceSourcesSections,
fetchBaseData, fetchBaseData,
fetchDesignDocIndex,
fetchFactorChoices, fetchFactorChoices,
fetchPriceSources, fetchPriceSources,
type BaseDataDto, type BaseDataDto,
type DesignDocDto,
type FactorChoicesDto, type FactorChoicesDto,
type PriceSourcesDto, type PriceSourcesDto,
} from "./B09_Estimation_UI_BaseData"; } 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], ["duration", "B09_Estimation_Tab_Duration", false],
["supply", "B09_Estimation_Tab_Supply", true], ["supply", "B09_Estimation_Tab_Supply", true],
["base_data", "B09_Estimation_Tab_BaseData", true], ["base_data", "B09_Estimation_Tab_BaseData", true],
["design_doc", "B09_Estimation_Tab_DesignDoc", true],
]; ];
function buildTabs(active: string, onSelect: (key: string) => void): HTMLElement { 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 baseData: BaseDataDto | null = null;
let priceSources: PriceSourcesDto | null = null; let priceSources: PriceSourcesDto | null = null;
let factorChoices: FactorChoicesDto | null = null; let factorChoices: FactorChoicesDto | null = null;
let designDoc: DesignDocDto | null = null;
let sheet: CostSheetDto | null = null; let sheet: CostSheetDto | null = null;
let unitPriceList: UnitPriceListDto | null = null; let unitPriceList: UnitPriceListDto | null = null;
let unitPriceDetail: UnitPriceDetailDto | null = null; let unitPriceDetail: UnitPriceDetailDto | null = null;
@@ -1168,6 +1173,28 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
drawMaterialTab(); drawMaterialTab();
return; 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 (activeTab === "base_data" || activeTab === "machine") {
// 기초자료 네 표 — 없으면 한 번 받아 오고, 받은 뒤 다시 그린다. // 기초자료 네 표 — 없으면 한 번 받아 오고, 받은 뒤 다시 그린다.
if (!baseData) { 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"]
+1
View File
@@ -827,6 +827,7 @@ export const ui_locales_b2 = {
B09_Estimation_Tab_Duration: ["공사기간", "Duration"], B09_Estimation_Tab_Duration: ["공사기간", "Duration"],
B09_Estimation_Tab_Supply: ["관급·사급", "Supplied Materials"], B09_Estimation_Tab_Supply: ["관급·사급", "Supplied Materials"],
B09_Estimation_Tab_BaseData: ["기초자료", "Base Data"], B09_Estimation_Tab_BaseData: ["기초자료", "Base Data"],
B09_Estimation_Tab_DesignDoc: ["설계서 구성", "Design Document Set"],
B09_Estimation_Group_Condition: ["공사 조건", "Project Conditions"], B09_Estimation_Group_Condition: ["공사 조건", "Project Conditions"],
B09_Estimation_Group_RateVersion: ["요율 판", "Rate Edition"], B09_Estimation_Group_RateVersion: ["요율 판", "Rate Edition"],
B09_Estimation_Group_Supplied: ["관급자재", "Owner-Supplied"], B09_Estimation_Group_Supplied: ["관급자재", "Owner-Supplied"],