From e38b832353b49b48c9600f947355e79334befa9f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 23:16:19 +0900 Subject: [PATCH] =?UTF-8?q?feat(B09):=20=EC=84=A4=EA=B3=84=EC=84=9C=20?= =?UTF-8?q?=EA=B5=AC=EC=84=B1=ED=91=9C=20=E2=80=94=20=EB=B2=95=EC=9D=B4=20?= =?UTF-8?q?=EC=A0=95=ED=95=9C=20=EB=AA=A9=EC=B0=A8=EC=99=80=20=EC=9A=B0?= =?UTF-8?q?=EB=A6=AC=EA=B0=80=20=EB=82=B4=EB=8A=94=20=EA=B2=83=EC=9D=84=20?= =?UTF-8?q?=EB=A7=9E=EB=8C=80=20=EB=B4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 별표2 (5)(가) 가 설계서 작성 순서를 열셋으로 못 박아 두었는데, 우리가 무엇을 내고 무엇을 못 내는지가 계획서에만 있었음. 원가계산에 「설계서 구성」 탭으로 세움. - 법이 적은 차례 그대로. 줄마다 상태(있음·반쪽·없음)와 어디서 나오는지를 적음. - ⚠ 「없음」과 「우리 몫 아님」을 가름 — 일반시방서·특별시방서·예정공정표는 현장을 아는 사람이 쓰는 문서라 프로그램이 지어낼 것이 아님. 법이 목차를 정했다고 열셋을 다 우리가 만드는 것은 아님. - 못 내는 것에는 무엇을 받아야 내는지를 적음(공사설명서 = 서식·설계하중 표기). - 사방사업은 목록이 다름(별표0 은 위치도·관계지적조서·설계도면을 더 둠)을 함께 적음. ⇒ 법이 정한 13 중 7 이 서 있고, 3 은 설계자·발주청 몫, 우리가 더 낼 것은 3 (공사설명서 · 각종 중기경비계산서 · 산출기초). Co-Authored-By: Claude Opus 5 (1M context) --- .../B09_Estimation_DesignDocIndex.py | 190 ++++++++++++++++++ B09_Estimation/B09_Estimation_Router.py | 19 ++ B09_Estimation/B09_Estimation_UI_BaseData.ts | 51 +++++ B09_Estimation/B09_Estimation_UI_Page.ts | 27 +++ resources/tester/test_b09_design_doc_index.py | 91 +++++++++ ui_template/ui_template_locale_b2.ts | 1 + 6 files changed, 379 insertions(+) create mode 100644 B09_Estimation/B09_Estimation_DesignDocIndex.py create mode 100644 resources/tester/test_b09_design_doc_index.py diff --git a/B09_Estimation/B09_Estimation_DesignDocIndex.py b/B09_Estimation/B09_Estimation_DesignDocIndex.py new file mode 100644 index 00000000..2a666ae3 --- /dev/null +++ b/B09_Estimation/B09_Estimation_DesignDocIndex.py @@ -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) 기준입니다.", + ], + } diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 6e5c1ce7..068885bb 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -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: """**산출 조건** — 품셈이 범위로 준 계수와 장비 규격 (사용자 확정 ① 딸림 지시). diff --git a/B09_Estimation/B09_Estimation_UI_BaseData.ts b/B09_Estimation/B09_Estimation_UI_BaseData.ts index 3f92f70f..e506637f 100644 --- a/B09_Estimation/B09_Estimation_UI_BaseData.ts +++ b/B09_Estimation/B09_Estimation_UI_BaseData.ts @@ -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; + items: Array<{ + order: number; + name: string; + status: string; + owner: string; + where: string; + note: string; + }>; + notes: string[]; +} + +export async function fetchDesignDocIndex(projectId: string): Promise { + 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"))); diff --git a/B09_Estimation/B09_Estimation_UI_Page.ts b/B09_Estimation/B09_Estimation_UI_Page.ts index a8d1dc56..cfe77279 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.ts +++ b/B09_Estimation/B09_Estimation_UI_Page.ts @@ -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 { 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 { 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) { diff --git a/resources/tester/test_b09_design_doc_index.py b/resources/tester/test_b09_design_doc_index.py new file mode 100644 index 00000000..6ba8e4be --- /dev/null +++ b/resources/tester/test_b09_design_doc_index.py @@ -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"] diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 553bda58..2d713882 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -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"],